Files
PostTracker/extension/content.js
2025-10-05 20:12:13 +02:00

3801 lines
111 KiB
JavaScript

// Facebook Post Tracker Extension
// Uses API_BASE_URL from config.js
const EXTENSION_VERSION = '1.1.0';
const PROCESSED_ATTR = 'data-fb-tracker-processed';
const PENDING_ATTR = 'data-fb-tracker-pending';
const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]';
const API_URL = `${API_BASE_URL}/api`;
const MAX_SELECTION_LENGTH = 5000;
const postSelectionCache = new WeakMap();
const LAST_SELECTION_MAX_AGE = 5000;
let selectionCacheTimeout = null;
let lastGlobalSelection = { text: '', timestamp: 0 };
const processedPostUrls = new Map();
const SEARCH_RESULTS_PATH = '/search/top';
const FEED_HOME_PATHS = ['/', '/home.php'];
const sessionSearchRecordedUrls = new Set();
const sessionSearchInfoCache = new Map();
const trackerElementsByPost = new WeakMap();
function getTrackerElementForPost(postElement) {
if (!postElement) {
return null;
}
const tracker = trackerElementsByPost.get(postElement);
if (tracker && tracker.isConnected) {
return tracker;
}
if (tracker) {
trackerElementsByPost.delete(postElement);
}
return null;
}
function setTrackerElementForPost(postElement, trackerElement) {
if (!postElement) {
return;
}
if (trackerElement && trackerElement.isConnected) {
trackerElementsByPost.set(postElement, trackerElement);
} else {
trackerElementsByPost.delete(postElement);
}
}
function clearTrackerElementForPost(postElement, trackerElement = null) {
if (!postElement) {
return;
}
if (!trackerElementsByPost.has(postElement)) {
return;
}
const current = trackerElementsByPost.get(postElement);
if (trackerElement && current && current !== trackerElement) {
return;
}
trackerElementsByPost.delete(postElement);
}
const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache
const aiCredentialCache = {
data: null,
timestamp: 0,
pending: null
};
console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL);
function backendFetch(url, options = {}) {
const config = {
...options,
credentials: 'include'
};
if (options && options.headers) {
config.headers = { ...options.headers };
}
return fetch(url, config);
}
async function fetchActiveAICredentials(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && aiCredentialCache.data && (now - aiCredentialCache.timestamp < AI_CREDENTIAL_CACHE_TTL)) {
return aiCredentialCache.data;
}
if (aiCredentialCache.pending) {
try {
return await aiCredentialCache.pending;
} catch (error) {
// fall through to retry below
}
}
aiCredentialCache.pending = (async () => {
const response = await backendFetch(`${API_URL}/ai-credentials`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'AI-Provider konnten nicht geladen werden');
}
const credentials = await response.json();
let active = Array.isArray(credentials)
? credentials.filter(entry => entry && Number(entry.is_active) === 1)
: [];
if (active.length === 0 && Array.isArray(credentials)) {
active = credentials.slice();
}
aiCredentialCache.data = active;
aiCredentialCache.timestamp = Date.now();
return active;
})();
try {
return await aiCredentialCache.pending;
} finally {
aiCredentialCache.pending = null;
}
}
function formatAICredentialLabel(credential) {
if (!credential || typeof credential !== 'object') {
return 'Unbekannte AI';
}
const name = (credential.name || '').trim();
const provider = (credential.provider || '').trim();
const model = (credential.model || '').trim();
if (name) {
if (provider && model) {
return `${name} · ${provider}/${model}`;
}
if (provider) {
return `${name} · ${provider}`;
}
if (model) {
return `${name} · ${model}`;
}
return name;
}
if (provider && model) {
return `${provider}/${model}`;
}
if (model) {
return model;
}
if (provider) {
return provider;
}
return 'AI Provider';
}
document.addEventListener('selectionchange', () => {
if (selectionCacheTimeout) {
clearTimeout(selectionCacheTimeout);
}
selectionCacheTimeout = setTimeout(() => {
cacheCurrentSelection();
selectionCacheTimeout = null;
}, 50);
});
// Profile state helpers
async function fetchBackendProfileNumber() {
try {
const response = await backendFetch(`${API_URL}/profile-state`);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data && data.profile_number) {
return data.profile_number;
}
} catch (error) {
console.warn('[FB Tracker] Failed to fetch profile state from backend:', error);
}
return null;
}
function extractAuthorName(postElement) {
if (!postElement) {
return null;
}
const selectors = [
'h2 strong a[role="link"]',
'h2 span a[role="link"]',
'a[role="link"][tabindex="0"] strong',
'a[role="link"][tabindex="0"]'
];
for (const selector of selectors) {
const node = postElement.querySelector(selector);
if (node && node.textContent) {
const text = node.textContent.trim();
if (text) {
return text;
}
}
}
const ariaLabelNode = postElement.querySelector('[aria-label]');
if (ariaLabelNode) {
const ariaLabel = ariaLabelNode.getAttribute('aria-label');
if (ariaLabel && ariaLabel.trim()) {
return ariaLabel.trim();
}
}
return null;
}
function storeProfileNumberLocally(profileNumber) {
chrome.storage.sync.set({ profileNumber });
}
async function getProfileNumber() {
const backendProfile = await fetchBackendProfileNumber();
if (backendProfile) {
storeProfileNumberLocally(backendProfile);
console.log('[FB Tracker] Profile number (backend):', backendProfile);
return backendProfile;
}
return new Promise((resolve) => {
chrome.storage.sync.get(['profileNumber'], (result) => {
const profile = result.profileNumber || 1;
console.log('[FB Tracker] Profile number (local):', profile);
resolve(profile);
});
});
}
// Extract post URL from post element
function cleanPostUrl(rawUrl) {
if (!rawUrl) {
return '';
}
const cftIndex = rawUrl.indexOf('__cft__');
let trimmed = cftIndex !== -1 ? rawUrl.slice(0, cftIndex) : rawUrl;
trimmed = trimmed.replace(/[?&]$/, '');
return trimmed;
}
function toAbsoluteFacebookUrl(rawUrl) {
if (!rawUrl) {
return null;
}
const cleaned = cleanPostUrl(rawUrl);
let url;
try {
url = new URL(cleaned);
} catch (error) {
try {
url = new URL(cleaned, window.location.origin);
} catch (innerError) {
return null;
}
}
const host = url.hostname.toLowerCase();
if (!host.endsWith('facebook.com')) {
return null;
}
return url;
}
function isValidFacebookPostUrl(url) {
if (!url) {
return false;
}
const path = url.pathname.toLowerCase();
const searchParams = url.searchParams;
const postPathPatterns = [
'/posts/',
'/permalink/',
'/photos/',
'/videos/',
'/reel/',
'/watch/'
];
if (postPathPatterns.some(pattern => path.includes(pattern))) {
return true;
}
if (path.startsWith('/photo') && searchParams.has('fbid')) {
return true;
}
if (path.startsWith('/photo.php') && searchParams.has('fbid')) {
return true;
}
if (path === '/permalink.php' && searchParams.has('story_fbid')) {
return true;
}
if (path.startsWith('/groups/') && searchParams.has('multi_permalinks')) {
return true;
}
// /watch/ URLs with video ID parameter
if (path.startsWith('/watch') && searchParams.has('v')) {
return true;
}
return false;
}
function formatFacebookPostUrl(url) {
if (!url) {
return '';
}
let search = url.search;
if (search.endsWith('?') || search.endsWith('&')) {
search = search.slice(0, -1);
}
return `${url.origin}${url.pathname}${search}`;
}
function extractPostUrlCandidate(rawUrl) {
const absoluteUrl = toAbsoluteFacebookUrl(rawUrl);
if (!absoluteUrl || !isValidFacebookPostUrl(absoluteUrl)) {
return '';
}
const formatted = formatFacebookPostUrl(absoluteUrl);
return normalizeFacebookPostUrl(formatted);
}
function getPostUrl(postElement, postNum = '?') {
console.log('[FB Tracker] Post #' + postNum + ' - Extracting URL from:', postElement);
const allCandidates = [];
// Strategy 1: Look for attribution links inside post
const attributionLinks = postElement.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]');
for (const link of attributionLinks) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
// Strategy 2: Look in parent elements (timestamp might be outside container)
let current = postElement;
for (let i = 0; i < 5 && current; i++) {
const parentLinks = current.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]');
for (const link of parentLinks) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
current = current.parentElement;
}
// Strategy 3: Check all links in post
const links = postElement.querySelectorAll('a[href]');
for (const link of links) {
const candidate = extractPostUrlCandidate(link.href);
if (candidate && !allCandidates.includes(candidate)) {
allCandidates.push(candidate);
}
}
// Prefer main post links over photo/video links
const mainPostLink = allCandidates.find(url =>
url.includes('/posts/') || url.includes('/permalink/') || url.includes('/permalink.php')
);
if (mainPostLink) {
console.log('[FB Tracker] Post #' + postNum + ' - Found main post URL:', mainPostLink, postElement);
return { url: mainPostLink, allCandidates };
}
// Fallback to first candidate
if (allCandidates.length > 0) {
console.log('[FB Tracker] Post #' + postNum + ' - Using first candidate URL:', allCandidates[0], postElement);
return { url: allCandidates[0], allCandidates };
}
const fallbackCandidate = extractPostUrlCandidate(window.location.href);
if (fallbackCandidate) {
console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement);
return { url: fallbackCandidate, allCandidates: [fallbackCandidate] };
}
console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement);
return { url: '', allCandidates: [] };
}
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
async function checkPostStatus(postUrl, allUrlCandidates = []) {
try {
const normalizedUrl = normalizeFacebookPostUrl(postUrl);
if (!normalizedUrl) {
console.warn('[FB Tracker] Überspringe Statusabfrage, URL ungültig:', postUrl);
return null;
}
// Build list of URLs to check (primary + all candidates)
const urlsToCheck = [normalizedUrl];
console.log('[FB Tracker] Received candidates to check:', allUrlCandidates);
for (const candidate of allUrlCandidates) {
const normalized = normalizeFacebookPostUrl(candidate);
if (normalized && !urlsToCheck.includes(normalized)) {
urlsToCheck.push(normalized);
}
}
console.log('[FB Tracker] Checking post status for URLs:', urlsToCheck);
let foundPost = null;
let foundUrl = null;
// Check each URL
for (const url of urlsToCheck) {
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`);
if (response.ok) {
const data = await response.json();
if (data && data.id) {
console.log('[FB Tracker] Post found with URL:', url, data);
foundPost = data;
foundUrl = url;
break;
} else {
console.log('[FB Tracker] URL not found in backend:', url);
}
} else {
console.log('[FB Tracker] Backend error for URL:', url, response.status);
}
}
// If post found and we have a better main post URL, update it
if (foundPost && foundUrl !== normalizedUrl) {
const isMainPostUrl = normalizedUrl.includes('/posts/') || normalizedUrl.includes('/permalink/');
const isPhotoUrl = foundUrl.includes('/photo');
if (isMainPostUrl && isPhotoUrl) {
console.log('[FB Tracker] Updating post URL from photo link to main post link:', foundUrl, '->', normalizedUrl);
await updatePostUrl(foundPost.id, normalizedUrl);
foundPost.url = normalizedUrl; // Update local copy
}
}
if (foundPost) {
return foundPost;
}
console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.length, 'URLs)');
return null;
} catch (error) {
console.error('[FB Tracker] Error checking post status:', error);
return null;
}
}
async function recordSearchResultPost(primaryUrl, allUrlCandidates = [], options = {}) {
try {
if (!primaryUrl) {
return null;
}
const { skipIncrement = false, forceHide = false } = options || {};
const payload = {
url: primaryUrl,
candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [],
skip_increment: !!skipIncrement,
force_hide: !!forceHide
};
const response = await backendFetch(`${API_URL}/search-posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
console.warn('[FB Tracker] Failed to record search post occurrence:', response.status);
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error('[FB Tracker] Error recording search result post:', error);
return null;
}
}
// Update post URL
async function updatePostUrl(postId, newUrl) {
try {
const response = await backendFetch(`${API_URL}/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: newUrl })
});
if (response.ok) {
console.log('[FB Tracker] Post URL updated successfully');
return true;
} else {
console.error('[FB Tracker] Failed to update post URL:', response.status);
return false;
}
} catch (error) {
console.error('[FB Tracker] Error updating post URL:', error);
return false;
}
}
// Add post to tracking
async function markPostChecked(postId, profileNumber) {
try {
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
const response = await backendFetch(`${API_URL}/posts/${postId}/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_number: profileNumber })
});
if (response.ok) {
const data = await response.json();
console.log('[FB Tracker] Post marked as checked:', data);
return data;
}
if (response.status === 409) {
console.log('[FB Tracker] Post already checked by this profile');
return null;
}
console.error('[FB Tracker] Failed to mark post as checked:', response.status);
return null;
} catch (error) {
console.error('[FB Tracker] Error marking post as checked:', error);
return null;
}
}
async function addPostToTracking(postUrl, targetCount, profileNumber, options = {}) {
try {
console.log('[FB Tracker] Adding post:', postUrl, 'Target:', targetCount, 'Profile:', profileNumber);
let createdByName = null;
if (options && options.postElement) {
createdByName = extractAuthorName(options.postElement) || null;
}
let deadlineIso = null;
if (options && typeof options.deadline === 'string' && options.deadline.trim()) {
const parsedDeadline = new Date(options.deadline.trim());
if (!Number.isNaN(parsedDeadline.getTime())) {
deadlineIso = parsedDeadline.toISOString();
}
}
const normalizedUrl = normalizeFacebookPostUrl(postUrl);
if (!normalizedUrl) {
console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl);
return null;
}
const payload = {
url: normalizedUrl,
target_count: targetCount,
profile_number: profileNumber,
created_by_profile: profileNumber
};
if (createdByName) {
payload.created_by_name = createdByName;
}
if (deadlineIso) {
payload.deadline_at = deadlineIso;
}
const response = await backendFetch(`${API_URL}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
console.log('[FB Tracker] Post added successfully:', data);
if (data && data.id) {
const checkedData = await markPostChecked(data.id, profileNumber);
await captureAndUploadScreenshot(data.id, options.postElement || null);
if (checkedData) {
return checkedData;
}
}
return data;
} else {
console.error('[FB Tracker] Failed to add post:', response.status);
return null;
}
} catch (error) {
console.error('[FB Tracker] Error adding post:', error);
return null;
}
}
function normalizeButtonLabel(button) {
const aria = button.getAttribute('aria-label');
if (aria) {
return aria.trim().toLowerCase();
}
const title = button.getAttribute('title');
if (title) {
return title.trim().toLowerCase();
}
return (button.textContent || '').trim().toLowerCase();
}
const LIKE_LABEL_KEYWORDS = ['gefällt mir', 'like', 'mag ich'];
const COMMENT_LABEL_KEYWORDS = ['kommentieren', 'comment', 'kommentar', 'antwort hinterlassen'];
const SHARE_LABEL_KEYWORDS = ['teilen', 'share', 'senden', 'posten', 'profil posten'];
const REPLY_LABEL_KEYWORDS = ['antworten', 'reply'];
const LIKE_ROLE_KEYWORDS = ['gefällt mir', 'like'];
const COMMENT_ROLE_KEYWORDS = ['comment'];
const SHARE_ROLE_KEYWORDS = ['share'];
const REPLY_ROLE_KEYWORDS = ['reply'];
function matchesKeyword(label, keywords) {
return keywords.some((keyword) => label.includes(keyword));
}
function isPostLikedByCurrentUser(likeButton, postElement) {
const candidates = [];
if (likeButton) {
candidates.push(likeButton);
}
if (postElement) {
postElement.querySelectorAll('[data-ad-rendering-role*="gefällt" i], [aria-label*="gefällt" i]').forEach((node) => {
if (node && !candidates.includes(node)) {
candidates.push(node);
}
});
}
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const styleTarget = candidate.matches('[data-ad-rendering-role*="gefällt" i]')
? candidate
: candidate.querySelector && candidate.querySelector('[data-ad-rendering-role*="gefällt" i]');
if (styleTarget) {
const inlineStyle = (styleTarget.getAttribute('style') || '').trim();
if (inlineStyle && /(#0?866ff|reaction-like)/i.test(inlineStyle)) {
return true;
}
try {
const computed = window.getComputedStyle(styleTarget);
if (computed && computed.color) {
const color = computed.color.toLowerCase();
if (color.includes('rgb(8, 102, 255)') || color.includes('rgba(8, 102, 255')) {
return true;
}
}
} catch (error) {
console.debug('[FB Tracker] Unable to inspect computed style for like button:', error);
}
}
const ariaPressed = candidate.getAttribute && candidate.getAttribute('aria-pressed');
if (ariaPressed && ariaPressed.toLowerCase() === 'true') {
return true;
}
const ariaLabel = (candidate.getAttribute && candidate.getAttribute('aria-label')) || '';
const buttonText = candidate.textContent || '';
const combined = `${ariaLabel} ${buttonText}`.toLowerCase();
const likedIndicators = ['gefällt dir', 'gefällt dir nicht mehr', 'unlike', 'remove like', 'nicht mehr gefällt'];
if (likedIndicators.some(indicator => combined.includes(indicator))) {
return true;
}
}
return false;
}
function hidePostElement(postElement) {
if (!postElement) {
return;
}
const postContainer = ensurePrimaryPostElement(postElement);
if (postContainer && postContainer.closest(DIALOG_ROOT_SELECTOR)) {
console.log('[FB Tracker] Skipping hide for dialog/modal context');
return;
}
if (postContainer && !isMainPost(postContainer, null)) {
console.log('[FB Tracker] Skipping hide for comment container');
return;
}
const removalSelectors = [
'[role="listitem"][aria-posinset]',
'div[aria-posinset]',
'div[data-ad-comet-feed-verbose-tracking]',
'div[data-ad-preview]',
'article[role="article"]',
'article'
];
let elementToRemove = null;
for (const selector of removalSelectors) {
const candidate = postElement.closest(selector);
if (candidate && candidate !== document.body && candidate !== document.documentElement) {
elementToRemove = candidate;
break;
}
}
if (!elementToRemove) {
elementToRemove = postElement;
}
let removalRoot = elementToRemove;
let parent = removalRoot.parentElement;
while (parent && parent !== document.body && parent !== document.documentElement) {
if (parent.childElementCount > 1) {
break;
}
if (parent.matches('[role="feed"], [role="main"], [role="region"], [data-pagelet], [data-testid="SEARCH_RESULT_CONTAINER"], #ssrb_feed_start')) {
break;
}
removalRoot = parent;
parent = parent.parentElement;
}
removalRoot.setAttribute('data-fb-tracker-hidden', '1');
const removalParent = removalRoot.parentElement;
if (removalParent) {
removalParent.removeChild(removalRoot);
let current = removalParent;
while (current && current !== document.body && current !== document.documentElement) {
if (current.childElementCount > 0) {
break;
}
const nextParent = current.parentElement;
if (!nextParent) {
break;
}
if (nextParent === document.body || nextParent === document.documentElement) {
current.remove();
break;
}
if (nextParent.childElementCount > 1) {
current.remove();
break;
}
current.remove();
current = nextParent;
}
} else {
removalRoot.style.display = 'none';
}
}
function collectButtonMeta(button) {
const textParts = [];
const roleParts = [];
const label = normalizeButtonLabel(button);
if (label) {
textParts.push(label);
}
const collectRole = (value) => {
if (!value) {
return;
}
const lower = value.toLowerCase();
roleParts.push(lower);
const tokens = lower.split(/[_\-\s]+/);
tokens.forEach((token) => {
if (token) {
roleParts.push(token);
}
});
};
collectRole(button.getAttribute('data-ad-rendering-role'));
const descendantRoles = button.querySelectorAll('[data-ad-rendering-role]');
descendantRoles.forEach((el) => {
collectRole(el.getAttribute('data-ad-rendering-role'));
if (el.textContent) {
textParts.push(normalizeButtonLabel(el));
}
});
return {
text: textParts.join(' '),
roles: roleParts.join(' ')
};
}
function buttonClassification(button) {
const meta = collectButtonMeta(button);
const text = meta.text;
const roles = meta.roles;
const combined = `${text} ${roles}`;
return {
isLike: matchesKeyword(combined, LIKE_LABEL_KEYWORDS) || matchesKeyword(roles, LIKE_ROLE_KEYWORDS),
isComment: matchesKeyword(combined, COMMENT_LABEL_KEYWORDS) || matchesKeyword(roles, COMMENT_ROLE_KEYWORDS),
isShare: matchesKeyword(combined, SHARE_LABEL_KEYWORDS) || matchesKeyword(roles, SHARE_ROLE_KEYWORDS),
isReply: matchesKeyword(combined, REPLY_LABEL_KEYWORDS) || matchesKeyword(roles, REPLY_ROLE_KEYWORDS)
};
}
function findShareButtonForArticle(article) {
const direct = article.querySelector('[data-ad-rendering-role="share_button"]');
if (direct) {
return direct;
}
const shareButtons = document.querySelectorAll('[data-ad-rendering-role="share_button"]');
for (const button of shareButtons) {
const owningArticle = button.closest('[role="article"]');
if (owningArticle === article) {
return button;
}
}
return null;
}
function hasInteractionButtons(container) {
if (!container) {
return false;
}
const buttons = container.querySelectorAll('[role="button"], button');
if (!buttons.length) {
return false;
}
let hasLike = false;
let hasComment = false;
let hasShare = false;
let hasReply = false;
for (const button of buttons) {
const info = buttonClassification(button);
if (info.isLike) {
hasLike = true;
}
if (info.isComment) {
hasComment = true;
}
if (info.isShare) {
hasShare = true;
}
if (info.isReply) {
hasReply = true;
}
}
if (hasLike || hasComment || hasShare) {
console.log('[FB Tracker] Container analysis', {
tag: container.tagName,
classes: container.className,
hasLike,
hasComment,
hasShare,
hasReply,
buttonCount: buttons.length
});
}
const interactionCount = [hasLike, hasComment, hasShare].filter(Boolean).length;
if (interactionCount === 0) {
return false;
}
if (hasShare) {
return true;
}
if (interactionCount >= 2) {
return true;
}
return !hasReply;
}
function findButtonBar(postElement) {
const shareAnchor = findShareButtonForArticle(postElement);
if (shareAnchor) {
let container = shareAnchor.closest('[role="button"]') || shareAnchor.closest('button');
for (let i = 0; i < 4 && container; i++) {
if (hasInteractionButtons(container)) {
console.log('[FB Tracker] Found button bar via share anchor');
return container;
}
container = container.parentElement;
}
}
// Gather all accessible buttons inside the article
const buttons = Array.from(postElement.querySelectorAll('[role="button"], button'));
const interactionButtons = buttons.filter((button) => {
const info = buttonClassification(button);
return info.isLike || info.isComment || info.isShare;
});
const anchorElements = [
postElement.querySelector('[data-ad-rendering-role="share_button"]'),
postElement.querySelector('[data-ad-rendering-role="comment_button"]'),
postElement.querySelector('[data-ad-rendering-role*="gefällt"]')
].filter(Boolean);
const candidates = new Set(interactionButtons);
anchorElements.forEach((el) => {
const buttonContainer = el.closest('[role="button"]') || el.closest('button');
if (buttonContainer) {
candidates.add(buttonContainer);
}
});
const seen = new Set();
for (const button of candidates) {
const info = buttonClassification(button);
console.log('[FB Tracker] Candidate button', {
label: normalizeButtonLabel(button),
hasLike: info.isLike,
hasComment: info.isComment,
hasShare: info.isShare,
hasReply: info.isReply,
classes: button.className
});
let current = button;
while (current && current !== postElement && current !== document.body) {
if (seen.has(current)) {
current = current.parentElement;
continue;
}
seen.add(current);
const hasBar = hasInteractionButtons(current);
if (hasBar) {
console.log('[FB Tracker] Found button bar');
return current;
}
current = current.parentElement;
}
}
let sibling = postElement.nextElementSibling;
for (let i = 0; i < 6 && sibling; i++) {
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
console.log('[FB Tracker] Found button bar via next sibling');
return sibling;
}
sibling = sibling.nextElementSibling;
}
sibling = postElement.previousElementSibling;
for (let i = 0; i < 3 && sibling; i++) {
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
console.log('[FB Tracker] Found button bar via previous sibling');
return sibling;
}
sibling = sibling.previousElementSibling;
}
let parent = postElement.parentElement;
for (let depth = 0; depth < 4 && parent; depth++) {
if (!seen.has(parent) && hasInteractionButtons(parent)) {
console.log('[FB Tracker] Found button bar via parent');
return parent;
}
let next = parent.nextElementSibling;
for (let i = 0; i < 4 && next; i++) {
if (!seen.has(next) && hasInteractionButtons(next)) {
console.log('[FB Tracker] Found button bar via parent sibling');
return next;
}
next = next.nextElementSibling;
}
let prev = parent.previousElementSibling;
for (let i = 0; i < 3 && prev; i++) {
if (!seen.has(prev) && hasInteractionButtons(prev)) {
console.log('[FB Tracker] Found button bar via parent previous sibling');
return prev;
}
prev = prev.previousElementSibling;
}
parent = parent.parentElement;
}
console.log('[FB Tracker] Button bar not found');
return null;
}
function findPostContainers() {
const containers = [];
const seen = new Set();
const candidateSelectors = [
'div[role="dialog"] article',
'div[role="dialog"] div[aria-posinset]',
'[data-pagelet*="FeedUnit"] article',
'div[role="main"] article',
'[data-visualcompletion="ignore-dynamic"] article',
'div[aria-posinset]',
'article[role="article"]',
'article',
'div[data-pagelet*="Reel"]',
'div[data-pagelet*="WatchFeed"]',
'div[data-pagelet*="QPE_PublisherStory"]'
];
const candidateElements = document.querySelectorAll(candidateSelectors.join(', '));
candidateElements.forEach((element) => {
const container = ensurePrimaryPostElement(element);
if (!container) {
return;
}
if (seen.has(container)) {
return;
}
const buttonBar = findButtonBar(container);
if (!isMainPost(container, buttonBar)) {
return;
}
const likeButton = findLikeButtonWithin(container);
seen.add(container);
if (likeButton) {
containers.push({ container, likeButton, buttonBar: buttonBar || null });
}
});
return containers;
}
function findLikeButtonWithin(container) {
if (!container) {
return null;
}
const selectors = [
'[data-ad-rendering-role="gefällt"]',
'[data-ad-rendering-role="gefällt mir_button"]',
'[data-ad-rendering-role*="gefällt" i]',
'[aria-label*="gefällt" i]',
'[aria-label*="like" i]',
'div[role="button"][aria-pressed]'
];
for (const selector of selectors) {
const button = container.querySelector(selector);
if (button) {
return button;
}
}
return container.querySelector('div[role="button"]');
}
function findLikeButtonWithin(container) {
if (!container) {
return null;
}
const selectors = [
'[data-ad-rendering-role="gefällt"]',
'[data-ad-rendering-role="gefällt mir_button"]',
'[data-ad-rendering-role*="gefällt" i]',
'[aria-label*="gefällt" i]',
'[aria-label*="like" i]',
'[aria-pressed="true"]',
'div[role="button"]'
];
for (const selector of selectors) {
const button = container.querySelector(selector);
if (button) {
return button;
}
}
return null;
}
function captureScreenshot(screenshotRect) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'captureScreenshot', screenshotRect }, (response) => {
if (chrome.runtime.lastError) {
console.warn('[FB Tracker] Screenshot capture failed:', chrome.runtime.lastError.message);
resolve(null);
return;
}
if (!response || response.error) {
if (response && response.error) {
console.warn('[FB Tracker] Screenshot capture reported error:', response.error);
}
resolve(null);
return;
}
if (!response.imageData) {
console.warn('[FB Tracker] Screenshot capture returned no data');
resolve(null);
return;
}
(async () => {
try {
let result = response.imageData;
if (screenshotRect) {
const cropped = await cropScreenshot(result, screenshotRect);
if (cropped) {
result = cropped;
}
}
resolve(result);
} catch (error) {
console.warn('[FB Tracker] Screenshot processing failed:', error);
resolve(response.imageData);
}
})();
});
});
}
async function uploadScreenshot(postId, imageData) {
try {
const response = await backendFetch(`${API_URL}/posts/${postId}/screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ imageData })
});
if (!response.ok) {
console.warn('[FB Tracker] Screenshot upload failed:', response.status);
}
} catch (error) {
console.error('[FB Tracker] Screenshot upload error:', error);
}
}
async function captureAndUploadScreenshot(postId, postElement) {
const imageData = await captureElementScreenshot(postElement);
if (!imageData) {
return;
}
const optimized = await maybeDownscaleScreenshot(imageData);
await uploadScreenshot(postId, optimized);
}
async function captureElementScreenshot(element) {
if (!element) {
return await captureScreenshot();
}
const horizontalMargin = 32;
const verticalMargin = 96;
const maxSegments = 12;
const delayBetweenScrolls = 200;
const originalScrollX = window.scrollX;
const originalScrollY = window.scrollY;
const devicePixelRatio = window.devicePixelRatio || 1;
const stickyOffset = getStickyHeaderHeight();
const segments = [];
const elementRect = element.getBoundingClientRect();
const elementTop = elementRect.top + window.scrollY;
const elementBottom = elementRect.bottom + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
const startY = Math.max(0, elementTop - verticalMargin - stickyOffset);
const endY = Math.min(documentHeight, elementBottom + verticalMargin);
const baseDocTop = Math.max(0, elementTop - verticalMargin);
try {
let iteration = 0;
let targetScroll = startY;
while (iteration < maxSegments) {
iteration += 1;
window.scrollTo({ top: targetScroll, left: window.scrollX, behavior: 'auto' });
await delay(delayBetweenScrolls);
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const captureTop = Math.max(0, rect.top - verticalMargin - stickyOffset);
const captureBottom = Math.min(viewportHeight, rect.bottom + verticalMargin);
const captureHeight = captureBottom - captureTop;
if (captureHeight <= 0) {
break;
}
const captureRect = {
left: rect.left - horizontalMargin,
top: captureTop,
width: rect.width + horizontalMargin * 2,
height: captureHeight,
devicePixelRatio
};
const segmentData = await captureScreenshot(captureRect);
if (!segmentData) {
break;
}
const docTop = Math.max(0, window.scrollY + captureTop);
const docBottom = docTop + captureHeight;
segments.push({ data: segmentData, docTop, docBottom });
const reachedBottom = docBottom >= endY - 4;
if (reachedBottom) {
break;
}
const nextScroll = docBottom - Math.max(0, (viewportHeight - stickyOffset) * 0.5);
const maxScroll = Math.max(0, endY - viewportHeight);
targetScroll = Math.min(nextScroll, maxScroll);
if (targetScroll <= window.scrollY + 1) {
targetScroll = window.scrollY + Math.max(160, viewportHeight * 0.6);
}
if (targetScroll <= window.scrollY + 1 || targetScroll >= endY) {
break;
}
}
} finally {
window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' });
}
if (!segments.length) {
return await captureScreenshot();
}
const stitched = await stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop);
return stitched;
}
async function stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop) {
const images = [];
let maxDocBottom = baseDocTop;
for (const segment of segments) {
const img = await loadImage(segment.data);
if (!img) {
continue;
}
images.push({ img, docTop: segment.docTop, docBottom: segment.docBottom });
if (segment.docBottom > maxDocBottom) {
maxDocBottom = segment.docBottom;
}
}
if (!images.length) {
return null;
}
const width = images.reduce((max, item) => Math.max(max, item.img.width), 0);
const totalHeightPx = Math.max(1, Math.round((maxDocBottom - baseDocTop) * devicePixelRatio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = totalHeightPx;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, totalHeightPx);
for (const { img, docTop } of images) {
const offsetY = Math.round(Math.max(0, docTop - baseDocTop) * devicePixelRatio);
ctx.drawImage(img, 0, offsetY);
}
return canvas.toDataURL('image/jpeg', 0.85);
}
async function cropScreenshot(imageData, rect) {
if (!rect) {
return imageData;
}
try {
const image = await loadImage(imageData);
if (!image) {
return imageData;
}
const ratio = rect.devicePixelRatio || window.devicePixelRatio || 1;
const rawLeft = (rect.left || 0) * ratio;
const rawTop = (rect.top || 0) * ratio;
const rawWidth = (rect.width || image.width) * ratio;
const rawHeight = (rect.height || image.height) * ratio;
const rawRight = rawLeft + rawWidth;
const rawBottom = rawTop + rawHeight;
const left = Math.max(0, Math.floor(rawLeft));
const top = Math.max(0, Math.floor(rawTop));
const right = Math.min(image.width, Math.ceil(rawRight));
const bottom = Math.min(image.height, Math.ceil(rawBottom));
const width = Math.max(1, right - left);
const height = Math.max(1, bottom - top);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, left, top, width, height, 0, 0, width, height);
return canvas.toDataURL('image/jpeg', 0.85);
} catch (error) {
console.warn('[FB Tracker] Failed to crop screenshot:', error);
return imageData;
}
}
async function maybeDownscaleScreenshot(imageData) {
try {
const maxWidth = 1600;
const current = await loadImage(imageData);
if (!current) {
return imageData;
}
if (current.width <= maxWidth) {
return imageData;
}
const scale = maxWidth / current.width;
const canvas = document.createElement('canvas');
canvas.width = Math.round(current.width * scale);
canvas.height = Math.round(current.height * scale);
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(current, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', 0.8);
} catch (error) {
console.warn('[FB Tracker] Failed to downscale screenshot:', error);
return imageData;
}
}
function getStickyHeaderHeight() {
try {
const banner = document.querySelector('[role="banner"], header[role="banner"]');
if (!banner) {
return 0;
}
const rect = banner.getBoundingClientRect();
if (!rect || !rect.height) {
return 0;
}
return Math.min(rect.height, 160);
} catch (error) {
console.warn('[FB Tracker] Failed to determine sticky header height:', error);
return 0;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function loadImage(dataUrl) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}
function toDateTimeLocalString(date) {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function getNextDayDefaultDeadlineValue() {
const tomorrow = new Date();
tomorrow.setHours(0, 0, 0, 0);
tomorrow.setDate(tomorrow.getDate() + 1);
return toDateTimeLocalString(tomorrow);
}
function extractDeadlineFromPostText(postElement) {
if (!postElement) {
return null;
}
// Get all text content from the post
const textNodes = [];
const walker = document.createTreeWalker(
postElement,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.trim()) {
textNodes.push(node.textContent.trim());
}
}
const fullText = textNodes.join(' ');
const today = new Date();
today.setHours(0, 0, 0, 0);
const monthNames = {
'januar': 1, 'jan': 1,
'februar': 2, 'feb': 2,
'märz': 3, 'mär': 3, 'maerz': 3,
'april': 4, 'apr': 4,
'mai': 5,
'juni': 6, 'jun': 6,
'juli': 7, 'jul': 7,
'august': 8, 'aug': 8,
'september': 9, 'sep': 9, 'sept': 9,
'oktober': 10, 'okt': 10,
'november': 11, 'nov': 11,
'dezember': 12, 'dez': 12
};
// German date patterns
const patterns = [
// DD.MM.YYYY or DD.MM.YY
/\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/g,
// DD.MM (without year)
/\b(\d{1,2})\.(\d{1,2})\.\s*(?!\d)/g
];
const foundDates = [];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(fullText)) !== null) {
const day = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
let year = match[3] ? parseInt(match[3], 10) : today.getFullYear();
// Handle 2-digit years
if (year < 100) {
year += 2000;
}
// Validate date
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
// Check if date is valid (e.g., not 31.02.)
if (date.getMonth() === month - 1 && date.getDate() === day) {
// Only add if date is in the future
if (date > today) {
foundDates.push(date);
}
}
}
}
}
// Pattern for "12. Oktober" or "12 Oktober"
const monthPattern = /\b(\d{1,2})\.?\s+(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\b/gi;
let monthMatch;
while ((monthMatch = monthPattern.exec(fullText)) !== null) {
const day = parseInt(monthMatch[1], 10);
const monthStr = monthMatch[2].toLowerCase();
const month = monthNames[monthStr];
const year = today.getFullYear();
if (month && day >= 1 && day <= 31) {
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
// Check if date is valid
if (date.getMonth() === month - 1 && date.getDate() === day) {
// If date has passed this year, assume next year
if (date <= today) {
date.setFullYear(year + 1);
}
foundDates.push(date);
}
}
}
// Return the earliest future date
if (foundDates.length > 0) {
foundDates.sort((a, b) => a - b);
return toDateTimeLocalString(foundDates[0]);
}
return null;
}
function normalizeFacebookPostUrl(rawValue) {
if (typeof rawValue !== 'string') {
return '';
}
let value = rawValue.trim();
if (!value) {
return '';
}
const trackingIndex = value.indexOf('__cft__');
if (trackingIndex !== -1) {
value = value.slice(0, trackingIndex);
}
value = value.replace(/[?&]$/, '');
let parsed;
try {
parsed = new URL(value);
} catch (error) {
try {
parsed = new URL(value, 'https://www.facebook.com');
} catch (fallbackError) {
return '';
}
}
if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) {
return '';
}
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase();
if (
lowerKey.startsWith('__cft__')
|| lowerKey.startsWith('__tn__')
|| lowerKey.startsWith('__eep__')
|| lowerKey.startsWith('mibextid')
|| lowerKey === 'set'
|| lowerKey === 'comment_id'
|| lowerKey === 'hoisted_section_header_type'
) {
return;
}
cleanedParams.append(paramKey, paramValue);
});
const multiPermalinkId = cleanedParams.get('multi_permalinks');
if (multiPermalinkId) {
cleanedParams.delete('multi_permalinks');
const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/);
if (groupMatch && multiPermalinkId.match(/^[0-9]+$/)) {
parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`;
} else if (groupMatch) {
parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`;
}
}
const search = cleanedParams.toString();
const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`;
return formatted.replace(/[?&]$/, '');
}
// Create the tracking UI
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
// Normalize to top-level post container if nested element passed
postElement = ensurePrimaryPostElement(postElement);
let existingUI = getTrackerElementForPost(postElement);
if (!existingUI) {
existingUI = postElement.querySelector('.fb-tracker-ui');
if (existingUI && existingUI.isConnected) {
setTrackerElementForPost(postElement, existingUI);
}
}
if (existingUI && !existingUI.isConnected) {
clearTrackerElementForPost(postElement, existingUI);
existingUI = null;
}
if (existingUI) {
console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element');
postElement.setAttribute(PROCESSED_ATTR, '1');
return;
}
// Mark immediately to prevent duplicate creation during async operations
if (postElement.getAttribute(PROCESSED_ATTR) === '1') {
console.log('[FB Tracker] Post #' + postNum + ' - Already processed:', postElement);
return;
}
postElement.setAttribute(PROCESSED_ATTR, '1');
const postUrlData = getPostUrl(postElement, postNum);
if (!postUrlData.url) {
console.log('[FB Tracker] Post #' + postNum + ' - No URL found:', postElement);
postElement.removeAttribute(PROCESSED_ATTR);
clearTrackerElementForPost(postElement);
return;
}
console.log('[FB Tracker] Post #' + postNum + ' - Creating tracker UI for:', postUrlData.url, postElement);
const encodedUrl = encodeURIComponent(postUrlData.url);
const existingEntry = processedPostUrls.get(encodedUrl);
if (existingEntry && existingEntry.element && existingEntry.element !== postElement) {
if (document.body.contains(existingEntry.element)) {
existingEntry.element.removeAttribute(PROCESSED_ATTR);
clearTrackerElementForPost(existingEntry.element);
} else {
processedPostUrls.delete(encodedUrl);
}
const otherUI = document.querySelector(`.fb-tracker-ui[data-post-url="${encodedUrl}"]`);
if (otherUI) {
otherUI.remove();
}
}
const { likeButton: sourceLikeButton = null, isSearchResult = false, isDialogContext = false } = options;
const currentPath = window.location.pathname || '/';
const isFeedHome = FEED_HOME_PATHS.includes(currentPath);
const likedByCurrentUser = isPostLikedByCurrentUser(sourceLikeButton, postElement);
// Create UI container
const container = document.createElement('div');
container.className = 'fb-tracker-ui';
container.id = 'fb-tracker-ui-post-' + postNum;
container.setAttribute('data-post-num', postNum);
container.setAttribute('data-post-url', encodedUrl);
container.style.cssText = `
padding: 6px 12px;
background-color: #f0f2f5;
border-top: 1px solid #e4e6eb;
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: 8px;
row-gap: 6px;
width: 100%;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
`;
// Check current status (check all URL candidates to avoid duplicates)
const profileNumber = await getProfileNumber();
const postData = await checkPostStatus(postUrlData.url, postUrlData.allCandidates);
const isTracked = !!postData;
let searchTrackingInfo = null;
if (isSearchResult) {
const cacheKey = encodedUrl;
const alreadyRecorded = sessionSearchRecordedUrls.has(cacheKey);
const latestInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
skipIncrement: alreadyRecorded
});
if (!alreadyRecorded && latestInfo) {
sessionSearchRecordedUrls.add(cacheKey);
}
if (latestInfo) {
sessionSearchInfoCache.set(cacheKey, latestInfo);
searchTrackingInfo = latestInfo;
} else if (sessionSearchInfoCache.has(cacheKey)) {
searchTrackingInfo = sessionSearchInfoCache.get(cacheKey);
} else {
searchTrackingInfo = latestInfo;
}
}
let manualHideInfo = null;
if (!isSearchResult && isFeedHome) {
try {
manualHideInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
skipIncrement: true
});
} catch (error) {
console.debug('[FB Tracker] Manual hide lookup failed:', error);
}
}
if (isSearchResult && (isTracked || likedByCurrentUser)) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results (tracked or liked) but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results (tracked or liked)');
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: null
});
return;
}
}
if (searchTrackingInfo && searchTrackingInfo.should_hide && !isTracked && !likedByCurrentUser) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results after repeated sightings but skipping in dialog context:', searchTrackingInfo);
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results after repeated sightings:', searchTrackingInfo);
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: typeof searchTrackingInfo.seen_count === 'number' ? searchTrackingInfo.seen_count : null
});
return;
}
}
if (!isSearchResult && manualHideInfo && (manualHideInfo.manually_hidden || manualHideInfo.should_hide)) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (manually hidden) but skipping in dialog context:', manualHideInfo);
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (manually hidden):', manualHideInfo);
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null
});
return;
}
}
if (postData) {
const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0);
const statusText = `${checkedCount}/${postData.target_count}`;
const completed = checkedCount >= postData.target_count;
const lastCheck = Array.isArray(postData.checks) && postData.checks.length
? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })
: null;
// Check if deadline has passed
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : '';
// Check if current profile can check this post
const canCurrentProfileCheck = postData.next_required_profile === profileNumber;
const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber);
if (isFeedHome && isCurrentProfileDone) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)');
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null
});
return;
}
}
let statusHtml = `
<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>` : ''}
`;
// Add check button if current profile can check and not expired
if (canCurrentProfileCheck && !isExpired && !completed) {
statusHtml += `
<button class="fb-tracker-check-btn" style="
padding: 4px 12px;
background-color: #42b72a;
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
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;
// Add AI button
await addAICommentButton(container, postElement);
// Add event listener for check button
const checkBtn = container.querySelector('.fb-tracker-check-btn');
if (checkBtn) {
checkBtn.addEventListener('click', async () => {
checkBtn.disabled = true;
checkBtn.textContent = 'Wird bestätigt...';
const result = await markPostChecked(postData.id, profileNumber);
if (result) {
const newCheckedCount = result.checked_count ?? checkedCount + 1;
const newStatusText = `${newCheckedCount}/${postData.target_count}`;
const newCompleted = newCheckedCount >= postData.target_count;
const newLastCheck = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
container.innerHTML = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${newStatusText}${newCompleted ? ' ✓' : ''}
</div>
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${newLastCheck}</div>
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
✓ Von dir bestätigt
</div>
`;
// Re-add AI button after update
await addAICommentButton(container, postElement);
} else {
checkBtn.disabled = false;
checkBtn.textContent = 'Fehler - Erneut versuchen';
checkBtn.style.backgroundColor = '#e74c3c';
}
});
}
console.log('[FB Tracker] Showing status:', statusText);
} else {
// Post not tracked - show add UI
const selectId = `tracker-select-${Date.now()}`;
const deadlineId = `tracker-deadline-${Date.now()}`;
container.innerHTML = `
<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;
" />
<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';
if (deadlineInput) {
// Try to extract deadline from post text first
const extractedDeadline = extractDeadlineFromPostText(postElement);
deadlineInput.value = extractedDeadline || getNextDayDefaultDeadlineValue();
}
addButton.addEventListener('click', async () => {
const targetCount = parseInt(selectElement.value, 10);
console.log('[FB Tracker] Add button clicked, target:', targetCount);
addButton.disabled = true;
addButton.textContent = 'Wird hinzugefügt...';
postElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' });
await delay(220);
const deadlineValue = deadlineInput ? deadlineInput.value : '';
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
postElement,
deadline: deadlineValue
});
if (result) {
const checks = Array.isArray(result.checks) ? result.checks : [];
const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length;
const targetTotal = result.target_count || targetCount;
const statusText = `${checkedCount}/${targetTotal}`;
const completed = checkedCount >= targetTotal;
const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null;
let statusHtml = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}
</div>
`;
if (lastCheck) {
statusHtml += `
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">
Letzte: ${lastCheck}
</div>
`;
}
container.innerHTML = statusHtml;
if (deadlineInput) {
deadlineInput.value = '';
}
await addAICommentButton(container, postElement);
} else {
// Error
addButton.disabled = false;
addButton.textContent = 'Fehler - Erneut versuchen';
addButton.style.backgroundColor = '#e74c3c';
if (deadlineInput) {
deadlineInput.value = getNextDayDefaultDeadlineValue();
}
}
});
console.log('[FB Tracker] Post #' + postNum + ' - UI created for new post');
// Add AI button for new posts
await addAICommentButton(container, postElement);
}
if (isSearchResult) {
const info = document.createElement('button');
info.type = 'button';
info.className = 'fb-tracker-search-info';
info.title = 'Beitrag künftig in den Suchergebnissen ausblenden';
info.setAttribute('aria-label', 'Beitrag künftig in den Suchergebnissen ausblenden');
const countText = searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: (isTracked ? 'gespeichert' : 'n/v');
const iconSpan = document.createElement('span');
iconSpan.setAttribute('aria-hidden', 'true');
iconSpan.style.fontSize = '15px';
const countSpan = document.createElement('span');
countSpan.textContent = countText;
info.appendChild(iconSpan);
info.appendChild(countSpan);
info.style.cssText = `
color: #1d2129;
font-weight: 600;
border-radius: 999px;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(29, 33, 41, 0.18);
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
transition: transform 150ms ease, box-shadow 150ms ease, opacity 120ms ease;
cursor: pointer;
outline: none;
`;
const OPEN_EYES_ICON = '😳';
const CLOSED_EYES_ICON = '😌';
const setIconOpen = () => {
iconSpan.textContent = OPEN_EYES_ICON;
};
const setIconClosed = () => {
iconSpan.textContent = CLOSED_EYES_ICON;
};
setIconOpen();
const resetHover = () => {
setIconOpen();
info.style.transform = 'scale(1)';
info.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.08)';
};
info.addEventListener('mouseenter', () => {
info.style.transform = 'translateY(-1px) scale(1.05)';
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
setIconClosed();
});
info.addEventListener('mouseleave', () => {
resetHover();
});
info.addEventListener('focus', () => {
info.style.transform = 'translateY(-1px) scale(1.05)';
info.style.boxShadow = '0 6px 14px rgba(24, 119, 242, 0.35)';
setIconClosed();
});
info.addEventListener('blur', () => {
resetHover();
});
info.addEventListener('mousedown', () => {
info.style.transform = 'translateY(0) scale(0.96)';
info.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.18)';
});
info.addEventListener('mouseup', () => {
info.style.transform = 'translateY(-1px) scale(1.03)';
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
});
const handleManualHide = async (event) => {
event.preventDefault();
event.stopPropagation();
if (postElement.getAttribute('data-fb-tracker-hidden') === '1') {
return;
}
info.disabled = true;
info.style.cursor = 'progress';
info.style.opacity = '0.75';
let hideResult = null;
try {
hideResult = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
forceHide: true
});
} catch (error) {
console.error('[FB Tracker] Failed to hide search result manually:', error);
}
if (!hideResult) {
info.disabled = false;
info.style.cursor = 'pointer';
info.style.opacity = '1';
resetHover();
return;
}
if (isSearchResult) {
const cacheKeyForHide = encodedUrl;
sessionSearchRecordedUrls.add(cacheKeyForHide);
sessionSearchInfoCache.set(cacheKeyForHide, hideResult);
}
setIconClosed();
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Manual hide skipped in dialog context');
} else {
hidePostElement(postElement);
const seenCountValue = typeof hideResult.seen_count === 'number' ? hideResult.seen_count : null;
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: seenCountValue
});
}
};
info.addEventListener('click', handleManualHide);
resetHover();
container.insertBefore(info, container.firstChild);
}
// Insert UI - try multiple strategies to find stable insertion point
let inserted = false;
// Strategy 1: After button bar's parent (more stable)
if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) {
const grandParent = buttonBar.parentElement.parentElement;
grandParent.insertBefore(container, buttonBar.parentElement.nextSibling);
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar parent. ID: #' + container.id);
inserted = true;
}
// Strategy 2: After button bar directly
else if (buttonBar && buttonBar.parentElement) {
buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling);
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar. ID: #' + container.id);
inserted = true;
}
// Strategy 3: Append to post element
else {
postElement.appendChild(container);
console.log('[FB Tracker] Post #' + postNum + ' - UI inserted into article (fallback). ID: #' + container.id);
inserted = true;
}
if (inserted) {
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
? searchTrackingInfo.seen_count
: null,
hidden: false
});
setTrackerElementForPost(postElement, container);
}
// Monitor if the UI gets removed and re-insert it
if (inserted) {
const observer = new MutationObserver((mutations) => {
if (!document.getElementById(container.id)) {
console.log('[FB Tracker] Post #' + postNum + ' - UI was removed, re-inserting...');
observer.disconnect();
// Try to re-insert
if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) {
buttonBar.parentElement.parentElement.insertBefore(container, buttonBar.parentElement.nextSibling);
} else if (postElement.parentElement) {
postElement.parentElement.appendChild(container);
}
if (container.isConnected) {
setTrackerElementForPost(postElement, container);
}
}
});
// Observe the parent for changes
const observeTarget = container.parentElement || postElement;
observer.observe(observeTarget, { childList: true, subtree: false });
// Stop observing after 5 seconds
setTimeout(() => observer.disconnect(), 5000);
}
}
// Check if article is a main post (not a comment)
function isMainPost(article, buttonBar) {
if (!article || article === document.body) {
return false;
}
const roleDescription = (article.getAttribute('aria-roledescription') || '').toLowerCase();
if (roleDescription && (roleDescription.includes('kommentar') || roleDescription.includes('comment'))) {
return false;
}
if (article.matches('[data-testid*="comment" i], [data-scope="comment"]')) {
return false;
}
if (article.closest('[data-testid*="comment" i]')) {
return false;
}
if (buttonBar) {
const shareIndicator = buttonBar.querySelector('[data-ad-rendering-role="share_button"]');
if (shareIndicator) {
return true;
}
}
// Comments are usually nested inside other articles or comment sections
// Check if this article is inside another article (likely a comment)
let parent = article.parentElement;
while (parent && parent !== document.body) {
if (parent.getAttribute('role') === 'article') {
const parentRoleDescription = (parent.getAttribute('aria-roledescription') || '').toLowerCase();
if (parentRoleDescription.includes('kommentar') || parentRoleDescription.includes('comment')) {
return false;
}
// This article is inside another article - it's a comment
return false;
}
parent = parent.parentElement;
}
// Additional check: Main posts usually have a link with /posts/ or /permalink/
const postLinks = article.querySelectorAll('a[href*="/posts/"], a[href*="/permalink/"], a[href*="/photo"], a[href*="/videos/"], a[href*="/reel/"]');
if (postLinks.length === 0) {
if (article.querySelector('[data-testid*="comment" i]')) {
return false;
}
if (article.querySelector('[data-ad-rendering-role="share_button"]')) {
return true;
}
const nextSibling = article.nextElementSibling;
if (nextSibling && nextSibling.querySelector('[data-ad-rendering-role="share_button"]')) {
const nextButtons = nextSibling.querySelectorAll('[role="button"]');
if (nextButtons.length > 30) {
return true;
}
}
// No post-type links found - likely a comment
return false;
}
return true;
}
// Global post counter for unique IDs
let globalPostCounter = 0;
// Find all Facebook posts on the page
function findPosts() {
console.log('[FB Tracker] Scanning for posts...');
const postContainers = findPostContainers();
const seenFeedContainers = new Set();
const seenDialogContainers = new Set();
console.log('[FB Tracker] Found', postContainers.length, 'candidate containers');
let processed = 0;
const isSearchResultsPage = window.location.pathname.startsWith(SEARCH_RESULTS_PATH);
for (const { container: originalContainer, likeButton, buttonBar: precomputedButtonBar } of postContainers) {
let container = ensurePrimaryPostElement(originalContainer);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR);
const isInDialog = !!dialogRoot;
const seenSet = isInDialog ? seenDialogContainers : seenFeedContainers;
if (seenSet.has(container)) {
continue;
}
const isPending = container.getAttribute(PENDING_ATTR) === '1';
if (isPending) {
seenSet.add(container);
continue;
}
let existingTracker = getTrackerElementForPost(container);
if (!existingTracker) {
existingTracker = container.querySelector('.fb-tracker-ui');
if (existingTracker && existingTracker.isConnected) {
setTrackerElementForPost(container, existingTracker);
}
}
if (existingTracker && !existingTracker.isConnected) {
clearTrackerElementForPost(container, existingTracker);
existingTracker = null;
}
const alreadyProcessed = container.getAttribute(PROCESSED_ATTR) === '1';
const trackerDialogRoot = existingTracker ? existingTracker.closest(DIALOG_ROOT_SELECTOR) : null;
const trackerInSameDialog = Boolean(
existingTracker
&& existingTracker.isConnected
&& (
trackerDialogRoot === dialogRoot
|| (dialogRoot && dialogRoot.contains(existingTracker))
)
);
if (isInDialog) {
if (trackerInSameDialog) {
seenSet.add(container);
continue;
}
if (existingTracker && existingTracker.isConnected) {
existingTracker.remove();
clearTrackerElementForPost(container, existingTracker);
}
if (alreadyProcessed) {
container.removeAttribute(PROCESSED_ATTR);
}
} else {
if (alreadyProcessed || (existingTracker && existingTracker.isConnected)) {
seenSet.add(container);
continue;
}
}
const buttonBar = precomputedButtonBar || findButtonBar(container);
if (!buttonBar) {
console.log('[FB Tracker] Proceeding without button bar, will fallback to post container insertion');
}
if (!isMainPost(container, buttonBar)) {
console.log('[FB Tracker] Skipping non-main post container');
continue;
}
seenSet.add(container);
processed++;
globalPostCounter++;
const postNum = globalPostCounter;
console.log('[FB Tracker] Post #' + postNum + ' - Adding tracker:', container);
container.setAttribute(PENDING_ATTR, '1');
createTrackerUI(container, buttonBar, postNum, {
likeButton,
isSearchResult: isSearchResultsPage,
isDialogContext: isInDialog
}).catch((error) => {
console.error('[FB Tracker] Post #' + postNum + ' - Failed to create UI:', error, container);
}).finally(() => {
container.removeAttribute(PENDING_ATTR);
});
}
console.log('[FB Tracker] Total processed posts this scan:', processed, '| Global count:', globalPostCounter);
}
// Initialize
console.log('[FB Tracker] Initializing...');
// Run multiple times to catch loading posts
setTimeout(findPosts, 2000);
setTimeout(findPosts, 4000);
setTimeout(findPosts, 6000);
// Debounced scan function
let scanTimeout = null;
function scheduleScan() {
if (scanTimeout) {
clearTimeout(scanTimeout);
}
scanTimeout = setTimeout(() => {
console.log('[FB Tracker] Scheduled scan triggered');
findPosts();
}, 1000);
}
// Watch for new posts being added to the page
const observer = new MutationObserver((mutations) => {
scheduleScan();
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Trigger scan on scroll (for infinite scroll)
let scrollTimeout = null;
window.addEventListener('scroll', () => {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
console.log('[FB Tracker] Scroll detected, scanning...');
findPosts();
}, 1000);
});
// Use IntersectionObserver to detect when posts become visible
const visibilityObserver = new IntersectionObserver((entries) => {
let needsScan = false;
entries.forEach(entry => {
if (entry.isIntersecting) {
const postContainer = entry.target;
// Check if already processed
if (postContainer.getAttribute(PROCESSED_ATTR) !== '1') {
console.log('[FB Tracker] Post became visible and not yet processed:', postContainer);
needsScan = true;
}
}
});
if (needsScan) {
// Delay scan slightly to let Facebook finish loading the post content
setTimeout(() => {
console.log('[FB Tracker] Scanning newly visible posts...');
findPosts();
}, 500);
}
}, {
root: null,
rootMargin: '50px',
threshold: 0.1
});
// Watch for new posts and observe them
const postObserver = new MutationObserver((mutations) => {
// Find all posts and observe them for visibility
const posts = document.querySelectorAll('div[aria-posinset]');
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 initialPosts = document.querySelectorAll('div[aria-posinset]');
initialPosts.forEach(post => {
post.dataset.trackerObserved = 'true';
visibilityObserver.observe(post);
});
console.log('[FB Tracker] Observer with IntersectionObserver started');
// Store the element where context menu was opened
let contextMenuTarget = null;
document.addEventListener('contextmenu', (event) => {
contextMenuTarget = event.target;
console.log('[FB Tracker] Context menu opened on:', contextMenuTarget);
}, true);
// Listen for manual reparse command
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'reparsePost') {
console.log('[FB Tracker] Manual reparse triggered');
// Use the stored context menu target, fallback to elementFromPoint
let clickedElement = contextMenuTarget;
if (!clickedElement && message.x !== undefined && message.y !== undefined) {
clickedElement = document.elementFromPoint(message.x, message.y);
}
if (!clickedElement) {
console.log('[FB Tracker] No element found');
sendResponse({ success: false });
return true;
}
console.log('[FB Tracker] Searching for post container starting from:', clickedElement);
// Find the post container (aria-posinset)
let postContainer = clickedElement.closest('div[aria-posinset]');
if (!postContainer) {
console.log('[FB Tracker] No post container found for clicked element:', clickedElement);
sendResponse({ success: false });
return true;
}
console.log('[FB Tracker] Found post container:', postContainer);
const normalizedContainer = ensurePrimaryPostElement(postContainer);
if (normalizedContainer && normalizedContainer !== postContainer) {
console.log('[FB Tracker] Normalized post container to:', normalizedContainer);
postContainer = normalizedContainer;
}
// Remove processed attribute and existing UI
postContainer.removeAttribute(PROCESSED_ATTR);
const existingUI = postContainer.querySelector('.fb-tracker-ui');
if (existingUI) {
existingUI.remove();
clearTrackerElementForPost(postContainer, existingUI);
console.log('[FB Tracker] Removed existing UI');
}
// Find button bar and create UI
let buttonBar = findButtonBar(postContainer);
if (!buttonBar) {
let fallback = postContainer.parentElement;
while (!buttonBar && fallback && fallback !== document.body) {
buttonBar = findButtonBar(fallback);
fallback = fallback.parentElement;
}
}
if (!buttonBar) {
console.log('[FB Tracker] No button bar found for this post, proceeding with fallback');
}
globalPostCounter++;
const postNum = globalPostCounter;
console.log('[FB Tracker] Reparsing post as #' + postNum);
createTrackerUI(postContainer, buttonBar, postNum).then(() => {
sendResponse({ success: true });
}).catch((error) => {
console.error('[FB Tracker] Failed to reparse:', error);
sendResponse({ success: false });
});
return true;
}
});
// ============================================================================
// AI COMMENT GENERATION
// ============================================================================
/**
* Show a toast notification
*/
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: ${type === 'error' ? '#e74c3c' : type === 'success' ? '#42b72a' : '#1877f2'};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
z-index: 999999;
max-width: 350px;
animation: slideIn 0.3s ease-out;
`;
toast.textContent = message;
// Add animation keyframes
if (!document.getElementById('fb-tracker-toast-styles')) {
const style = document.createElement('style');
style.id = 'fb-tracker-toast-styles';
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Extract post text from a Facebook post element
*/
function normalizeSelectionText(text) {
if (!text) {
return '';
}
const trimmed = text.trim();
if (!trimmed) {
return '';
}
return trimmed.length > MAX_SELECTION_LENGTH
? trimmed.substring(0, MAX_SELECTION_LENGTH)
: trimmed;
}
function ensurePrimaryPostElement(element) {
if (!element) {
return element;
}
const selectors = [
'div[role="dialog"] article',
'div[role="dialog"] div[aria-posinset]',
'[data-pagelet*="FeedUnit"] article',
'div[role="main"] article',
'[data-visualcompletion="ignore-dynamic"] article',
'div[aria-posinset]',
'article[role="article"]',
'article'
];
let current = element;
while (current && current !== document.body && current !== document.documentElement) {
for (const selector of selectors) {
if (current.matches && current.matches(selector)) {
return current;
}
}
current = current.parentElement;
}
return element;
}
function cacheSelectionForPost(postElement) {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const text = normalizeSelectionText(selection.toString());
if (text) {
postSelectionCache.set(postElement, {
text,
timestamp: Date.now()
});
lastGlobalSelection = { text, timestamp: Date.now() };
}
} catch (error) {
console.warn('[FB Tracker] Failed to cache selection text:', error);
}
}
function cacheCurrentSelection() {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const text = normalizeSelectionText(selection.toString());
if (text) {
lastGlobalSelection = {
text,
timestamp: Date.now()
};
}
} catch (error) {
console.debug('[FB Tracker] Unable to cache current selection:', error);
}
}
function getSelectedTextFromPost(postElement) {
try {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('No active selection');
}
const text = normalizeSelectionText(selection.toString());
if (text) {
postSelectionCache.set(postElement, { text, timestamp: Date.now() });
lastGlobalSelection = { text, timestamp: Date.now() };
return text;
}
throw new Error('Empty selection');
} catch (error) {
if (error && error.message) {
console.debug('[FB Tracker] Selection fallback:', error.message);
}
const cached = postSelectionCache.get(postElement);
if (cached && Date.now() - cached.timestamp < LAST_SELECTION_MAX_AGE) {
return cached.text;
}
if (lastGlobalSelection.text && Date.now() - lastGlobalSelection.timestamp < LAST_SELECTION_MAX_AGE) {
return lastGlobalSelection.text;
}
return '';
}
}
function extractPostText(postElement) {
if (!postElement) {
return '';
}
// Try to find the post content div
const contentSelectors = [
'[data-ad-preview="message"]',
'[data-ad-comet-preview="message"]',
'div[dir="auto"][style*="text-align"]',
'div[data-ad-comet-preview] > div > div > span',
'.x193iq5w.xeuugli' // Common Facebook text class
];
const uiTextPattern = /(Gefällt mir|Kommentieren|Teilen|Like|Comment|Share)/gi;
const timePattern = /\d+\s*(Std\.|Min\.|Tag|hour|minute|day)/gi;
const sponsoredPattern = /(Gesponsert|Sponsored)/gi;
const cleanCandidate = (text) => {
if (!text) {
return '';
}
const cleaned = text
.replace(uiTextPattern, ' ')
.replace(timePattern, ' ')
.replace(sponsoredPattern, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) {
return '';
}
// Ignore very short snippets that are likely button labels
if (cleaned.length < 5 && cleaned.split(/\s+/).length < 2) {
return '';
}
return cleaned;
};
const candidates = [];
for (const selector of contentSelectors) {
const elements = postElement.querySelectorAll(selector);
for (const element of elements) {
const candidate = cleanCandidate(element.innerText || element.textContent || '');
if (candidate) {
candidates.push(candidate);
}
}
if (candidates.length) {
break;
}
}
let textContent = '';
if (candidates.length) {
textContent = candidates.reduce((longest, current) => (
current.length > longest.length ? current : longest
), '');
}
// Fallback: Get all text but filter out common UI elements
if (!textContent) {
const allText = postElement.innerText || postElement.textContent || '';
textContent = cleanCandidate(allText);
}
return textContent.substring(0, 2000); // Limit length
}
/**
* Find and click the comment button to open comment field
*/
function findAndClickCommentButton(postElement) {
if (!postElement) {
return false;
}
// Look for comment button with various selectors
const commentButtonSelectors = [
'[data-ad-rendering-role="comment_button"]',
'[aria-label*="Kommentieren"]',
'[aria-label*="Comment"]'
];
for (const selector of commentButtonSelectors) {
const button = postElement.querySelector(selector);
if (button) {
console.log('[FB Tracker] Found comment button, clicking it');
button.click();
return true;
}
}
// Try in parent elements
let parent = postElement;
for (let i = 0; i < 3; i++) {
parent = parent.parentElement;
if (!parent) break;
for (const selector of commentButtonSelectors) {
const button = parent.querySelector(selector);
if (button) {
console.log('[FB Tracker] Found comment button in parent, clicking it');
button.click();
return true;
}
}
}
return false;
}
/**
* Find comment input field on current page
*/
function findCommentInput(postElement, options = {}) {
const {
preferredRoot = null,
includeParents = true
} = options;
if (!postElement && !preferredRoot) {
return null;
}
const selectors = [
'div[contenteditable="true"][role="textbox"]',
'div[aria-label*="Kommentar"][contenteditable="true"]',
'div[aria-label*="comment"][contenteditable="true"]',
'div[aria-label*="Write a comment"][contenteditable="true"]'
];
const searchInRoot = (root) => {
if (!root) {
return null;
}
for (const selector of selectors) {
const input = root.querySelector(selector);
if (input && isElementVisible(input)) {
return input;
}
}
return null;
};
const roots = [];
if (postElement) {
roots.push(postElement);
}
if (preferredRoot && preferredRoot.isConnected && !roots.includes(preferredRoot)) {
roots.push(preferredRoot);
}
for (const root of roots) {
const input = searchInRoot(root);
if (input) {
return input;
}
}
if (includeParents && postElement) {
let parent = postElement.parentElement;
for (let i = 0; i < 3 && parent; i++) {
if (preferredRoot && !preferredRoot.contains(parent)) {
parent = parent.parentElement;
continue;
}
const input = searchInRoot(parent);
if (input) {
return input;
}
parent = parent.parentElement;
}
}
return null;
}
function isElementVisible(element) {
if (!element || !element.isConnected) {
return false;
}
if (typeof element.offsetParent !== 'undefined' && element.offsetParent !== null) {
return true;
}
const rects = element.getClientRects();
return rects && rects.length > 0;
}
function isCancellationError(error) {
if (!error) {
return false;
}
if (error.name === 'AICancelled' || error.name === 'AbortError') {
return true;
}
if (typeof error.message === 'string') {
const normalized = error.message.toLowerCase();
if (normalized === 'ai_cancelled' || normalized === 'abgebrochen') {
return true;
}
}
return false;
}
async function waitForCommentInput(postElement, options = {}) {
const {
encodedPostUrl = null,
timeout = 6000,
interval = 200,
context = null,
preferredRoot: rawPreferredRoot = null
} = options;
const deadline = Date.now() + Math.max(timeout, 0);
let attempts = 0;
const preferredRoot = rawPreferredRoot && rawPreferredRoot.isConnected
? rawPreferredRoot
: null;
const findByEncodedUrl = () => {
if (context && context.cancelled) {
return null;
}
if (!encodedPostUrl) {
return null;
}
const trackers = document.querySelectorAll(`.fb-tracker-ui[data-post-url="${encodedPostUrl}"]`);
for (const tracker of trackers) {
if (!tracker.isConnected) {
continue;
}
if (preferredRoot && !preferredRoot.contains(tracker)) {
continue;
}
const trackerContainer = tracker.closest('div[aria-posinset], article[role="article"], article');
if (trackerContainer) {
const input = findCommentInput(trackerContainer, { preferredRoot });
if (isElementVisible(input)) {
return input;
}
}
const dialogRoot = preferredRoot || tracker.closest(DIALOG_ROOT_SELECTOR);
if (dialogRoot) {
const dialogInput = dialogRoot.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
return dialogInput;
}
}
}
return null;
};
while (Date.now() <= deadline) {
if (context && context.cancelled) {
return null;
}
attempts++;
let input = findCommentInput(postElement, { preferredRoot });
if (isElementVisible(input)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (post context)');
}
return input;
}
input = findByEncodedUrl();
if (input) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (encoded URL context)');
}
return input;
}
const dialogRootFromPost = preferredRoot
|| (postElement ? postElement.closest(DIALOG_ROOT_SELECTOR) : null);
if (dialogRootFromPost) {
const dialogInput = dialogRootFromPost.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (dialog context)');
}
return dialogInput;
}
}
if (!preferredRoot) {
const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR);
if (fallbackDialog && fallbackDialog !== dialogRootFromPost) {
const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]');
if (isElementVisible(dialogInput)) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (fallback dialog)');
}
return dialogInput;
}
}
}
const globalInputs = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"]')).filter(isElementVisible);
if (preferredRoot) {
const scopedInputs = globalInputs.filter(input => preferredRoot.contains(input));
if (scopedInputs.length > 0) {
const lastInput = scopedInputs[scopedInputs.length - 1];
if (lastInput) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (preferred root fallback)');
}
return lastInput;
}
}
}
if (globalInputs.length > 0) {
const lastInput = globalInputs[globalInputs.length - 1];
if (lastInput) {
if (attempts > 1) {
console.log('[FB Tracker] Comment input located after', attempts, 'attempts (global fallback)');
}
return lastInput;
}
}
await delay(interval);
}
console.log('[FB Tracker] Comment input wait timed out after', timeout, 'ms');
return null;
}
/**
* Set text in comment input field
*/
async function setCommentText(inputElement, text, options = {}) {
const { context = null } = options;
const ensureNotCancelled = () => {
if (context && context.cancelled) {
const cancelError = new Error('AI_CANCELLED');
cancelError.name = 'AICancelled';
throw cancelError;
}
};
if (!inputElement || !text) {
return false;
}
try {
ensureNotCancelled();
console.log('[FB Tracker] Setting comment text:', text.substring(0, 50) + '...');
console.log('[FB Tracker] Input element:', inputElement);
// Focus and click to ensure field is active
inputElement.focus();
inputElement.click();
// Small delay to ensure field is ready
await delay(50);
ensureNotCancelled();
// Clear existing content
inputElement.textContent = '';
// Method 1: Try execCommand first (best for Facebook)
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(inputElement);
selection.removeAllRanges();
selection.addRange(range);
const execSuccess = document.execCommand('insertText', false, text);
console.log('[FB Tracker] execCommand result:', execSuccess);
// Wait a bit and check if it worked
await delay(100);
ensureNotCancelled();
let currentContent = inputElement.textContent || inputElement.innerText || '';
console.log('[FB Tracker] Content after execCommand:', currentContent);
// If execCommand didn't work, use direct method
if (!currentContent || currentContent.trim().length === 0) {
console.log('[FB Tracker] execCommand failed, using direct method');
inputElement.textContent = text;
currentContent = text;
}
// Trigger input events
inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true }));
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
// Final verification
ensureNotCancelled();
const finalContent = inputElement.textContent || inputElement.innerText || '';
console.log('[FB Tracker] Final content:', finalContent.substring(0, 50));
return finalContent.length > 0;
} catch (error) {
console.error('[FB Tracker] Failed to set comment text:', error);
return false;
}
}
/**
* Generate AI comment for a post
*/
async function generateAIComment(postText, profileNumber, options = {}) {
const { signal = null, preferredCredentialId = null } = options;
try {
const payload = {
postText,
profileNumber
};
if (typeof preferredCredentialId === 'number') {
payload.preferredCredentialId = preferredCredentialId;
}
const response = await backendFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate comment');
}
const data = await response.json();
return data.comment;
} catch (error) {
console.error('[FB Tracker] AI comment generation failed:', error);
throw error;
}
}
/**
* Check if AI is enabled
*/
async function isAIEnabled() {
try {
const response = await backendFetch(`${API_URL}/ai-settings`);
if (!response.ok) {
return false;
}
const settings = await response.json();
return settings && settings.enabled === 1;
} catch (error) {
console.warn('[FB Tracker] Failed to check AI settings:', error);
return false;
}
}
/**
* Add AI comment button to tracker UI
*/
async function addAICommentButton(container, postElement) {
const aiEnabled = await isAIEnabled();
if (!aiEnabled) {
return;
}
const encodedPostUrl = container && container.getAttribute('data-post-url')
? container.getAttribute('data-post-url')
: null;
const wrapper = document.createElement('div');
wrapper.className = 'fb-tracker-ai-wrapper';
wrapper.style.cssText = `
margin-left: auto;
position: relative;
display: inline-flex;
align-items: stretch;
border-radius: 6px;
overflow: visible;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
`;
const button = document.createElement('button');
button.type = 'button';
button.className = 'fb-tracker-btn fb-tracker-btn-ai';
button.textContent = '✨ AI';
button.title = 'Generiere automatisch einen passenden Kommentar';
button.style.cssText = `
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 6px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
flex: 1 1 auto;
border-radius: 6px 0 0 6px;
transition: all 0.2s ease;
`;
const dropdownButton = document.createElement('button');
dropdownButton.type = 'button';
dropdownButton.className = 'fb-tracker-btn fb-tracker-btn-ai-dropdown';
dropdownButton.textContent = '▾';
dropdownButton.title = 'AI auswählen';
dropdownButton.setAttribute('aria-label', 'AI auswählen');
dropdownButton.setAttribute('aria-haspopup', 'menu');
dropdownButton.setAttribute('aria-expanded', 'false');
dropdownButton.style.cssText = `
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
width: 34px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 0 6px 6px 0;
transition: all 0.2s ease;
`;
const dropdown = document.createElement('div');
dropdown.className = 'fb-tracker-ai-dropdown';
dropdown.style.cssText = `
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 220px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
z-index: 2147483647;
padding: 6px 0;
`;
wrapper.appendChild(button);
wrapper.appendChild(dropdownButton);
wrapper.appendChild(dropdown);
container.appendChild(wrapper);
button.addEventListener('mouseenter', () => {
if ((button.dataset.aiState || 'idle') === 'idle') {
button.style.transform = 'translateY(-2px)';
button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
dropdownButton.style.transform = 'translateY(-2px)';
dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
}
});
button.addEventListener('mouseleave', () => {
button.style.transform = 'translateY(0)';
button.style.boxShadow = 'none';
dropdownButton.style.transform = 'translateY(0)';
dropdownButton.style.boxShadow = 'none';
});
dropdownButton.addEventListener('mouseenter', () => {
if ((button.dataset.aiState || 'idle') === 'idle') {
button.style.transform = 'translateY(-2px)';
button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
dropdownButton.style.transform = 'translateY(-2px)';
dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
}
});
dropdownButton.addEventListener('mouseleave', () => {
if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open')) {
button.style.transform = 'translateY(0)';
button.style.boxShadow = 'none';
dropdownButton.style.transform = 'translateY(0)';
dropdownButton.style.boxShadow = 'none';
}
});
button.addEventListener('pointerdown', () => {
const contextElement = container.closest('div[aria-posinset], article[role="article"], article');
const normalized = contextElement ? ensurePrimaryPostElement(contextElement) : null;
const fallbackNormalized = postElement ? ensurePrimaryPostElement(postElement) : null;
const target = normalized || fallbackNormalized || contextElement || postElement || container;
cacheSelectionForPost(target);
});
button.dataset.aiState = 'idle';
button.dataset.aiOriginalText = button.textContent;
let dropdownOpen = false;
const closeDropdown = () => {
if (!dropdownOpen) {
return;
}
dropdown.style.display = 'none';
dropdownOpen = false;
dropdownButton.setAttribute('aria-expanded', 'false');
dropdownButton.textContent = '▾';
wrapper.classList.remove('fb-tracker-ai-wrapper--open');
document.removeEventListener('click', handleOutsideClick, true);
document.removeEventListener('keydown', handleKeydown, true);
button.style.transform = 'translateY(0)';
dropdownButton.style.transform = 'translateY(0)';
button.style.boxShadow = 'none';
dropdownButton.style.boxShadow = 'none';
};
const handleOutsideClick = (event) => {
if (!wrapper.contains(event.target)) {
closeDropdown();
}
};
const handleKeydown = (event) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
const renderDropdownItems = async () => {
dropdown.innerHTML = '';
const loading = document.createElement('div');
loading.textContent = 'Lade AI-Auswahl...';
loading.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #555;';
dropdown.appendChild(loading);
try {
const credentials = await fetchActiveAICredentials();
dropdown.innerHTML = '';
if (!credentials || credentials.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Keine aktiven AI-Anbieter gefunden';
empty.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #888;';
dropdown.appendChild(empty);
return;
}
credentials.forEach((credential) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'fb-tracker-ai-option';
option.style.cssText = `
width: 100%;
padding: 8px 14px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 2px;
`;
option.addEventListener('mouseenter', () => {
option.style.background = '#f0f2f5';
});
option.addEventListener('mouseleave', () => {
option.style.background = 'transparent';
});
const label = document.createElement('span');
label.textContent = formatAICredentialLabel(credential);
label.style.cssText = 'font-weight: 600; color: #1d2129;';
const metaParts = [];
if (credential.provider) {
metaParts.push(`Provider: ${credential.provider}`);
}
if (credential.model) {
metaParts.push(`Modell: ${credential.model}`);
}
if (metaParts.length > 0) {
const meta = document.createElement('span');
meta.textContent = metaParts.join(' · ');
meta.style.cssText = 'font-size: 12px; color: #65676b;';
option.appendChild(label);
option.appendChild(meta);
} else {
option.appendChild(label);
}
option.addEventListener('click', () => {
closeDropdown();
if ((button.dataset.aiState || 'idle') === 'idle') {
cacheSelectionForPost(postElement);
startAIFlow(credential.id);
}
});
dropdown.appendChild(option);
});
} catch (error) {
dropdown.innerHTML = '';
const errorItem = document.createElement('div');
errorItem.textContent = error.message || 'AI-Anbieter konnten nicht geladen werden';
errorItem.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #c0392b; white-space: normal;';
dropdown.appendChild(errorItem);
}
};
const toggleDropdown = async () => {
if ((button.dataset.aiState || 'idle') !== 'idle') {
return;
}
if (dropdownOpen) {
closeDropdown();
return;
}
dropdownOpen = true;
wrapper.classList.add('fb-tracker-ai-wrapper--open');
dropdownButton.textContent = '▴';
dropdown.style.display = 'block';
dropdownButton.setAttribute('aria-expanded', 'true');
document.addEventListener('click', handleOutsideClick, true);
document.addEventListener('keydown', handleKeydown, true);
await renderDropdownItems();
};
dropdownButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
toggleDropdown();
});
const startAIFlow = async (preferredCredentialId = null) => {
closeDropdown();
const originalText = button.dataset.aiOriginalText || '✨ AI';
const currentState = button.dataset.aiState || 'idle';
if (currentState === 'processing') {
const runningContext = button._aiContext;
if (runningContext && !runningContext.cancelled) {
runningContext.cancel();
button.dataset.aiState = 'cancelling';
button.textContent = '✋ Abbruch...';
button.setAttribute('aria-busy', 'true');
button.style.cursor = 'progress';
dropdownButton.style.cursor = 'progress';
button.classList.remove('fb-tracker-btn-ai--processing');
button.classList.add('fb-tracker-btn-ai--cancelling');
}
return;
}
if (currentState === 'cancelling') {
return;
}
const aiContext = {
cancelled: false,
abortController: new AbortController(),
cancel() {
if (!this.cancelled) {
this.cancelled = true;
this.abortController.abort();
}
}
};
const throwIfCancelled = () => {
if (aiContext.cancelled) {
const cancelError = new Error('AI_CANCELLED');
cancelError.name = 'AICancelled';
throw cancelError;
}
};
const updateProcessingText = (text) => {
if (button.dataset.aiState === 'processing' && !aiContext.cancelled) {
button.textContent = text;
}
};
const restoreIdle = (text, revertDelay = 0) => {
button.dataset.aiState = 'idle';
button._aiContext = null;
button.removeAttribute('aria-busy');
button.classList.remove('fb-tracker-btn-ai--processing', 'fb-tracker-btn-ai--cancelling');
button.style.cursor = 'pointer';
dropdownButton.disabled = false;
dropdownButton.style.opacity = '1';
dropdownButton.style.cursor = 'pointer';
dropdownButton.textContent = '▾';
dropdownButton.setAttribute('aria-busy', 'false');
button.textContent = text;
if (revertDelay > 0) {
setTimeout(() => {
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = originalText;
}
}, revertDelay);
}
};
button._aiContext = aiContext;
button.dataset.aiState = 'processing';
button.setAttribute('aria-busy', 'true');
button.classList.add('fb-tracker-btn-ai--processing');
button.classList.remove('fb-tracker-btn-ai--cancelling');
button.style.cursor = 'progress';
dropdownButton.disabled = true;
dropdownButton.style.opacity = '0.5';
dropdownButton.style.cursor = 'not-allowed';
dropdownButton.setAttribute('aria-busy', 'true');
button.textContent = '⏳ Generiere...';
try {
const contextCandidate = container.closest('div[aria-posinset], article[role="article"], article');
const normalizedContext = contextCandidate ? ensurePrimaryPostElement(contextCandidate) : null;
const fallbackContext = postElement ? ensurePrimaryPostElement(postElement) : null;
const postContext = normalizedContext || fallbackContext || contextCandidate || postElement || container;
const selectionKeys = [];
if (postContext) {
selectionKeys.push(postContext);
}
if (postElement && postElement !== postContext) {
selectionKeys.push(postElement);
}
if (contextCandidate && contextCandidate !== postContext && contextCandidate !== postElement) {
selectionKeys.push(contextCandidate);
}
const resolveRecentSelection = () => {
for (const key of selectionKeys) {
if (!key) {
continue;
}
const entry = postSelectionCache.get(key);
if (entry && Date.now() - entry.timestamp < LAST_SELECTION_MAX_AGE) {
return entry;
}
}
return null;
};
let postText = '';
const cachedSelection = resolveRecentSelection();
if (cachedSelection) {
console.log('[FB Tracker] Using cached selection text');
postText = cachedSelection.text;
}
throwIfCancelled();
if (!postText) {
const selectionSource = postContext || postElement;
if (selectionSource) {
postText = getSelectedTextFromPost(selectionSource);
if (postText) {
console.log('[FB Tracker] Using active selection text');
}
}
}
if (!postText) {
const latestCached = resolveRecentSelection();
if (latestCached) {
console.log('[FB Tracker] Using latest cached selection after check');
postText = latestCached.text;
}
}
if (!postText) {
postText = extractPostText(postContext);
if (postText) {
console.log('[FB Tracker] Fallback to DOM extraction');
}
}
if (!postText) {
throw new Error('Konnte Post-Text nicht extrahieren');
}
selectionKeys.forEach((key) => {
if (key) {
postSelectionCache.delete(key);
}
});
throwIfCancelled();
console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100));
const profileNumber = await getProfileNumber();
throwIfCancelled();
const comment = await generateAIComment(postText, profileNumber, {
signal: aiContext.abortController.signal,
preferredCredentialId
});
throwIfCancelled();
console.log('[FB Tracker] Generated comment:', comment);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR)
|| (postContext && postContext.closest(DIALOG_ROOT_SELECTOR));
let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot });
let waitedForInput = false;
if (!commentInput) {
console.log('[FB Tracker] Comment input not found, trying to click comment button');
let buttonClicked = findAndClickCommentButton(postContext);
if (!buttonClicked && dialogRoot) {
const dialogCommentButton = dialogRoot.querySelector('[data-ad-rendering-role="comment_button"], [aria-label*="Kommentieren"], [aria-label*="Comment"]');
if (dialogCommentButton && isElementVisible(dialogCommentButton)) {
dialogCommentButton.click();
buttonClicked = true;
}
}
updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...');
waitedForInput = true;
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
timeout: buttonClicked ? 8000 : 5000,
interval: 250,
context: aiContext,
preferredRoot: dialogRoot
});
}
if (!commentInput && !waitedForInput) {
updateProcessingText('⏳ Suche Kommentarfeld...');
waitedForInput = true;
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
timeout: 4000,
interval: 200,
context: aiContext,
preferredRoot: dialogRoot
});
}
throwIfCancelled();
if (!commentInput) {
throwIfCancelled();
await navigator.clipboard.writeText(comment);
throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
return;
}
if (waitedForInput) {
updateProcessingText('⏳ Füge Kommentar ein...');
}
const success = await setCommentText(commentInput, comment, { context: aiContext });
throwIfCancelled();
if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000);
} else {
await navigator.clipboard.writeText(comment);
throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
}
} catch (error) {
const cancelled = aiContext.cancelled || isCancellationError(error);
if (cancelled) {
console.log('[FB Tracker] AI comment operation cancelled');
restoreIdle('✋ Abgebrochen', 1500);
showToast('⏹️ Vorgang abgebrochen', 'info');
return;
}
console.error('[FB Tracker] AI comment error:', error);
showToast(`${error.message}`, 'error');
restoreIdle(originalText);
}
};
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
startAIFlow();
});
container.appendChild(wrapper);
}
// Expose function globally so it can be called from createTrackerUI
window.addAICommentButton = addAICommentButton;