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