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