Files
PostTracker/extension/content.js
2026-04-07 16:11:32 +02:00

7762 lines
236 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Facebook Post Tracker Extension
// Uses API_BASE_URL from config.js
const EXTENSION_VERSION = '1.2.1';
const PROCESSED_ATTR = 'data-fb-tracker-processed';
const PENDING_ATTR = 'data-fb-tracker-pending';
const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]';
const API_URL = `${API_BASE_URL}/api`;
const WEBAPP_BASE_URL = API_BASE_URL.replace(/\/+$/, '');
const MAX_SELECTION_LENGTH = 5000;
const postSelectionCache = new WeakMap();
const LAST_SELECTION_MAX_AGE = 5000;
let selectionCacheTimeout = null;
let lastGlobalSelection = { text: '', timestamp: 0 };
const processedPostUrls = new Map();
const SEARCH_RESULTS_PATH_PREFIX = '/search';
const FEED_HOME_PATHS = ['/', '/home.php'];
const sessionSearchRecordedUrls = new Set();
const sessionSearchInfoCache = new Map();
let profileSelectionNoticeShown = false;
function isOnSearchResultsPage() {
try {
const pathname = window.location && window.location.pathname;
return typeof pathname === 'string' && pathname.startsWith(SEARCH_RESULTS_PATH_PREFIX);
} catch (error) {
return false;
}
}
const trackerElementsByPost = new WeakMap();
const trackerAnchorsByPost = new WeakMap();
const postAdditionalNotes = new WeakMap();
const REELS_PATH_PREFIX = '/reel/';
const POST_TEXT_LOG_TAG = '[FB PostText]';
function isOnReelsPage() {
try {
const pathname = window.location && window.location.pathname;
return typeof pathname === 'string' && pathname.startsWith(REELS_PATH_PREFIX);
} catch (error) {
return false;
}
}
function maybeRedirectPageReelsToMain() {
try {
const { location } = window;
const pathname = location && location.pathname;
if (typeof pathname !== 'string') {
return false;
}
if (pathname.toLowerCase() === '/profile.php') {
const params = new URLSearchParams(location.search || '');
const profileId = params.get('id');
const sk = params.get('sk');
if (profileId && sk && sk.toLowerCase().startsWith('reel')) {
const targetUrl = `${location.origin}/profile.php?id=${encodeURIComponent(profileId)}`;
if (location.href !== targetUrl) {
location.replace(targetUrl);
return true;
}
return false;
}
}
const match = pathname.match(/^\/([^/]+)\/reels\/?$/i);
if (!match) {
return false;
}
const pageSlug = match[1];
if (!pageSlug) {
return false;
}
const targetUrl = `${location.origin}/${pageSlug}/`;
if (location.href === targetUrl) {
return false;
}
location.replace(targetUrl);
return true;
} catch (error) {
return false;
}
}
let debugLoggingEnabled = false;
const originalConsoleLog = console.log.bind(console);
const originalConsoleDebug = console.debug ? console.debug.bind(console) : null;
const originalConsoleInfo = console.info ? console.info.bind(console) : null;
function shouldSuppressTrackerLog(args) {
if (!args || args.length === 0) {
return false;
}
const [first] = args;
if (typeof first === 'string' && first.startsWith('[FB Tracker]')) {
return !debugLoggingEnabled;
}
return false;
}
console.log = (...args) => {
if (shouldSuppressTrackerLog(args)) {
return;
}
originalConsoleLog(...args);
};
if (originalConsoleDebug) {
console.debug = (...args) => {
if (shouldSuppressTrackerLog(args)) {
return;
}
originalConsoleDebug(...args);
};
}
if (originalConsoleInfo) {
console.info = (...args) => {
if (shouldSuppressTrackerLog(args)) {
return;
}
originalConsoleInfo(...args);
};
}
function applyDebugLoggingPreference(value) {
debugLoggingEnabled = Boolean(value);
if (debugLoggingEnabled) {
originalConsoleLog('[FB Tracker] Debug logging enabled');
}
}
chrome.storage.sync.get(['debugLoggingEnabled'], (result) => {
applyDebugLoggingPreference(result && typeof result.debugLoggingEnabled !== 'undefined'
? result.debugLoggingEnabled
: false);
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes && Object.prototype.hasOwnProperty.call(changes, 'debugLoggingEnabled')) {
applyDebugLoggingPreference(changes.debugLoggingEnabled.newValue);
}
});
function getTrackerElementForPost(postElement) {
if (!postElement) {
return null;
}
const tracker = trackerElementsByPost.get(postElement);
if (tracker && tracker.isConnected) {
return tracker;
}
if (tracker) {
trackerElementsByPost.delete(postElement);
}
return null;
}
function setTrackerElementForPost(postElement, trackerElement) {
if (!postElement) {
return;
}
if (trackerElement && trackerElement.isConnected) {
trackerElementsByPost.set(postElement, trackerElement);
} else {
trackerElementsByPost.delete(postElement);
}
}
function clearTrackerElementForPost(postElement, trackerElement = null) {
if (!postElement) {
return;
}
if (!trackerElementsByPost.has(postElement)) {
return;
}
const current = trackerElementsByPost.get(postElement);
if (trackerElement && current && current !== trackerElement) {
return;
}
trackerElementsByPost.delete(postElement);
}
function getTrackerAnchorForPost(postElement) {
if (!postElement) {
return null;
}
const anchor = trackerAnchorsByPost.get(postElement);
if (anchor && anchor.isConnected) {
return anchor;
}
if (anchor) {
trackerAnchorsByPost.delete(postElement);
}
return null;
}
function setTrackerAnchorForPost(postElement, anchorElement) {
if (!postElement) {
return;
}
if (anchorElement && anchorElement.isConnected) {
trackerAnchorsByPost.set(postElement, anchorElement);
} else {
trackerAnchorsByPost.delete(postElement);
}
}
function clearTrackerAnchorForPost(postElement, anchorElement = null) {
if (!postElement) {
return;
}
if (!trackerAnchorsByPost.has(postElement)) {
return;
}
const current = trackerAnchorsByPost.get(postElement);
if (anchorElement && current && current !== anchorElement) {
return;
}
trackerAnchorsByPost.delete(postElement);
}
function getTrackerScopeRoot(element) {
return element ? element.closest(DIALOG_ROOT_SELECTOR) : null;
}
function getTrackersForUrlInScope(encodedUrl, scopeRoot) {
if (!encodedUrl) {
return [];
}
const selector = `.fb-tracker-ui[data-post-url="${encodedUrl}"]`;
const trackers = Array.from(document.querySelectorAll(selector)).filter((tracker) => tracker && tracker.isConnected);
return trackers.filter((tracker) => {
const trackerScopeRoot = getTrackerScopeRoot(tracker);
if (scopeRoot) {
return trackerScopeRoot === scopeRoot;
}
return !trackerScopeRoot;
});
}
function dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, preferredTracker = null) {
const trackers = getTrackersForUrlInScope(encodedUrl, scopeRoot);
if (!trackers.length) {
return null;
}
const keep = preferredTracker && trackers.includes(preferredTracker)
? preferredTracker
: trackers[0];
trackers.forEach((tracker) => {
if (tracker !== keep) {
tracker.remove();
}
});
return keep;
}
function getTrackerHostElement(trackerElement) {
if (!trackerElement) {
return null;
}
const rawHost = trackerElement.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]');
return rawHost ? ensurePrimaryPostElement(rawHost) : null;
}
function isTrackerInsertionMarkerLocked(marker) {
return Boolean(marker && marker.dataset && marker.dataset.anchorLocked === '1');
}
function setTrackerInsertionMarkerLocked(marker, locked) {
if (!marker || !marker.dataset) {
return;
}
marker.dataset.anchorLocked = locked ? '1' : '0';
}
function isNodeAfter(node, referenceNode) {
if (!node || !referenceNode || node === referenceNode) {
return false;
}
return Boolean(node.compareDocumentPosition(referenceNode) & Node.DOCUMENT_POSITION_PRECEDING);
}
function hasCommentComposerSignal(node) {
if (!node || !node.querySelector) {
return false;
}
return Boolean(
node.querySelector(
'div[role="textbox"][contenteditable="true"], [role="textbox"][aria-label*="komment" i], [role="textbox"][aria-label*="comment" i], [role="textbox"][aria-placeholder*="komment" i], [role="textbox"][aria-placeholder*="comment" i]'
)
);
}
function findCommentComposerAnchorInPost(postElement) {
if (!postElement) {
return null;
}
const composerCandidates = Array.from(
postElement.querySelectorAll(
'div[role="textbox"][contenteditable="true"], [role="textbox"][aria-label*="komment" i], [role="textbox"][aria-label*="comment" i], [role="textbox"][aria-placeholder*="komment" i], [role="textbox"][aria-placeholder*="comment" i]'
)
);
for (const composer of composerCandidates) {
if (!composer || composer.closest('.fb-tracker-ui')) {
continue;
}
const composerRoot = composer.closest('form[role="presentation"]')
|| composer.closest('form')
|| composer;
const topLevelAnchor = getTopLevelAnchorWithinPost(postElement, composerRoot) || composerRoot;
if (topLevelAnchor && topLevelAnchor.parentElement && postElement.contains(topLevelAnchor)) {
return topLevelAnchor;
}
}
return null;
}
function countActionRoleSignals(node) {
if (!node || !node.querySelector) {
return 0;
}
const hasLike = Boolean(node.querySelector('[data-ad-rendering-role="like_button"], [data-ad-rendering-role*="gefällt" i]'));
const hasComment = Boolean(node.querySelector('[data-ad-rendering-role="comment_button"]'));
const hasShare = Boolean(node.querySelector('[data-ad-rendering-role="share_button"]'));
return [hasLike, hasComment, hasShare].filter(Boolean).length;
}
function hasCommentSortSignal(node) {
if (!node || !node.querySelector) {
return false;
}
return Boolean(
node.querySelector(
'[aria-label*="relevantes zuerst" i], [aria-label*="most relevant" i], [aria-label*="neueste" i], [aria-label*="newest" i]'
)
);
}
function isActionRowShapeCandidate(postElement, candidate) {
if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) {
return false;
}
if (candidate.closest('.fb-tracker-ui')) {
return false;
}
if (
hasCommentComposerSignal(candidate)
|| hasCommentContentSignal(candidate)
|| hasCommentSortSignal(candidate)
) {
return false;
}
const buttonCount = candidate.querySelectorAll('[role="button"], button').length;
if (buttonCount < 3 || buttonCount > 24) {
return false;
}
return true;
}
function isCompactActionBarCandidate(postElement, candidate) {
if (!isActionRowShapeCandidate(postElement, candidate)) {
return false;
}
const actionRoleSignals = countActionRoleSignals(candidate);
if (actionRoleSignals >= 2) {
return true;
}
return hasInteractionButtons(candidate);
}
function hasMainPostContentSignal(node) {
if (!node || !node.querySelector) {
return false;
}
return Boolean(
node.querySelector(
'[data-ad-preview*="message" i], [data-ad-comet-preview*="message" i], [data-ad-preview*="story" i], [data-ad-comet-preview*="story" i]'
)
);
}
function normalizeActionBarCandidate(postElement, candidate) {
if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) {
return null;
}
let fallback = null;
let current = candidate;
for (let depth = 0; depth < 12 && current && current !== postElement; depth++) {
if (isCompactActionBarCandidate(postElement, current)) {
return current;
}
if (!fallback && isActionRowShapeCandidate(postElement, current)) {
fallback = current;
}
current = current.parentElement;
}
if (fallback) {
return fallback;
}
return isReliableActionBarCandidate(postElement, candidate) ? candidate : null;
}
function findFeedbackSectionAnchor(postElement, actionAnchor = null) {
if (!postElement) {
return null;
}
const resolvedAction = actionAnchor && postElement.contains(actionAnchor)
? actionAnchor
: resolveActionButtonBar(postElement, actionAnchor);
if (!resolvedAction || !resolvedAction.isConnected || !postElement.contains(resolvedAction)) {
return null;
}
const commentBoundary = findCommentBoundaryAnchorInPost(postElement, resolvedAction);
let candidate = resolvedAction;
let current = resolvedAction;
for (let depth = 0; depth < 12 && current && current !== postElement; depth++) {
const parent = current.parentElement;
if (!parent || !postElement.contains(parent) || parent === postElement) {
break;
}
if (hasMainPostContentSignal(parent)) {
break;
}
const parentLooksFeedback =
(commentBoundary && parent.contains(commentBoundary))
|| hasCommentComposerSignal(parent)
|| hasCommentSortSignal(parent)
|| hasCommentContentSignal(parent);
if (!parentLooksFeedback) {
// Keep climbing through transparent wrappers that only wrap this subtree.
if (parent.childElementCount === 1) {
candidate = parent;
current = parent;
continue;
}
break;
}
candidate = parent;
current = parent;
}
return candidate;
}
function isReliableActionBarCandidate(postElement, candidate) {
if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) {
return false;
}
if (candidate.closest('.fb-tracker-ui')) {
return false;
}
if (
hasCommentComposerSignal(candidate)
|| hasCommentContentSignal(candidate)
|| hasCommentSortSignal(candidate)
) {
return false;
}
const buttonCount = candidate.querySelectorAll('[role="button"], button').length;
if (buttonCount > 80) {
return false;
}
const actionRoleSignals = countActionRoleSignals(candidate);
if (actionRoleSignals >= 2) {
return true;
}
return hasInteractionButtons(candidate);
}
function findActionBarByDataRoles(postElement) {
if (!postElement) {
return null;
}
const commentBoundary = findCommentComposerAnchorInPost(postElement)
|| findCommentSortAnchorInPost(postElement);
const markers = Array.from(postElement.querySelectorAll(
'[data-ad-rendering-role="like_button"], [data-ad-rendering-role*="gefällt" i], [data-ad-rendering-role="comment_button"], [data-ad-rendering-role="share_button"]'
));
for (const marker of markers) {
if (
marker.closest('[aria-roledescription*="comment" i], [aria-roledescription*="kommentar" i], [data-testid*="comment" i], [data-scope="comment"]')
) {
continue;
}
if (commentBoundary && isNodeAfter(marker, commentBoundary)) {
continue;
}
let current = marker;
for (let depth = 0; depth < 8 && current && current !== postElement; depth++) {
if (commentBoundary && isNodeAfter(current, commentBoundary)) {
current = current.parentElement;
continue;
}
if (isReliableActionBarCandidate(postElement, current)) {
return current;
}
current = current.parentElement;
}
}
return null;
}
function resolveActionButtonBar(postElement, buttonBar) {
if (!postElement) {
return null;
}
const candidates = [
findActionBarByDataRoles(postElement),
buttonBar,
findButtonBar(postElement)
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const normalized = normalizeActionBarCandidate(postElement, candidate);
if (normalized) {
return normalized;
}
}
return null;
}
function getDirectActionAnchorInPost(postElement, buttonBar) {
if (!postElement) {
return null;
}
const resolvedButtonBar = resolveActionButtonBar(postElement, buttonBar);
if (!resolvedButtonBar) {
return null;
}
// Use the local action bar container directly.
// Promoting to post-level wrappers can place the marker below the entire comments block.
return resolvedButtonBar;
}
function getTopLevelAnchorWithinPost(postElement, element) {
if (!postElement || !element || !postElement.contains(element)) {
return null;
}
let anchor = element;
while (anchor.parentElement && anchor.parentElement !== postElement) {
anchor = anchor.parentElement;
}
return anchor && anchor.parentElement === postElement ? anchor : null;
}
function isLikelyCommentSortNode(node) {
if (!node || !isElementVisible(node) || node.closest('.fb-tracker-ui')) {
return false;
}
const keywords = ['relevantes zuerst', 'most relevant', 'neueste', 'newest'];
const text = (node.textContent || '').trim().toLowerCase();
const ariaLabel = (node.getAttribute('aria-label') || '').trim().toLowerCase();
if (ariaLabel && keywords.some((keyword) => ariaLabel.includes(keyword))) {
return true;
}
if (text && text.length <= 120 && keywords.some((keyword) => text.includes(keyword))) {
return true;
}
return false;
}
function hasCommentContentSignal(node) {
if (!node) {
return false;
}
if (node.matches && node.matches('[data-testid*="comment" i], [data-scope="comment"]')) {
return true;
}
return Boolean(
node.querySelector(
'[data-testid*="comment" i], [data-scope="comment"], [aria-roledescription*="comment" i], [aria-roledescription*="kommentar" i], div[role="textbox"][contenteditable="true"]'
)
);
}
function findAdjacentCommentSortAnchor(postElement, actionAnchor = null) {
if (!postElement || !actionAnchor || !postElement.contains(actionAnchor)) {
return null;
}
let sibling = actionAnchor.nextElementSibling;
for (let i = 0; i < 4 && sibling; i++) {
if (sibling.classList && sibling.classList.contains('fb-tracker-ui')) {
sibling = sibling.nextElementSibling;
continue;
}
if (isLikelyCommentSortNode(sibling)) {
return sibling;
}
const innerSortNode = sibling.querySelector && sibling.querySelector([
'[aria-label*="relevantes zuerst" i]',
'[aria-label*="most relevant" i]',
'[aria-label*="neueste" i]',
'[aria-label*="newest" i]',
'[role="button"]',
'button'
].join(', '));
if (innerSortNode && isLikelyCommentSortNode(innerSortNode)) {
return sibling;
}
if (hasCommentContentSignal(sibling)) {
break;
}
sibling = sibling.nextElementSibling;
}
return null;
}
function findCommentSortAnchorInPost(postElement) {
if (!postElement) {
return null;
}
const sortCandidates = Array.from(
postElement.querySelectorAll('[aria-label*="relevantes zuerst" i], [aria-label*="most relevant" i], [aria-label*="neueste" i], [aria-label*="newest" i], [role="button"], button')
);
for (const candidate of sortCandidates) {
if (!isLikelyCommentSortNode(candidate)) {
continue;
}
const topLevel = getTopLevelAnchorWithinPost(postElement, candidate) || candidate;
if (topLevel && topLevel.parentElement && postElement.contains(topLevel)) {
return topLevel;
}
}
return null;
}
function findCommentBoundaryAnchorInPost(postElement, actionAnchor = null) {
if (!postElement) {
return null;
}
const adjacentSortAnchor = actionAnchor
? findAdjacentCommentSortAnchor(postElement, actionAnchor)
: null;
if (adjacentSortAnchor) {
return adjacentSortAnchor;
}
return findCommentSortAnchorInPost(postElement) || findCommentComposerAnchorInPost(postElement);
}
function createTrackerInsertionMarker() {
const marker = document.createElement('div');
marker.className = 'fb-tracker-insertion-anchor';
marker.setAttribute('aria-hidden', 'true');
setTrackerInsertionMarkerLocked(marker, false);
marker.style.cssText = `
display: block;
width: 100%;
height: 0;
margin: 0;
padding: 0;
border: 0;
pointer-events: none;
`;
return marker;
}
function isInteractiveContainer(element) {
return Boolean(
element
&& element.matches
&& element.matches('a, button, [role="button"]')
);
}
function normalizeTrackerInsertionAnchor(postElement, anchor) {
if (!postElement || !anchor || !anchor.isConnected || !postElement.contains(anchor)) {
return null;
}
let current = anchor;
while (current && current !== postElement) {
const parent = current.parentElement;
if (!parent || !postElement.contains(parent)) {
break;
}
if (isInteractiveContainer(current) || isInteractiveContainer(parent)) {
current = parent;
continue;
}
break;
}
return current;
}
function insertMarkerBeforeAnchor(postElement, marker, anchor) {
const target = normalizeTrackerInsertionAnchor(postElement, anchor);
if (!target || !target.parentElement) {
return false;
}
target.parentElement.insertBefore(marker, target);
return true;
}
function getNextNonTrackerSibling(node) {
if (!node) {
return null;
}
let current = node.nextElementSibling;
while (current) {
if (
!current.classList
|| (
!current.classList.contains('fb-tracker-ui')
&& !current.classList.contains('fb-tracker-insertion-anchor')
)
) {
return current;
}
current = current.nextElementSibling;
}
return null;
}
function positionTrackerInsertionMarker(postElement, marker, buttonBar, postNum = '?') {
if (!postElement || !marker) {
return false;
}
const resolvedButtonBar = resolveActionButtonBar(postElement, buttonBar);
const actionAnchor = getDirectActionAnchorInPost(postElement, resolvedButtonBar);
const feedbackAnchor = findFeedbackSectionAnchor(postElement, actionAnchor || resolvedButtonBar);
if (feedbackAnchor && insertMarkerBeforeAnchor(postElement, marker, feedbackAnchor)) {
setTrackerInsertionMarkerLocked(marker, true);
console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above feedback section');
return true;
}
if (actionAnchor && insertMarkerBeforeAnchor(postElement, marker, actionAnchor)) {
setTrackerInsertionMarkerLocked(marker, true);
console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above action bar');
return true;
}
if (resolvedButtonBar && insertMarkerBeforeAnchor(postElement, marker, resolvedButtonBar)) {
setTrackerInsertionMarkerLocked(marker, true);
console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above resolved button bar');
return true;
}
if (postElement.firstChild) {
postElement.insertBefore(marker, postElement.firstChild);
} else {
postElement.appendChild(marker);
}
setTrackerInsertionMarkerLocked(marker, false);
console.log('[FB Tracker] Post #' + postNum + ' - Marker fallback prepended to post');
return true;
}
function isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar) {
if (!postElement || !marker || !marker.isConnected || !postElement.contains(marker)) {
return false;
}
if (marker.closest('a, button, [role="button"]')) {
return false;
}
const actionAnchor = getDirectActionAnchorInPost(postElement, buttonBar);
const feedbackAnchor = findFeedbackSectionAnchor(postElement, actionAnchor || buttonBar);
if (feedbackAnchor && isNodeAfter(marker, feedbackAnchor)) {
return false;
}
if (feedbackAnchor && marker.parentElement === feedbackAnchor.parentElement) {
const nextSibling = getNextNonTrackerSibling(marker);
if (nextSibling && nextSibling !== feedbackAnchor) {
return false;
}
}
if (actionAnchor) {
if (isNodeAfter(marker, actionAnchor)) {
return false;
}
if (actionAnchor.contains(marker) || marker.contains(actionAnchor)) {
return false;
}
if (marker.parentElement === actionAnchor.parentElement) {
const nextSibling = getNextNonTrackerSibling(marker);
if (nextSibling && nextSibling !== actionAnchor) {
return false;
}
}
}
const commentBoundary = findCommentBoundaryAnchorInPost(postElement);
if (commentBoundary && isNodeAfter(marker, commentBoundary)) {
return false;
}
return true;
}
function ensureTrackerInsertionMarker(postElement, buttonBar, postNum = '?') {
if (!postElement) {
return null;
}
let marker = getTrackerAnchorForPost(postElement);
if (!marker) {
const existingMarkers = Array.from(postElement.querySelectorAll('.fb-tracker-insertion-anchor'));
if (existingMarkers.length > 0) {
marker = existingMarkers[0];
existingMarkers.slice(1).forEach((duplicate) => duplicate.remove());
setTrackerAnchorForPost(postElement, marker);
}
}
if (!marker || !marker.isConnected) {
marker = createTrackerInsertionMarker();
}
if (marker.isConnected && postElement.contains(marker) && isTrackerInsertionMarkerLocked(marker)) {
if (!isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar)) {
setTrackerInsertionMarkerLocked(marker, false);
} else {
setTrackerAnchorForPost(postElement, marker);
return marker;
}
}
if (marker.isConnected && postElement.contains(marker) && !isTrackerInsertionMarkerLocked(marker)) {
if (isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar)) {
setTrackerAnchorForPost(postElement, marker);
return marker;
}
}
const positioned = positionTrackerInsertionMarker(postElement, marker, buttonBar, postNum);
if (!positioned || !marker.isConnected) {
clearTrackerAnchorForPost(postElement, marker);
return null;
}
setTrackerAnchorForPost(postElement, marker);
return marker;
}
function insertTrackerAtMarker(marker, trackerElement) {
if (!marker || !trackerElement || !marker.parentElement) {
return false;
}
if (
trackerElement.parentElement === marker.parentElement
&& trackerElement.previousElementSibling === marker
) {
return true;
}
marker.parentElement.insertBefore(trackerElement, marker.nextSibling);
return true;
}
const TRACKER_INTERACTION_GRACE_MS = 2200;
function markTrackerInteraction(trackerElement) {
if (!trackerElement || !trackerElement.dataset) {
return;
}
trackerElement.dataset.realignBlockedUntil = String(Date.now() + TRACKER_INTERACTION_GRACE_MS);
}
function isTrackerInteractionActive(trackerElement) {
if (!trackerElement || !trackerElement.isConnected) {
return false;
}
const blockedUntil = parseInt(trackerElement.dataset.realignBlockedUntil || '0', 10);
if (!Number.isNaN(blockedUntil) && blockedUntil > Date.now()) {
return true;
}
if (trackerElement.querySelector('.fb-tracker-ai-wrapper--open')) {
return true;
}
const activeElement = document.activeElement;
return Boolean(activeElement && trackerElement.contains(activeElement));
}
function bindTrackerInteractionGuard(trackerElement) {
if (!trackerElement || trackerElement.dataset.realignGuardBound === '1') {
return;
}
trackerElement.dataset.realignGuardBound = '1';
const bumpInteraction = () => markTrackerInteraction(trackerElement);
trackerElement.addEventListener('focusin', bumpInteraction, true);
trackerElement.addEventListener('pointerdown', bumpInteraction, true);
trackerElement.addEventListener('mousedown', bumpInteraction, true);
trackerElement.addEventListener('click', bumpInteraction, true);
trackerElement.addEventListener('keydown', bumpInteraction, true);
}
function realignExistingTrackerUI(postElement, buttonBar, trackerElement, postNum = '?') {
if (!postElement || !trackerElement || !trackerElement.isConnected) {
return false;
}
bindTrackerInteractionGuard(trackerElement);
if (isTrackerInteractionActive(trackerElement)) {
return true;
}
const marker = ensureTrackerInsertionMarker(postElement, buttonBar, postNum);
if (!marker) {
return false;
}
return insertTrackerAtMarker(marker, trackerElement);
}
const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache
const aiCredentialCache = {
data: null,
timestamp: 0,
pending: null
};
const AI_SETTINGS_CACHE_TTL = 30 * 1000;
const aiSettingsCache = {
data: null,
timestamp: 0,
pending: null
};
const MODERATION_SETTINGS_CACHE_TTL = 5 * 60 * 1000;
const moderationSettingsCache = {
data: null,
timestamp: 0,
pending: null
};
const SPORTS_SCORING_DEFAULTS = {
threshold: 5,
weights: {
scoreline: 3,
scoreEmoji: 2,
sportEmoji: 2,
sportVerb: 1.5,
sportNoun: 2,
hashtag: 1.5,
teamToken: 2,
competition: 2,
celebration: 1,
location: 1
}
};
const DEFAULT_SPORT_TERMS = {
nouns: [
'auswärtssieg', 'heimsieg', 'derbysieg', 'revanche', 'spiel', 'spieltag', 'match', 'derby', 'finale',
'cup', 'pokal', 'liga', 'bundesliga', 'oberliga', 'kreisliga', 'bezirksliga', 'meisterschaft',
'turnier', 'halbzeit', 'tabellenplatz', 'tabelle', 'tor', 'tore', 'treffer', 'stadion', 'arena', 'halle',
'trainerteam', 'mannschaft', 'fans', 'fanblock', 'jugend', 'u17', 'u19', 'u15'
],
verbs: [
'gewinnen', 'siegen', 'geholt', 'erkämpfen', 'erkämpft', 'erkämpfen', 'drehen', 'punkten',
'trifft', 'treffen', 'schießt', 'schiesst', 'schießen', 'schiessen', 'verteidigen', 'stürmen', 'kämpfen'
],
competitions: [
'bundesliga', 'liga', 'serie a', 'premier league', 'champions league', 'europa league', 'dfb-pokal',
'cup', 'pokal', 'qualifikation', 'qualirunde', 'halbfinale', 'viertelfinale', 'achtelfinale', 'relegation'
],
celebrations: [
'sieg', 'siege', 'auswärtssieg', 'heimsieg', 'auswärtsspiel', 'punkte', 'punkte geholt', 'man of the match', 'motm',
'tabellenführung', 'tabellenplatz', 'tabellendritter', 'tabellenzweiter'
],
locations: [
'auswärts', 'heimspiel', 'derby', 'arena', 'stadion', 'halle', 'bolle', 'bölle', 'mosel'
],
negatives: [
'rezept', 'kochen', 'politik', 'wahl', 'bundestag', 'landtag', 'software', 'release', 'update', 'konzert',
'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung'
]
};
console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL);
function ensureTrackerActionsContainer(container) {
if (!container) {
return null;
}
const existingContainers = Array.from(container.querySelectorAll('.fb-tracker-actions-end'))
.filter((candidate) => candidate && candidate.closest('.fb-tracker-ui') === container);
let actionsContainer = existingContainers.length > 0 ? existingContainers[0] : null;
if (actionsContainer && actionsContainer.isConnected) {
existingContainers.slice(1).forEach((duplicate) => {
while (duplicate.firstChild) {
actionsContainer.appendChild(duplicate.firstChild);
}
duplicate.remove();
});
} else {
actionsContainer = null;
}
if (!actionsContainer) {
actionsContainer = document.createElement('div');
actionsContainer.className = 'fb-tracker-actions-end';
container.appendChild(actionsContainer);
}
// Pull any legacy/orphan action elements into the single canonical actions container.
const orphanActions = Array.from(
container.querySelectorAll('.fb-tracker-ai-wrapper, .fb-tracker-webapp-link')
).filter((node) => node && node.parentElement && node.parentElement !== actionsContainer);
orphanActions.forEach((node) => actionsContainer.appendChild(node));
const aiWrappers = Array.from(actionsContainer.querySelectorAll('.fb-tracker-ai-wrapper'));
aiWrappers.slice(1).forEach((duplicate) => duplicate.remove());
const webAppLinks = Array.from(actionsContainer.querySelectorAll('.fb-tracker-webapp-link'));
webAppLinks.slice(1).forEach((duplicate) => duplicate.remove());
actionsContainer.style.cssText = `
margin-left: auto;
margin-top: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
`;
return actionsContainer;
}
function backendFetch(url, options = {}) {
const config = {
...options,
credentials: 'include'
};
if (options && options.headers) {
config.headers = { ...options.headers };
}
return fetch(url, config);
}
async function fetchActiveAICredentials(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && aiCredentialCache.data && (now - aiCredentialCache.timestamp < AI_CREDENTIAL_CACHE_TTL)) {
return aiCredentialCache.data;
}
if (aiCredentialCache.pending) {
try {
return await aiCredentialCache.pending;
} catch (error) {
// fall through to retry below
}
}
aiCredentialCache.pending = (async () => {
const response = await backendFetch(`${API_URL}/ai-credentials`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'AI-Provider konnten nicht geladen werden');
}
const credentials = await response.json();
let active = Array.isArray(credentials)
? credentials.filter(entry => entry && Number(entry.is_active) === 1)
: [];
if (active.length === 0 && Array.isArray(credentials)) {
active = credentials.slice();
}
aiCredentialCache.data = active;
aiCredentialCache.timestamp = Date.now();
return active;
})();
try {
return await aiCredentialCache.pending;
} finally {
aiCredentialCache.pending = null;
}
}
async function fetchAISettings(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && aiSettingsCache.data && (now - aiSettingsCache.timestamp < AI_SETTINGS_CACHE_TTL)) {
return aiSettingsCache.data;
}
if (aiSettingsCache.pending) {
try {
return await aiSettingsCache.pending;
} catch (error) {
// fall through to retry below
}
}
aiSettingsCache.pending = (async () => {
const response = await backendFetch(`${API_URL}/ai-settings`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'AI-Einstellungen konnten nicht geladen werden');
}
const settings = await response.json();
aiSettingsCache.data = settings;
aiSettingsCache.timestamp = Date.now();
return settings;
})();
try {
return await aiSettingsCache.pending;
} finally {
aiSettingsCache.pending = null;
}
}
function getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber) {
const normalizedProfile = parseInt(profileNumber, 10);
if (!settings || !Number.isInteger(normalizedProfile)) {
return null;
}
const statuses = Array.isArray(settings.rate_limit_statuses) ? settings.rate_limit_statuses : [];
return statuses.find((entry) => parseInt(entry?.profile_number, 10) === normalizedProfile) || null;
}
function formatAIAutoCommentRateLimitReason(status) {
if (!status || !status.blocked_reason) {
return 'Aktion aktuell gesperrt';
}
const reasonMap = {
cooldown: 'Cooldown aktiv',
active_hours: 'Außerhalb der Aktivzeiten',
min_delay: 'Mindestabstand noch nicht erreicht',
per_minute: 'Minutenlimit erreicht',
burst: 'Burst-Limit erreicht',
per_hour: 'Stundenlimit erreicht',
per_day: 'Tageslimit erreicht'
};
return reasonMap[status.blocked_reason] || 'Aktion aktuell gesperrt';
}
function formatAIAutoCommentRateLimitUntil(iso) {
if (!iso) {
return '';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function normalizeModerationSettings(payload) {
if (!payload || typeof payload !== 'object') {
return {
sports_scoring_enabled: true,
sports_score_threshold: SPORTS_SCORING_DEFAULTS.threshold,
sports_score_weights: SPORTS_SCORING_DEFAULTS.weights,
sports_terms: DEFAULT_SPORT_TERMS,
sports_auto_hide_enabled: false
};
}
const threshold = (() => {
const parsed = parseFloat(payload.sports_score_threshold);
if (Number.isNaN(parsed) || parsed < 0) {
return SPORTS_SCORING_DEFAULTS.threshold;
}
return Math.min(50, Math.max(0, parsed));
})();
const weightsSource = payload.sports_score_weights && typeof payload.sports_score_weights === 'object'
? payload.sports_score_weights
: {};
const normalizedWeights = { ...SPORTS_SCORING_DEFAULTS.weights };
for (const key of Object.keys(SPORTS_SCORING_DEFAULTS.weights)) {
const raw = weightsSource[key];
const parsed = typeof raw === 'number' ? raw : parseFloat(raw);
if (Number.isFinite(parsed)) {
normalizedWeights[key] = Math.max(0, Math.min(10, parsed));
}
}
const normalizeTerms = (terms) => {
const base = { ...DEFAULT_SPORT_TERMS };
const src = terms && typeof terms === 'object' ? terms : {};
const normalizeList = (list, fallback) => {
if (!Array.isArray(list)) return fallback;
const cleaned = list
.map((entry) => typeof entry === 'string' ? entry.trim().toLowerCase() : '')
.filter((entry) => entry);
const unique = Array.from(new Set(cleaned)).slice(0, 200);
return unique.length ? unique : fallback;
};
return {
nouns: normalizeList(src.nouns, base.nouns),
verbs: normalizeList(src.verbs, base.verbs),
competitions: normalizeList(src.competitions, base.competitions),
celebrations: normalizeList(src.celebrations, base.celebrations),
locations: normalizeList(src.locations, base.locations),
negatives: normalizeList(src.negatives, base.negatives)
};
};
return {
sports_scoring_enabled: payload.sports_scoring_enabled !== false,
sports_score_threshold: threshold,
sports_score_weights: normalizedWeights,
sports_terms: normalizeTerms(payload.sports_terms),
sports_auto_hide_enabled: !!payload.sports_auto_hide_enabled
};
}
async function fetchModerationSettings(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && moderationSettingsCache.data && (now - moderationSettingsCache.timestamp < MODERATION_SETTINGS_CACHE_TTL)) {
return moderationSettingsCache.data;
}
if (moderationSettingsCache.pending) {
try {
return await moderationSettingsCache.pending;
} catch (error) {
// fallthrough to retry
}
}
moderationSettingsCache.pending = (async () => {
const response = await backendFetch(`${API_URL}/moderation-settings`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Moderations-Einstellungen konnten nicht geladen werden');
}
const data = await response.json();
const normalized = normalizeModerationSettings(data);
moderationSettingsCache.data = normalized;
moderationSettingsCache.timestamp = Date.now();
return normalized;
})();
try {
return await moderationSettingsCache.pending;
} finally {
moderationSettingsCache.pending = null;
}
}
function formatAICredentialLabel(credential) {
if (!credential || typeof credential !== 'object') {
return 'Unbekannte AI';
}
const name = (credential.name || '').trim();
const provider = (credential.provider || '').trim();
const model = (credential.model || '').trim();
if (name) {
if (provider && model) {
return `${name} · ${provider}/${model}`;
}
if (provider) {
return `${name} · ${provider}`;
}
if (model) {
return `${name} · ${model}`;
}
return name;
}
if (provider && model) {
return `${provider}/${model}`;
}
if (model) {
return model;
}
if (provider) {
return provider;
}
return 'AI Provider';
}
document.addEventListener('selectionchange', () => {
if (selectionCacheTimeout) {
clearTimeout(selectionCacheTimeout);
}
selectionCacheTimeout = setTimeout(() => {
cacheCurrentSelection();
selectionCacheTimeout = null;
}, 50);
});
// Profile state helpers
async function fetchBackendProfileNumber() {
try {
const response = await backendFetch(`${API_URL}/profile-state`);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data && typeof data.profile_number !== 'undefined') {
const parsed = parseInt(data.profile_number, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
} catch (error) {
console.warn('[FB Tracker] Failed to fetch profile state from backend:', error);
}
return null;
}
function extractAuthorName(postElement) {
if (!postElement) {
return null;
}
const selectors = [
'h2 strong a[role="link"]',
'h2 span a[role="link"]',
'a[role="link"][tabindex="0"] strong',
'a[role="link"][tabindex="0"]'
];
for (const selector of selectors) {
const node = postElement.querySelector(selector);
if (node && node.textContent) {
const text = node.textContent.trim();
if (text) {
return text;
}
}
}
const ariaLabelNode = postElement.querySelector('[aria-label]');
if (ariaLabelNode) {
const ariaLabel = ariaLabelNode.getAttribute('aria-label');
if (ariaLabel && ariaLabel.trim()) {
return ariaLabel.trim();
}
}
return null;
}
async function getProfileNumber() {
try {
const backendProfile = await fetchBackendProfileNumber();
if (backendProfile) {
console.log('[FB Tracker] Profile number (backend):', backendProfile);
return backendProfile;
}
} catch (error) {
console.warn('[FB Tracker] Failed to resolve profile number from backend:', error);
}
if (!profileSelectionNoticeShown) {
profileSelectionNoticeShown = true;
showToast('Bitte zuerst ein Profil im Tracker auswählen.', 'error');
}
return null;
}
// Extract post URL from post element
function cleanPostUrl(rawUrl) {
if (!rawUrl) {
return '';
}
const cftIndex = rawUrl.indexOf('__cft__');
let trimmed = cftIndex !== -1 ? rawUrl.slice(0, cftIndex) : rawUrl;
trimmed = trimmed.replace(/[?&]$/, '');
return trimmed;
}
function toAbsoluteFacebookUrl(rawUrl) {
if (!rawUrl) {
return null;
}
const cleaned = cleanPostUrl(rawUrl);
let url;
try {
url = new URL(cleaned);
} catch (error) {
try {
url = new URL(cleaned, window.location.origin);
} catch (innerError) {
return null;
}
}
const host = url.hostname.toLowerCase();
if (!host.endsWith('facebook.com')) {
return null;
}
return url;
}
function isValidFacebookPostUrl(url) {
if (!url) {
return false;
}
const path = url.pathname.toLowerCase();
const searchParams = url.searchParams;
const postPathPatterns = [
'/posts/',
'/permalink/',
'/photos/',
'/videos/',
'/reel/',
'/watch/'
];
if (postPathPatterns.some(pattern => path.includes(pattern))) {
return true;
}
if (path.startsWith('/photo') && searchParams.has('fbid')) {
return true;
}
if (path.startsWith('/photo.php') && searchParams.has('fbid')) {
return true;
}
if (path === '/permalink.php' && searchParams.has('story_fbid')) {
return true;
}
if (path.startsWith('/groups/') && searchParams.has('multi_permalinks')) {
return true;
}
// /watch/ URLs with video ID parameter
if (path.startsWith('/watch') && searchParams.has('v')) {
return true;
}
return false;
}
function formatFacebookPostUrl(url) {
if (!url) {
return '';
}
let search = url.search;
if (search.endsWith('?') || search.endsWith('&')) {
search = search.slice(0, -1);
}
return `${url.origin}${url.pathname}${search}`;
}
function extractPostUrlCandidate(rawUrl) {
const absoluteUrl = toAbsoluteFacebookUrl(rawUrl);
if (!absoluteUrl || !isValidFacebookPostUrl(absoluteUrl)) {
return '';
}
const formatted = formatFacebookPostUrl(absoluteUrl);
return normalizeFacebookPostUrl(formatted);
}
function getPostUrl(postElement, postNum = '?') {
console.log('[FB Tracker] Post #' + postNum + ' - Extracting URL from:', postElement);
const allCandidates = [];
// Strategy 1: Look for attribution links inside post
const attributionLinks = postElement.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]');
for (const link of attributionLinks) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
// Strategy 2: Look in parent elements (timestamp might be outside container)
let current = postElement;
for (let i = 0; i < 5 && current; i++) {
const parentLinks = current.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]');
for (const link of parentLinks) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
current = current.parentElement;
}
// Strategy 3: Check all links in post
const links = postElement.querySelectorAll('a[href]');
for (const link of links) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
// Prefer main post links over photo/video links
const mainPostLink = allCandidates.find(url =>
url.includes('/posts/') || url.includes('/permalink/') || url.includes('/permalink.php')
);
if (mainPostLink) {
console.log('[FB Tracker] Post #' + postNum + ' - Found main post URL:', mainPostLink, postElement);
return { url: mainPostLink, allCandidates, mainUrl: mainPostLink };
}
// Fallback to first candidate
if (allCandidates.length > 0) {
console.log('[FB Tracker] Post #' + postNum + ' - Using first candidate URL:', allCandidates[0], postElement);
return { url: allCandidates[0], allCandidates, mainUrl: '' };
}
const fallbackCandidate = extractPostUrlCandidate(window.location.href);
if (fallbackCandidate) {
console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement);
return { url: fallbackCandidate, allCandidates: [fallbackCandidate], mainUrl: '' };
}
console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement);
return { url: '', allCandidates: [], mainUrl: '' };
}
function expandPhotoUrlHostVariants(url) {
if (typeof url !== 'string' || !url) {
return [];
}
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
if (!hostname.endsWith('facebook.com')) {
return [];
}
const pathname = parsed.pathname.toLowerCase();
if (!pathname.startsWith('/photo')) {
return [];
}
const search = parsed.search || '';
const protocol = parsed.protocol || 'https:';
const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com'];
const variants = [];
for (const candidateHost of hosts) {
if (candidateHost === hostname) {
continue;
}
const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`;
const normalizedVariant = normalizeFacebookPostUrl(candidateUrl);
if (
normalizedVariant
&& normalizedVariant !== url
&& !variants.includes(normalizedVariant)
) {
variants.push(normalizedVariant);
}
}
return variants;
} catch (error) {
return [];
}
}
async function fetchPostByUrl(url) {
const normalizedUrl = normalizeFacebookPostUrl(url);
if (!normalizedUrl) {
return null;
}
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(normalizedUrl)}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data && data.id ? data : null;
}
async function fetchPostById(postId) {
if (!postId) {
return null;
}
try {
const response = await backendFetch(`${API_URL}/posts`);
if (!response.ok) {
return null;
}
const posts = await response.json();
if (!Array.isArray(posts)) {
return null;
}
return posts.find(post => post && post.id === postId) || null;
} catch (error) {
return null;
}
}
async function buildSimilarityPayload(postElement) {
let postText = null;
try {
postText = extractPostText(postElement) || null;
} catch (error) {
console.debug('[FB Tracker] Failed to extract post text for similarity:', error);
}
const imageInfo = await getFirstPostImageInfo(postElement);
return {
postText,
firstImageHash: imageInfo.hash,
firstImageUrl: imageInfo.url
};
}
async function findSimilarPost({ url, postText, firstImageHash }) {
if (!url) {
return null;
}
if (!postText && !firstImageHash) {
return null;
}
try {
const payload = {
url,
post_text: postText || null,
first_image_hash: firstImageHash || null
};
const response = await backendFetch(`${API_URL}/posts/similar`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
return null;
}
const data = await response.json();
return data && data.match ? data : null;
} catch (error) {
console.warn('[FB Tracker] Similarity check failed:', error);
return null;
}
}
function shortenInline(text, maxLength = 64) {
if (!text) {
return '';
}
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 3)}...`;
}
function formatSimilarityLabel(similarity) {
if (!similarity || !similarity.match) {
return '';
}
const match = similarity.match;
const base = match.title || match.created_by_name || match.url || 'Beitrag';
const details = [];
if (similarity.similarity && typeof similarity.similarity.text === 'number') {
details.push(`Text ${Math.round(similarity.similarity.text * 100)}%`);
}
if (similarity.similarity && typeof similarity.similarity.image_distance === 'number') {
details.push(`Bild Δ${similarity.similarity.image_distance}`);
}
const detailText = details.length ? ` (${details.join(', ')})` : '';
return `Ähnlich zu: ${shortenInline(base, 64)}${detailText}`;
}
async function attachUrlToExistingPost(postId, urls, payload = {}) {
if (!postId) {
return false;
}
try {
const body = {
urls: Array.isArray(urls) ? urls : [],
skip_content_key_check: true
};
if (payload.firstImageHash) {
body.first_image_hash = payload.firstImageHash;
}
if (payload.firstImageUrl) {
body.first_image_url = payload.firstImageUrl;
}
const response = await backendFetch(`${API_URL}/posts/${postId}/urls`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
return response.ok;
} catch (error) {
console.warn('[FB Tracker] Failed to attach URL to existing post:', error);
return false;
}
}
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
async function checkPostStatus(postUrl, allUrlCandidates = []) {
try {
const normalizedUrl = normalizeFacebookPostUrl(postUrl);
if (!normalizedUrl) {
console.warn('[FB Tracker] Überspringe Statusabfrage, URL ungültig:', postUrl);
return null;
}
// Build list of URLs to check (primary + all candidates)
const urlsToCheck = [normalizedUrl];
console.log('[FB Tracker] Received candidates to check:', allUrlCandidates);
for (const candidate of allUrlCandidates) {
const normalized = normalizeFacebookPostUrl(candidate);
if (normalized && !urlsToCheck.includes(normalized)) {
urlsToCheck.push(normalized);
}
}
const photoHostVariants = [];
for (const candidateUrl of urlsToCheck) {
const variants = expandPhotoUrlHostVariants(candidateUrl);
for (const variant of variants) {
if (!urlsToCheck.includes(variant) && !photoHostVariants.includes(variant)) {
photoHostVariants.push(variant);
}
}
}
const allUrlsToCheck = photoHostVariants.length
? urlsToCheck.concat(photoHostVariants)
: urlsToCheck;
if (photoHostVariants.length) {
console.log('[FB Tracker] Added photo host variants for status check:', photoHostVariants);
}
console.log('[FB Tracker] Checking post status for URLs:', allUrlsToCheck);
let foundPost = null;
let foundUrl = null;
// Check each URL
for (const url of allUrlsToCheck) {
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`);
if (response.ok) {
const data = await response.json();
if (data && data.id) {
console.log('[FB Tracker] Post found with URL:', url, data);
foundPost = data;
foundUrl = url;
break;
} else {
console.log('[FB Tracker] URL not found in backend:', url);
}
} else {
console.log('[FB Tracker] Backend error for URL:', url, response.status);
}
}
// If post found and we have a better main post URL, update it
if (foundPost && foundUrl !== normalizedUrl) {
const isMainPostUrl = normalizedUrl.includes('/posts/') || normalizedUrl.includes('/permalink/');
const isPhotoUrl = foundUrl.includes('/photo');
if (isMainPostUrl && isPhotoUrl) {
console.log('[FB Tracker] Updating post URL from photo link to main post link:', foundUrl, '->', normalizedUrl);
await updatePostUrl(foundPost.id, normalizedUrl);
foundPost.url = normalizedUrl; // Update local copy
}
}
if (foundPost) {
const urlsForPersistence = allUrlsToCheck.filter(urlValue => urlValue && urlValue !== foundPost.url);
if (urlsForPersistence.length) {
await persistAlternatePostUrls(foundPost.id, urlsForPersistence);
}
return foundPost;
}
console.log('[FB Tracker] Post not tracked yet (checked', allUrlsToCheck.length, 'URLs)');
return null;
} catch (error) {
console.error('[FB Tracker] Error checking post status:', error);
return null;
}
}
async function recordSearchResultPost(primaryUrl, allUrlCandidates = [], options = {}) {
try {
if (!primaryUrl) {
return null;
}
const { skipIncrement = false, forceHide = false, sportsAutoHide = false } = options || {};
const payload = {
url: primaryUrl,
candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [],
skip_increment: !!skipIncrement,
force_hide: !!forceHide,
sports_auto_hide: !!sportsAutoHide
};
const response = await backendFetch(`${API_URL}/search-posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
console.warn('[FB Tracker] Failed to record search post occurrence:', response.status);
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error('[FB Tracker] Error recording search result post:', error);
return null;
}
}
// Update post URL
async function updatePostUrl(postId, newUrl) {
try {
const response = await backendFetch(`${API_URL}/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: newUrl })
});
if (response.ok) {
console.log('[FB Tracker] Post URL updated successfully');
return true;
} else {
console.error('[FB Tracker] Failed to update post URL:', response.status);
return false;
}
} catch (error) {
console.error('[FB Tracker] Error updating post URL:', error);
return false;
}
}
async function persistAlternatePostUrls(postId, urls = []) {
if (!postId || !Array.isArray(urls) || urls.length === 0) {
return;
}
const uniqueUrls = Array.from(new Set(urls.filter(url => typeof url === 'string' && url.trim())));
if (!uniqueUrls.length) {
return;
}
try {
await backendFetch(`${API_URL}/posts/${postId}/urls`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ urls: uniqueUrls })
});
} catch (error) {
console.debug('[FB Tracker] Persisting alternate URLs failed:', error);
}
}
// Add post to tracking
async function markPostChecked(postId, profileNumber, options = {}) {
try {
const ignoreOrder = options && options.ignoreOrder === true;
const returnError = options && options.returnError === true;
const allowOutOfOrderOnConflict = options && options.allowOutOfOrderOnConflict !== false;
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
profile_number: profileNumber,
ignore_order: ignoreOrder
})
});
if (response.ok) {
const data = await response.json();
console.log('[FB Tracker] Post marked as checked:', data);
return data;
}
if (response.status === 409) {
const payload = await response.json().catch(() => ({}));
const message = payload && payload.error ? payload.error : 'Beitrag kann aktuell nicht bestätigt werden.';
console.log('[FB Tracker] Post check blocked:', message);
if (!ignoreOrder && allowOutOfOrderOnConflict && Array.isArray(payload.missing_profiles) && payload.missing_profiles.length) {
return markPostChecked(postId, profileNumber, {
...options,
ignoreOrder: true,
returnError: true,
allowOutOfOrderOnConflict: false
});
}
return returnError ? { error: message, status: response.status } : null;
}
console.error('[FB Tracker] Failed to mark post as checked:', response.status);
return returnError
? { error: 'Beitrag konnte nicht bestätigt werden.', status: response.status }
: null;
} catch (error) {
console.error('[FB Tracker] Error marking post as checked:', error);
return (options && options.returnError)
? { error: 'Beitrag konnte nicht bestätigt werden.', status: 0 }
: null;
}
}
async function addPostToTracking(postUrl, targetCount, profileNumber, options = {}) {
try {
console.log('[FB Tracker] Adding post:', postUrl, 'Target:', targetCount, 'Profile:', profileNumber);
let createdByName = null;
if (options && options.postElement) {
createdByName = extractAuthorName(options.postElement) || null;
}
let postText = null;
if (options && typeof options.postText === 'string') {
postText = options.postText;
} else if (options && options.postElement) {
try {
postText = extractPostText(options.postElement) || null;
} catch (error) {
console.debug('[FB Tracker] Failed to extract post text:', error);
}
}
let deadlineIso = null;
if (options && typeof options.deadline === 'string' && options.deadline.trim()) {
const parsedDeadline = new Date(options.deadline.trim());
if (!Number.isNaN(parsedDeadline.getTime())) {
deadlineIso = parsedDeadline.toISOString();
}
}
const alternateCandidates = Array.isArray(options && options.candidates) ? options.candidates : [];
const normalizedUrl = normalizeFacebookPostUrl(postUrl);
if (!normalizedUrl) {
console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl);
return null;
}
const payload = {
url: normalizedUrl,
target_count: targetCount,
profile_number: profileNumber,
created_by_profile: profileNumber
};
if (alternateCandidates.length) {
payload.alternate_urls = alternateCandidates;
}
if (createdByName) {
payload.created_by_name = createdByName;
}
if (deadlineIso) {
payload.deadline_at = deadlineIso;
}
if (postText) {
payload.post_text = postText;
}
if (options && typeof options.firstImageHash === 'string' && options.firstImageHash.trim()) {
payload.first_image_hash = options.firstImageHash.trim();
}
if (options && typeof options.firstImageUrl === 'string' && options.firstImageUrl.trim()) {
payload.first_image_url = options.firstImageUrl.trim();
}
const response = await backendFetch(`${API_URL}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
console.log('[FB Tracker] Post added successfully:', data);
if (data && data.id) {
await captureAndUploadScreenshot(data.id, options.postElement || null);
}
return data;
} else {
console.error('[FB Tracker] Failed to add post:', response.status);
return null;
}
} catch (error) {
console.error('[FB Tracker] Error adding post:', error);
return null;
}
}
function normalizeButtonLabel(button) {
const aria = button.getAttribute('aria-label');
if (aria) {
return aria.trim().toLowerCase();
}
const title = button.getAttribute('title');
if (title) {
return title.trim().toLowerCase();
}
return (button.textContent || '').trim().toLowerCase();
}
const LIKE_LABEL_KEYWORDS = ['gefällt mir', 'like', 'mag ich'];
const COMMENT_LABEL_KEYWORDS = ['kommentieren', 'comment', 'kommentar', 'antwort hinterlassen'];
const SHARE_LABEL_KEYWORDS = ['teilen', 'share', 'senden', 'posten', 'profil posten'];
const REPLY_LABEL_KEYWORDS = ['antworten', 'reply'];
const LIKE_ROLE_KEYWORDS = ['gefällt mir', 'like'];
const COMMENT_ROLE_KEYWORDS = ['comment'];
const SHARE_ROLE_KEYWORDS = ['share'];
const REPLY_ROLE_KEYWORDS = ['reply'];
function matchesKeyword(label, keywords) {
return keywords.some((keyword) => label.includes(keyword));
}
function styleIndicatesLiked(styleValue) {
if (!styleValue || typeof styleValue !== 'string') {
return false;
}
const normalized = styleValue.toLowerCase();
return (
normalized.includes('reaction-like')
|| normalized.includes('#0866ff')
|| normalized.includes('rgb(8, 102, 255)')
|| normalized.includes('--reaction-like')
);
}
function elementIndicatesLiked(element) {
if (!element) {
return false;
}
const inlineStyle = (element.getAttribute('style') || '').trim();
if (styleIndicatesLiked(inlineStyle)) {
return true;
}
try {
const computed = window.getComputedStyle(element);
if (computed && computed.color && styleIndicatesLiked(computed.color)) {
return true;
}
} catch (error) {
console.debug('[FB Tracker] Unable to inspect computed style:', error);
}
return false;
}
function isPostLikedByCurrentUser(likeButton, postElement) {
const candidates = [];
if (likeButton) {
candidates.push(likeButton);
}
if (postElement) {
postElement.querySelectorAll('[data-ad-rendering-role*="gefällt" i], [aria-label*="gefällt" i]').forEach((node) => {
if (node && !candidates.includes(node)) {
candidates.push(node);
}
});
}
for (const candidate of candidates) {
if (!candidate) {
continue;
}
if (elementIndicatesLiked(candidate)) {
return true;
}
const styleTarget = candidate.matches('[data-ad-rendering-role*="gefällt" i]')
? candidate
: candidate.querySelector && candidate.querySelector('[data-ad-rendering-role*="gefällt" i]');
if (styleTarget) {
if (elementIndicatesLiked(styleTarget)) {
return true;
}
}
const styledDescendant = candidate.querySelector && candidate.querySelector('[style*="reaction-like"], [style*="#0866FF"], [style*="rgb(8, 102, 255)"], [style*="--reaction-like"]');
if (styledDescendant && elementIndicatesLiked(styledDescendant)) {
return true;
}
const pressedAncestor = candidate.closest && candidate.closest('[aria-pressed="true"]');
if (pressedAncestor && pressedAncestor !== candidate) {
return true;
}
const ariaPressed = candidate.getAttribute && candidate.getAttribute('aria-pressed');
if (ariaPressed && ariaPressed.toLowerCase() === 'true') {
return true;
}
const ariaLabel = (candidate.getAttribute && candidate.getAttribute('aria-label')) || '';
const buttonText = candidate.textContent || '';
const combined = `${ariaLabel} ${buttonText}`.toLowerCase();
const likedIndicators = ['gefällt dir', 'gefällt dir nicht mehr', 'unlike', 'remove like', 'nicht mehr gefällt'];
if (likedIndicators.some(indicator => combined.includes(indicator))) {
return true;
}
}
return false;
}
function hidePostElement(postElement) {
if (!postElement) {
return;
}
const postContainer = ensurePrimaryPostElement(postElement);
if (postContainer && postContainer.closest(DIALOG_ROOT_SELECTOR)) {
console.log('[FB Tracker] Skipping hide for dialog/modal context');
return;
}
if (postContainer && !isMainPost(postContainer, null)) {
console.log('[FB Tracker] Skipping hide for comment container');
return;
}
const removalSelectors = [
'div[role="complementary"]',
'[role="listitem"][aria-posinset]',
'div[aria-posinset]',
'div[data-ad-comet-feed-verbose-tracking]',
'div[data-ad-preview]',
'article[role="article"]',
'article'
];
let elementToRemove = null;
for (const selector of removalSelectors) {
const candidate = postElement.closest(selector);
if (candidate && candidate !== document.body && candidate !== document.documentElement) {
elementToRemove = candidate;
break;
}
}
if (!elementToRemove) {
elementToRemove = postElement;
}
let removalRoot = elementToRemove;
let parentForTimestampCheck = removalRoot.parentElement;
while (parentForTimestampCheck && parentForTimestampCheck !== document.body && parentForTimestampCheck !== document.documentElement) {
const siblings = Array.from(parentForTimestampCheck.children);
const nonRootSiblings = siblings.filter((sibling) => sibling !== removalRoot);
if (!nonRootSiblings.length) {
break;
}
const hasOnlyTimestampSiblings = nonRootSiblings.every((sibling) => isTimestampArtifactNode(sibling));
if (hasOnlyTimestampSiblings) {
removalRoot = parentForTimestampCheck;
parentForTimestampCheck = removalRoot.parentElement;
continue;
}
const hasTimestampSibling = nonRootSiblings.some((sibling) => isTimestampArtifactNode(sibling));
if (hasTimestampSibling && siblings.length <= 3) {
removalRoot = parentForTimestampCheck;
parentForTimestampCheck = removalRoot.parentElement;
continue;
}
break;
}
let parent = removalRoot.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
if (parent.childElementCount > 1) {
break;
}
if (parent.matches('[role="feed"], [role="main"], [role="region"], [data-pagelet], [data-testid="SEARCH_RESULT_CONTAINER"], #ssrb_feed_start')) {
break;
}
removalRoot = parent;
parent = parent.parentElement;
}
removalRoot.setAttribute('data-fb-tracker-hidden', '1');
const removalParent = removalRoot.parentElement;
if (removalParent) {
removalParent.removeChild(removalRoot);
let current = removalParent;
while (current && current !== document.body && current !== document.documentElement) {
if (current.childElementCount > 0) {
break;
}
const nextParent = current.parentElement;
if (!nextParent) {
break;
}
if (nextParent === document.body || nextParent === document.documentElement) {
current.remove();
break;
}
if (nextParent.childElementCount > 1) {
current.remove();
break;
}
current.remove();
current = nextParent;
}
} else {
removalRoot.style.display = 'none';
}
if (removalParent) {
cleanupDanglingSearchArtifacts(removalParent);
} else {
cleanupDanglingSearchArtifacts(document);
}
}
function collectButtonMeta(button) {
const textParts = [];
const roleParts = [];
const label = normalizeButtonLabel(button);
if (label) {
textParts.push(label);
}
const collectRole = (value) => {
if (!value) {
return;
}
const lower = value.toLowerCase();
roleParts.push(lower);
const tokens = lower.split(/[_\-\s]+/);
tokens.forEach((token) => {
if (token) {
roleParts.push(token);
}
});
};
collectRole(button.getAttribute('data-ad-rendering-role'));
const descendantRoles = button.querySelectorAll('[data-ad-rendering-role]');
descendantRoles.forEach((el) => {
collectRole(el.getAttribute('data-ad-rendering-role'));
if (el.textContent) {
textParts.push(normalizeButtonLabel(el));
}
});
return {
text: textParts.join(' '),
roles: roleParts.join(' ')
};
}
function buttonClassification(button) {
const meta = collectButtonMeta(button);
const text = meta.text;
const roles = meta.roles;
const combined = `${text} ${roles}`;
return {
isLike: matchesKeyword(combined, LIKE_LABEL_KEYWORDS) || matchesKeyword(roles, LIKE_ROLE_KEYWORDS),
isComment: matchesKeyword(combined, COMMENT_LABEL_KEYWORDS) || matchesKeyword(roles, COMMENT_ROLE_KEYWORDS),
isShare: matchesKeyword(combined, SHARE_LABEL_KEYWORDS) || matchesKeyword(roles, SHARE_ROLE_KEYWORDS),
isReply: matchesKeyword(combined, REPLY_LABEL_KEYWORDS) || matchesKeyword(roles, REPLY_ROLE_KEYWORDS)
};
}
function findShareButtonForArticle(article) {
const direct = article.querySelector('[data-ad-rendering-role="share_button"]');
if (direct) {
return direct;
}
const shareButtons = document.querySelectorAll('[data-ad-rendering-role="share_button"]');
for (const button of shareButtons) {
const owningArticle = button.closest('[role="article"]');
if (owningArticle === article) {
return button;
}
}
return null;
}
function hasInteractionButtons(container) {
if (!container) {
return false;
}
const buttons = container.querySelectorAll('[role="button"], button');
if (!buttons.length) {
return false;
}
let hasLike = false;
let hasComment = false;
let hasShare = false;
let hasReply = false;
for (const button of buttons) {
const info = buttonClassification(button);
if (info.isLike) {
hasLike = true;
}
if (info.isComment) {
hasComment = true;
}
if (info.isShare) {
hasShare = true;
}
if (info.isReply) {
hasReply = true;
}
}
if (hasLike || hasComment || hasShare) {
console.log('[FB Tracker] Container analysis', {
tag: container.tagName,
classes: container.className,
hasLike,
hasComment,
hasShare,
hasReply,
buttonCount: buttons.length
});
}
const interactionCount = [hasLike, hasComment, hasShare].filter(Boolean).length;
if (interactionCount === 0) {
return false;
}
if (hasShare) {
return true;
}
if (interactionCount >= 2) {
return true;
}
return !hasReply;
}
function findButtonBar(postElement) {
const shareAnchor = findShareButtonForArticle(postElement);
if (shareAnchor) {
let container = shareAnchor.closest('[role="button"]') || shareAnchor.closest('button');
for (let i = 0; i < 4 && container; i++) {
if (hasInteractionButtons(container)) {
console.log('[FB Tracker] Found button bar via share anchor');
return container;
}
container = container.parentElement;
}
}
// Gather all accessible buttons inside the article
const buttons = Array.from(postElement.querySelectorAll('[role="button"], button'));
const interactionButtons = buttons.filter((button) => {
const info = buttonClassification(button);
return info.isLike || info.isComment || info.isShare;
});
const anchorElements = [
postElement.querySelector('[data-ad-rendering-role="share_button"]'),
postElement.querySelector('[data-ad-rendering-role="comment_button"]'),
postElement.querySelector('[data-ad-rendering-role*="gefällt"]')
].filter(Boolean);
const candidates = new Set(interactionButtons);
anchorElements.forEach((el) => {
const buttonContainer = el.closest('[role="button"]') || el.closest('button');
if (buttonContainer) {
candidates.add(buttonContainer);
}
});
const seen = new Set();
for (const button of candidates) {
const info = buttonClassification(button);
console.log('[FB Tracker] Candidate button', {
label: normalizeButtonLabel(button),
hasLike: info.isLike,
hasComment: info.isComment,
hasShare: info.isShare,
hasReply: info.isReply,
classes: button.className
});
let current = button;
while (current && current !== postElement && current !== document.body) {
if (seen.has(current)) {
current = current.parentElement;
continue;
}
seen.add(current);
const hasBar = hasInteractionButtons(current);
if (hasBar) {
console.log('[FB Tracker] Found button bar');
return current;
}
current = current.parentElement;
}
}
let sibling = postElement.nextElementSibling;
for (let i = 0; i < 6 && sibling; i++) {
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
console.log('[FB Tracker] Found button bar via next sibling');
return sibling;
}
sibling = sibling.nextElementSibling;
}
sibling = postElement.previousElementSibling;
for (let i = 0; i < 3 && sibling; i++) {
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
console.log('[FB Tracker] Found button bar via previous sibling');
return sibling;
}
sibling = sibling.previousElementSibling;
}
let parent = postElement.parentElement;
for (let depth = 0; depth < 4 && parent; depth++) {
if (!seen.has(parent) && hasInteractionButtons(parent)) {
console.log('[FB Tracker] Found button bar via parent');
return parent;
}
let next = parent.nextElementSibling;
for (let i = 0; i < 4 && next; i++) {
if (!seen.has(next) && hasInteractionButtons(next)) {
console.log('[FB Tracker] Found button bar via parent sibling');
return next;
}
next = next.nextElementSibling;
}
let prev = parent.previousElementSibling;
for (let i = 0; i < 3 && prev; i++) {
if (!seen.has(prev) && hasInteractionButtons(prev)) {
console.log('[FB Tracker] Found button bar via parent previous sibling');
return prev;
}
prev = prev.previousElementSibling;
}
parent = parent.parentElement;
}
console.log('[FB Tracker] Button bar not found');
return null;
}
function findPostContainers() {
const containers = [];
const seen = new Set();
const candidateSelectors = [
'div[role="dialog"] article',
'div[role="dialog"] div[aria-posinset]',
'[data-pagelet*="FeedUnit"] article',
'div[role="main"] article',
'[data-visualcompletion="ignore-dynamic"] article',
'div[aria-posinset]',
'article[role="article"]',
'article',
'div[data-pagelet*="Reel"]',
'div[data-pagelet*="WatchFeed"]',
'div[data-pagelet*="QPE_PublisherStory"]'
];
if (isOnReelsPage()) {
candidateSelectors.push('div[role="complementary"]');
}
const candidateElements = document.querySelectorAll(candidateSelectors.join(', '));
candidateElements.forEach((element) => {
const container = ensurePrimaryPostElement(element);
if (!container) {
return;
}
if (seen.has(container)) {
return;
}
const buttonBar = findButtonBar(container);
if (!isMainPost(container, buttonBar)) {
return;
}
const likeButton = findLikeButtonWithin(container);
seen.add(container);
if (likeButton || isOnReelsPage()) {
containers.push({ container, likeButton, buttonBar: buttonBar || null });
}
});
return containers;
}
function findLikeButtonWithin(container) {
if (!container) {
return null;
}
const selectors = [
'[data-ad-rendering-role="gefällt"]',
'[data-ad-rendering-role="gefällt mir_button"]',
'[data-ad-rendering-role*="gefällt" i]',
'[aria-label*="gefällt" i]',
'[aria-label*="like" i]',
'div[role="button"][aria-pressed]'
];
for (const selector of selectors) {
const button = container.querySelector(selector);
if (button) {
return button;
}
}
return container.querySelector('div[role="button"]');
}
function findLikeButtonWithin(container) {
if (!container) {
return null;
}
const selectors = [
'[data-ad-rendering-role="gefällt"]',
'[data-ad-rendering-role="gefällt mir_button"]',
'[data-ad-rendering-role*="gefällt" i]',
'[aria-label*="gefällt" i]',
'[aria-label*="like" i]',
'[aria-pressed="true"]',
'div[role="button"]'
];
for (const selector of selectors) {
const button = container.querySelector(selector);
if (button) {
return button;
}
}
return null;
}
function captureScreenshot(screenshotRect) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'captureScreenshot', screenshotRect }, (response) => {
if (chrome.runtime.lastError) {
console.warn('[FB Tracker] Screenshot capture failed:', chrome.runtime.lastError.message);
resolve(null);
return;
}
if (!response || response.error) {
if (response && response.error) {
console.warn('[FB Tracker] Screenshot capture reported error:', response.error);
}
resolve(null);
return;
}
if (!response.imageData) {
console.warn('[FB Tracker] Screenshot capture returned no data');
resolve(null);
return;
}
(async () => {
try {
let result = response.imageData;
if (screenshotRect) {
const cropped = await cropScreenshot(result, screenshotRect);
if (cropped) {
result = cropped;
}
}
resolve(result);
} catch (error) {
console.warn('[FB Tracker] Screenshot processing failed:', error);
resolve(response.imageData);
}
})();
});
});
}
async function uploadScreenshot(postId, imageData) {
try {
const response = await backendFetch(`${API_URL}/posts/${postId}/screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ imageData })
});
if (!response.ok) {
console.warn('[FB Tracker] Screenshot upload failed:', response.status);
}
} catch (error) {
console.error('[FB Tracker] Screenshot upload error:', error);
}
}
async function captureAndUploadScreenshot(postId, postElement) {
const imageData = await captureElementScreenshot(postElement);
if (!imageData) {
return;
}
const optimized = await maybeDownscaleScreenshot(imageData);
await uploadScreenshot(postId, optimized);
}
async function captureElementScreenshot(element) {
if (!element) {
return await captureScreenshot();
}
const horizontalMargin = 32;
const verticalMargin = 96;
const maxSegments = 12;
const delayBetweenScrolls = 200;
const originalScrollX = window.scrollX;
const originalScrollY = window.scrollY;
const devicePixelRatio = window.devicePixelRatio || 1;
const stickyOffset = getStickyHeaderHeight();
const segments = [];
const elementRect = element.getBoundingClientRect();
const elementTop = elementRect.top + window.scrollY;
const elementBottom = elementRect.bottom + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
const startY = Math.max(0, elementTop - verticalMargin - stickyOffset);
const endY = Math.min(documentHeight, elementBottom + verticalMargin);
const baseDocTop = Math.max(0, elementTop - verticalMargin);
const restoreScrollPosition = () => {
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
if (document.documentElement) {
document.documentElement.scrollTop = originalScrollY;
document.documentElement.scrollLeft = originalScrollX;
}
if (document.body) {
document.body.scrollTop = originalScrollY;
document.body.scrollLeft = originalScrollX;
}
};
try {
let iteration = 0;
let targetScroll = startY;
while (iteration < maxSegments) {
iteration += 1;
window.scrollTo({ top: targetScroll, left: window.scrollX, behavior: 'auto' });
await delay(delayBetweenScrolls);
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const captureTop = Math.max(0, rect.top - verticalMargin - stickyOffset);
const captureBottom = Math.min(viewportHeight, rect.bottom + verticalMargin);
const captureHeight = captureBottom - captureTop;
if (captureHeight <= 0) {
break;
}
const captureRect = {
left: rect.left - horizontalMargin,
top: captureTop,
width: rect.width + horizontalMargin * 2,
height: captureHeight,
devicePixelRatio
};
const segmentData = await captureScreenshot(captureRect);
if (!segmentData) {
break;
}
const docTop = Math.max(0, window.scrollY + captureTop);
const docBottom = docTop + captureHeight;
segments.push({ data: segmentData, docTop, docBottom });
const reachedBottom = docBottom >= endY - 4;
if (reachedBottom) {
break;
}
const nextScroll = docBottom - Math.max(0, (viewportHeight - stickyOffset) * 0.5);
const maxScroll = Math.max(0, endY - viewportHeight);
targetScroll = Math.min(nextScroll, maxScroll);
if (targetScroll <= window.scrollY + 1) {
targetScroll = window.scrollY + Math.max(160, viewportHeight * 0.6);
}
if (targetScroll <= window.scrollY + 1 || targetScroll >= endY) {
break;
}
}
} finally {
restoreScrollPosition();
await delay(0);
restoreScrollPosition();
}
if (!segments.length) {
return await captureScreenshot();
}
const stitched = await stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop);
return stitched;
}
async function stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop) {
const images = [];
let maxDocBottom = baseDocTop;
for (const segment of segments) {
const img = await loadImage(segment.data);
if (!img) {
continue;
}
images.push({ img, docTop: segment.docTop, docBottom: segment.docBottom });
if (segment.docBottom > maxDocBottom) {
maxDocBottom = segment.docBottom;
}
}
if (!images.length) {
return null;
}
const width = images.reduce((max, item) => Math.max(max, item.img.width), 0);
const totalHeightPx = Math.max(1, Math.round((maxDocBottom - baseDocTop) * devicePixelRatio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = totalHeightPx;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, totalHeightPx);
for (const { img, docTop } of images) {
const offsetY = Math.round(Math.max(0, docTop - baseDocTop) * devicePixelRatio);
ctx.drawImage(img, 0, offsetY);
}
return canvas.toDataURL('image/jpeg', 0.85);
}
async function cropScreenshot(imageData, rect) {
if (!rect) {
return imageData;
}
try {
const image = await loadImage(imageData);
if (!image) {
return imageData;
}
const ratio = rect.devicePixelRatio || window.devicePixelRatio || 1;
const rawLeft = (rect.left || 0) * ratio;
const rawTop = (rect.top || 0) * ratio;
const rawWidth = (rect.width || image.width) * ratio;
const rawHeight = (rect.height || image.height) * ratio;
const rawRight = rawLeft + rawWidth;
const rawBottom = rawTop + rawHeight;
const left = Math.max(0, Math.floor(rawLeft));
const top = Math.max(0, Math.floor(rawTop));
const right = Math.min(image.width, Math.ceil(rawRight));
const bottom = Math.min(image.height, Math.ceil(rawBottom));
const width = Math.max(1, right - left);
const height = Math.max(1, bottom - top);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, left, top, width, height, 0, 0, width, height);
return canvas.toDataURL('image/jpeg', 0.85);
} catch (error) {
console.warn('[FB Tracker] Failed to crop screenshot:', error);
return imageData;
}
}
async function maybeDownscaleScreenshot(imageData) {
try {
const maxWidth = 1600;
const current = await loadImage(imageData);
if (!current) {
return imageData;
}
if (current.width <= maxWidth) {
return imageData;
}
const scale = maxWidth / current.width;
const canvas = document.createElement('canvas');
canvas.width = Math.round(current.width * scale);
canvas.height = Math.round(current.height * scale);
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(current, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', 0.8);
} catch (error) {
console.warn('[FB Tracker] Failed to downscale screenshot:', error);
return imageData;
}
}
function isLikelyPostImage(img) {
if (!img) {
return false;
}
const src = img.currentSrc || img.src || '';
if (!src) {
return false;
}
if (src.startsWith('data:')) {
return false;
}
const lowerSrc = src.toLowerCase();
if (lowerSrc.includes('emoji') || lowerSrc.includes('static.xx') || lowerSrc.includes('sprite')) {
return false;
}
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (width < 120 || height < 120) {
return false;
}
return true;
}
function waitForImageLoad(img, timeoutMs = 1500) {
return new Promise((resolve) => {
if (!img) {
resolve(false);
return;
}
if (img.complete && img.naturalWidth > 0) {
resolve(true);
return;
}
let resolved = false;
const finish = (value) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const timer = setTimeout(() => finish(false), timeoutMs);
img.addEventListener('load', () => {
clearTimeout(timer);
finish(true);
}, { once: true });
img.addEventListener('error', () => {
clearTimeout(timer);
finish(false);
}, { once: true });
});
}
function buildDHashFromPixels(imageData) {
if (!imageData || !imageData.data) {
return null;
}
const { data } = imageData;
const bits = [];
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 8; x += 1) {
const leftIndex = ((y * 9) + x) * 4;
const rightIndex = ((y * 9) + x + 1) * 4;
const left = 0.299 * data[leftIndex] + 0.587 * data[leftIndex + 1] + 0.114 * data[leftIndex + 2];
const right = 0.299 * data[rightIndex] + 0.587 * data[rightIndex + 1] + 0.114 * data[rightIndex + 2];
bits.push(left > right ? 1 : 0);
}
}
let hex = '';
for (let i = 0; i < bits.length; i += 4) {
const value = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3];
hex += value.toString(16);
}
return hex.padStart(16, '0');
}
async function computeDHashFromUrl(imageUrl) {
if (!imageUrl) {
return null;
}
try {
const response = await fetch(imageUrl);
if (!response.ok) {
return null;
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = 9;
canvas.height = 8;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.drawImage(bitmap, 0, 0, 9, 8);
const imageData = ctx.getImageData(0, 0, 9, 8);
return buildDHashFromPixels(imageData);
} catch (error) {
return null;
}
}
async function getFirstPostImageInfo(postElement) {
if (!postElement) {
return { hash: null, url: null };
}
const images = Array.from(postElement.querySelectorAll('img')).filter(isLikelyPostImage);
for (const img of images.slice(0, 5)) {
const loaded = await waitForImageLoad(img);
if (!loaded) {
continue;
}
const src = img.currentSrc || img.src;
const hash = await computeDHashFromUrl(src);
if (hash) {
return { hash, url: src };
}
}
return { hash: null, url: null };
}
function getStickyHeaderHeight() {
try {
const banner = document.querySelector('[role="banner"], header[role="banner"]');
if (!banner) {
return 0;
}
const rect = banner.getBoundingClientRect();
if (!rect || !rect.height) {
return 0;
}
return Math.min(rect.height, 160);
} catch (error) {
console.warn('[FB Tracker] Failed to determine sticky header height:', error);
return 0;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function loadImage(dataUrl) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}
function toDateTimeLocalString(date) {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function getNextDayDefaultDeadlineValue() {
const tomorrow = new Date();
tomorrow.setHours(0, 0, 0, 0);
tomorrow.setDate(tomorrow.getDate() + 1);
return toDateTimeLocalString(tomorrow);
}
function extractDeadlineFromPostText(postElement) {
if (!postElement) {
return null;
}
// Get all text content from the post
const textNodes = [];
const walker = document.createTreeWalker(
postElement,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.trim()) {
textNodes.push(node.textContent.trim());
}
}
const fullText = textNodes.join(' ');
const normalizedText = fullText
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\S\r\n]+/g, ' ')
.replace(/(\d)\s*([.:])\s*(\d)/g, '$1$2$3');
const normalizedLower = normalizedText.toLowerCase();
const today = new Date();
today.setHours(0, 0, 0, 0);
const monthNames = {
'januar': 1, 'jan': 1,
'februar': 2, 'feb': 2,
'märz': 3, 'mär': 3, 'maerz': 3, 'marz': 3,
'april': 4, 'apr': 4,
'mai': 5,
'juni': 6, 'jun': 6,
'juli': 7, 'jul': 7,
'august': 8, 'aug': 8,
'september': 9, 'sep': 9, 'sept': 9,
'oktober': 10, 'okt': 10,
'november': 11, 'nov': 11,
'dezember': 12, 'dez': 12
};
// German date patterns
const patterns = [
// DD.MM.YYYY or DD.MM.YY (with optional time like ", 23:59Uhr")
/\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})(?!\d)/g,
// DD.MM (without year, optional trailing time)
/\b(\d{1,2})\.(\d{1,2})\.(?!\d)/g
];
const extractTimeAfterIndex = (text, index) => {
const tail = text.slice(index, index + 80);
if (/^\s*(?:-||—|bis)\s*\d{1,2}\.\d{1,2}\./i.test(tail)) {
return null;
}
const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:/|-]|\b)?\s*(?:um|ab|bis|gegen|spaetestens|spatestens|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail);
if (!timeMatch) {
return null;
}
const hour = parseInt(timeMatch[1], 10);
const minute = typeof timeMatch[2] === 'string' && timeMatch[2].length
? parseInt(timeMatch[2], 10)
: 0;
if (Number.isNaN(hour) || Number.isNaN(minute)) {
return null;
}
if (hour === 24 && minute === 0) {
return { hour: 23, minute: 59 };
}
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return { hour, minute };
};
const hasInclusiveKeywordNear = (text, index) => {
const windowStart = Math.max(0, index - 40);
const windowText = text.slice(windowStart, index).toLowerCase();
return /\b(einschlie(?:ß|ss)lich|einschl\.?|inklusive|inkl\.)\b/.test(windowText);
};
const hasDeadlineKeywordNear = (text, index) => {
const windowStart = Math.max(0, index - 50);
const windowText = text.slice(windowStart, index).toLowerCase();
return /\b(bis|spaetestens|spatestens|spätestens|teilnahmeschluss|einsendeschluss|anmeldeschluss|anmeldefrist|abgabeschluss|stichtag)\b/.test(windowText);
};
const addCandidateDate = (date, hasTime) => {
if (date > today) {
foundDates.push({ date, hasTime });
}
};
const weekdayNames = {
montag: 1,
dienstag: 2,
mittwoch: 3,
donnerstag: 4,
freitag: 5,
samstag: 6,
sonntag: 0
};
const getNextWeekdayDate = (weekday, preferNext) => {
const base = new Date(today);
let delta = (weekday - base.getDay() + 7) % 7;
if (delta === 0 && preferNext) {
delta = 7;
}
base.setDate(base.getDate() + delta);
return base;
};
const foundDates = [];
const rangePatterns = [
// DD.MM.YYYY - DD.MM.YYYY
/\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\s*(?:-||—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i,
// DD.DD.MM.YYYY (start day only)
/\b(\d{1,2})\.\s*(?:-||—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i,
// vom DD.MM bis DD.MM.YYYY
/\bvom\s*(\d{1,2})\.(\d{1,2})\.?\s*(?:-||—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i
];
for (const rangePattern of rangePatterns) {
const rangeMatch = rangePattern.exec(normalizedText);
if (!rangeMatch) {
continue;
}
const endDay = parseInt(rangeMatch[rangeMatch.length - 3], 10);
const endMonth = parseInt(rangeMatch[rangeMatch.length - 2], 10);
let endYear = parseInt(rangeMatch[rangeMatch.length - 1], 10);
if (endYear < 100) {
endYear += 2000;
}
if (endMonth >= 1 && endMonth <= 12 && endDay >= 1 && endDay <= 31) {
const endDate = new Date(endYear, endMonth - 1, endDay, 23, 59, 0, 0);
if (endDate.getMonth() === endMonth - 1 && endDate.getDate() === endDay && endDate > today) {
return toDateTimeLocalString(endDate);
}
}
}
// Weekday-only patterns like "bis nächsten Mittwoch" or "Sonntag, 23:59 Uhr"
const weekdayPattern = /\b(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b/gi;
let weekdayMatch;
while ((weekdayMatch = weekdayPattern.exec(normalizedLower)) !== null) {
const weekdayKey = weekdayMatch[1].toLowerCase();
const weekday = weekdayNames[weekdayKey];
if (typeof weekday !== 'number') {
continue;
}
const matchIndex = weekdayMatch.index;
const windowStart = Math.max(0, matchIndex - 30);
const windowText = normalizedLower.slice(windowStart, matchIndex);
const preferNext = /\b(naechst|nachst|kommend)\b/.test(windowText);
const date = getNextWeekdayDate(weekday, preferNext);
const timeInfo = extractTimeAfterIndex(normalizedText, weekdayPattern.lastIndex);
const hasTime = Boolean(timeInfo);
if (timeInfo) {
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
} else if (hasInclusiveKeywordNear(normalizedText, matchIndex) || hasDeadlineKeywordNear(normalizedText, matchIndex)) {
date.setHours(23, 59, 0, 0);
}
const recordHasTime = hasTime || hasInclusiveKeywordNear(normalizedText, matchIndex) || hasDeadlineKeywordNear(normalizedText, matchIndex);
addCandidateDate(date, recordHasTime);
}
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(normalizedText)) !== null) {
const day = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
let year = match[3] ? parseInt(match[3], 10) : today.getFullYear();
const matchIndex = match.index;
// Handle 2-digit years
if (year < 100) {
year += 2000;
}
// Validate date
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
// Check if date is valid (e.g., not 31.02.)
if (date.getMonth() === month - 1 && date.getDate() === day) {
const timeInfo = extractTimeAfterIndex(normalizedText, pattern.lastIndex);
const hasTime = Boolean(timeInfo);
if (timeInfo) {
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
} else if (hasInclusiveKeywordNear(normalizedText, matchIndex) || hasDeadlineKeywordNear(normalizedText, matchIndex)) {
date.setHours(23, 59, 0, 0);
}
const hasInclusiveTime = hasInclusiveKeywordNear(normalizedText, matchIndex);
const hasDeadlineTime = hasDeadlineKeywordNear(normalizedText, matchIndex);
const recordHasTime = hasTime || hasInclusiveTime || hasDeadlineTime;
if (hasInclusiveTime && !hasTime) {
date.setHours(23, 59, 0, 0);
}
addCandidateDate(date, recordHasTime);
}
}
}
}
// Pattern for "12. Oktober" or "12 Oktober"
const monthPattern = /\b(\d{1,2})\.?\s*(januar|jan|februar|feb|märz|mär|maerz|marz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\s*(\d{2,4})?\b/gi;
let monthMatch;
while ((monthMatch = monthPattern.exec(normalizedText)) !== null) {
const day = parseInt(monthMatch[1], 10);
const monthStr = monthMatch[2].toLowerCase();
const month = monthNames[monthStr];
let year = today.getFullYear();
if (monthMatch[3]) {
year = parseInt(monthMatch[3], 10);
if (year < 100) {
year += 2000;
}
}
const matchIndex = monthMatch.index;
if (month && day >= 1 && day <= 31) {
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
// Check if date is valid
if (date.getMonth() === month - 1 && date.getDate() === day) {
const timeInfo = extractTimeAfterIndex(normalizedText, monthPattern.lastIndex);
const hasTime = Boolean(timeInfo);
if (timeInfo) {
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
} else if (hasInclusiveKeywordNear(normalizedText, matchIndex) || hasDeadlineKeywordNear(normalizedText, matchIndex)) {
date.setHours(23, 59, 0, 0);
}
const hasInclusiveTime = hasInclusiveKeywordNear(normalizedText, matchIndex);
const hasDeadlineTime = hasDeadlineKeywordNear(normalizedText, matchIndex);
const recordHasTime = hasTime || hasInclusiveTime || hasDeadlineTime;
if (hasInclusiveTime && !hasTime) {
date.setHours(23, 59, 0, 0);
}
// If date has passed this year, assume next year
if (date <= today) {
date.setFullYear(year + 1);
}
addCandidateDate(date, recordHasTime);
}
}
}
// Prefer dates with explicit time; otherwise fall back to earliest date.
if (foundDates.length > 0) {
const withTime = foundDates.filter((entry) => entry.hasTime);
const candidates = withTime.length ? withTime : foundDates;
candidates.sort((a, b) => a.date - b.date);
return toDateTimeLocalString(candidates[0].date);
}
return null;
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collectKeywordMatches(keywords, text, limit = 20) {
if (!Array.isArray(keywords) || !keywords.length || !text) {
return [];
}
const found = [];
for (const keyword of keywords) {
if (!keyword) continue;
const pattern = new RegExp(`\\b${escapeRegex(keyword)}\\b`, 'gi');
const matches = text.match(pattern);
if (matches && matches.length) {
found.push(keyword);
if (found.length >= limit) {
break;
}
}
}
return Array.from(new Set(found));
}
function collectRegexMatches(regex, text, limit = 20) {
if (!regex || !(regex instanceof RegExp) || !text) {
return [];
}
const matches = Array.from(text.matchAll(regex)).map((m) => m[0]);
if (!matches.length) {
return [];
}
return Array.from(new Set(matches)).slice(0, limit);
}
function filterScorelines(candidates = [], sourceText = '') {
const filtered = [];
const lowerSource = typeof sourceText === 'string' ? sourceText.toLowerCase() : '';
for (const raw of candidates) {
const value = typeof raw === 'string' ? raw : (raw && raw.value) || '';
const index = typeof raw === 'string' ? -1 : (raw && typeof raw.index === 'number' ? raw.index : -1);
const parts = value.split(':').map((part) => part.trim());
if (parts.length !== 2) {
continue;
}
const [a, b] = parts.map((p) => parseInt(p, 10));
if (Number.isNaN(a) || Number.isNaN(b)) {
continue;
}
if (a < 0 || b < 0) {
continue;
}
if (a > 15 || b > 15) {
continue;
}
if (index >= 0 && lowerSource) {
const contextStart = Math.max(0, index - 12);
const contextEnd = Math.min(lowerSource.length, index + value.length + 8);
const context = lowerSource.slice(contextStart, contextEnd);
const before = lowerSource.slice(Math.max(0, index - 6), index);
const hasTimeIndicatorBefore = /\bum\s*$/.test(before);
const hasTimeIndicatorAfter = /\buhr/.test(context);
if (hasTimeIndicatorBefore || hasTimeIndicatorAfter) {
continue;
}
}
filtered.push(`${a}:${b}`);
}
return filtered;
}
function evaluateSportsScore(text, moderationSettings = null) {
if (!text || typeof text !== 'string') {
return null;
}
const normalizedText = text.toLowerCase();
const weights = {
...SPORTS_SCORING_DEFAULTS.weights,
...(moderationSettings && moderationSettings.sports_score_weights ? moderationSettings.sports_score_weights : {})
};
const threshold = moderationSettings && typeof moderationSettings.sports_score_threshold === 'number'
? moderationSettings.sports_score_threshold
: SPORTS_SCORING_DEFAULTS.threshold;
const terms = (() => {
const base = DEFAULT_SPORT_TERMS;
const incoming = moderationSettings && moderationSettings.sports_terms ? moderationSettings.sports_terms : null;
const normalizeList = (list, fallback) => {
if (!Array.isArray(list)) return fallback;
const cleaned = list
.map((entry) => typeof entry === 'string' ? entry.trim().toLowerCase() : '')
.filter((entry) => entry);
const unique = Array.from(new Set(cleaned)).slice(0, 200);
return unique.length ? unique : fallback;
};
const src = incoming && typeof incoming === 'object' ? incoming : {};
return {
nouns: normalizeList(src.nouns, base.nouns),
verbs: normalizeList(src.verbs, base.verbs),
competitions: normalizeList(src.competitions, base.competitions),
celebrations: normalizeList(src.celebrations, base.celebrations),
locations: normalizeList(src.locations, base.locations),
negatives: normalizeList(src.negatives, base.negatives)
};
})();
const matchesCount = (regex) => {
if (!regex || !(regex instanceof RegExp)) {
return 0;
}
const matches = normalizedText.match(regex);
return matches ? matches.length : 0;
};
const applyWeight = (count, weight, label, matches = []) => {
if (!count || !weight) {
return 0;
}
const effective = Math.min(count, 5);
const gain = effective * weight;
if (matches && matches.length) {
hitDetails.push(`${label}: ${matches.slice(0, 10).join(', ')}`);
} else {
hitDetails.push(`${label} x${effective} (+${gain.toFixed(1)})`);
}
score += gain;
return gain;
};
const hitDetails = [];
let score = 0;
const scorelineMatchesRaw = Array.from(normalizedText.matchAll(/\b\d{1,2}\s*:\s*\d{1,2}\b/g))
.map((match) => ({
value: match[0],
index: typeof match.index === 'number' ? match.index : -1
}));
const scorelineMatches = filterScorelines(scorelineMatchesRaw, normalizedText);
applyWeight(scorelineMatches.length, weights.scoreline, 'Ergebnis', scorelineMatches);
const scoreEmojiMatches = collectRegexMatches(/[0-9]️⃣/g, normalizedText)
.concat(collectRegexMatches(/\+\s*\d\b/g, normalizedText));
applyWeight(scoreEmojiMatches.length, weights.scoreEmoji, 'Punkte', scoreEmojiMatches);
const sportEmojiMatches = collectRegexMatches(/[⚽🏐🏀🏈🎾🏉🥅🏒🏑🏓🏸🤾🏏🎽🎳🥊🥋⛳]/g, normalizedText);
applyWeight(sportEmojiMatches.length, weights.sportEmoji, 'Sport-Emoji', sportEmojiMatches);
const verbMatches = collectKeywordMatches(terms.verbs, normalizedText);
applyWeight(verbMatches.length, weights.sportVerb, 'Sport-Verben', verbMatches);
const nounMatches = collectKeywordMatches(terms.nouns, normalizedText);
applyWeight(nounMatches.length, weights.sportNoun, 'Sport-Vokabeln', nounMatches);
const hashtagMatches = collectRegexMatches(/#(?:auswärtssieg|heimsieg|derbysieg|bundesliga|liga|pokal|cup|fc[a-z0-9]+|sv[a-z0-9]+|tsv[a-z0-9]+|sg[a-z0-9]+)/g, normalizedText);
applyWeight(hashtagMatches.length, weights.hashtag, 'Sport-Hashtags', hashtagMatches);
const teamMatches = collectRegexMatches(/\b(?:fc|sv|tsv|ssv|bvb|sge|fcb|hsv|vfb|fsv|sg|scl|djk)[\s\-]?[a-zäöüß0-9]+/gi, normalizedText);
applyWeight(teamMatches.length, weights.teamToken, 'Team-Kürzel', teamMatches);
const competitionMatches = collectKeywordMatches(terms.competitions, normalizedText);
applyWeight(competitionMatches.length, weights.competition, 'Liga/Turnier', competitionMatches);
const celebrationMatches = collectKeywordMatches(terms.celebrations, normalizedText);
applyWeight(celebrationMatches.length, weights.celebration, 'Ergebnisbezug', celebrationMatches);
const locationMatches = collectKeywordMatches(terms.locations, normalizedText);
applyWeight(locationMatches.length, weights.location, 'Spielort', locationMatches);
const nonSportMatches = collectKeywordMatches(terms.negatives, normalizedText);
const nonSportHits = nonSportMatches.length;
if (nonSportHits) {
const penalty = Math.min(2, nonSportHits) * 1;
score -= penalty;
hitDetails.push(`Gegenindizien: ${nonSportMatches.slice(0, 10).join(', ')}`);
}
const finalScore = Math.round(score * 10) / 10;
return {
score: finalScore,
threshold,
wouldHide: finalScore >= threshold,
hits: hitDetails
};
}
function buildSportsScoreBadge(scoreInfo) {
if (!scoreInfo) {
return null;
}
// Show badge only for strictly positive scores; hide zero/negative
if (typeof scoreInfo.score !== 'number' || scoreInfo.score <= 0) {
return null;
}
const badge = document.createElement('span');
const wouldHide = !!scoreInfo.wouldHide;
const bg = wouldHide ? 'rgba(245, 158, 11, 0.18)' : 'rgba(59, 130, 246, 0.12)';
const border = wouldHide ? 'rgba(245, 158, 11, 0.5)' : 'rgba(59, 130, 246, 0.35)';
const color = wouldHide ? '#b45309' : '#1d4ed8';
badge.className = 'fb-tracker-score-badge';
badge.textContent = `Sport-Score ${scoreInfo.score.toFixed(1)} / ${scoreInfo.threshold}`;
if (scoreInfo.hits && scoreInfo.hits.length) {
const lines = scoreInfo.hits.map((hit) => `${hit}`).join('\n');
badge.title = `${lines}\n${wouldHide ? '≥ Schwellwert' : '< Schwellwert'}`;
} else {
badge.title = wouldHide ? 'Über Schwellwert' : 'Unter Schwellwert';
}
badge.style.cssText = `
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: ${bg};
border: 1px solid ${border};
border-radius: 999px;
font-weight: 600;
color: ${color};
font-size: 12px;
`;
return badge;
}
function normalizeFacebookPostUrl(rawValue) {
if (typeof rawValue !== 'string') {
return '';
}
let value = rawValue.trim();
if (!value) {
return '';
}
const trackingIndex = value.indexOf('__cft__');
if (trackingIndex !== -1) {
value = value.slice(0, trackingIndex);
}
value = value.replace(/[?&]$/, '');
let parsed;
try {
parsed = new URL(value);
} catch (error) {
try {
parsed = new URL(value, 'https://www.facebook.com');
} catch (fallbackError) {
return '';
}
}
if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) {
return '';
}
const normalizedPathBeforeTrim = parsed.pathname.replace(/\/+$/, '') || '/';
const lowerPathBeforeTrim = normalizedPathBeforeTrim.toLowerCase();
const watchId = parsed.searchParams.get('v') || parsed.searchParams.get('video_id');
if ((lowerPathBeforeTrim === '/watch' || lowerPathBeforeTrim === '/video.php') && watchId) {
parsed.pathname = `/reel/${watchId}/`;
parsed.search = '';
} else {
const reelMatch = lowerPathBeforeTrim.match(/^\/reel\/([^/]+)$/);
if (reelMatch) {
parsed.pathname = `/reel/${reelMatch[1]}/`;
parsed.search = '';
}
}
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase();
const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
if (
lowerKey.startsWith('__cft__')
|| lowerKey.startsWith('__tn__')
|| lowerKey.startsWith('__eep__')
|| lowerKey.startsWith('mibextid')
|| lowerKey === 'set'
|| lowerKey === 'comment_id'
|| lowerKey === 'hoisted_section_header_type'
|| isSingleUnitParam
) {
return;
}
cleanedParams.append(paramKey, paramValue);
});
const multiPermalinkId = cleanedParams.get('multi_permalinks');
if (multiPermalinkId) {
cleanedParams.delete('multi_permalinks');
const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/);
if (groupMatch && multiPermalinkId.match(/^[0-9]+$/)) {
parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`;
} else if (groupMatch) {
parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`;
}
}
const normalizedPath = parsed.pathname.replace(/\/+$/, '').toLowerCase();
if (normalizedPath.startsWith('/hashtag/') || normalizedPath.startsWith('/watch/hashtag/')) {
return '';
}
const search = cleanedParams.toString();
const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`;
return formatted.replace(/[?&]$/, '');
}
async function renderTrackedStatus({
container,
postElement,
postData,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
}) {
if (!postData) {
container.innerHTML = '';
return { hidden: false };
}
const parsedProfile = parseInt(profileNumber, 10);
const effectiveProfileNumber = Number.isNaN(parsedProfile) ? null : parsedProfile;
if (postData.id) {
container.dataset.postId = postData.id;
}
const checks = Array.isArray(postData.checks) ? postData.checks : [];
const checkedCount = postData.checked_count ?? checks.length;
const targetTotal = postData.target_count || checks.length || 0;
const statusText = `${checkedCount}/${targetTotal}`;
const completed = checkedCount >= targetTotal && targetTotal > 0;
const lastCheck = checks.length
? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })
: null;
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
const requiredProfiles = Array.isArray(postData.required_profiles) && postData.required_profiles.length
? postData.required_profiles
.map((value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return null;
}
return Math.min(5, Math.max(1, parsed));
})
.filter(Boolean)
: Array.from({ length: Math.max(1, Math.min(5, parseInt(postData.target_count, 10) || 1)) }, (_, index) => index + 1);
const isCurrentProfileRequired = effectiveProfileNumber !== null && requiredProfiles.includes(effectiveProfileNumber);
const parsedNextRequired = parseInt(postData.next_required_profile, 10);
const nextRequiredProfile = Number.isNaN(parsedNextRequired) ? null : parsedNextRequired;
const canCurrentProfileCheck = isCurrentProfileRequired && nextRequiredProfile === effectiveProfileNumber;
const isCurrentProfileDone = effectiveProfileNumber !== null
&& checks.some(check => check.profile_number === effectiveProfileNumber);
if (isFeedHome && isCurrentProfileDone) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)');
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number'
? manualHideInfo.seen_count
: null
});
return { hidden: true };
}
}
const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : '';
let statusHtml = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}${expiredText}
</div>
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
`;
if (!isExpired && !completed && !isCurrentProfileDone && isCurrentProfileRequired) {
const checkButtonEnabled = canCurrentProfileCheck;
const buttonColor = checkButtonEnabled ? '#42b72a' : '#f39c12';
const cursorStyle = 'pointer';
const buttonTitle = checkButtonEnabled
? 'Beitrag bestätigen'
: 'Wartet auf vorherige Profile';
statusHtml += `
<button class="fb-tracker-check-btn" title="${buttonTitle}" style="
padding: 4px 12px;
background-color: ${buttonColor};
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: ${cursorStyle};
font-size: 13px;
white-space: nowrap;
margin-left: 8px;
">
✓ Bestätigen
</button>
`;
} else if (!isExpired && !completed && !isCurrentProfileDone && effectiveProfileNumber === null) {
statusHtml += `
<button class="fb-tracker-check-btn" title="Profilstatus nicht geladen" style="
padding: 4px 12px;
background-color: #9ca3af;
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: not-allowed;
font-size: 13px;
white-space: nowrap;
margin-left: 8px;
" disabled>
✓ Bestätigen
</button>
`;
} else if (isCurrentProfileDone) {
statusHtml += `
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
✓ Von dir bestätigt
</div>
`;
}
container.innerHTML = statusHtml;
if (postData.id) {
const actionsContainer = ensureTrackerActionsContainer(container);
if (actionsContainer) {
const webAppUrl = (() => {
try {
const baseUrl = `${WEBAPP_BASE_URL}/`;
const url = new URL('', baseUrl);
url.searchParams.set('tab', 'all');
url.searchParams.set('postId', String(postData.id));
if (postData.url) {
url.searchParams.set('postUrl', postData.url);
}
return url.toString();
} catch (error) {
console.debug('[FB Tracker] Failed to build WebApp URL, falling back to base:', error);
return `${WEBAPP_BASE_URL}/?tab=all`;
}
})();
let webAppLink = actionsContainer.querySelector('.fb-tracker-webapp-link');
if (!webAppLink) {
webAppLink = document.createElement('a');
webAppLink.className = 'fb-tracker-webapp-link';
webAppLink.target = '_blank';
webAppLink.rel = 'noopener noreferrer';
webAppLink.setAttribute('aria-label', 'In der Webapp anzeigen');
webAppLink.title = 'In der Webapp anzeigen';
webAppLink.textContent = '📋';
webAppLink.style.cssText = `
text-decoration: none;
font-size: 18px;
line-height: 1;
padding: 4px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: inherit;
transition: background-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
`;
webAppLink.addEventListener('mouseenter', () => {
webAppLink.style.backgroundColor = 'rgba(0, 0, 0, 0.08)';
webAppLink.style.transform = 'translateY(-1px)';
});
webAppLink.addEventListener('mouseleave', () => {
webAppLink.style.backgroundColor = 'transparent';
webAppLink.style.transform = 'translateY(0)';
});
actionsContainer.insertBefore(webAppLink, actionsContainer.firstChild);
}
webAppLink.href = webAppUrl;
}
}
await addAICommentButton(container, postElement);
const checkBtn = container.querySelector('.fb-tracker-check-btn');
if (checkBtn) {
checkBtn.addEventListener('click', async () => {
if (effectiveProfileNumber === null) {
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
return;
}
checkBtn.disabled = true;
checkBtn.textContent = 'Wird bestätigt...';
const result = await markPostChecked(postData.id, effectiveProfileNumber, { returnError: true });
if (result && !result.error) {
await renderTrackedStatus({
container,
postElement,
postData: result,
profileNumber: effectiveProfileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
} else {
checkBtn.disabled = false;
checkBtn.textContent = '✓ Bestätigen';
if (result && result.error) {
showToast(result.error, 'error');
} else {
checkBtn.textContent = 'Fehler - Erneut versuchen';
checkBtn.style.backgroundColor = '#e74c3c';
}
}
});
}
console.log('[FB Tracker] Showing status:', statusText);
return { hidden: false };
}
// Create the tracking UI
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
// Normalize to top-level post container if nested element passed
postElement = ensurePrimaryPostElement(postElement);
let existingUI = getTrackerElementForPost(postElement);
if (!existingUI) {
existingUI = postElement.querySelector('.fb-tracker-ui');
if (existingUI && existingUI.isConnected) {
setTrackerElementForPost(postElement, existingUI);
}
}
if (existingUI && !existingUI.isConnected) {
clearTrackerElementForPost(postElement, existingUI);
existingUI = null;
}
if (existingUI) {
realignExistingTrackerUI(postElement, buttonBar, existingUI, postNum);
console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element');
postElement.setAttribute(PROCESSED_ATTR, '1');
return;
}
// Mark immediately to prevent duplicate creation during async operations
if (postElement.getAttribute(PROCESSED_ATTR) === '1') {
console.log('[FB Tracker] Post #' + postNum + ' - Already processed:', postElement);
return;
}
postElement.setAttribute(PROCESSED_ATTR, '1');
const postUrlData = getPostUrl(postElement, postNum);
if (!postUrlData.url) {
console.log('[FB Tracker] Post #' + postNum + ' - No URL found:', postElement);
postElement.removeAttribute(PROCESSED_ATTR);
clearTrackerElementForPost(postElement);
return;
}
console.log('[FB Tracker] Post #' + postNum + ' - Creating tracker UI for:', postUrlData.url, postElement);
const encodedUrl = encodeURIComponent(postUrlData.url);
const scopeRoot = getTrackerScopeRoot(postElement);
const existingScopedTrackers = getTrackersForUrlInScope(encodedUrl, scopeRoot);
if (existingScopedTrackers.length > 0) {
const scopedTracker = dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, existingScopedTrackers[0]);
if (scopedTracker) {
const trackerHost = getTrackerHostElement(scopedTracker);
if (trackerHost && trackerHost !== postElement) {
console.log('[FB Tracker] Post #' + postNum + ' - Reusing tracker skipped (different host container)');
postElement.setAttribute(PROCESSED_ATTR, '1');
return;
}
realignExistingTrackerUI(postElement, buttonBar, scopedTracker, postNum);
console.log('[FB Tracker] Post #' + postNum + ' - Reusing existing tracker UI in same scope');
setTrackerElementForPost(postElement, scopedTracker);
postElement.setAttribute(PROCESSED_ATTR, '1');
return;
}
}
const existingEntry = processedPostUrls.get(encodedUrl);
if (existingEntry && existingEntry.element && existingEntry.element !== postElement) {
if (document.body.contains(existingEntry.element)) {
existingEntry.element.removeAttribute(PROCESSED_ATTR);
clearTrackerElementForPost(existingEntry.element);
} else {
processedPostUrls.delete(encodedUrl);
}
getTrackersForUrlInScope(encodedUrl, scopeRoot).forEach((tracker) => tracker.remove());
}
const { likeButton: sourceLikeButton = null, isSearchResult = false, isDialogContext = false } = options;
const currentPath = window.location.pathname || '/';
const isFeedHome = FEED_HOME_PATHS.includes(currentPath);
const likedByCurrentUser = isPostLikedByCurrentUser(sourceLikeButton, postElement);
// Create UI container
const container = document.createElement('div');
container.className = 'fb-tracker-ui';
container.id = 'fb-tracker-ui-post-' + postNum;
container.setAttribute('data-post-num', postNum);
container.setAttribute('data-post-url', encodedUrl);
container.dataset.isFeedHome = isFeedHome ? '1' : '0';
container.dataset.isDialogContext = isDialogContext ? '1' : '0';
container.style.cssText = `
padding: 6px 12px;
background-color: #f0f2f5;
border-top: 1px solid #e4e6eb;
margin-bottom: 6px;
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: 8px;
row-gap: 6px;
flex: 0 0 100%;
width: 100%;
box-sizing: border-box;
position: static;
z-index: auto;
pointer-events: auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
`;
bindTrackerInteractionGuard(container);
// Check current status (check all URL candidates to avoid duplicates)
const profileNumber = await getProfileNumber();
const postData = await checkPostStatus(postUrlData.url, postUrlData.allCandidates);
const isTracked = !!postData;
let searchTrackingInfo = null;
if (isSearchResult) {
const cacheKey = encodedUrl;
const alreadyRecorded = sessionSearchRecordedUrls.has(cacheKey);
const latestInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
skipIncrement: alreadyRecorded
});
if (!alreadyRecorded && latestInfo) {
sessionSearchRecordedUrls.add(cacheKey);
}
if (latestInfo) {
sessionSearchInfoCache.set(cacheKey, latestInfo);
searchTrackingInfo = latestInfo;
} else if (sessionSearchInfoCache.has(cacheKey)) {
searchTrackingInfo = sessionSearchInfoCache.get(cacheKey);
} else {
searchTrackingInfo = latestInfo;
}
}
let manualHideInfo = null;
if (!isSearchResult && isFeedHome) {
try {
manualHideInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
skipIncrement: true
});
} catch (error) {
console.debug('[FB Tracker] Manual hide lookup failed:', error);
}
}
if (isSearchResult && (isTracked || likedByCurrentUser)) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results (tracked or liked) but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results (tracked or liked)');
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: null
});
return;
}
}
if (searchTrackingInfo && searchTrackingInfo.should_hide && !isTracked && !likedByCurrentUser) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results after repeated sightings but skipping in dialog context:', searchTrackingInfo);
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results after repeated sightings:', searchTrackingInfo);
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: typeof searchTrackingInfo.seen_count === 'number' ? searchTrackingInfo.seen_count : null
});
return;
}
}
if (!isSearchResult && manualHideInfo && (manualHideInfo.manually_hidden || manualHideInfo.should_hide)) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (manually hidden) but skipping in dialog context:', manualHideInfo);
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (manually hidden):', manualHideInfo);
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null
});
return;
}
}
if (postData) {
const renderResult = await renderTrackedStatus({
container,
postElement,
postData,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
if (renderResult && renderResult.hidden) {
return;
}
} else {
// Post not tracked - show add UI
const selectId = `tracker-select-${Date.now()}`;
const deadlineId = `tracker-deadline-${Date.now()}`;
container.innerHTML = `
<label for="${selectId}" style="color: #65676b; font-weight: 500; font-size: 13px; white-space: nowrap;">
Ziel:
</label>
<select id="${selectId}" style="
padding: 4px 8px;
border: 1px solid #ccd0d5;
border-radius: 4px;
background: white;
font-size: 13px;
cursor: pointer;
">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<label for="${deadlineId}" style="color: #65676b; font-weight: 500; font-size: 13px; white-space: nowrap;">
Deadline:
</label>
<input id="${deadlineId}" type="datetime-local" style="
padding: 4px 8px;
border: 1px solid #ccd0d5;
border-radius: 4px;
font-size: 13px;
max-width: 160px;
" />
<div class="fb-tracker-similarity" style="
display: none;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid #f0c36d;
background: #fff6d5;
border-radius: 6px;
font-size: 12px;
color: #6b5b00;
flex-basis: 100%;
">
<span class="fb-tracker-similarity__text"></span>
<button class="fb-tracker-merge-btn" type="button" style="
border: 1px solid #caa848;
background: white;
color: #7a5d00;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
">Mergen</button>
<a class="fb-tracker-similarity-link" href="#" target="_blank" rel="noopener" style="
color: #7a5d00;
text-decoration: none;
font-weight: 600;
display: none;
">Öffnen</a>
</div>
<button class="fb-tracker-add-btn" style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
">
Hinzufügen
</button>
`;
// Add click handler for the button
const addButton = container.querySelector('.fb-tracker-add-btn');
const selectElement = container.querySelector(`#${selectId}`);
const deadlineInput = container.querySelector(`#${deadlineId}`);
selectElement.value = '2';
const similarityBox = container.querySelector('.fb-tracker-similarity');
const similarityText = container.querySelector('.fb-tracker-similarity__text');
const mergeButton = container.querySelector('.fb-tracker-merge-btn');
const similarityLink = container.querySelector('.fb-tracker-similarity-link');
const similarityPayloadPromise = buildSimilarityPayload(postElement);
let similarityPayload = null;
let similarityMatch = null;
const mainLinkUrl = postUrlData.mainUrl;
const resolveSimilarityPayload = async () => {
if (!similarityPayload) {
similarityPayload = await similarityPayloadPromise;
}
return similarityPayload;
};
if (similarityBox && similarityText && mergeButton) {
(async () => {
const payload = await resolveSimilarityPayload();
const similarity = await findSimilarPost({
url: postUrlData.url,
postText: payload.postText,
firstImageHash: payload.firstImageHash
});
if (!similarity || !similarity.match) {
return;
}
similarityMatch = similarity.match;
similarityText.textContent = formatSimilarityLabel(similarity);
similarityBox.style.display = 'flex';
if (!addButton.disabled) {
addButton.textContent = 'Neu speichern';
}
if (similarityLink) {
similarityLink.style.display = 'inline';
similarityLink.textContent = 'Öffnen';
if (similarityMatch.url) {
similarityLink.href = similarityMatch.url;
similarityLink.dataset.ready = '1';
} else {
similarityLink.href = '#';
similarityLink.dataset.ready = '0';
similarityLink.dataset.postId = similarityMatch.id;
}
}
})();
mergeButton.addEventListener('click', async () => {
if (!similarityMatch) {
return;
}
mergeButton.disabled = true;
const previousLabel = mergeButton.textContent;
mergeButton.textContent = 'Mergen...';
const payload = await resolveSimilarityPayload();
const urlCandidates = [postUrlData.url, ...postUrlData.allCandidates];
const uniqueUrls = Array.from(new Set(urlCandidates.filter(Boolean)));
const attached = await attachUrlToExistingPost(similarityMatch.id, uniqueUrls, payload);
if (attached) {
const updatedPost = await fetchPostByUrl(similarityMatch.url);
if (updatedPost) {
await renderTrackedStatus({
container,
postElement,
postData: updatedPost,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
return;
}
}
mergeButton.disabled = false;
mergeButton.textContent = previousLabel;
});
}
if (similarityLink && !similarityLink.dataset.bound) {
similarityLink.dataset.bound = '1';
similarityLink.addEventListener('click', async (event) => {
if (similarityLink.dataset.ready === '1') {
return;
}
event.preventDefault();
const postId = similarityLink.dataset.postId;
if (!postId) {
return;
}
const resolved = await fetchPostById(postId);
if (resolved && resolved.url) {
similarityLink.href = resolved.url;
similarityLink.dataset.ready = '1';
window.open(resolved.url, '_blank', 'noopener');
}
});
}
if (mainLinkUrl) {
const mainLinkButton = document.createElement('button');
mainLinkButton.className = 'fb-tracker-mainlink-btn';
mainLinkButton.type = 'button';
mainLinkButton.title = 'Main-Link öffnen';
mainLinkButton.setAttribute('aria-label', 'Main-Link öffnen');
mainLinkButton.style.cssText = `
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
border: 1px solid #ccd0d5;
background-color: #ffffff;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2365766b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1 1'/><path d='M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7l1-1'/></svg>");
background-repeat: no-repeat;
background-position: center;
background-size: 16px 16px;
cursor: pointer;
`;
addButton.insertAdjacentElement('afterend', mainLinkButton);
mainLinkButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
window.open(mainLinkUrl, '_blank', 'noopener');
});
}
if (deadlineInput) {
// Try to extract deadline from post text first
const extractedDeadline = extractDeadlineFromPostText(postElement);
deadlineInput.value = extractedDeadline || getNextDayDefaultDeadlineValue();
}
addButton.addEventListener('click', async () => {
const targetCount = parseInt(selectElement.value, 10);
console.log('[FB Tracker] Add button clicked, target:', targetCount);
if (!profileNumber) {
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
addButton.disabled = false;
addButton.textContent = 'Erneut versuchen';
return;
}
addButton.disabled = true;
addButton.textContent = 'Wird hinzugefügt...';
postElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' });
await delay(220);
const deadlineValue = deadlineInput ? deadlineInput.value : '';
const payload = await resolveSimilarityPayload();
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
postElement,
deadline: deadlineValue,
candidates: postUrlData.allCandidates,
postText: payload.postText,
firstImageHash: payload.firstImageHash,
firstImageUrl: payload.firstImageUrl
});
if (result) {
const renderOutcome = await renderTrackedStatus({
container,
postElement,
postData: result,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
if (renderOutcome && renderOutcome.hidden) {
return;
}
return;
} else {
// Error
addButton.disabled = false;
addButton.textContent = 'Fehler - Erneut versuchen';
addButton.style.backgroundColor = '#e74c3c';
if (deadlineInput) {
deadlineInput.value = getNextDayDefaultDeadlineValue();
}
}
});
console.log('[FB Tracker] Post #' + postNum + ' - UI created for new post');
// Add AI button for new posts
await addAICommentButton(container, postElement);
}
let sportsScoreInfo = null;
try {
const moderationSettings = await fetchModerationSettings();
if (moderationSettings && moderationSettings.sports_scoring_enabled !== false) {
const postTextForScore = extractPostText(postElement);
if (postTextForScore) {
sportsScoreInfo = evaluateSportsScore(postTextForScore, moderationSettings);
}
if (
moderationSettings.sports_auto_hide_enabled
&& sportsScoreInfo
&& sportsScoreInfo.wouldHide
&& !isTracked
&& !likedByCurrentUser
) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would auto-hide by sports score but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Auto-hidden by sports score', sportsScoreInfo);
try {
await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, { forceHide: true, sportsAutoHide: true });
} catch (error) {
console.debug('[FB Tracker] Auto-hide scoring could not persist hide state:', error);
}
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: null
});
return;
}
}
}
} catch (error) {
console.debug('[FB Tracker] Sport-Scoring nicht verfügbar:', error);
}
if (isSearchResult) {
const info = document.createElement('button');
info.type = 'button';
info.className = 'fb-tracker-search-info';
info.title = 'Beitrag künftig in den Suchergebnissen ausblenden';
info.setAttribute('aria-label', 'Beitrag künftig in den Suchergebnissen ausblenden');
const countText = searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: (isTracked ? 'gespeichert' : 'n/v');
const iconSpan = document.createElement('span');
iconSpan.setAttribute('aria-hidden', 'true');
iconSpan.style.fontSize = '15px';
const countSpan = document.createElement('span');
countSpan.textContent = countText;
info.appendChild(iconSpan);
info.appendChild(countSpan);
info.style.cssText = `
color: #1d2129;
font-weight: 600;
border-radius: 999px;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(29, 33, 41, 0.18);
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
transition: transform 150ms ease, box-shadow 150ms ease, opacity 120ms ease;
cursor: pointer;
outline: none;
`;
const OPEN_EYES_ICON = '😳';
const CLOSED_EYES_ICON = '😌';
const setIconOpen = () => {
iconSpan.textContent = OPEN_EYES_ICON;
};
const setIconClosed = () => {
iconSpan.textContent = CLOSED_EYES_ICON;
};
setIconOpen();
const resetHover = () => {
setIconOpen();
info.style.transform = 'scale(1)';
info.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.08)';
};
info.addEventListener('mouseenter', () => {
info.style.transform = 'translateY(-1px) scale(1.05)';
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
setIconClosed();
});
info.addEventListener('mouseleave', () => {
resetHover();
});
info.addEventListener('focus', () => {
info.style.transform = 'translateY(-1px) scale(1.05)';
info.style.boxShadow = '0 6px 14px rgba(24, 119, 242, 0.35)';
setIconClosed();
});
info.addEventListener('blur', () => {
resetHover();
});
info.addEventListener('mousedown', () => {
info.style.transform = 'translateY(0) scale(0.96)';
info.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.18)';
});
info.addEventListener('mouseup', () => {
info.style.transform = 'translateY(-1px) scale(1.03)';
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
});
const handleManualHide = async (event) => {
event.preventDefault();
event.stopPropagation();
if (postElement.getAttribute('data-fb-tracker-hidden') === '1') {
return;
}
info.disabled = true;
info.style.cursor = 'progress';
info.style.opacity = '0.75';
let hideResult = null;
try {
hideResult = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
forceHide: true
});
} catch (error) {
console.error('[FB Tracker] Failed to hide search result manually:', error);
}
if (!hideResult) {
info.disabled = false;
info.style.cursor = 'pointer';
info.style.opacity = '1';
resetHover();
return;
}
if (isSearchResult) {
const cacheKeyForHide = encodedUrl;
sessionSearchRecordedUrls.add(cacheKeyForHide);
sessionSearchInfoCache.set(cacheKeyForHide, hideResult);
}
setIconClosed();
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Manual hide skipped in dialog context');
} else {
hidePostElement(postElement);
const seenCountValue = typeof hideResult.seen_count === 'number' ? hideResult.seen_count : null;
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: seenCountValue
});
}
};
info.addEventListener('click', handleManualHide);
resetHover();
container.insertBefore(info, container.firstChild);
const sportsScoreBadge = buildSportsScoreBadge(sportsScoreInfo);
if (sportsScoreBadge) {
container.insertBefore(sportsScoreBadge, info.nextSibling);
}
} else {
const sportsScoreBadge = buildSportsScoreBadge(sportsScoreInfo);
if (sportsScoreBadge) {
container.insertBefore(sportsScoreBadge, container.firstChild);
}
}
// Insert UI - try multiple strategies to find stable insertion point
let inserted = false;
const tryInsertBeforeReelsCommentComposer = () => {
const textboxCandidates = postElement ? postElement.querySelectorAll('div[role="textbox"]') : [];
const composerElement = Array.from(textboxCandidates).find((element) => {
const ariaLabel = (element.getAttribute('aria-label') || '').toLowerCase();
const ariaPlaceholder = (element.getAttribute('aria-placeholder') || '').toLowerCase();
const combined = `${ariaLabel} ${ariaPlaceholder}`;
return combined.includes('komment') || combined.includes('comment');
});
if (!composerElement) {
return false;
}
const anchorRoot = composerElement.closest('form[role="presentation"]')
|| composerElement.closest('form')
|| composerElement.parentElement;
if (!anchorRoot || !anchorRoot.parentElement) {
return false;
}
anchorRoot.parentElement.insertBefore(container, anchorRoot);
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted before comment composer (Reels complementary). ID: #' + container.id);
return true;
};
if (!inserted && isOnReelsPage() && postElement && postElement.matches('div[role="complementary"]')) {
inserted = tryInsertBeforeReelsCommentComposer();
}
const insertionMarker = !inserted
? ensureTrackerInsertionMarker(postElement, buttonBar, postNum)
: null;
// Strategy 1: Insert at stable marker that is locked below the action bar
if (!inserted && insertionMarker) {
inserted = insertTrackerAtMarker(insertionMarker, container);
if (inserted) {
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted at stable marker. ID: #' + container.id);
}
}
// Strategy 2: Last fallback append to post element
if (!inserted) {
postElement.appendChild(container);
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted into article (fallback). ID: #' + container.id);
inserted = true;
}
if (inserted) {
const dedupedTracker = dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, container);
if (dedupedTracker && dedupedTracker !== container) {
clearTrackerElementForPost(postElement, container);
}
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: null,
hidden: false
});
setTrackerElementForPost(postElement, dedupedTracker || container);
}
// Monitor if the UI gets removed and re-insert it
if (inserted) {
const observer = new MutationObserver((mutations) => {
if (!document.getElementById(container.id)) {
console.log('[FB Tracker] Post #' + postNum + ' - UI was removed, re-inserting...');
observer.disconnect();
// Try to re-insert
const currentMarker = ensureTrackerInsertionMarker(postElement, buttonBar, postNum);
if (!currentMarker || !insertTrackerAtMarker(currentMarker, container)) {
postElement.appendChild(container);
}
if (container.isConnected) {
dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, container);
setTrackerElementForPost(postElement, container);
}
}
});
// Observe the parent for changes
const observeTarget = container.parentElement || postElement;
observer.observe(observeTarget, { childList: true, subtree: false });
// Stop observing after 5 seconds
setTimeout(() => observer.disconnect(), 5000);
}
}
// Check if article is a main post (not a comment)
function isMainPost(article, buttonBar) {
if (!article || article === document.body) {
return false;
}
if (isOnReelsPage()) {
if (article.matches('div[role="complementary"]') || article.closest('div[role="complementary"]')) {
return true;
}
}
const roleDescription = (article.getAttribute('aria-roledescription') || '').toLowerCase();
if (roleDescription && (roleDescription.includes('kommentar') || roleDescription.includes('comment'))) {
return false;
}
if (article.matches('[data-testid*="comment" i], [data-scope="comment"]')) {
return false;
}
if (article.closest('[data-testid*="comment" i]')) {
return false;
}
if (buttonBar) {
const shareIndicator = buttonBar.querySelector('[data-ad-rendering-role="share_button"]');
if (shareIndicator) {
return true;
}
}
// Comments are usually nested inside other articles or comment sections
// Check if this article is inside another article (likely a comment)
let parent = article.parentElement;
while (parent && parent !== document.body) {
if (parent.getAttribute('role') === 'article') {
const parentRoleDescription = (parent.getAttribute('aria-roledescription') || '').toLowerCase();
if (parentRoleDescription.includes('kommentar') || parentRoleDescription.includes('comment')) {
return false;
}
// This article is inside another article - it's a comment
return false;
}
parent = parent.parentElement;
}
// Additional check: Main posts usually have a link with /posts/ or /permalink/
const postLinks = article.querySelectorAll('a[href*="/posts/"], a[href*="/permalink/"], a[href*="/photo"], a[href*="/videos/"], a[href*="/reel/"]');
if (postLinks.length === 0) {
if (article.querySelector('[data-testid*="comment" i]')) {
return false;
}
if (article.querySelector('[data-ad-rendering-role="share_button"]')) {
return true;
}
const nextSibling = article.nextElementSibling;
if (nextSibling && nextSibling.querySelector('[data-ad-rendering-role="share_button"]')) {
const nextButtons = nextSibling.querySelectorAll('[role="button"]');
if (nextButtons.length > 30) {
return true;
}
}
// No post-type links found - likely a comment
return false;
}
return true;
}
// Global post counter for unique IDs
let globalPostCounter = 0;
// Find all Facebook posts on the page
function findPosts() {
if (maybeRedirectPageReelsToMain()) {
return;
}
console.log('[FB Tracker] Scanning for posts...');
const postContainers = findPostContainers();
const seenFeedContainers = new Set();
const seenDialogContainers = new Set();
console.log('[FB Tracker] Found', postContainers.length, 'candidate containers');
let processed = 0;
const pathname = window.location.pathname || '';
const isSearchResultsPage = typeof pathname === 'string' && pathname.startsWith(SEARCH_RESULTS_PATH_PREFIX);
for (const { container: originalContainer, likeButton, buttonBar: precomputedButtonBar } of postContainers) {
let container = ensurePrimaryPostElement(originalContainer);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR);
const isInDialog = !!dialogRoot;
const seenSet = isInDialog ? seenDialogContainers : seenFeedContainers;
if (seenSet.has(container)) {
continue;
}
const isPending = container.getAttribute(PENDING_ATTR) === '1';
if (isPending) {
seenSet.add(container);
continue;
}
let existingTracker = getTrackerElementForPost(container);
if (!existingTracker) {
existingTracker = container.querySelector('.fb-tracker-ui');
if (existingTracker && existingTracker.isConnected) {
setTrackerElementForPost(container, existingTracker);
}
}
if (existingTracker && !existingTracker.isConnected) {
clearTrackerElementForPost(container, existingTracker);
existingTracker = null;
}
const alreadyProcessed = container.getAttribute(PROCESSED_ATTR) === '1';
const trackerDialogRoot = existingTracker ? existingTracker.closest(DIALOG_ROOT_SELECTOR) : null;
const trackerInSameDialog = Boolean(
existingTracker
&& existingTracker.isConnected
&& (
trackerDialogRoot === dialogRoot
|| (dialogRoot && dialogRoot.contains(existingTracker))
)
);
if (isInDialog) {
if (trackerInSameDialog) {
realignExistingTrackerUI(container, precomputedButtonBar || null, existingTracker, 'existing');
seenSet.add(container);
continue;
}
if (existingTracker && existingTracker.isConnected) {
existingTracker.remove();
clearTrackerElementForPost(container, existingTracker);
}
if (alreadyProcessed) {
container.removeAttribute(PROCESSED_ATTR);
}
} else {
if (alreadyProcessed || (existingTracker && existingTracker.isConnected)) {
if (existingTracker && existingTracker.isConnected) {
realignExistingTrackerUI(container, precomputedButtonBar || null, existingTracker, 'existing');
setTrackerElementForPost(container, existingTracker);
} else if (alreadyProcessed) {
container.removeAttribute(PROCESSED_ATTR);
}
seenSet.add(container);
continue;
}
}
const buttonBar = precomputedButtonBar || findButtonBar(container);
if (!buttonBar) {
console.log('[FB Tracker] Proceeding without button bar, will fallback to post container insertion');
}
if (!isMainPost(container, buttonBar)) {
console.log('[FB Tracker] Skipping non-main post container');
continue;
}
seenSet.add(container);
processed++;
globalPostCounter++;
const postNum = globalPostCounter;
console.log('[FB Tracker] Post #' + postNum + ' - Adding tracker:', container);
container.setAttribute(PENDING_ATTR, '1');
createTrackerUI(container, buttonBar, postNum, {
likeButton,
isSearchResult: isSearchResultsPage,
isDialogContext: isInDialog
}).catch((error) => {
console.error('[FB Tracker] Post #' + postNum + ' - Failed to create UI:', error, container);
}).finally(() => {
container.removeAttribute(PENDING_ATTR);
});
}
console.log('[FB Tracker] Total processed posts this scan:', processed, '| Global count:', globalPostCounter);
}
// Initialize
console.log('[FB Tracker] Initializing...');
maybeRedirectPageReelsToMain();
// Run multiple times to catch loading posts
setTimeout(findPosts, 2000);
setTimeout(findPosts, 4000);
setTimeout(findPosts, 6000);
// Debounced scan function
let scanTimeout = null;
function scheduleScan() {
if (scanTimeout) {
clearTimeout(scanTimeout);
}
scanTimeout = setTimeout(() => {
console.log('[FB Tracker] Scheduled scan triggered');
findPosts();
}, 1000);
}
// Watch for new posts being added to the page
const observer = new MutationObserver((mutations) => {
scheduleScan();
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Trigger scan on scroll (for infinite scroll)
let scrollTimeout = null;
window.addEventListener('scroll', () => {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
console.log('[FB Tracker] Scroll detected, scanning...');
findPosts();
}, 1000);
});
// Use IntersectionObserver to detect when posts become visible
const visibilityObserver = new IntersectionObserver((entries) => {
let needsScan = false;
entries.forEach(entry => {
if (entry.isIntersecting) {
const postContainer = entry.target;
// Check if already processed
if (postContainer.getAttribute(PROCESSED_ATTR) !== '1') {
console.log('[FB Tracker] Post became visible and not yet processed:', postContainer);
needsScan = true;
}
}
});
if (needsScan) {
// Delay scan slightly to let Facebook finish loading the post content
setTimeout(() => {
console.log('[FB Tracker] Scanning newly visible posts...');
findPosts();
}, 500);
}
}, {
root: null,
rootMargin: '50px',
threshold: 0.1
});
// Watch for new posts and observe them
const postObserver = new MutationObserver((mutations) => {
// Find all posts and observe them for visibility
const postSelector = isOnReelsPage()
? 'div[aria-posinset], div[role="complementary"]'
: 'div[aria-posinset]';
const posts = document.querySelectorAll(postSelector);
posts.forEach(post => {
if (!post.dataset.trackerObserved) {
post.dataset.trackerObserved = 'true';
visibilityObserver.observe(post);
}
});
scheduleScan();
});
// Start observing
postObserver.observe(document.body, {
childList: true,
subtree: true
});
// Initial observation of existing posts
const initialPostSelector = isOnReelsPage()
? 'div[aria-posinset], div[role="complementary"]'
: 'div[aria-posinset]';
const initialPosts = document.querySelectorAll(initialPostSelector);
initialPosts.forEach(post => {
post.dataset.trackerObserved = 'true';
visibilityObserver.observe(post);
});
console.log('[FB Tracker] Observer with IntersectionObserver started');
// Store the element where context menu was opened
let contextMenuTarget = null;
document.addEventListener('contextmenu', (event) => {
contextMenuTarget = event.target;
console.log('[FB Tracker] Context menu opened on:', contextMenuTarget);
}, true);
// Floating AI button on text selection
let selectionAIContainer = null;
let selectionAIButton = null;
let selectionAINoteButton = null;
let selectionAIRaf = null;
let selectionAIHideTimeout = null;
let selectionAIEnabledCached = null;
let selectionAIContextElement = null;
const clearSelectionAIHideTimeout = () => {
if (selectionAIHideTimeout) {
clearTimeout(selectionAIHideTimeout);
selectionAIHideTimeout = null;
}
};
const hideSelectionAIButton = () => {
clearSelectionAIHideTimeout();
if (selectionAIContainer) {
selectionAIContainer.style.display = 'none';
}
selectionAIContextElement = null;
if (selectionAIButton) {
selectionAIButton.dataset.selectionText = '';
}
if (selectionAINoteButton) {
selectionAINoteButton.dataset.selectionText = '';
}
};
const ensureSelectionAIButton = () => {
if (selectionAIContainer && selectionAIContainer.isConnected && selectionAIButton && selectionAINoteButton) {
return selectionAIContainer;
}
const container = document.createElement('div');
container.style.cssText = `
position: fixed;
z-index: 2147483647;
display: none;
align-items: center;
gap: 8px;
pointer-events: auto;
`;
const noteButton = document.createElement('button');
noteButton.type = 'button';
noteButton.textContent = ' Zusatzinfo';
noteButton.title = 'Aktuelle Auswahl als Zusatzinfo speichern';
noteButton.style.cssText = `
padding: 7px 10px;
border-radius: 10px;
border: 1px solid #d1d5db;
background: #fff;
color: #111827;
font-weight: 700;
font-size: 12px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
display: inline-flex;
align-items: center;
gap: 6px;
`;
noteButton.addEventListener('mouseenter', () => {
noteButton.style.transform = 'translateY(-1px)';
noteButton.style.boxShadow = '0 8px 18px rgba(0, 0, 0, 0.18)';
});
noteButton.addEventListener('mouseleave', () => {
noteButton.style.transform = 'translateY(0)';
noteButton.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.12)';
});
noteButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const selectedText = noteButton.dataset.selectionText || '';
if (!selectedText.trim()) {
showToast('Keine Textauswahl gefunden', 'error');
return;
}
const selection = window.getSelection();
const anchorNode = selection ? (selection.anchorNode || selection.focusNode) : null;
if (anchorNode && isSelectionInsideEditable(anchorNode)) {
showToast('Textauswahl aus Eingabefeldern kann nicht genutzt werden', 'error');
return;
}
const anchorElement = anchorNode && anchorNode.nodeType === Node.TEXT_NODE
? anchorNode.parentElement
: anchorNode;
const postContext = selectionAIContextElement
|| (anchorElement ? ensurePrimaryPostElement(anchorElement) : null);
if (!postContext) {
showToast('Keinen zugehörigen Beitrag gefunden', 'error');
return;
}
const normalized = normalizeSelectionText(selectedText);
if (!normalized) {
showToast('Keine Textauswahl gefunden', 'error');
return;
}
postAdditionalNotes.set(postContext, normalized);
showToast('Auswahl als Zusatzinfo gesetzt', 'success');
});
const button = document.createElement('button');
button.type = 'button';
button.textContent = '✨ AI';
button.title = 'Auswahl mit AI beantworten';
button.style.cssText = `
padding: 8px 12px;
padding: 8px 12px;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-weight: 700;
font-size: 13px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22);
cursor: pointer;
align-items: center;
gap: 6px;
transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
`;
button.addEventListener('mouseenter', () => {
button.style.transform = 'translateY(-1px) scale(1.02)';
button.style.boxShadow = '0 10px 22px rgba(0, 0, 0, 0.26)';
});
button.addEventListener('mouseleave', () => {
button.style.transform = 'translateY(0)';
button.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.22)';
});
button.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
const selectedText = button.dataset.selectionText || '';
hideSelectionAIButton();
if (!selectedText.trim()) {
return;
}
const originalLabel = button.textContent;
button.textContent = '⏳ AI läuft...';
try {
await handleSelectionAIRequest(selectedText, () => {});
} finally {
button.textContent = originalLabel;
}
});
container.appendChild(noteButton);
container.appendChild(button);
document.body.appendChild(container);
selectionAIContainer = container;
selectionAIButton = button;
selectionAINoteButton = noteButton;
selectionAIButton = button;
return container;
};
const isSelectionInsideEditable = (node) => {
if (!node) {
return false;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node;
if (el.closest('input, textarea, [contenteditable="true"]')) {
return true;
}
}
if (node.parentElement && node.parentElement.closest('input, textarea, [contenteditable="true"]')) {
return true;
}
return false;
};
const positionSelectionAIButton = (rect) => {
if (!selectionAIContainer || !rect) {
return;
}
const viewportPadding = 8;
const containerWidth = selectionAIContainer.offsetWidth || 160;
let left = rect.right + 8;
let top = rect.top - (selectionAIContainer.offsetHeight || 40) - 8;
if (left + containerWidth + viewportPadding > window.innerWidth) {
left = Math.max(viewportPadding, rect.right - containerWidth - 8);
}
if (top < viewportPadding) {
top = rect.bottom + 8;
}
selectionAIContainer.style.left = `${Math.max(viewportPadding, left)}px`;
selectionAIContainer.style.top = `${Math.max(viewportPadding, top)}px`;
};
const updateSelectionAIButton = async () => {
clearSelectionAIHideTimeout();
if (selectionAIEnabledCached === null) {
try {
selectionAIEnabledCached = await isAIEnabled();
} catch (error) {
console.warn('[FB Tracker] AI enable check failed for selection button:', error);
selectionAIEnabledCached = false;
}
}
if (!selectionAIEnabledCached) {
hideSelectionAIButton();
return;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
hideSelectionAIButton();
return;
}
const selectionText = (selection.toString() || '').trim();
if (!selectionText || selectionText.length > MAX_SELECTION_LENGTH) {
hideSelectionAIButton();
return;
}
const anchorNode = selection.anchorNode || selection.focusNode;
if (isSelectionInsideEditable(anchorNode)) {
hideSelectionAIButton();
return;
}
if (!selection.rangeCount) {
hideSelectionAIButton();
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect || (rect.width === 0 && rect.height === 0)) {
hideSelectionAIButton();
return;
}
const contextElement = (() => {
const containerNode = range.commonAncestorContainer || anchorNode;
if (!containerNode) {
return null;
}
const element = containerNode.nodeType === Node.TEXT_NODE
? containerNode.parentElement
: containerNode;
return element ? ensurePrimaryPostElement(element) : null;
})();
selectionAIContextElement = contextElement;
const container = ensureSelectionAIButton();
if (!selectionAIButton || !selectionAINoteButton) {
hideSelectionAIButton();
return;
}
selectionAIButton.dataset.selectionText = selectionText;
selectionAINoteButton.dataset.selectionText = selectionText;
container.style.display = 'inline-flex';
positionSelectionAIButton(rect);
selectionAIHideTimeout = setTimeout(() => {
hideSelectionAIButton();
}, 8000);
};
const scheduleSelectionAIUpdate = () => {
if (selectionAIRaf) {
return;
}
selectionAIRaf = requestAnimationFrame(() => {
selectionAIRaf = null;
updateSelectionAIButton();
});
};
const initSelectionAIFloatingButton = () => {
document.addEventListener('selectionchange', scheduleSelectionAIUpdate, true);
document.addEventListener('mouseup', scheduleSelectionAIUpdate, true);
document.addEventListener('keyup', scheduleSelectionAIUpdate, true);
window.addEventListener('scroll', hideSelectionAIButton, true);
window.addEventListener('blur', hideSelectionAIButton, true);
};
initSelectionAIFloatingButton();
// Listen for manual reparse command
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'generateSelectionAI') {
handleSelectionAIRequest(message.selectionText || '', sendResponse);
return true;
}
if (message && message.type === 'reparsePost') {
console.log('[FB Tracker] Manual reparse triggered');
// Use the stored context menu target, fallback to elementFromPoint
let clickedElement = contextMenuTarget;
if (!clickedElement && message.x !== undefined && message.y !== undefined) {
clickedElement = document.elementFromPoint(message.x, message.y);
}
if (!clickedElement) {
console.log('[FB Tracker] No element found');
sendResponse({ success: false });
return true;
}
console.log('[FB Tracker] Searching for post container starting from:', clickedElement);
// Find the post container (aria-posinset)
let postContainer = clickedElement.closest('div[aria-posinset]');
if (!postContainer && isOnReelsPage()) {
postContainer = clickedElement.closest('div[role="complementary"]');
}
if (!postContainer) {
console.log('[FB Tracker] No post container found for clicked element:', clickedElement);
sendResponse({ success: false });
return true;
}
console.log('[FB Tracker] Found post container:', postContainer);
const normalizedContainer = ensurePrimaryPostElement(postContainer);
if (normalizedContainer && normalizedContainer !== postContainer) {
console.log('[FB Tracker] Normalized post container to:', normalizedContainer);
postContainer = normalizedContainer;
}
// Remove processed attribute and existing UI
postContainer.removeAttribute(PROCESSED_ATTR);
const existingUI = postContainer.querySelector('.fb-tracker-ui');
if (existingUI) {
existingUI.remove();
clearTrackerElementForPost(postContainer, existingUI);
console.log('[FB Tracker] Removed existing UI');
}
// Find button bar and create UI
let buttonBar = findButtonBar(postContainer);
if (!buttonBar) {
let fallback = postContainer.parentElement;
while (!buttonBar && fallback && fallback !== document.body) {
buttonBar = findButtonBar(fallback);
fallback = fallback.parentElement;
}
}
if (!buttonBar) {
console.log('[FB Tracker] No button bar found for this post, proceeding with fallback');
}
globalPostCounter++;
const postNum = globalPostCounter;
console.log('[FB Tracker] Reparsing post as #' + postNum);
createTrackerUI(postContainer, buttonBar, postNum).then(() => {
sendResponse({ success: true });
}).catch((error) => {
console.error('[FB Tracker] Failed to reparse:', error);
sendResponse({ success: false });
});
return true;
}
});
// ============================================================================
// AI COMMENT GENERATION
// ============================================================================
/**
* Show a toast notification
*/
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: ${type === 'error' ? '#e74c3c' : type === 'success' ? '#42b72a' : '#1877f2'};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
z-index: 999999;
max-width: 350px;
animation: slideIn 0.3s ease-out;
`;
toast.textContent = message;
// Add animation keyframes
if (!document.getElementById('fb-tracker-toast-styles')) {
const style = document.createElement('style');
style.id = 'fb-tracker-toast-styles';
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function copyTextToClipboard(text) {
if (typeof text !== 'string') {
return false;
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.warn('[FB Tracker] navigator.clipboard.writeText failed:', error);
}
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = '-999px';
textarea.style.left = '-999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
} catch (error) {
console.warn('[FB Tracker] execCommand copy fallback failed:', error);
return false;
}
}
/**
* Extract post text from a Facebook post element
*/
function normalizeSelectionText(text) {
if (!text) {
return '';
}
const trimmed = text.trim();
if (!trimmed) {
return '';
}
return trimmed.length > MAX_SELECTION_LENGTH
? trimmed.substring(0, MAX_SELECTION_LENGTH)
: trimmed;
}
function ensurePrimaryPostElement(element) {
if (!element) {
return element;
}
const selectors = [
'div[role="dialog"] article',
'div[role="dialog"] div[aria-posinset]',
'[data-pagelet*="FeedUnit"] article',
'div[role="main"] article',
'[data-visualcompletion="ignore-dynamic"] article',
'div[aria-posinset]',
'article[role="article"]',
'article'
];
if (isOnReelsPage()) {
selectors.unshift('div[role="complementary"]');
}
let current = element;
while (current && current !== document.body && current !== document.documentElement) {
for (const selector of selectors) {
if (current.matches && current.matches(selector)) {
return current;
}
}
current = current.parentElement;
}
return element;
}
function cacheSelectionForPost(postElement) {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const text = normalizeSelectionText(selection.toString());
if (text) {
postSelectionCache.set(postElement, {
text,
timestamp: Date.now()
});
lastGlobalSelection = { text, timestamp: Date.now() };
}
} catch (error) {
console.warn('[FB Tracker] Failed to cache selection text:', error);
}
}
function cacheCurrentSelection() {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const text = normalizeSelectionText(selection.toString());
if (text) {
lastGlobalSelection = {
text,
timestamp: Date.now()
};
}
} catch (error) {
console.debug('[FB Tracker] Unable to cache current selection:', error);
}
}
function getSelectedTextFromPost(postElement) {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('No active selection');
}
const text = normalizeSelectionText(selection.toString());
if (text) {
postSelectionCache.set(postElement, { text, timestamp: Date.now() });
lastGlobalSelection = { text, timestamp: Date.now() };
return text;
}
throw new Error('Empty selection');
} catch (error) {
if (error && error.message) {
console.debug('[FB Tracker] Selection fallback:', error.message);
}
const cached = postSelectionCache.get(postElement);
if (cached && Date.now() - cached.timestamp < LAST_SELECTION_MAX_AGE) {
return cached.text;
}
if (lastGlobalSelection.text && Date.now() - lastGlobalSelection.timestamp < LAST_SELECTION_MAX_AGE) {
return lastGlobalSelection.text;
}
return '';
}
}
function extractPostText(postElement) {
if (!postElement) {
return '';
}
const logPostText = (...args) => {
try {
console.log(POST_TEXT_LOG_TAG, ...args);
} catch (error) {
// ignore logging failure
}
};
const hasEmojiChars = (text) => /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\u2600-\u27BF]/.test(text);
const injectEmojiLabels = (root) => {
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
const emojiNodes = root.querySelectorAll('img[alt], [role="img"][aria-label]');
emojiNodes.forEach((node) => {
const label = node.getAttribute('alt') || node.getAttribute('aria-label');
if (!label || !hasEmojiChars(label)) {
return;
}
const textNode = node.ownerDocument.createTextNode(label);
node.replaceWith(textNode);
});
};
const getTextWithEmojis = (element) => {
if (!element) {
return '';
}
const clone = element.cloneNode(true);
injectEmojiLabels(clone);
return clone.innerText || clone.textContent || '';
};
const SKIP_TEXT_CONTAINERS_SELECTOR = [
'div[role="textbox"]',
'[contenteditable="true"]',
'[data-lexical-editor="true"]',
'form[role="presentation"]',
'form[method]',
'.fb-tracker-ui',
'.fb-tracker-ai-wrapper',
'[aria-label*="komment"]',
'[aria-label*="comment"]',
'[aria-roledescription*="komment"]',
'[aria-roledescription*="comment"]'
].join(', ');
const KEYWORD_HINTS = ['meta', 'facebook', 'instagram'];
const isInsideSkippedRegion = (element) => {
if (!element || typeof element.closest !== 'function') {
return false;
}
return Boolean(element.closest(SKIP_TEXT_CONTAINERS_SELECTOR));
};
const scoreCandidate = (text) => {
const base = text.length;
const lower = text.toLowerCase();
let bonus = 0;
for (const keyword of KEYWORD_HINTS) {
if (lower.includes(keyword)) {
bonus += 200;
}
}
return base + bonus;
};
const makeSnippet = (text) => {
if (!text) {
return '';
}
const trimmed = text.trim();
return trimmed.length > 140 ? `${trimmed.substring(0, 137)}` : trimmed;
};
const contentSelectors = [
'[data-ad-preview="message"]',
'[data-ad-comet-preview="message"]',
'div[data-ad-comet-preview] > div > div > span',
'.x193iq5w.xeuugli', // Common Facebook text class
'span[dir="auto"]',
'div[dir="auto"]'
];
const uiTextPattern = /(Gefällt mir|Kommentieren|Teilen|Like|Comment|Share)/gi;
const timePattern = /\d+\s*(Std\.|Min\.|Tag|hour|minute|day)/gi;
const sponsoredPattern = /(Gesponsert|Sponsored)/gi;
const cleanCandidate = (text) => {
if (!text) {
return '';
}
const cleaned = text
.replace(uiTextPattern, ' ')
.replace(timePattern, ' ')
.replace(sponsoredPattern, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) {
logPostText('Discard empty candidate after cleaning');
return '';
}
// Ignore very short snippets that are likely button labels
if (cleaned.length < 5 && cleaned.split(/\s+/).length < 2) {
logPostText('Discard very short candidate', makeSnippet(text));
return '';
}
return cleaned;
};
const candidates = [];
const seen = new Set();
const tryAddCandidate = (rawText, element = null, context = {}) => {
const candidate = cleanCandidate(rawText);
if (!candidate) {
if (rawText) {
logPostText('Candidate rejected during cleaning', makeSnippet(rawText), context);
}
return;
}
if (seen.has(candidate)) {
logPostText('Candidate skipped as duplicate', makeSnippet(candidate), context);
return;
}
if (element && isInsideSkippedRegion(element)) {
logPostText('Candidate inside skipped region', makeSnippet(candidate), context);
return;
}
seen.add(candidate);
candidates.push({
text: candidate,
score: scoreCandidate(candidate)
});
logPostText('Candidate accepted', {
score: scoreCandidate(candidate),
snippet: makeSnippet(candidate),
context
});
};
logPostText('Begin extraction');
for (const selector of contentSelectors) {
const elements = postElement.querySelectorAll(selector);
for (const element of elements) {
if (isInsideSkippedRegion(element)) {
logPostText('Selector result skipped (inside disallowed region)', selector, makeSnippet(element.innerText || element.textContent || ''));
continue;
}
tryAddCandidate(getTextWithEmojis(element), element, { selector });
}
}
let textContent = '';
if (candidates.length) {
const best = candidates.reduce((top, current) => (
current.score > top.score ? current : top
), candidates[0]);
textContent = best.text;
logPostText('Best candidate selected', {
score: best.score,
snippet: makeSnippet(best.text)
});
}
if (!textContent) {
let fallbackText = '';
try {
const clone = postElement.cloneNode(true);
const elementsToRemove = clone.querySelectorAll(SKIP_TEXT_CONTAINERS_SELECTOR);
elementsToRemove.forEach((node) => node.remove());
injectEmojiLabels(clone);
const cloneText = clone.innerText || clone.textContent || '';
fallbackText = cleanCandidate(cloneText);
logPostText('Fallback extracted from clone', makeSnippet(fallbackText || cloneText));
} catch (error) {
const allText = getTextWithEmojis(postElement);
fallbackText = cleanCandidate(allText);
logPostText('Fallback extracted from original element', makeSnippet(fallbackText || allText));
}
textContent = fallbackText;
}
if (!textContent) {
logPostText('No usable text found');
return '';
}
logPostText('Final post text', makeSnippet(textContent));
return textContent.substring(0, 2000); // Limit length
}
/**
* Find and click the comment button to open comment field
*/
function findAndClickCommentButton(postElement) {
if (!postElement) {
return false;
}
// Look for comment button with various selectors
const commentButtonSelectors = [
'[data-ad-rendering-role="comment_button"]',
'[aria-label*="Kommentieren"]',
'[aria-label*="Comment"]'
];
for (const selector of commentButtonSelectors) {
const button = postElement.querySelector(selector);
if (button) {
console.log('[FB Tracker] Found comment button, clicking it');
button.click();
return true;
}
}
// Try in parent elements
let parent = postElement;
for (let i = 0; i < 3; i++) {
parent = parent.parentElement;
if (!parent) break;
for (const selector of commentButtonSelectors) {
const button = parent.querySelector(selector);
if (button) {
console.log('[FB Tracker] Found comment button in parent, clicking it');
button.click();
return true;
}
}
}
return false;
}
function containsPostContent(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return false;
}
if (element.matches && element.matches('article, [role="article"]')) {
return true;
}
if (element.querySelector && element.querySelector('article, [role="article"]')) {
return true;
}
if (element.matches && element.matches('[data-fb-tracker-processed="1"]')) {
return true;
}
return false;
}
function isTimestampArtifactNode(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return false;
}
if (!node.classList) {
return false;
}
if (!node.classList.contains('__fb-light-mode') && !node.classList.contains('__fb-dark-mode')) {
return false;
}
if (node.querySelector && node.querySelector('article, [role="article"]')) {
return false;
}
const text = node.textContent ? node.textContent.trim() : '';
if (!text) {
return true;
}
if (text.length > 80) {
return false;
}
const lowered = text.toLowerCase();
const hasDate = /\b\d{1,2}\.\s*(?:jan|feb|mär|mae|apr|mai|jun|jul|aug|sep|okt|nov|dez)/i.test(lowered);
const hasTime = /\b\d{1,2}[:.]\d{2}\b/.test(lowered);
const hasMonthWord = /\b(?:januar|februar|märz|maerz|april|mai|juni|juli|august|september|oktober|november|dezember)\b/.test(lowered);
if (hasDate || hasTime || hasMonthWord) {
return true;
}
return false;
}
function removeNodeAndEmptyAncestors(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const parents = [];
let currentParent = node.parentElement;
node.remove();
while (currentParent && currentParent !== document.body && currentParent !== document.documentElement) {
parents.push(currentParent);
currentParent = currentParent.parentElement;
}
parents.forEach((parentNode) => {
if (parentNode.childElementCount === 0) {
parentNode.remove();
}
});
}
function cleanupDanglingSearchArtifacts(context) {
if (!isOnSearchResultsPage()) {
return;
}
const scope = (context && context.nodeType === Node.ELEMENT_NODE)
? context
: document;
const candidates = scope.querySelectorAll('div.__fb-light-mode, div.__fb-dark-mode');
candidates.forEach((node) => {
if (!isTimestampArtifactNode(node)) {
return;
}
const parent = node.parentElement;
if (parent) {
const hasContentSibling = Array.from(parent.children).some((child) => {
if (child === node) {
return false;
}
return containsPostContent(child);
});
if (hasContentSibling) {
return;
}
}
removeNodeAndEmptyAncestors(node);
});
}
/**
* Find comment input field on current page
*/
function findCommentInput(postElement, options = {}) {
const {
preferredRoot = null,
includeParents = true
} = options;
if (!postElement && !preferredRoot) {
return null;
}
const selectors = [
'div[contenteditable="true"][role="textbox"]',
'div[aria-label*="Kommentar"][contenteditable="true"]',
'div[aria-label*="comment"][contenteditable="true"]',
'div[aria-label*="Write a comment"][contenteditable="true"]'
];
const searchInRoot = (root) => {
if (!root) {
return null;
}
for (const selector of selectors) {
const input = root.querySelector(selector);
if (input && isElementVisible(input)) {
return input;
}
}
return null;
};
const roots = [];
if (postElement) {
roots.push(postElement);
}
if (preferredRoot && preferredRoot.isConnected && !roots.includes(preferredRoot)) {
roots.push(preferredRoot);
}
for (const root of roots) {
const input = searchInRoot(root);
if (input) {
return input;
}
}
if (includeParents && postElement) {
let parent = postElement.parentElement;
for (let i = 0; i < 3 && parent; i++) {
if (preferredRoot && !preferredRoot.contains(parent)) {
parent = parent.parentElement;
continue;
}
const input = searchInRoot(parent);
if (input) {
return input;
}
parent = parent.parentElement;
}
}
return null;
}
function isElementVisible(element) {
if (!element || !element.isConnected) {
return false;
}
if (typeof element.offsetParent !== 'undefined' && element.offsetParent !== null) {
return true;
}
const rects = element.getClientRects();
return rects && rects.length > 0;
}
function isCancellationError(error) {
if (!error) {
return false;
}
if (error.name === 'AICancelled' || error.name === 'AbortError') {
return true;
}
if (typeof error.message === 'string') {
const normalized = error.message.toLowerCase();
if (normalized === 'ai_cancelled' || normalized === 'abgebrochen') {
return true;
}
}
return false;
}
async function waitForCommentInput(postElement, options = {}) {
const {
encodedPostUrl = null,
timeout = 6000,
interval = 200,
context = null,
preferredRoot: rawPreferredRoot = null
} = options;
const deadline = Date.now() + Math.max(timeout, 0);
let attempts = 0;
const preferredRoot = rawPreferredRoot && rawPreferredRoot.isConnected
? rawPreferredRoot
: null;
const findByEncodedUrl = () => {
if (context && context.cancelled) {
return null;
}
if (!encodedPostUrl) {
return null;
}
const trackers = document.querySelectorAll(`.fb-tracker-ui[data-post-url="${encodedPostUrl}"]`);
for (const tracker of trackers) {
if (!tracker.isConnected) {
continue;
}
if (preferredRoot && !preferredRoot.contains(tracker)) {
continue;
}
const trackerContainer = tracker.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]');
if (trackerContainer) {
const input = findCommentInput(trackerContainer, { preferredRoot });
if (isElementVisible(input)) {
return input;
}
}
const dialogRoot = preferredRoot || tracker.closest(DIALOG_ROOT_SELECTOR);
if (dialogRoot) {
const dialogInput = dialogRoot.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
return dialogInput;
}
}
}
return null;
};
while (Date.now() <= deadline) {
if (context && context.cancelled) {
return null;
}
attempts++;
let input = findCommentInput(postElement, { preferredRoot });
if (isElementVisible(input)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (post context)');
}
return input;
}
input = findByEncodedUrl();
if (input) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (encoded URL context)');
}
return input;
}
const dialogRootFromPost = preferredRoot
|| (postElement ? postElement.closest(DIALOG_ROOT_SELECTOR) : null);
if (dialogRootFromPost) {
const dialogInput = dialogRootFromPost.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (dialog context)');
}
return dialogInput;
}
}
if (!preferredRoot) {
const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR);
if (fallbackDialog && fallbackDialog !== dialogRootFromPost) {
const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (fallback dialog)');
}
return dialogInput;
}
}
}
const globalInputs = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"]')).filter(isElementVisible);
if (preferredRoot) {
const scopedInputs = globalInputs.filter(input => preferredRoot.contains(input));
if (scopedInputs.length > 0) {
const lastInput = scopedInputs[scopedInputs.length - 1];
if (lastInput) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (preferred root fallback)');
}
return lastInput;
}
}
}
if (globalInputs.length > 0) {
const lastInput = globalInputs[globalInputs.length - 1];
if (lastInput) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (global fallback)');
}
return lastInput;
}
}
await delay(interval);
}
console.log('[FB Tracker] Comment input wait timed out after', timeout, 'ms');
return null;
}
/**
* Set text in comment input field
*/
async function setCommentText(inputElement, text, options = {}) {
const { context = null } = options;
const ensureNotCancelled = () => {
if (context && context.cancelled) {
const cancelError = new Error('AI_CANCELLED');
cancelError.name = 'AICancelled';
throw cancelError;
}
};
if (!inputElement || !text) {
return false;
}
try {
ensureNotCancelled();
console.log('[FB Tracker] Setting comment text:', text.substring(0, 50) + '...');
console.log('[FB Tracker] Input element:', inputElement);
// Focus and click to ensure field is active
inputElement.focus();
inputElement.click();
// Small delay to ensure field is ready
await delay(50);
ensureNotCancelled();
// Clear existing content
inputElement.textContent = '';
// Method 1: Try execCommand first (best for Facebook)
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(inputElement);
selection.removeAllRanges();
selection.addRange(range);
const execSuccess = document.execCommand('insertText', false, text);
console.log('[FB Tracker] execCommand result:', execSuccess);
// Wait a bit and check if it worked
await delay(100);
ensureNotCancelled();
let currentContent = inputElement.textContent || inputElement.innerText || '';
console.log('[FB Tracker] Content after execCommand:', currentContent);
// If execCommand didn't work, use direct method
if (!currentContent || currentContent.trim().length === 0) {
console.log('[FB Tracker] execCommand failed, using direct method');
inputElement.textContent = text;
currentContent = text;
}
// Trigger input events
inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true }));
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
// Final verification
ensureNotCancelled();
const finalContent = inputElement.textContent || inputElement.innerText || '';
console.log('[FB Tracker] Final content:', finalContent.substring(0, 50));
return finalContent.length > 0;
} catch (error) {
console.error('[FB Tracker] Failed to set comment text:', error);
return false;
}
}
function sanitizeAIComment(comment) {
if (!comment || typeof comment !== 'string') {
return '';
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = comment;
const sanitized = tempDiv.textContent || tempDiv.innerText || '';
return sanitized.trim();
}
function nowPerformanceMs() {
if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function roundDurationMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return Math.round(numeric * 1000) / 1000;
}
function buildAITraceId(prefix = 'trace') {
const safePrefix = String(prefix || 'trace').trim().replace(/[^a-z0-9_-]/gi, '').toLowerCase() || 'trace';
if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') {
return `${safePrefix}-${crypto.randomUUID()}`;
}
const randomPart = Math.random().toString(36).slice(2, 10);
return `${safePrefix}-${Date.now().toString(36)}-${randomPart}`;
}
async function reportAIDebugFrontendTrace(payload = {}) {
try {
const body = {
traceId: payload.traceId || null,
flowId: payload.flowId || null,
source: payload.source || 'extension-ai-button',
status: payload.status || 'frontend_reported',
requestMeta: payload.requestMeta || null,
frontendTimings: payload.frontendTimings || null,
frontendSteps: Array.isArray(payload.frontendSteps) ? payload.frontendSteps : null,
frontendError: payload.frontendError || null,
totalDurationMs: payload.totalDurationMs
};
await backendFetch(`${API_URL}/ai/debug-traces/frontend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (error) {
console.warn('[FB Tracker] Failed to submit AI frontend trace:', error);
}
}
/**
* Generate AI comment for a post
*/
async function generateAIComment(postText, profileNumber, options = {}) {
const {
signal = null,
preferredCredentialId = null,
maxAttempts = 3,
flowId = null,
source = 'extension-ai-button',
returnMeta = false
} = options;
const normalizedFlowId = typeof flowId === 'string' && flowId.trim()
? flowId.trim()
: buildAITraceId('flow');
const basePayload = {
postText,
profileNumber,
flowId: normalizedFlowId,
traceSource: source
};
if (typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)) {
basePayload.preferredCredentialId = preferredCredentialId;
}
const requestAttempts = [];
let lastError = null;
let lastTraceId = null;
const attempts = Math.max(1, maxAttempts);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const requestTraceId = `${normalizedFlowId}-r${attempt}`;
const startedAt = new Date().toISOString();
const attemptStartedMs = nowPerformanceMs();
const payload = {
...basePayload,
traceId: requestTraceId,
requestAttempt: attempt
};
try {
const response = await backendFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-ai-trace-id': requestTraceId,
'x-ai-flow-id': normalizedFlowId,
'x-ai-trace-source': source
},
body: JSON.stringify(payload),
signal
});
const durationMs = roundDurationMs(nowPerformanceMs() - attemptStartedMs);
const responseTraceId = response.headers.get('x-ai-trace-id') || requestTraceId;
const responseFlowId = response.headers.get('x-ai-flow-id') || normalizedFlowId;
lastTraceId = responseTraceId || lastTraceId;
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error || 'Failed to generate comment';
requestAttempts.push({
attempt,
traceId: responseTraceId,
flowId: responseFlowId,
status: 'http_error',
startedAt,
durationMs,
httpStatus: response.status,
error: message
});
const error = new Error(message);
error.status = response.status;
error.responseData = errorData;
error.aiTrace = {
traceId: responseTraceId,
flowId: responseFlowId,
requestAttempts: requestAttempts.slice()
};
throw error;
}
const data = await response.json();
const sanitizedComment = sanitizeAIComment(data.comment);
const effectiveTraceId = data.traceId || responseTraceId;
const effectiveFlowId = data.flowId || responseFlowId;
lastTraceId = effectiveTraceId || lastTraceId;
requestAttempts.push({
attempt,
traceId: effectiveTraceId,
flowId: effectiveFlowId,
status: sanitizedComment ? 'success' : 'empty',
startedAt,
durationMs,
httpStatus: response.status,
backendTimings: data.timings && data.timings.backend ? data.timings.backend : null
});
if (sanitizedComment) {
const result = {
comment: sanitizedComment,
traceId: effectiveTraceId,
flowId: effectiveFlowId,
requestAttempts,
backendTimings: data.timings && data.timings.backend ? data.timings.backend : null,
autoCommentRateLimitStatus: data.autoCommentRateLimitStatus || null
};
return returnMeta ? result : sanitizedComment;
}
lastError = new Error('AI response empty');
} catch (error) {
const durationMs = roundDurationMs(nowPerformanceMs() - attemptStartedMs);
const cancelled = error && (error.name === 'AbortError' || isCancellationError(error));
if (!error || !error.aiTrace) {
requestAttempts.push({
attempt,
traceId: requestTraceId,
flowId: normalizedFlowId,
status: cancelled ? 'cancelled' : 'request_error',
startedAt,
durationMs,
error: error && error.message ? String(error.message) : 'Unbekannter Fehler'
});
} else if (error.aiTrace && error.aiTrace.traceId) {
lastTraceId = error.aiTrace.traceId;
}
lastError = error;
if (cancelled || (error && error.status === 429)) {
break;
}
}
if (attempt < attempts) {
console.warn(`[FB Tracker] AI comment generation attempt ${attempt} failed, retrying...`, lastError);
await delay(200);
}
}
if (lastError) {
if (!lastError.aiTrace) {
lastError.aiTrace = {
traceId: lastTraceId,
flowId: normalizedFlowId,
requestAttempts: requestAttempts.slice()
};
}
if (lastError.name === 'AbortError' || isCancellationError(lastError) || lastError.status === 429) {
throw lastError;
}
}
console.error('[FB Tracker] AI comment generation failed after retries:', lastError);
const finalError = new Error('AI-Antwort konnte nicht erzeugt werden. Bitte später erneut versuchen.');
finalError.aiTrace = {
traceId: lastTraceId,
flowId: normalizedFlowId,
requestAttempts: requestAttempts.slice()
};
throw finalError;
}
async function handleSelectionAIRequest(selectionText, sendResponse) {
try {
const normalizedSelection = normalizeSelectionText(selectionText);
if (!normalizedSelection) {
showToast('Keine gültige Auswahl gefunden', 'error');
sendResponse({ success: false, error: 'Keine gültige Auswahl gefunden' });
return;
}
showToast('AI verarbeitet Auswahl...', 'info');
const profileNumber = await getProfileNumber();
if (!profileNumber) {
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
sendResponse({ error: 'profile-missing' });
return;
}
const comment = await generateAIComment(normalizedSelection, profileNumber, {
source: 'selection-ai'
});
if (!comment) {
throw new Error('Keine Antwort vom AI-Dienst erhalten');
}
const copied = await copyTextToClipboard(comment);
if (!copied) {
throw new Error('Antwort konnte nicht in die Zwischenablage kopiert werden');
}
showToast('AI-Antwort in die Zwischenablage kopiert', 'success');
sendResponse({ success: true, comment });
} catch (error) {
console.error('[FB Tracker] Selection AI error:', error);
showToast(`${error.message || 'Fehler bei AI-Anfrage'}`, 'error');
sendResponse({ success: false, error: error.message || 'Unbekannter Fehler' });
}
}
/**
* Check if AI is enabled
*/
async function isAIEnabled() {
try {
const settings = await fetchAISettings();
return settings && settings.enabled === 1;
} catch (error) {
console.warn('[FB Tracker] Failed to check AI settings:', error);
return false;
}
}
/**
* Add AI comment button to tracker UI
*/
async function addAICommentButton(container, postElement) {
const aiEnabled = await isAIEnabled();
if (!aiEnabled) {
return;
}
const actionsContainer = ensureTrackerActionsContainer(container);
if (!actionsContainer) {
return;
}
const existingWrapper = actionsContainer.querySelector('.fb-tracker-ai-wrapper');
if (existingWrapper && existingWrapper.isConnected) {
return;
}
const encodedPostUrl = container && container.getAttribute('data-post-url')
? container.getAttribute('data-post-url')
: null;
const wrapper = document.createElement('div');
wrapper.className = 'fb-tracker-ai-wrapper';
wrapper.style.cssText = `
position: relative;
display: inline-flex;
align-items: stretch;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: transform 0.2s ease, box-shadow 0.2s ease;
flex: 0 1 auto;
max-width: 100%;
white-space: nowrap;
`;
const button = document.createElement('button');
button.type = 'button';
button.className = 'fb-tracker-btn fb-tracker-btn-ai';
button.textContent = '✨ AI';
button.title = 'Generiere automatisch einen passenden Kommentar';
button.style.cssText = `
background: transparent;
color: white;
border: none;
padding: 6px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
flex: 1 1 auto;
border-radius: 0;
transition: background-color 0.2s ease;
`;
const dropdownButton = document.createElement('button');
dropdownButton.type = 'button';
dropdownButton.className = 'fb-tracker-btn fb-tracker-btn-ai-dropdown';
dropdownButton.textContent = '▾';
dropdownButton.title = 'AI auswählen';
dropdownButton.setAttribute('aria-label', 'AI auswählen');
dropdownButton.setAttribute('aria-haspopup', 'menu');
dropdownButton.setAttribute('aria-expanded', 'false');
dropdownButton.style.cssText = `
background: transparent;
color: white;
border: none;
width: 34px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 0;
transition: background-color 0.2s ease;
`;
const dropdown = document.createElement('div');
dropdown.className = 'fb-tracker-ai-dropdown';
dropdown.style.cssText = `
display: none;
min-width: 220px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
z-index: 2147483647;
padding: 6px 0;
`;
wrapper.appendChild(button);
wrapper.appendChild(dropdownButton);
wrapper.appendChild(dropdown);
actionsContainer.appendChild(wrapper);
const baseWrapperShadow = '0 1px 2px rgba(0, 0, 0, 0.12)';
const setHoverState = (active) => {
if (active) {
wrapper.style.transform = 'translateY(-2px)';
wrapper.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
button.style.backgroundColor = 'rgba(255, 255, 255, 0.08)';
dropdownButton.style.backgroundColor = 'rgba(255, 255, 255, 0.08)';
} else {
wrapper.style.transform = 'translateY(0)';
wrapper.style.boxShadow = baseWrapperShadow;
button.style.backgroundColor = 'transparent';
dropdownButton.style.backgroundColor = 'transparent';
}
};
setHoverState(false);
const baseButtonText = button.textContent;
const resolvePostContexts = () => {
const contextCandidate = container.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]');
const normalizedContext = contextCandidate ? ensurePrimaryPostElement(contextCandidate) : null;
const fallbackContext = postElement ? ensurePrimaryPostElement(postElement) : null;
const postContext = normalizedContext || fallbackContext || contextCandidate || postElement || container;
return { postContext, contextCandidate, fallbackContext, normalizedContext };
};
const resolvePostContext = () => resolvePostContexts().postContext;
const getAdditionalNote = () => {
const context = resolvePostContext();
return context ? (postAdditionalNotes.get(context) || '') : '';
};
let notePreviewElement = null;
let noteClearButton = null;
let rateLimitRefreshPromise = null;
const truncateNoteForPreview = (note) => {
if (!note) {
return '';
}
return note.length > 120 ? `${note.slice(0, 117)}` : note;
};
const getDefaultButtonTitle = () => {
const hasNote = getAdditionalNote().trim().length > 0;
return hasNote
? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)'
: 'Generiere automatisch einen passenden Kommentar';
};
const buildBlockedButtonTitle = (status) => {
if (!status || !status.blocked) {
return getDefaultButtonTitle();
}
const untilText = status.blocked_until
? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.`
: '';
return `${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim();
};
const showBlockedToast = (status) => {
if (!status || !status.blocked) {
return;
}
const untilText = status.blocked_until
? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.`
: '';
showToast(`${status.profile_name || 'Profil'}: ${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim(), 'info');
};
const applyAvailabilityState = (status = null, options = {}) => {
const { preserveText = false } = options;
const blocked = Boolean(status && status.blocked);
button._aiAvailability = status || null;
button.dataset.aiAvailability = blocked ? 'blocked' : 'available';
if (blocked) {
wrapper.style.opacity = '0.72';
wrapper.style.boxShadow = baseWrapperShadow;
wrapper.style.transform = 'translateY(0)';
wrapper.style.background = 'linear-gradient(135deg, #8b929e 0%, #5f6773 100%)';
button.style.cursor = 'not-allowed';
dropdownButton.style.cursor = 'not-allowed';
dropdownButton.style.opacity = '0.75';
button.title = buildBlockedButtonTitle(status);
dropdownButton.title = buildBlockedButtonTitle(status);
dropdownButton.setAttribute('aria-label', buildBlockedButtonTitle(status));
if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = `${button.dataset.aiOriginalText || baseButtonText} 🔒`;
}
} else {
wrapper.style.opacity = '1';
wrapper.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
button.style.cursor = 'pointer';
dropdownButton.style.cursor = 'pointer';
dropdownButton.style.opacity = '1';
button.title = getDefaultButtonTitle();
dropdownButton.title = 'AI auswählen';
dropdownButton.setAttribute('aria-label', 'AI auswählen');
if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = button.dataset.aiOriginalText || baseButtonText;
}
if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open') && !(button.matches(':hover') || dropdownButton.matches(':hover'))) {
setHoverState(false);
}
}
};
const refreshAvailabilityState = async (forceRefresh = false, explicitProfileNumber = null) => {
if (rateLimitRefreshPromise) {
try {
return await rateLimitRefreshPromise;
} catch (error) {
return null;
}
}
rateLimitRefreshPromise = (async () => {
try {
const profileNumber = explicitProfileNumber || await fetchBackendProfileNumber();
if (!profileNumber) {
applyAvailabilityState(null);
return null;
}
const settings = await fetchAISettings(forceRefresh);
const status = getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber);
applyAvailabilityState(status);
return status;
} catch (error) {
console.warn('[FB Tracker] Failed to refresh AI rate limit status:', error);
applyAvailabilityState(null);
return null;
} finally {
rateLimitRefreshPromise = null;
}
})();
return await rateLimitRefreshPromise;
};
const updateNoteIndicator = () => {
const note = getAdditionalNote();
const hasNote = note.trim().length > 0;
button.dataset.aiOriginalText = hasNote ? `${baseButtonText}` : baseButtonText;
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = button.dataset.aiOriginalText;
}
applyAvailabilityState(button._aiAvailability || null);
};
const updateNotePreview = () => {
updateNoteIndicator();
if (notePreviewElement) {
const note = getAdditionalNote();
notePreviewElement.textContent = note
? `Aktuelle Zusatzinfo: ${truncateNoteForPreview(note)}`
: 'Keine Zusatzinfo gesetzt';
if (noteClearButton) {
const hasNote = note.trim().length > 0;
noteClearButton.disabled = !hasNote;
noteClearButton.style.opacity = hasNote ? '1' : '0.6';
noteClearButton.style.cursor = hasNote ? 'pointer' : 'default';
}
}
};
const setAdditionalNote = (value) => {
const context = resolvePostContext();
if (!context) {
return;
}
const trimmed = (value || '').trim();
if (trimmed) {
postAdditionalNotes.set(context, trimmed);
} else {
postAdditionalNotes.delete(context);
}
updateNotePreview();
};
const maybeActivateHover = () => {
if ((button.dataset.aiState || 'idle') === 'idle') {
setHoverState(true);
}
};
const maybeDeactivateHover = () => {
if (wrapper.classList.contains('fb-tracker-ai-wrapper--open')) {
return;
}
if (button.matches(':hover') || dropdownButton.matches(':hover')) {
return;
}
setHoverState(false);
};
button.addEventListener('mouseenter', maybeActivateHover);
button.addEventListener('mouseleave', maybeDeactivateHover);
dropdownButton.addEventListener('mouseenter', maybeActivateHover);
dropdownButton.addEventListener('mouseleave', maybeDeactivateHover);
button.addEventListener('pointerdown', () => {
const context = resolvePostContext();
const target = context || postElement || container;
cacheSelectionForPost(target);
});
button.dataset.aiState = 'idle';
button.dataset.aiOriginalText = button.textContent;
let dropdownOpen = false;
let dropdownPortalParent = null;
const resolveDropdownPortalParent = () => {
if (dropdownPortalParent && dropdownPortalParent.isConnected) {
return dropdownPortalParent;
}
const candidate = document.body || document.documentElement;
dropdownPortalParent = candidate;
return dropdownPortalParent;
};
const mountDropdownInPortal = () => {
const portalParent = resolveDropdownPortalParent();
if (!portalParent) {
return;
}
if (dropdown.parentElement !== portalParent) {
portalParent.appendChild(dropdown);
}
};
const restoreDropdownToWrapper = () => {
if (dropdown.parentElement !== wrapper) {
wrapper.appendChild(dropdown);
}
};
const closeDropdown = () => {
if (!dropdownOpen) {
return;
}
dropdown.style.display = 'none';
dropdownOpen = false;
dropdownButton.setAttribute('aria-expanded', 'false');
dropdownButton.textContent = '▾';
wrapper.classList.remove('fb-tracker-ai-wrapper--open');
document.removeEventListener('click', handleOutsideClick, true);
document.removeEventListener('keydown', handleKeydown, true);
if (button.matches(':hover') || dropdownButton.matches(':hover')) {
setHoverState(true);
} else {
setHoverState(false);
}
dropdown.style.position = '';
dropdown.style.top = '';
dropdown.style.left = '';
dropdown.style.maxHeight = '';
dropdown.style.overflowY = '';
restoreDropdownToWrapper();
window.removeEventListener('scroll', repositionDropdown, true);
window.removeEventListener('resize', repositionDropdown);
};
const getDecodedPostUrl = () => {
const raw = encodedPostUrl || (container && container.getAttribute('data-post-url'));
if (!raw) {
return null;
}
try {
return decodeURIComponent(raw);
} catch (error) {
console.warn('[FB Tracker] Konnte Post-URL nicht dekodieren:', error);
return null;
}
};
const confirmParticipationAfterAI = async (profileNumber) => {
try {
if (!container) {
return;
}
const effectiveProfile = profileNumber || await getProfileNumber();
if (!effectiveProfile) {
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
return;
}
const decodedUrl = getDecodedPostUrl();
const isFeedHomeFlag = container.dataset.isFeedHome === '1';
const isDialogFlag = container.dataset.isDialogContext === '1';
const postNumValue = container.getAttribute('data-post-num') || '?';
const encodedUrlValue = container.getAttribute('data-post-url') || '';
let latestData = null;
let postId = container.dataset.postId || '';
if (postId) {
latestData = await markPostChecked(postId, effectiveProfile, { ignoreOrder: true });
if (!latestData && decodedUrl) {
const refreshed = await checkPostStatus(decodedUrl);
if (refreshed && refreshed.id) {
container.dataset.postId = refreshed.id;
latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
}
}
} else if (decodedUrl) {
const refreshed = await checkPostStatus(decodedUrl);
if (refreshed && refreshed.id) {
container.dataset.postId = refreshed.id;
latestData = await markPostChecked(refreshed.id, effectiveProfile, { ignoreOrder: true }) || refreshed;
}
}
if (!latestData && decodedUrl) {
const fallbackStatus = await checkPostStatus(decodedUrl);
if (fallbackStatus) {
latestData = fallbackStatus;
}
}
if (latestData) {
await renderTrackedStatus({
container,
postElement,
postData: latestData,
profileNumber: effectiveProfile,
isFeedHome: isFeedHomeFlag,
isDialogContext: isDialogFlag,
manualHideInfo: null,
encodedUrl: encodedUrlValue,
postNum: postNumValue
});
}
} catch (error) {
console.error('[FB Tracker] Auto-confirm nach AI fehlgeschlagen:', error);
}
};
const handleOutsideClick = (event) => {
if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) {
closeDropdown();
}
};
const handleKeydown = (event) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
const renderDropdownItems = async () => {
dropdown.innerHTML = '';
const loading = document.createElement('div');
loading.textContent = 'Lade AI-Auswahl...';
loading.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #555;';
dropdown.appendChild(loading);
const appendNoteUI = () => {
noteClearButton = null;
const noteSection = document.createElement('div');
noteSection.style.cssText = 'padding: 10px 14px; display: flex; flex-direction: column; gap: 6px;';
notePreviewElement = document.createElement('div');
notePreviewElement.style.cssText = 'font-size: 12px; color: #65676b; white-space: normal;';
noteSection.appendChild(notePreviewElement);
const buttonsRow = document.createElement('div');
buttonsRow.style.cssText = 'display: flex; gap: 8px; flex-wrap: wrap;';
const selectionButton = document.createElement('button');
selectionButton.type = 'button';
selectionButton.textContent = 'Auswahl als Zusatzinfo';
selectionButton.style.cssText = 'padding: 6px 10px; border-radius: 6px; border: 1px solid #ccd0d5; background: #fff; cursor: pointer; font-size: 12px; font-weight: 600; color: #1d2129;';
selectionButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const selection = window.getSelection();
const anchorNode = selection ? (selection.anchorNode || selection.focusNode) : null;
if (anchorNode && isSelectionInsideEditable(anchorNode)) {
showToast('Textauswahl aus Eingabefeldern kann nicht genutzt werden', 'error');
return;
}
const context = resolvePostContext();
let selectedText = context ? getSelectedTextFromPost(context) : '';
if (!selectedText && selection) {
selectedText = normalizeSelectionText(selection.toString());
}
if (!selectedText && lastGlobalSelection.text && Date.now() - lastGlobalSelection.timestamp < LAST_SELECTION_MAX_AGE) {
selectedText = normalizeSelectionText(lastGlobalSelection.text);
}
if (!selectedText) {
showToast('Keine Textauswahl gefunden', 'error');
return;
}
setAdditionalNote(selectedText);
showToast('Auswahl als Zusatzinfo gesetzt', 'success');
});
buttonsRow.appendChild(selectionButton);
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.textContent = 'Zusatzinfo bearbeiten';
editButton.style.cssText = 'padding: 6px 10px; border-radius: 6px; border: 1px solid #ccd0d5; background: #fff; cursor: pointer; font-size: 12px; font-weight: 600; color: #1d2129;';
editButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const existingNote = getAdditionalNote();
const input = window.prompt('Zusatzinfo für den AI-Prompt eingeben:', existingNote);
if (input === null) {
return;
}
const trimmed = (input || '').trim();
setAdditionalNote(trimmed);
if (trimmed) {
showToast('Zusatzinfo gespeichert', 'success');
} else {
showToast('Zusatzinfo entfernt', 'success');
}
});
buttonsRow.appendChild(editButton);
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.textContent = 'Zurücksetzen';
clearButton.style.cssText = 'padding: 6px 10px; border-radius: 6px; border: 1px solid #ccd0d5; background: #fff; cursor: pointer; font-size: 12px; font-weight: 600; color: #a11900;';
clearButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (!getAdditionalNote()) {
return;
}
setAdditionalNote('');
showToast('Zusatzinfo entfernt', 'success');
});
buttonsRow.appendChild(clearButton);
noteClearButton = clearButton;
noteSection.appendChild(buttonsRow);
dropdown.appendChild(noteSection);
updateNotePreview();
};
try {
const credentials = await fetchActiveAICredentials();
dropdown.innerHTML = '';
appendNoteUI();
if (!credentials || credentials.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Keine aktiven AI-Anbieter gefunden';
empty.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #888;';
dropdown.appendChild(empty);
} else {
const divider = document.createElement('div');
divider.style.cssText = 'border-top: 1px solid #e4e6eb; margin: 6px 0;';
dropdown.appendChild(divider);
credentials.forEach((credential) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'fb-tracker-ai-option';
option.style.cssText = `
width: 100%;
padding: 8px 14px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 2px;
`;
option.addEventListener('mouseenter', () => {
option.style.background = '#f0f2f5';
});
option.addEventListener('mouseleave', () => {
option.style.background = 'transparent';
});
const label = document.createElement('span');
label.textContent = formatAICredentialLabel(credential);
label.style.cssText = 'font-weight: 600; color: #1d2129;';
const metaParts = [];
if (credential.provider) {
metaParts.push(`Provider: ${credential.provider}`);
}
if (credential.model) {
metaParts.push(`Modell: ${credential.model}`);
}
if (metaParts.length > 0) {
const meta = document.createElement('span');
meta.textContent = metaParts.join(' · ');
meta.style.cssText = 'font-size: 12px; color: #65676b;';
option.appendChild(label);
option.appendChild(meta);
} else {
option.appendChild(label);
}
option.addEventListener('click', () => {
closeDropdown();
if ((button.dataset.aiState || 'idle') === 'idle') {
cacheSelectionForPost(postElement);
startAIFlow(credential.id);
}
});
dropdown.appendChild(option);
});
}
} catch (error) {
dropdown.innerHTML = '';
appendNoteUI();
const errorItem = document.createElement('div');
errorItem.textContent = error.message || 'AI-Anbieter konnten nicht geladen werden';
errorItem.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #c0392b; white-space: normal;';
dropdown.appendChild(errorItem);
}
};
const positionDropdown = () => {
if (!dropdownOpen) {
return;
}
mountDropdownInPortal();
dropdown.style.position = 'fixed';
dropdown.style.maxHeight = `${Math.max(200, Math.floor(window.innerHeight * 0.6))}px`;
dropdown.style.overflowY = 'auto';
const rect = wrapper.getBoundingClientRect();
const dropdownRect = dropdown.getBoundingClientRect();
const margin = 8;
let top = rect.top - dropdownRect.height - margin;
if (top < margin) {
top = rect.bottom + margin;
}
const viewportPadding = 8;
let left = rect.right - dropdownRect.width;
if (left < viewportPadding) {
left = viewportPadding;
}
const maxLeft = window.innerWidth - dropdownRect.width - viewportPadding;
if (left > maxLeft) {
left = Math.max(viewportPadding, maxLeft);
}
const maxTop = window.innerHeight - dropdownRect.height - margin;
if (top > maxTop) {
top = Math.max(viewportPadding, maxTop);
}
dropdown.style.top = `${top}px`;
dropdown.style.left = `${left}px`;
};
const repositionDropdown = () => {
if (dropdownOpen) {
positionDropdown();
}
};
const toggleDropdown = async () => {
if ((button.dataset.aiState || 'idle') !== 'idle') {
return;
}
if (button.dataset.aiAvailability === 'blocked') {
showBlockedToast(button._aiAvailability || null);
return;
}
if (dropdownOpen) {
closeDropdown();
return;
}
dropdownOpen = true;
wrapper.classList.add('fb-tracker-ai-wrapper--open');
dropdownButton.textContent = '▴';
setHoverState(true);
mountDropdownInPortal();
dropdown.style.display = 'block';
dropdownButton.setAttribute('aria-expanded', 'true');
document.addEventListener('click', handleOutsideClick, true);
document.addEventListener('keydown', handleKeydown, true);
await renderDropdownItems();
positionDropdown();
window.addEventListener('scroll', repositionDropdown, true);
window.addEventListener('resize', repositionDropdown);
};
dropdownButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
toggleDropdown();
});
const startAIFlow = async (preferredCredentialId = null) => {
closeDropdown();
const originalText = button.dataset.aiOriginalText || '✨ AI';
const currentState = button.dataset.aiState || 'idle';
const currentAvailability = button._aiAvailability || null;
if (currentState === 'processing') {
const runningContext = button._aiContext;
if (runningContext && !runningContext.cancelled) {
runningContext.cancel();
button.dataset.aiState = 'cancelling';
button.textContent = '✋ Abbruch...';
button.setAttribute('aria-busy', 'true');
button.style.cursor = 'progress';
dropdownButton.style.cursor = 'progress';
button.classList.remove('fb-tracker-btn-ai--processing');
button.classList.add('fb-tracker-btn-ai--cancelling');
}
return;
}
if (currentState === 'cancelling') {
return;
}
if (currentAvailability && currentAvailability.blocked) {
showBlockedToast(currentAvailability);
return;
}
const aiContext = {
cancelled: false,
abortController: new AbortController(),
cancel() {
if (!this.cancelled) {
this.cancelled = true;
this.abortController.abort();
}
}
};
const flowTrace = {
source: 'extension-ai-button',
flowId: buildAITraceId('flow'),
traceId: null,
status: 'processing',
startedAt: new Date().toISOString(),
finishedAt: null,
frontendTimings: {},
frontendSteps: [],
frontendError: null,
totalDurationMs: null,
requestMeta: {
preferredCredentialId: typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)
? preferredCredentialId
: null
},
backend: null
};
const flowStartMs = nowPerformanceMs();
const phaseStartTimes = {};
const addStep = (step, payload = {}) => {
flowTrace.frontendSteps.push({
step,
at: new Date().toISOString(),
...(payload && typeof payload === 'object' ? payload : {})
});
};
const beginPhase = (name) => {
phaseStartTimes[name] = nowPerformanceMs();
};
const endPhase = (name, payload = null) => {
if (!Object.prototype.hasOwnProperty.call(phaseStartTimes, name)) {
return null;
}
const durationMs = roundDurationMs(nowPerformanceMs() - phaseStartTimes[name]);
flowTrace.frontendTimings[name] = durationMs;
if (payload && typeof payload === 'object') {
addStep(name, payload);
}
return durationMs;
};
const mergeTraceInfo = (tracePayload) => {
if (!tracePayload || typeof tracePayload !== 'object') {
return;
}
if (tracePayload.traceId) {
flowTrace.traceId = tracePayload.traceId;
}
if (tracePayload.flowId) {
flowTrace.flowId = tracePayload.flowId;
}
const backend = {};
if (Array.isArray(tracePayload.requestAttempts)) {
backend.requestAttempts = tracePayload.requestAttempts;
}
if (tracePayload.backendTimings && typeof tracePayload.backendTimings === 'object') {
backend.timings = tracePayload.backendTimings;
}
if (Object.keys(backend).length) {
flowTrace.backend = backend;
}
};
const throwIfCancelled = () => {
if (aiContext.cancelled) {
const cancelError = new Error('AI_CANCELLED');
cancelError.name = 'AICancelled';
throw cancelError;
}
};
const updateProcessingText = (text) => {
if (button.dataset.aiState === 'processing' && !aiContext.cancelled) {
button.textContent = text;
}
};
const restoreIdle = (text, revertDelay = 0) => {
button.dataset.aiState = 'idle';
button._aiContext = null;
button.removeAttribute('aria-busy');
button.classList.remove('fb-tracker-btn-ai--processing', 'fb-tracker-btn-ai--cancelling');
button.style.cursor = 'pointer';
dropdownButton.disabled = false;
dropdownButton.style.opacity = '1';
dropdownButton.style.cursor = 'pointer';
dropdownButton.textContent = '▾';
dropdownButton.setAttribute('aria-busy', 'false');
button.textContent = text;
applyAvailabilityState(button._aiAvailability || null, { preserveText: true });
if (revertDelay > 0) {
setTimeout(() => {
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = originalText;
applyAvailabilityState(button._aiAvailability || null);
}
}, revertDelay);
}
if (wrapper.classList.contains('fb-tracker-ai-wrapper--open')) {
setHoverState(true);
} else if (!(button.matches(':hover') || dropdownButton.matches(':hover'))) {
setHoverState(false);
}
};
button._aiContext = aiContext;
button.dataset.aiState = 'processing';
button.setAttribute('aria-busy', 'true');
button.classList.add('fb-tracker-btn-ai--processing');
button.classList.remove('fb-tracker-btn-ai--cancelling');
button.style.cursor = 'progress';
setHoverState(true);
dropdownButton.disabled = true;
dropdownButton.style.opacity = '0.5';
dropdownButton.style.cursor = 'not-allowed';
dropdownButton.setAttribute('aria-busy', 'true');
button.textContent = '⏳ Generiere...';
try {
const contexts = resolvePostContexts();
const { postContext, contextCandidate, fallbackContext } = contexts;
const selectionKeys = [];
if (postContext) {
selectionKeys.push(postContext);
}
if (postElement && postElement !== postContext) {
selectionKeys.push(postElement);
}
if (contextCandidate && contextCandidate !== postContext && contextCandidate !== postElement) {
selectionKeys.push(contextCandidate);
}
if (fallbackContext
&& fallbackContext !== postContext
&& fallbackContext !== postElement
&& fallbackContext !== contextCandidate) {
selectionKeys.push(fallbackContext);
}
const resolveRecentSelection = () => {
for (const key of selectionKeys) {
if (!key) {
continue;
}
const entry = postSelectionCache.get(key);
if (entry && Date.now() - entry.timestamp < LAST_SELECTION_MAX_AGE) {
return entry;
}
}
return null;
};
beginPhase('extractPostTextMs');
let postText = '';
const cachedSelection = resolveRecentSelection();
if (cachedSelection) {
console.log('[FB Tracker] Using cached selection text');
postText = cachedSelection.text;
}
throwIfCancelled();
if (!postText) {
const selectionSource = postContext || postElement;
if (selectionSource) {
postText = getSelectedTextFromPost(selectionSource);
if (postText) {
console.log('[FB Tracker] Using active selection text');
}
}
}
if (!postText) {
const latestCached = resolveRecentSelection();
if (latestCached) {
console.log('[FB Tracker] Using latest cached selection after check');
postText = latestCached.text;
}
}
if (!postText) {
postText = extractPostText(postContext);
if (postText) {
console.log('[FB Tracker] Fallback to DOM extraction');
}
}
if (!postText) {
throw new Error('Konnte Post-Text nicht extrahieren');
}
selectionKeys.forEach((key) => {
if (key) {
postSelectionCache.delete(key);
}
});
const additionalNote = postContext ? (postAdditionalNotes.get(postContext) || '') : '';
if (additionalNote) {
postText = `${postText}\n\nZusatzinfo:\n${additionalNote}`;
}
flowTrace.requestMeta.postTextLength = postText.length;
endPhase('extractPostTextMs', { postTextLength: postText.length });
throwIfCancelled();
console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100));
beginPhase('profileLookupMs');
const profileNumber = await getProfileNumber();
endPhase('profileLookupMs', { profileNumber: profileNumber || null });
if (!profileNumber) {
flowTrace.status = 'profile_missing';
flowTrace.frontendError = 'Profilstatus nicht geladen';
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
restoreIdle(originalText);
return;
}
throwIfCancelled();
beginPhase('rateLimitStatusLookupMs');
const liveRateLimitStatus = await refreshAvailabilityState(true, profileNumber);
endPhase('rateLimitStatusLookupMs', {
blocked: Boolean(liveRateLimitStatus && liveRateLimitStatus.blocked)
});
if (liveRateLimitStatus && liveRateLimitStatus.blocked) {
flowTrace.status = 'blocked';
flowTrace.frontendError = liveRateLimitStatus.blocked_reason || 'AI_RATE_LIMIT_BLOCKED';
restoreIdle(`${originalText} 🔒`);
showBlockedToast(liveRateLimitStatus);
return;
}
beginPhase('aiRequestMs');
const aiResult = await generateAIComment(postText, profileNumber, {
signal: aiContext.abortController.signal,
preferredCredentialId,
flowId: flowTrace.flowId,
source: flowTrace.source,
returnMeta: true
});
endPhase('aiRequestMs', {
traceId: aiResult.traceId || null,
requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0
});
mergeTraceInfo(aiResult);
if (aiResult.autoCommentRateLimitStatus) {
applyAvailabilityState(aiResult.autoCommentRateLimitStatus);
} else {
void refreshAvailabilityState(true);
}
throwIfCancelled();
const comment = aiResult.comment;
console.log('[FB Tracker] Generated comment:', comment);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR)
|| (postContext && postContext.closest(DIALOG_ROOT_SELECTOR));
let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot });
let waitedForInput = false;
let waitStartedMs = null;
if (!commentInput) {
console.log('[FB Tracker] Comment input not found, trying to click comment button');
let buttonClicked = findAndClickCommentButton(postContext);
if (!buttonClicked && dialogRoot) {
const dialogCommentButton = dialogRoot.querySelector('[data-ad-rendering-role="comment_button"], [aria-label*="Kommentieren"], [aria-label*="Comment"]');
if (dialogCommentButton && isElementVisible(dialogCommentButton)) {
dialogCommentButton.click();
buttonClicked = true;
}
}
updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...');
waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
timeout: buttonClicked ? 8000 : 5000,
interval: 250,
context: aiContext,
preferredRoot: dialogRoot
});
}
if (!commentInput && !waitedForInput) {
updateProcessingText('⏳ Suche Kommentarfeld...');
waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
timeout: 4000,
interval: 200,
context: aiContext,
preferredRoot: dialogRoot
});
}
if (waitStartedMs !== null) {
flowTrace.frontendTimings.waitForCommentInputMs = roundDurationMs(nowPerformanceMs() - waitStartedMs);
addStep('waitForCommentInputMs', {
durationMs: flowTrace.frontendTimings.waitForCommentInputMs,
found: Boolean(commentInput)
});
}
flowTrace.requestMeta.waitedForInput = waitedForInput;
flowTrace.requestMeta.commentInputFound = Boolean(commentInput);
throwIfCancelled();
if (!commentInput) {
beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'input_missing' });
throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
return;
}
if (waitedForInput) {
updateProcessingText('⏳ Füge Kommentar ein...');
}
beginPhase('setCommentTextMs');
const success = await setCommentText(commentInput, comment, { context: aiContext });
endPhase('setCommentTextMs', { success: Boolean(success) });
throwIfCancelled();
if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000);
flowTrace.status = 'success';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
} else {
beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'set_comment_failed' });
throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
}
} catch (error) {
if (error && error.aiTrace) {
mergeTraceInfo(error.aiTrace);
}
if (error && error.status === 429) {
await refreshAvailabilityState(true);
}
const cancelled = aiContext.cancelled || isCancellationError(error);
if (cancelled) {
console.log('[FB Tracker] AI comment operation cancelled');
flowTrace.status = 'cancelled';
flowTrace.frontendError = 'AI_CANCELLED';
restoreIdle('✋ Abgebrochen', 1500);
showToast('⏹️ Vorgang abgebrochen', 'info');
return;
}
console.error('[FB Tracker] AI comment error:', error);
flowTrace.status = 'error';
flowTrace.frontendError = error && error.message ? String(error.message) : 'Unbekannter Fehler';
showToast(`${error.message}`, 'error');
restoreIdle(originalText);
} finally {
flowTrace.finishedAt = new Date().toISOString();
flowTrace.totalDurationMs = roundDurationMs(nowPerformanceMs() - flowStartMs);
flowTrace.frontendTimings.totalMs = flowTrace.totalDurationMs;
if (!flowTrace.status || flowTrace.status === 'processing') {
flowTrace.status = 'finished';
}
addStep('flowComplete', {
status: flowTrace.status,
totalDurationMs: flowTrace.totalDurationMs
});
void reportAIDebugFrontendTrace({
traceId: flowTrace.traceId,
flowId: flowTrace.flowId,
source: flowTrace.source,
status: flowTrace.status,
requestMeta: {
...flowTrace.requestMeta,
startedAt: flowTrace.startedAt,
finishedAt: flowTrace.finishedAt,
backend: flowTrace.backend
},
frontendTimings: flowTrace.frontendTimings,
frontendSteps: flowTrace.frontendSteps,
frontendError: flowTrace.frontendError,
totalDurationMs: flowTrace.totalDurationMs
});
}
};
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (button.dataset.aiAvailability === 'blocked') {
showBlockedToast(button._aiAvailability || null);
return;
}
startAIFlow();
});
void refreshAvailabilityState(false);
container.appendChild(wrapper);
}
// Expose function globally so it can be called from createTrackerUI
window.addAICommentButton = addAICommentButton;