// 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 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 = [ '[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"]' ]; 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) { 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); const existingUI = postElement.querySelector('.fb-tracker-ui'); if (existingUI) { console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element'); 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); 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); } 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 = `