// Facebook Post Tracker Extension // Uses API_BASE_URL from config.js const EXTENSION_VERSION = '1.1.0'; 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 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 = '/search/top'; const FEED_HOME_PATHS = ['/', '/home.php']; const sessionSearchRecordedUrls = new Set(); const sessionSearchInfoCache = new Map(); const trackerElementsByPost = new WeakMap(); const REELS_PATH_PREFIX = '/reel/'; function isOnReelsPage() { try { const pathname = window.location && window.location.pathname; return typeof pathname === 'string' && pathname.startsWith(REELS_PATH_PREFIX); } catch (error) { return false; } } 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); } const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache const aiCredentialCache = { data: null, timestamp: 0, pending: null }; console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL); 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; } } 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 && data.profile_number) { return data.profile_number; } } 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; } function storeProfileNumberLocally(profileNumber) { chrome.storage.sync.set({ profileNumber }); } async function getProfileNumber() { const backendProfile = await fetchBackendProfileNumber(); if (backendProfile) { storeProfileNumberLocally(backendProfile); console.log('[FB Tracker] Profile number (backend):', backendProfile); return backendProfile; } return new Promise((resolve) => { chrome.storage.sync.get(['profileNumber'], (result) => { const profile = result.profileNumber || 1; console.log('[FB Tracker] Profile number (local):', profile); resolve(profile); }); }); } // 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 }; } // 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 }; } const fallbackCandidate = extractPostUrlCandidate(window.location.href); if (fallbackCandidate) { console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement); return { url: fallbackCandidate, allCandidates: [fallbackCandidate] }; } console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement); return { url: '', allCandidates: [] }; } // 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); } } console.log('[FB Tracker] Checking post status for URLs:', urlsToCheck); let foundPost = null; let foundUrl = null; // Check each URL for (const url of urlsToCheck) { 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) { return foundPost; } console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.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 } = options || {}; const payload = { url: primaryUrl, candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [], skip_increment: !!skipIncrement, force_hide: !!forceHide }; 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; } } // Add post to tracking async function markPostChecked(postId, profileNumber) { try { 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 }) }); if (response.ok) { const data = await response.json(); console.log('[FB Tracker] Post marked as checked:', data); return data; } if (response.status === 409) { console.log('[FB Tracker] Post already checked by this profile'); return null; } console.error('[FB Tracker] Failed to mark post as checked:', response.status); return null; } catch (error) { console.error('[FB Tracker] Error marking post as checked:', error); return 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 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 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 (createdByName) { payload.created_by_name = createdByName; } if (deadlineIso) { payload.deadline_at = deadlineIso; } 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) { const checkedData = await markPostChecked(data.id, profileNumber); await captureAndUploadScreenshot(data.id, options.postElement || null); if (checkedData) { return checkedData; } } 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 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; } 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) { const inlineStyle = (styleTarget.getAttribute('style') || '').trim(); if (inlineStyle && /(#0?866ff|reaction-like)/i.test(inlineStyle)) { return true; } try { const computed = window.getComputedStyle(styleTarget); if (computed && computed.color) { const color = computed.color.toLowerCase(); if (color.includes('rgb(8, 102, 255)') || color.includes('rgba(8, 102, 255')) { return true; } } } catch (error) { console.debug('[FB Tracker] Unable to inspect computed style for like button:', error); } } 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 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'; } } 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); 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 { window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' }); } 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 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 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, '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 /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/g, // DD.MM (without year) /\b(\d{1,2})\.(\d{1,2})\.\s*(?!\d)/g ]; const foundDates = []; for (const pattern of patterns) { let match; while ((match = pattern.exec(fullText)) !== null) { const day = parseInt(match[1], 10); const month = parseInt(match[2], 10); let year = match[3] ? parseInt(match[3], 10) : today.getFullYear(); // 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) { // Only add if date is in the future if (date > today) { foundDates.push(date); } } } } } // Pattern for "12. Oktober" or "12 Oktober" const monthPattern = /\b(\d{1,2})\.?\s+(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\b/gi; let monthMatch; while ((monthMatch = monthPattern.exec(fullText)) !== null) { const day = parseInt(monthMatch[1], 10); const monthStr = monthMatch[2].toLowerCase(); const month = monthNames[monthStr]; const year = today.getFullYear(); 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) { // If date has passed this year, assume next year if (date <= today) { date.setFullYear(year + 1); } foundDates.push(date); } } } // Return the earliest future date if (foundDates.length > 0) { foundDates.sort((a, b) => a - b); return toDateTimeLocalString(foundDates[0]); } return null; } 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 cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); if ( lowerKey.startsWith('__cft__') || lowerKey.startsWith('__tn__') || lowerKey.startsWith('__eep__') || lowerKey.startsWith('mibextid') || lowerKey === 'set' || lowerKey === 'comment_id' || lowerKey === 'hoisted_section_header_type' ) { 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 search = cleanedParams.toString(); const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; return formatted.replace(/[?&]$/, ''); } // 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) { 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 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); } const otherUI = document.querySelector(`.fb-tracker-ui[data-post-url="${encodedUrl}"]`); if (otherUI) { otherUI.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.style.cssText = ` padding: 6px 12px; background-color: #f0f2f5; border-top: 1px solid #e4e6eb; display: flex; flex-wrap: wrap; flex-direction: row; align-items: center; gap: 8px; row-gap: 6px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; `; // 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 checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0); const statusText = `${checkedCount}/${postData.target_count}`; const completed = checkedCount >= postData.target_count; const lastCheck = Array.isArray(postData.checks) && postData.checks.length ? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null; // Check if deadline has passed const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : ''; // Check if current profile can check this post const canCurrentProfileCheck = postData.next_required_profile === profileNumber; const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber); 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; } } let statusHtml = `
Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText}
${lastCheck ? `
Letzte: ${lastCheck}
` : ''} `; // Add check button if current profile can check and not expired if (canCurrentProfileCheck && !isExpired && !completed) { statusHtml += ` `; } else if (isCurrentProfileDone) { statusHtml += `
✓ Von dir bestätigt
`; } container.innerHTML = statusHtml; // Add AI button await addAICommentButton(container, postElement); // Add event listener for check button const checkBtn = container.querySelector('.fb-tracker-check-btn'); if (checkBtn) { checkBtn.addEventListener('click', async () => { checkBtn.disabled = true; checkBtn.textContent = 'Wird bestätigt...'; const result = await markPostChecked(postData.id, profileNumber); if (result) { const newCheckedCount = result.checked_count ?? checkedCount + 1; const newStatusText = `${newCheckedCount}/${postData.target_count}`; const newCompleted = newCheckedCount >= postData.target_count; const newLastCheck = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }); container.innerHTML = `
Tracker: ${newStatusText}${newCompleted ? ' ✓' : ''}
Letzte: ${newLastCheck}
✓ Von dir bestätigt
`; // Re-add AI button after update await addAICommentButton(container, postElement); } else { checkBtn.disabled = false; checkBtn.textContent = 'Fehler - Erneut versuchen'; checkBtn.style.backgroundColor = '#e74c3c'; } }); } console.log('[FB Tracker] Showing status:', statusText); } else { // Post not tracked - show add UI const selectId = `tracker-select-${Date.now()}`; const deadlineId = `tracker-deadline-${Date.now()}`; container.innerHTML = ` `; // 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'; 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); 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 result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, { postElement, deadline: deadlineValue }); if (result) { const checks = Array.isArray(result.checks) ? result.checks : []; const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length; const targetTotal = result.target_count || targetCount; const statusText = `${checkedCount}/${targetTotal}`; const completed = checkedCount >= targetTotal; const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null; let statusHtml = `
Tracker: ${statusText}${completed ? ' ✓' : ''}
`; if (lastCheck) { statusHtml += `
Letzte: ${lastCheck}
`; } container.innerHTML = statusHtml; if (deadlineInput) { deadlineInput.value = ''; } await addAICommentButton(container, postElement); } 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); } 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); } // Insert UI - try multiple strategies to find stable insertion point let inserted = false; // Strategy 1: After button bar's parent (more stable) if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { const grandParent = buttonBar.parentElement.parentElement; grandParent.insertBefore(container, buttonBar.parentElement.nextSibling); console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar parent. ID: #' + container.id); inserted = true; } // Strategy 2: After button bar directly else if (buttonBar && buttonBar.parentElement) { buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling); console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar. ID: #' + container.id); inserted = true; } // Strategy 3: Append to post element else { postElement.appendChild(container); console.log('[FB Tracker] Post #' + postNum + ' - UI inserted into article (fallback). ID: #' + container.id); inserted = true; } if (inserted) { processedPostUrls.set(encodedUrl, { element: postElement, createdAt: Date.now(), searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number' ? searchTrackingInfo.seen_count : null, hidden: false }); setTrackerElementForPost(postElement, 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 if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { buttonBar.parentElement.parentElement.insertBefore(container, buttonBar.parentElement.nextSibling); } else if (postElement.parentElement) { postElement.parentElement.appendChild(container); } if (container.isConnected) { 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() { 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 isSearchResultsPage = window.location.pathname.startsWith(SEARCH_RESULTS_PATH); 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) { 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)) { 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...'); // 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); // Listen for manual reparse command chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 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); } /** * 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 ''; } // Try to find the post content div const contentSelectors = [ '[data-ad-preview="message"]', '[data-ad-comet-preview="message"]', 'div[dir="auto"][style*="text-align"]', 'div[data-ad-comet-preview] > div > div > span', '.x193iq5w.xeuugli' // Common Facebook text class ]; 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) { return ''; } // Ignore very short snippets that are likely button labels if (cleaned.length < 5 && cleaned.split(/\s+/).length < 2) { return ''; } return cleaned; }; const candidates = []; for (const selector of contentSelectors) { const elements = postElement.querySelectorAll(selector); for (const element of elements) { const candidate = cleanCandidate(element.innerText || element.textContent || ''); if (candidate) { candidates.push(candidate); } } if (candidates.length) { break; } } let textContent = ''; if (candidates.length) { textContent = candidates.reduce((longest, current) => ( current.length > longest.length ? current : longest ), ''); } // Fallback: Get all text but filter out common UI elements if (!textContent) { const allText = postElement.innerText || postElement.textContent || ''; textContent = cleanCandidate(allText); } 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; } /** * 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; } } /** * Generate AI comment for a post */ async function generateAIComment(postText, profileNumber, options = {}) { const { signal = null, preferredCredentialId = null } = options; try { const payload = { postText, profileNumber }; if (typeof preferredCredentialId === 'number') { payload.preferredCredentialId = preferredCredentialId; } const response = await backendFetch(`${API_URL}/ai/generate-comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to generate comment'); } const data = await response.json(); return data.comment; } catch (error) { console.error('[FB Tracker] AI comment generation failed:', error); throw error; } } /** * Check if AI is enabled */ async function isAIEnabled() { try { const response = await backendFetch(`${API_URL}/ai-settings`); if (!response.ok) { return false; } const settings = await response.json(); 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 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 = ` margin-left: auto; position: relative; display: inline-flex; align-items: stretch; border-radius: 6px; overflow: visible; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); `; 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: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 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: 6px 0 0 6px; transition: all 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: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 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 6px 6px 0; transition: all 0.2s ease; `; const dropdown = document.createElement('div'); dropdown.className = 'fb-tracker-ai-dropdown'; dropdown.style.cssText = ` display: none; position: absolute; right: 0; top: calc(100% + 6px); 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); container.appendChild(wrapper); button.addEventListener('mouseenter', () => { if ((button.dataset.aiState || 'idle') === 'idle') { button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; dropdownButton.style.transform = 'translateY(-2px)'; dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; } }); button.addEventListener('mouseleave', () => { button.style.transform = 'translateY(0)'; button.style.boxShadow = 'none'; dropdownButton.style.transform = 'translateY(0)'; dropdownButton.style.boxShadow = 'none'; }); dropdownButton.addEventListener('mouseenter', () => { if ((button.dataset.aiState || 'idle') === 'idle') { button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; dropdownButton.style.transform = 'translateY(-2px)'; dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; } }); dropdownButton.addEventListener('mouseleave', () => { if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open')) { button.style.transform = 'translateY(0)'; button.style.boxShadow = 'none'; dropdownButton.style.transform = 'translateY(0)'; dropdownButton.style.boxShadow = 'none'; } }); button.addEventListener('pointerdown', () => { const contextElement = container.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]'); const normalized = contextElement ? ensurePrimaryPostElement(contextElement) : null; const fallbackNormalized = postElement ? ensurePrimaryPostElement(postElement) : null; const target = normalized || fallbackNormalized || contextElement || postElement || container; cacheSelectionForPost(target); }); button.dataset.aiState = 'idle'; button.dataset.aiOriginalText = button.textContent; let dropdownOpen = false; 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); button.style.transform = 'translateY(0)'; dropdownButton.style.transform = 'translateY(0)'; button.style.boxShadow = 'none'; dropdownButton.style.boxShadow = 'none'; }; const handleOutsideClick = (event) => { if (!wrapper.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); try { const credentials = await fetchActiveAICredentials(); dropdown.innerHTML = ''; 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); return; } 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 = ''; 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 toggleDropdown = async () => { if ((button.dataset.aiState || 'idle') !== 'idle') { return; } if (dropdownOpen) { closeDropdown(); return; } dropdownOpen = true; wrapper.classList.add('fb-tracker-ai-wrapper--open'); dropdownButton.textContent = '▴'; dropdown.style.display = 'block'; dropdownButton.setAttribute('aria-expanded', 'true'); document.addEventListener('click', handleOutsideClick, true); document.addEventListener('keydown', handleKeydown, true); await renderDropdownItems(); }; 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'; 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; } const aiContext = { cancelled: false, abortController: new AbortController(), cancel() { if (!this.cancelled) { this.cancelled = true; this.abortController.abort(); } } }; 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; if (revertDelay > 0) { setTimeout(() => { if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { button.textContent = originalText; } }, revertDelay); } }; 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'; dropdownButton.disabled = true; dropdownButton.style.opacity = '0.5'; dropdownButton.style.cursor = 'not-allowed'; dropdownButton.setAttribute('aria-busy', 'true'); button.textContent = '⏳ Generiere...'; try { 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; const selectionKeys = []; if (postContext) { selectionKeys.push(postContext); } if (postElement && postElement !== postContext) { selectionKeys.push(postElement); } if (contextCandidate && contextCandidate !== postContext && contextCandidate !== postElement) { selectionKeys.push(contextCandidate); } 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; }; 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); } }); throwIfCancelled(); console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100)); const profileNumber = await getProfileNumber(); throwIfCancelled(); const comment = await generateAIComment(postText, profileNumber, { signal: aiContext.abortController.signal, preferredCredentialId }); throwIfCancelled(); 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; 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; commentInput = await waitForCommentInput(postContext, { encodedPostUrl, timeout: buttonClicked ? 8000 : 5000, interval: 250, context: aiContext, preferredRoot: dialogRoot }); } if (!commentInput && !waitedForInput) { updateProcessingText('⏳ Suche Kommentarfeld...'); waitedForInput = true; commentInput = await waitForCommentInput(postContext, { encodedPostUrl, timeout: 4000, interval: 200, context: aiContext, preferredRoot: dialogRoot }); } throwIfCancelled(); if (!commentInput) { throwIfCancelled(); await navigator.clipboard.writeText(comment); throwIfCancelled(); showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info'); restoreIdle('📋 Kopiert', 2000); return; } if (waitedForInput) { updateProcessingText('⏳ Füge Kommentar ein...'); } const success = await setCommentText(commentInput, comment, { context: aiContext }); throwIfCancelled(); if (success) { showToast('✓ Kommentar wurde eingefügt', 'success'); restoreIdle('✓ Eingefügt', 2000); } else { await navigator.clipboard.writeText(comment); throwIfCancelled(); showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info'); restoreIdle('📋 Kopiert', 2000); } } catch (error) { const cancelled = aiContext.cancelled || isCancellationError(error); if (cancelled) { console.log('[FB Tracker] AI comment operation cancelled'); restoreIdle('✋ Abgebrochen', 1500); showToast('⏹️ Vorgang abgebrochen', 'info'); return; } console.error('[FB Tracker] AI comment error:', error); showToast(`❌ ${error.message}`, 'error'); restoreIdle(originalText); } }; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); startAIFlow(); }); container.appendChild(wrapper); } // Expose function globally so it can be called from createTrackerUI window.addAICommentButton = addAICommentButton;