4644 lines
136 KiB
JavaScript
4644 lines
136 KiB
JavaScript
// Facebook Post Tracker Extension
|
|
// Uses API_BASE_URL from config.js
|
|
|
|
const EXTENSION_VERSION = '1.1.0';
|
|
const PROCESSED_ATTR = 'data-fb-tracker-processed';
|
|
const PENDING_ATTR = 'data-fb-tracker-pending';
|
|
const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]';
|
|
const API_URL = `${API_BASE_URL}/api`;
|
|
const 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 = '/search/top';
|
|
const FEED_HOME_PATHS = ['/', '/home.php'];
|
|
const sessionSearchRecordedUrls = new Set();
|
|
const sessionSearchInfoCache = new Map();
|
|
|
|
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
|
|
};
|
|
|
|
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 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 } = options || {};
|
|
|
|
const payload = {
|
|
url: primaryUrl,
|
|
candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [],
|
|
skip_increment: !!skipIncrement,
|
|
force_hide: !!forceHide
|
|
};
|
|
|
|
const response = await backendFetch(`${API_URL}/search-posts`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn('[FB Tracker] Failed to record search post occurrence:', response.status);
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data;
|
|
} catch (error) {
|
|
console.error('[FB Tracker] Error recording search result post:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Update post URL
|
|
async function updatePostUrl(postId, newUrl) {
|
|
try {
|
|
const response = await backendFetch(`${API_URL}/posts/${postId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ url: newUrl })
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log('[FB Tracker] Post URL updated successfully');
|
|
return true;
|
|
} else {
|
|
console.error('[FB Tracker] Failed to update post URL:', response.status);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('[FB Tracker] Error updating post URL:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 isPostLikedByCurrentUser(likeButton, postElement) {
|
|
const candidates = [];
|
|
if (likeButton) {
|
|
candidates.push(likeButton);
|
|
}
|
|
if (postElement) {
|
|
postElement.querySelectorAll('[data-ad-rendering-role*="gefällt" i], [aria-label*="gefällt" i]').forEach((node) => {
|
|
if (node && !candidates.includes(node)) {
|
|
candidates.push(node);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const styleTarget = candidate.matches('[data-ad-rendering-role*="gefällt" i]')
|
|
? candidate
|
|
: candidate.querySelector && candidate.querySelector('[data-ad-rendering-role*="gefällt" i]');
|
|
|
|
if (styleTarget) {
|
|
const inlineStyle = (styleTarget.getAttribute('style') || '').trim();
|
|
if (inlineStyle && /(#0?866ff|reaction-like)/i.test(inlineStyle)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const computed = window.getComputedStyle(styleTarget);
|
|
if (computed && computed.color) {
|
|
const color = computed.color.toLowerCase();
|
|
if (color.includes('rgb(8, 102, 255)') || color.includes('rgba(8, 102, 255')) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.debug('[FB Tracker] Unable to inspect computed style for like button:', error);
|
|
}
|
|
}
|
|
|
|
const ariaPressed = candidate.getAttribute && candidate.getAttribute('aria-pressed');
|
|
if (ariaPressed && ariaPressed.toLowerCase() === 'true') {
|
|
return true;
|
|
}
|
|
|
|
const ariaLabel = (candidate.getAttribute && candidate.getAttribute('aria-label')) || '';
|
|
const buttonText = candidate.textContent || '';
|
|
const combined = `${ariaLabel} ${buttonText}`.toLowerCase();
|
|
|
|
const likedIndicators = ['gefällt dir', 'gefällt dir nicht mehr', 'unlike', 'remove like', 'nicht mehr gefällt'];
|
|
if (likedIndicators.some(indicator => combined.includes(indicator))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function hidePostElement(postElement) {
|
|
if (!postElement) {
|
|
return;
|
|
}
|
|
|
|
const postContainer = ensurePrimaryPostElement(postElement);
|
|
if (postContainer && postContainer.closest(DIALOG_ROOT_SELECTOR)) {
|
|
console.log('[FB Tracker] Skipping hide for dialog/modal context');
|
|
return;
|
|
}
|
|
if (postContainer && !isMainPost(postContainer, null)) {
|
|
console.log('[FB Tracker] Skipping hide for comment container');
|
|
return;
|
|
}
|
|
const removalSelectors = [
|
|
'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 parent = removalRoot.parentElement;
|
|
|
|
while (parent && parent !== document.body && parent !== document.documentElement) {
|
|
if (parent.childElementCount > 1) {
|
|
break;
|
|
}
|
|
|
|
if (parent.matches('[role="feed"], [role="main"], [role="region"], [data-pagelet], [data-testid="SEARCH_RESULT_CONTAINER"], #ssrb_feed_start')) {
|
|
break;
|
|
}
|
|
|
|
removalRoot = parent;
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
removalRoot.setAttribute('data-fb-tracker-hidden', '1');
|
|
|
|
const removalParent = removalRoot.parentElement;
|
|
if (removalParent) {
|
|
removalParent.removeChild(removalRoot);
|
|
|
|
let current = removalParent;
|
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
if (current.childElementCount > 0) {
|
|
break;
|
|
}
|
|
const nextParent = current.parentElement;
|
|
if (!nextParent) {
|
|
break;
|
|
}
|
|
if (nextParent === document.body || nextParent === document.documentElement) {
|
|
current.remove();
|
|
break;
|
|
}
|
|
if (nextParent.childElementCount > 1) {
|
|
current.remove();
|
|
break;
|
|
}
|
|
current.remove();
|
|
current = nextParent;
|
|
}
|
|
} else {
|
|
removalRoot.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function collectButtonMeta(button) {
|
|
const textParts = [];
|
|
const roleParts = [];
|
|
|
|
const label = normalizeButtonLabel(button);
|
|
if (label) {
|
|
textParts.push(label);
|
|
}
|
|
|
|
const collectRole = (value) => {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
const lower = value.toLowerCase();
|
|
roleParts.push(lower);
|
|
|
|
const tokens = lower.split(/[_\-\s]+/);
|
|
tokens.forEach((token) => {
|
|
if (token) {
|
|
roleParts.push(token);
|
|
}
|
|
});
|
|
};
|
|
|
|
collectRole(button.getAttribute('data-ad-rendering-role'));
|
|
|
|
const descendantRoles = button.querySelectorAll('[data-ad-rendering-role]');
|
|
descendantRoles.forEach((el) => {
|
|
collectRole(el.getAttribute('data-ad-rendering-role'));
|
|
if (el.textContent) {
|
|
textParts.push(normalizeButtonLabel(el));
|
|
}
|
|
});
|
|
|
|
return {
|
|
text: textParts.join(' '),
|
|
roles: roleParts.join(' ')
|
|
};
|
|
}
|
|
|
|
function buttonClassification(button) {
|
|
const meta = collectButtonMeta(button);
|
|
const text = meta.text;
|
|
const roles = meta.roles;
|
|
const combined = `${text} ${roles}`;
|
|
return {
|
|
isLike: matchesKeyword(combined, LIKE_LABEL_KEYWORDS) || matchesKeyword(roles, LIKE_ROLE_KEYWORDS),
|
|
isComment: matchesKeyword(combined, COMMENT_LABEL_KEYWORDS) || matchesKeyword(roles, COMMENT_ROLE_KEYWORDS),
|
|
isShare: matchesKeyword(combined, SHARE_LABEL_KEYWORDS) || matchesKeyword(roles, SHARE_ROLE_KEYWORDS),
|
|
isReply: matchesKeyword(combined, REPLY_LABEL_KEYWORDS) || matchesKeyword(roles, REPLY_ROLE_KEYWORDS)
|
|
};
|
|
}
|
|
|
|
function findShareButtonForArticle(article) {
|
|
const direct = article.querySelector('[data-ad-rendering-role="share_button"]');
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
const shareButtons = document.querySelectorAll('[data-ad-rendering-role="share_button"]');
|
|
for (const button of shareButtons) {
|
|
const owningArticle = button.closest('[role="article"]');
|
|
if (owningArticle === article) {
|
|
return button;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function hasInteractionButtons(container) {
|
|
if (!container) {
|
|
return false;
|
|
}
|
|
|
|
const buttons = container.querySelectorAll('[role="button"], button');
|
|
if (!buttons.length) {
|
|
return false;
|
|
}
|
|
|
|
let hasLike = false;
|
|
let hasComment = false;
|
|
let hasShare = false;
|
|
let hasReply = false;
|
|
|
|
for (const button of buttons) {
|
|
const info = buttonClassification(button);
|
|
if (info.isLike) {
|
|
hasLike = true;
|
|
}
|
|
if (info.isComment) {
|
|
hasComment = true;
|
|
}
|
|
if (info.isShare) {
|
|
hasShare = true;
|
|
}
|
|
if (info.isReply) {
|
|
hasReply = true;
|
|
}
|
|
}
|
|
|
|
if (hasLike || hasComment || hasShare) {
|
|
console.log('[FB Tracker] Container analysis', {
|
|
tag: container.tagName,
|
|
classes: container.className,
|
|
hasLike,
|
|
hasComment,
|
|
hasShare,
|
|
hasReply,
|
|
buttonCount: buttons.length
|
|
});
|
|
}
|
|
|
|
const interactionCount = [hasLike, hasComment, hasShare].filter(Boolean).length;
|
|
if (interactionCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (hasShare) {
|
|
return true;
|
|
}
|
|
|
|
if (interactionCount >= 2) {
|
|
return true;
|
|
}
|
|
|
|
return !hasReply;
|
|
}
|
|
|
|
function findButtonBar(postElement) {
|
|
const shareAnchor = findShareButtonForArticle(postElement);
|
|
if (shareAnchor) {
|
|
let container = shareAnchor.closest('[role="button"]') || shareAnchor.closest('button');
|
|
for (let i = 0; i < 4 && container; i++) {
|
|
if (hasInteractionButtons(container)) {
|
|
console.log('[FB Tracker] Found button bar via share anchor');
|
|
return container;
|
|
}
|
|
container = container.parentElement;
|
|
}
|
|
}
|
|
|
|
// Gather all accessible buttons inside the article
|
|
const buttons = Array.from(postElement.querySelectorAll('[role="button"], button'));
|
|
const interactionButtons = buttons.filter((button) => {
|
|
const info = buttonClassification(button);
|
|
return info.isLike || info.isComment || info.isShare;
|
|
});
|
|
|
|
const anchorElements = [
|
|
postElement.querySelector('[data-ad-rendering-role="share_button"]'),
|
|
postElement.querySelector('[data-ad-rendering-role="comment_button"]'),
|
|
postElement.querySelector('[data-ad-rendering-role*="gefällt"]')
|
|
].filter(Boolean);
|
|
|
|
const candidates = new Set(interactionButtons);
|
|
anchorElements.forEach((el) => {
|
|
const buttonContainer = el.closest('[role="button"]') || el.closest('button');
|
|
if (buttonContainer) {
|
|
candidates.add(buttonContainer);
|
|
}
|
|
});
|
|
|
|
const seen = new Set();
|
|
|
|
for (const button of candidates) {
|
|
const info = buttonClassification(button);
|
|
console.log('[FB Tracker] Candidate button', {
|
|
label: normalizeButtonLabel(button),
|
|
hasLike: info.isLike,
|
|
hasComment: info.isComment,
|
|
hasShare: info.isShare,
|
|
hasReply: info.isReply,
|
|
classes: button.className
|
|
});
|
|
let current = button;
|
|
while (current && current !== postElement && current !== document.body) {
|
|
if (seen.has(current)) {
|
|
current = current.parentElement;
|
|
continue;
|
|
}
|
|
|
|
seen.add(current);
|
|
|
|
const hasBar = hasInteractionButtons(current);
|
|
if (hasBar) {
|
|
console.log('[FB Tracker] Found button bar');
|
|
return current;
|
|
}
|
|
|
|
current = current.parentElement;
|
|
}
|
|
}
|
|
|
|
let sibling = postElement.nextElementSibling;
|
|
for (let i = 0; i < 6 && sibling; i++) {
|
|
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
|
|
console.log('[FB Tracker] Found button bar via next sibling');
|
|
return sibling;
|
|
}
|
|
sibling = sibling.nextElementSibling;
|
|
}
|
|
|
|
sibling = postElement.previousElementSibling;
|
|
for (let i = 0; i < 3 && sibling; i++) {
|
|
if (!seen.has(sibling) && hasInteractionButtons(sibling)) {
|
|
console.log('[FB Tracker] Found button bar via previous sibling');
|
|
return sibling;
|
|
}
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
|
|
let parent = postElement.parentElement;
|
|
for (let depth = 0; depth < 4 && parent; depth++) {
|
|
if (!seen.has(parent) && hasInteractionButtons(parent)) {
|
|
console.log('[FB Tracker] Found button bar via parent');
|
|
return parent;
|
|
}
|
|
|
|
let next = parent.nextElementSibling;
|
|
for (let i = 0; i < 4 && next; i++) {
|
|
if (!seen.has(next) && hasInteractionButtons(next)) {
|
|
console.log('[FB Tracker] Found button bar via parent sibling');
|
|
return next;
|
|
}
|
|
next = next.nextElementSibling;
|
|
}
|
|
|
|
let prev = parent.previousElementSibling;
|
|
for (let i = 0; i < 3 && prev; i++) {
|
|
if (!seen.has(prev) && hasInteractionButtons(prev)) {
|
|
console.log('[FB Tracker] Found button bar via parent previous sibling');
|
|
return prev;
|
|
}
|
|
prev = prev.previousElementSibling;
|
|
}
|
|
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
console.log('[FB Tracker] Button bar not found');
|
|
return null;
|
|
}
|
|
|
|
function findPostContainers() {
|
|
const containers = [];
|
|
const seen = new Set();
|
|
|
|
const candidateSelectors = [
|
|
'div[role="dialog"] article',
|
|
'div[role="dialog"] div[aria-posinset]',
|
|
'[data-pagelet*="FeedUnit"] article',
|
|
'div[role="main"] article',
|
|
'[data-visualcompletion="ignore-dynamic"] article',
|
|
'div[aria-posinset]',
|
|
'article[role="article"]',
|
|
'article',
|
|
'div[data-pagelet*="Reel"]',
|
|
'div[data-pagelet*="WatchFeed"]',
|
|
'div[data-pagelet*="QPE_PublisherStory"]'
|
|
];
|
|
|
|
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*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?/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);
|
|
if (timeInfo) {
|
|
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
|
|
} else if (hasInclusiveKeywordNear(fullText, matchIndex)) {
|
|
date.setHours(23, 59, 0, 0);
|
|
}
|
|
|
|
// Only add if date is in the future
|
|
if (date > today) {
|
|
foundDates.push(date);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pattern for "12. Oktober" or "12 Oktober"
|
|
const monthPattern = /\b(\d{1,2})\.?\s+(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\b/gi;
|
|
let monthMatch;
|
|
while ((monthMatch = monthPattern.exec(fullText)) !== null) {
|
|
const day = parseInt(monthMatch[1], 10);
|
|
const monthStr = monthMatch[2].toLowerCase();
|
|
const month = monthNames[monthStr];
|
|
const year = today.getFullYear();
|
|
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);
|
|
if (timeInfo) {
|
|
date.setHours(timeInfo.hour, timeInfo.minute, 0, 0);
|
|
} else if (hasInclusiveKeywordNear(fullText, matchIndex)) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the earliest future date
|
|
if (foundDates.length > 0) {
|
|
foundDates.sort((a, b) => a - b);
|
|
return toDateTimeLocalString(foundDates[0]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeFacebookPostUrl(rawValue) {
|
|
if (typeof rawValue !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
let value = rawValue.trim();
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
const trackingIndex = value.indexOf('__cft__');
|
|
if (trackingIndex !== -1) {
|
|
value = value.slice(0, trackingIndex);
|
|
}
|
|
|
|
value = value.replace(/[?&]$/, '');
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch (error) {
|
|
try {
|
|
parsed = new URL(value, 'https://www.facebook.com');
|
|
} catch (fallbackError) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) {
|
|
return '';
|
|
}
|
|
|
|
const cleanedParams = new URLSearchParams();
|
|
parsed.searchParams.forEach((paramValue, paramKey) => {
|
|
const lowerKey = paramKey.toLowerCase();
|
|
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 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);
|
|
}
|
|
|
|
if (isSearchResult) {
|
|
const info = document.createElement('button');
|
|
info.type = 'button';
|
|
info.className = 'fb-tracker-search-info';
|
|
info.title = 'Beitrag künftig in den Suchergebnissen ausblenden';
|
|
info.setAttribute('aria-label', 'Beitrag künftig in den Suchergebnissen ausblenden');
|
|
const countText = searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number'
|
|
? searchTrackingInfo.seen_count
|
|
: (isTracked ? 'gespeichert' : 'n/v');
|
|
|
|
const iconSpan = document.createElement('span');
|
|
iconSpan.setAttribute('aria-hidden', 'true');
|
|
iconSpan.style.fontSize = '15px';
|
|
const countSpan = document.createElement('span');
|
|
countSpan.textContent = countText;
|
|
|
|
info.appendChild(iconSpan);
|
|
info.appendChild(countSpan);
|
|
|
|
info.style.cssText = `
|
|
color: #1d2129;
|
|
font-weight: 600;
|
|
border-radius: 999px;
|
|
padding: 4px 12px;
|
|
background: rgba(255, 255, 255, 0.98);
|
|
border: 1px solid rgba(29, 33, 41, 0.18);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
transition: transform 150ms ease, box-shadow 150ms ease, opacity 120ms ease;
|
|
cursor: pointer;
|
|
outline: none;
|
|
`;
|
|
|
|
const OPEN_EYES_ICON = '😳';
|
|
const CLOSED_EYES_ICON = '😌';
|
|
|
|
const setIconOpen = () => {
|
|
iconSpan.textContent = OPEN_EYES_ICON;
|
|
};
|
|
|
|
const setIconClosed = () => {
|
|
iconSpan.textContent = CLOSED_EYES_ICON;
|
|
};
|
|
|
|
setIconOpen();
|
|
|
|
const resetHover = () => {
|
|
setIconOpen();
|
|
info.style.transform = 'scale(1)';
|
|
info.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.08)';
|
|
};
|
|
|
|
info.addEventListener('mouseenter', () => {
|
|
info.style.transform = 'translateY(-1px) scale(1.05)';
|
|
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
|
|
setIconClosed();
|
|
});
|
|
info.addEventListener('mouseleave', () => {
|
|
resetHover();
|
|
});
|
|
info.addEventListener('focus', () => {
|
|
info.style.transform = 'translateY(-1px) scale(1.05)';
|
|
info.style.boxShadow = '0 6px 14px rgba(24, 119, 242, 0.35)';
|
|
setIconClosed();
|
|
});
|
|
info.addEventListener('blur', () => {
|
|
resetHover();
|
|
});
|
|
info.addEventListener('mousedown', () => {
|
|
info.style.transform = 'translateY(0) scale(0.96)';
|
|
info.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.18)';
|
|
});
|
|
info.addEventListener('mouseup', () => {
|
|
info.style.transform = 'translateY(-1px) scale(1.03)';
|
|
info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)';
|
|
});
|
|
|
|
const handleManualHide = async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (postElement.getAttribute('data-fb-tracker-hidden') === '1') {
|
|
return;
|
|
}
|
|
|
|
info.disabled = true;
|
|
info.style.cursor = 'progress';
|
|
info.style.opacity = '0.75';
|
|
|
|
let hideResult = null;
|
|
|
|
try {
|
|
hideResult = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, {
|
|
forceHide: true
|
|
});
|
|
} catch (error) {
|
|
console.error('[FB Tracker] Failed to hide search result manually:', error);
|
|
}
|
|
|
|
if (!hideResult) {
|
|
info.disabled = false;
|
|
info.style.cursor = 'pointer';
|
|
info.style.opacity = '1';
|
|
resetHover();
|
|
return;
|
|
}
|
|
|
|
if (isSearchResult) {
|
|
const cacheKeyForHide = encodedUrl;
|
|
sessionSearchRecordedUrls.add(cacheKeyForHide);
|
|
sessionSearchInfoCache.set(cacheKeyForHide, hideResult);
|
|
}
|
|
|
|
setIconClosed();
|
|
|
|
if (isDialogContext) {
|
|
console.log('[FB Tracker] Post #' + postNum + ' - Manual hide skipped in dialog context');
|
|
} else {
|
|
hidePostElement(postElement);
|
|
|
|
const seenCountValue = typeof hideResult.seen_count === 'number' ? hideResult.seen_count : null;
|
|
processedPostUrls.set(encodedUrl, {
|
|
element: postElement,
|
|
createdAt: Date.now(),
|
|
hidden: true,
|
|
searchSeenCount: seenCountValue
|
|
});
|
|
}
|
|
};
|
|
|
|
info.addEventListener('click', handleManualHide);
|
|
resetHover();
|
|
|
|
container.insertBefore(info, container.firstChild);
|
|
}
|
|
|
|
// Insert UI - try multiple strategies to find stable insertion point
|
|
let inserted = false;
|
|
|
|
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 isSearchResultsPage = window.location.pathname.startsWith(SEARCH_RESULTS_PATH);
|
|
|
|
for (const { container: originalContainer, likeButton, buttonBar: precomputedButtonBar } of postContainers) {
|
|
let container = ensurePrimaryPostElement(originalContainer);
|
|
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR);
|
|
const isInDialog = !!dialogRoot;
|
|
const seenSet = isInDialog ? seenDialogContainers : seenFeedContainers;
|
|
|
|
if (seenSet.has(container)) {
|
|
continue;
|
|
}
|
|
|
|
const isPending = container.getAttribute(PENDING_ATTR) === '1';
|
|
if (isPending) {
|
|
seenSet.add(container);
|
|
continue;
|
|
}
|
|
|
|
let existingTracker = getTrackerElementForPost(container);
|
|
if (!existingTracker) {
|
|
existingTracker = container.querySelector('.fb-tracker-ui');
|
|
if (existingTracker && existingTracker.isConnected) {
|
|
setTrackerElementForPost(container, existingTracker);
|
|
}
|
|
}
|
|
|
|
if (existingTracker && !existingTracker.isConnected) {
|
|
clearTrackerElementForPost(container, existingTracker);
|
|
existingTracker = null;
|
|
}
|
|
|
|
const alreadyProcessed = container.getAttribute(PROCESSED_ATTR) === '1';
|
|
const trackerDialogRoot = existingTracker ? existingTracker.closest(DIALOG_ROOT_SELECTOR) : null;
|
|
const trackerInSameDialog = Boolean(
|
|
existingTracker
|
|
&& existingTracker.isConnected
|
|
&& (
|
|
trackerDialogRoot === dialogRoot
|
|
|| (dialogRoot && dialogRoot.contains(existingTracker))
|
|
)
|
|
);
|
|
|
|
if (isInDialog) {
|
|
if (trackerInSameDialog) {
|
|
seenSet.add(container);
|
|
continue;
|
|
}
|
|
|
|
if (existingTracker && existingTracker.isConnected) {
|
|
existingTracker.remove();
|
|
clearTrackerElementForPost(container, existingTracker);
|
|
}
|
|
|
|
if (alreadyProcessed) {
|
|
container.removeAttribute(PROCESSED_ATTR);
|
|
}
|
|
} else {
|
|
if (alreadyProcessed || (existingTracker && existingTracker.isConnected)) {
|
|
seenSet.add(container);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const buttonBar = precomputedButtonBar || findButtonBar(container);
|
|
if (!buttonBar) {
|
|
console.log('[FB Tracker] Proceeding without button bar, will fallback to post container insertion');
|
|
}
|
|
|
|
if (!isMainPost(container, buttonBar)) {
|
|
console.log('[FB Tracker] Skipping non-main post container');
|
|
continue;
|
|
}
|
|
|
|
seenSet.add(container);
|
|
|
|
processed++;
|
|
globalPostCounter++;
|
|
const postNum = globalPostCounter;
|
|
console.log('[FB Tracker] Post #' + postNum + ' - Adding tracker:', container);
|
|
|
|
container.setAttribute(PENDING_ATTR, '1');
|
|
createTrackerUI(container, buttonBar, postNum, {
|
|
likeButton,
|
|
isSearchResult: isSearchResultsPage,
|
|
isDialogContext: isInDialog
|
|
}).catch((error) => {
|
|
console.error('[FB Tracker] Post #' + postNum + ' - Failed to create UI:', error, container);
|
|
}).finally(() => {
|
|
container.removeAttribute(PENDING_ATTR);
|
|
});
|
|
}
|
|
|
|
console.log('[FB Tracker] Total processed posts this scan:', processed, '| Global count:', globalPostCounter);
|
|
}
|
|
|
|
// Initialize
|
|
console.log('[FB Tracker] Initializing...');
|
|
|
|
// Run multiple times to catch loading posts
|
|
setTimeout(findPosts, 2000);
|
|
setTimeout(findPosts, 4000);
|
|
setTimeout(findPosts, 6000);
|
|
|
|
// Debounced scan function
|
|
let scanTimeout = null;
|
|
function scheduleScan() {
|
|
if (scanTimeout) {
|
|
clearTimeout(scanTimeout);
|
|
}
|
|
scanTimeout = setTimeout(() => {
|
|
console.log('[FB Tracker] Scheduled scan triggered');
|
|
findPosts();
|
|
}, 1000);
|
|
}
|
|
|
|
// Watch for new posts being added to the page
|
|
const observer = new MutationObserver((mutations) => {
|
|
scheduleScan();
|
|
});
|
|
|
|
// Start observing
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
// Trigger scan on scroll (for infinite scroll)
|
|
let scrollTimeout = null;
|
|
window.addEventListener('scroll', () => {
|
|
if (scrollTimeout) {
|
|
clearTimeout(scrollTimeout);
|
|
}
|
|
scrollTimeout = setTimeout(() => {
|
|
console.log('[FB Tracker] Scroll detected, scanning...');
|
|
findPosts();
|
|
}, 1000);
|
|
});
|
|
|
|
// Use IntersectionObserver to detect when posts become visible
|
|
const visibilityObserver = new IntersectionObserver((entries) => {
|
|
let needsScan = false;
|
|
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const postContainer = entry.target;
|
|
|
|
// Check if already processed
|
|
if (postContainer.getAttribute(PROCESSED_ATTR) !== '1') {
|
|
console.log('[FB Tracker] Post became visible and not yet processed:', postContainer);
|
|
needsScan = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (needsScan) {
|
|
// Delay scan slightly to let Facebook finish loading the post content
|
|
setTimeout(() => {
|
|
console.log('[FB Tracker] Scanning newly visible posts...');
|
|
findPosts();
|
|
}, 500);
|
|
}
|
|
}, {
|
|
root: null,
|
|
rootMargin: '50px',
|
|
threshold: 0.1
|
|
});
|
|
|
|
// Watch for new posts and observe them
|
|
const postObserver = new MutationObserver((mutations) => {
|
|
// Find all posts and observe them for visibility
|
|
const 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate AI comment for a post
|
|
*/
|
|
async function generateAIComment(postText, profileNumber, options = {}) {
|
|
const { signal = null, preferredCredentialId = null } = options;
|
|
try {
|
|
const payload = {
|
|
postText,
|
|
profileNumber
|
|
};
|
|
|
|
if (typeof preferredCredentialId === 'number') {
|
|
payload.preferredCredentialId = preferredCredentialId;
|
|
}
|
|
|
|
const response = await backendFetch(`${API_URL}/ai/generate-comment`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to generate comment');
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.comment;
|
|
|
|
} catch (error) {
|
|
console.error('[FB Tracker] AI comment generation failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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: visible;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
|
`;
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'fb-tracker-btn fb-tracker-btn-ai';
|
|
button.textContent = '✨ AI';
|
|
button.title = 'Generiere automatisch einen passenden Kommentar';
|
|
button.style.cssText = `
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 6px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
flex: 1 1 auto;
|
|
border-radius: 6px 0 0 6px;
|
|
transition: all 0.2s ease;
|
|
`;
|
|
|
|
const dropdownButton = document.createElement('button');
|
|
dropdownButton.type = 'button';
|
|
dropdownButton.className = 'fb-tracker-btn fb-tracker-btn-ai-dropdown';
|
|
dropdownButton.textContent = '▾';
|
|
dropdownButton.title = 'AI auswählen';
|
|
dropdownButton.setAttribute('aria-label', 'AI auswählen');
|
|
dropdownButton.setAttribute('aria-haspopup', 'menu');
|
|
dropdownButton.setAttribute('aria-expanded', 'false');
|
|
dropdownButton.style.cssText = `
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
border: none;
|
|
width: 34px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-left: 1px solid rgba(255, 255, 255, 0.35);
|
|
border-radius: 0 6px 6px 0;
|
|
transition: all 0.2s ease;
|
|
`;
|
|
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'fb-tracker-ai-dropdown';
|
|
dropdown.style.cssText = `
|
|
display: none;
|
|
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 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();
|
|
};
|
|
|
|
button.addEventListener('mouseenter', () => {
|
|
if ((button.dataset.aiState || 'idle') === 'idle') {
|
|
button.style.transform = 'translateY(-2px)';
|
|
button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
|
|
dropdownButton.style.transform = 'translateY(-2px)';
|
|
dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
|
|
}
|
|
});
|
|
|
|
button.addEventListener('mouseleave', () => {
|
|
button.style.transform = 'translateY(0)';
|
|
button.style.boxShadow = 'none';
|
|
dropdownButton.style.transform = 'translateY(0)';
|
|
dropdownButton.style.boxShadow = 'none';
|
|
});
|
|
|
|
dropdownButton.addEventListener('mouseenter', () => {
|
|
if ((button.dataset.aiState || 'idle') === 'idle') {
|
|
button.style.transform = 'translateY(-2px)';
|
|
button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
|
|
dropdownButton.style.transform = 'translateY(-2px)';
|
|
dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
|
|
}
|
|
});
|
|
|
|
dropdownButton.addEventListener('mouseleave', () => {
|
|
if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open')) {
|
|
button.style.transform = 'translateY(0)';
|
|
button.style.boxShadow = 'none';
|
|
dropdownButton.style.transform = 'translateY(0)';
|
|
dropdownButton.style.boxShadow = 'none';
|
|
}
|
|
});
|
|
|
|
button.addEventListener('pointerdown', () => {
|
|
const 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);
|
|
button.style.transform = 'translateY(0)';
|
|
dropdownButton.style.transform = 'translateY(0)';
|
|
button.style.boxShadow = 'none';
|
|
dropdownButton.style.boxShadow = 'none';
|
|
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 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 = '▴';
|
|
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);
|
|
}
|
|
};
|
|
|
|
button._aiContext = aiContext;
|
|
button.dataset.aiState = 'processing';
|
|
button.setAttribute('aria-busy', 'true');
|
|
button.classList.add('fb-tracker-btn-ai--processing');
|
|
button.classList.remove('fb-tracker-btn-ai--cancelling');
|
|
button.style.cursor = 'progress';
|
|
dropdownButton.disabled = true;
|
|
dropdownButton.style.opacity = '0.5';
|
|
dropdownButton.style.cursor = 'not-allowed';
|
|
dropdownButton.setAttribute('aria-busy', 'true');
|
|
button.textContent = '⏳ Generiere...';
|
|
|
|
try {
|
|
const 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;
|