Files
PostTracker/extension/content.js
2025-12-29 19:45:08 +01:00

6199 lines
187 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Facebook Post Tracker Extension
// Uses API_BASE_URL from config.js
const EXTENSION_VERSION = '1.2.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;
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);
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 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|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(fullText);
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(fullText)) !== 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(fullText, pattern.lastIndex);
const hasTime = Boolean(timeInfo);
if (timeInfo) {
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
} else if (hasInclusiveKeywordNear(fullText, matchIndex)) {
date.setHours(23, 59, 0, 0);
}
const hasInclusiveTime = hasInclusiveKeywordNear(fullText, 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(fullText)) !== 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(fullText, monthPattern.lastIndex);
const hasTime = Boolean(timeInfo);
if (timeInfo) {
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
} else if (hasInclusiveKeywordNear(fullText, matchIndex)) {
date.setHours(23, 59, 0, 0);
}
const hasInclusiveTime = hasInclusiveKeywordNear(fullText, 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 = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}${expiredText}
</div>
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
`;
if (!isExpired && !completed && !isCurrentProfileDone && isCurrentProfileRequired) {
const checkButtonEnabled = canCurrentProfileCheck;
const buttonColor = checkButtonEnabled ? '#42b72a' : '#f39c12';
const cursorStyle = 'pointer';
const buttonTitle = checkButtonEnabled
? 'Beitrag bestätigen'
: 'Wartet auf vorherige Profile';
statusHtml += `
<button class="fb-tracker-check-btn" title="${buttonTitle}" style="
padding: 4px 12px;
background-color: ${buttonColor};
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: ${cursorStyle};
font-size: 13px;
white-space: nowrap;
margin-left: 8px;
">
✓ Bestätigen
</button>
`;
} else if (isCurrentProfileDone) {
statusHtml += `
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
✓ Von dir bestätigt
</div>
`;
}
container.innerHTML = statusHtml;
if (postData.id) {
const actionsContainer = ensureTrackerActionsContainer(container);
if (actionsContainer) {
const webAppUrl = (() => {
try {
const baseUrl = `${WEBAPP_BASE_URL}/`;
const url = new URL('', baseUrl);
url.searchParams.set('tab', 'all');
url.searchParams.set('postId', String(postData.id));
if (postData.url) {
url.searchParams.set('postUrl', postData.url);
}
return url.toString();
} catch (error) {
console.debug('[FB Tracker] Failed to build WebApp URL, falling back to base:', error);
return `${WEBAPP_BASE_URL}/?tab=all`;
}
})();
let webAppLink = actionsContainer.querySelector('.fb-tracker-webapp-link');
if (!webAppLink) {
webAppLink = document.createElement('a');
webAppLink.className = 'fb-tracker-webapp-link';
webAppLink.target = '_blank';
webAppLink.rel = 'noopener noreferrer';
webAppLink.setAttribute('aria-label', 'In der Webapp anzeigen');
webAppLink.title = 'In der Webapp anzeigen';
webAppLink.textContent = '📋';
webAppLink.style.cssText = `
text-decoration: none;
font-size: 18px;
line-height: 1;
padding: 4px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: inherit;
transition: background-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
`;
webAppLink.addEventListener('mouseenter', () => {
webAppLink.style.backgroundColor = 'rgba(0, 0, 0, 0.08)';
webAppLink.style.transform = 'translateY(-1px)';
});
webAppLink.addEventListener('mouseleave', () => {
webAppLink.style.backgroundColor = 'transparent';
webAppLink.style.transform = 'translateY(0)';
});
actionsContainer.insertBefore(webAppLink, actionsContainer.firstChild);
}
webAppLink.href = webAppUrl;
}
}
await addAICommentButton(container, postElement);
const checkBtn = container.querySelector('.fb-tracker-check-btn');
if (checkBtn) {
checkBtn.addEventListener('click', async () => {
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 = `
<label for="${selectId}" style="color: #65676b; font-weight: 500; font-size: 13px; white-space: nowrap;">
Ziel:
</label>
<select id="${selectId}" style="
padding: 4px 8px;
border: 1px solid #ccd0d5;
border-radius: 4px;
background: white;
font-size: 13px;
cursor: pointer;
">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<label for="${deadlineId}" style="color: #65676b; font-weight: 500; font-size: 13px; white-space: nowrap;">
Deadline:
</label>
<input id="${deadlineId}" type="datetime-local" style="
padding: 4px 8px;
border: 1px solid #ccd0d5;
border-radius: 4px;
font-size: 13px;
max-width: 160px;
" />
<div class="fb-tracker-similarity" style="
display: none;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid #f0c36d;
background: #fff6d5;
border-radius: 6px;
font-size: 12px;
color: #6b5b00;
flex-basis: 100%;
">
<span class="fb-tracker-similarity__text"></span>
<button class="fb-tracker-merge-btn" type="button" style="
border: 1px solid #caa848;
background: white;
color: #7a5d00;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
">Mergen</button>
<a class="fb-tracker-similarity-link" href="#" target="_blank" rel="noopener" style="
color: #7a5d00;
text-decoration: none;
font-weight: 600;
display: none;
">Öffnen</a>
</div>
<button class="fb-tracker-add-btn" style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
">
Hinzufügen
</button>
`;
// Add click handler for the button
const addButton = container.querySelector('.fb-tracker-add-btn');
const selectElement = container.querySelector(`#${selectId}`);
const deadlineInput = container.querySelector(`#${deadlineId}`);
selectElement.value = '2';
const similarityBox = container.querySelector('.fb-tracker-similarity');
const similarityText = container.querySelector('.fb-tracker-similarity__text');
const mergeButton = container.querySelector('.fb-tracker-merge-btn');
const similarityLink = container.querySelector('.fb-tracker-similarity-link');
const similarityPayloadPromise = buildSimilarityPayload(postElement);
let similarityPayload = null;
let similarityMatch = null;
const mainLinkUrl = postUrlData.mainUrl;
const resolveSimilarityPayload = async () => {
if (!similarityPayload) {
similarityPayload = await similarityPayloadPromise;
}
return similarityPayload;
};
if (similarityBox && similarityText && mergeButton) {
(async () => {
const payload = await resolveSimilarityPayload();
const similarity = await findSimilarPost({
url: postUrlData.url,
postText: payload.postText,
firstImageHash: payload.firstImageHash
});
if (!similarity || !similarity.match) {
return;
}
similarityMatch = similarity.match;
similarityText.textContent = formatSimilarityLabel(similarity);
similarityBox.style.display = 'flex';
if (!addButton.disabled) {
addButton.textContent = 'Neu speichern';
}
if (similarityLink) {
similarityLink.style.display = 'inline';
similarityLink.textContent = 'Öffnen';
if (similarityMatch.url) {
similarityLink.href = similarityMatch.url;
similarityLink.dataset.ready = '1';
} else {
similarityLink.href = '#';
similarityLink.dataset.ready = '0';
similarityLink.dataset.postId = similarityMatch.id;
}
}
})();
mergeButton.addEventListener('click', async () => {
if (!similarityMatch) {
return;
}
mergeButton.disabled = true;
const previousLabel = mergeButton.textContent;
mergeButton.textContent = 'Mergen...';
const payload = await resolveSimilarityPayload();
const urlCandidates = [postUrlData.url, ...postUrlData.allCandidates];
const uniqueUrls = Array.from(new Set(urlCandidates.filter(Boolean)));
const attached = await attachUrlToExistingPost(similarityMatch.id, uniqueUrls, payload);
if (attached) {
const updatedPost = await fetchPostByUrl(similarityMatch.url);
if (updatedPost) {
await renderTrackedStatus({
container,
postElement,
postData: updatedPost,
profileNumber,
isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
return;
}
}
mergeButton.disabled = false;
mergeButton.textContent = previousLabel;
});
}
if (similarityLink && !similarityLink.dataset.bound) {
similarityLink.dataset.bound = '1';
similarityLink.addEventListener('click', async (event) => {
if (similarityLink.dataset.ready === '1') {
return;
}
event.preventDefault();
const postId = similarityLink.dataset.postId;
if (!postId) {
return;
}
const resolved = await fetchPostById(postId);
if (resolved && resolved.url) {
similarityLink.href = resolved.url;
similarityLink.dataset.ready = '1';
window.open(resolved.url, '_blank', 'noopener');
}
});
}
if (mainLinkUrl) {
const mainLinkButton = document.createElement('button');
mainLinkButton.className = 'fb-tracker-mainlink-btn';
mainLinkButton.type = 'button';
mainLinkButton.title = 'Main-Link öffnen';
mainLinkButton.setAttribute('aria-label', 'Main-Link öffnen');
mainLinkButton.style.cssText = `
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
border: 1px solid #ccd0d5;
background-color: #ffffff;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2365766b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1 1'/><path d='M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7l1-1'/></svg>");
background-repeat: no-repeat;
background-position: center;
background-size: 16px 16px;
cursor: pointer;
`;
addButton.insertAdjacentElement('afterend', mainLinkButton);
mainLinkButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
window.open(mainLinkUrl, '_blank', 'noopener');
});
}
if (deadlineInput) {
// Try to extract deadline from post text first
const extractedDeadline = extractDeadlineFromPostText(postElement);
deadlineInput.value = extractedDeadline || getNextDayDefaultDeadlineValue();
}
addButton.addEventListener('click', async () => {
const targetCount = parseInt(selectElement.value, 10);
console.log('[FB Tracker] Add button clicked, target:', targetCount);
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;