1129 lines
32 KiB
JavaScript
1129 lines
32 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 API_URL = `${API_BASE_URL}/api`;
|
|
|
|
console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL);
|
|
|
|
// Profile state helpers
|
|
async function fetchBackendProfileNumber() {
|
|
try {
|
|
const response = await fetch(`${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 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/'
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
|
|
return formatFacebookPostUrl(absoluteUrl);
|
|
}
|
|
|
|
function getPostUrl(postElement) {
|
|
console.log('[FB Tracker] Extracting URL from post element');
|
|
|
|
const attributionLinks = postElement.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]');
|
|
for (const link of attributionLinks) {
|
|
const candidate = extractPostUrlCandidate(link.href);
|
|
if (candidate) {
|
|
console.log('[FB Tracker] Found post URL via attribution link:', candidate);
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
const links = postElement.querySelectorAll('a[href]');
|
|
for (const link of links) {
|
|
const candidate = extractPostUrlCandidate(link.href);
|
|
if (candidate) {
|
|
console.log('[FB Tracker] Found post URL via fallback patterns:', candidate);
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
const fallbackCandidate = extractPostUrlCandidate(window.location.href);
|
|
if (fallbackCandidate) {
|
|
console.log('[FB Tracker] Using fallback URL:', fallbackCandidate);
|
|
return fallbackCandidate;
|
|
}
|
|
|
|
console.log('[FB Tracker] No valid post URL found');
|
|
return '';
|
|
}
|
|
|
|
// Check if post is already tracked
|
|
async function checkPostStatus(postUrl) {
|
|
try {
|
|
console.log('[FB Tracker] Checking post status for:', postUrl);
|
|
const response = await fetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(postUrl)}`);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('[FB Tracker] Post status:', data);
|
|
return data;
|
|
}
|
|
|
|
console.log('[FB Tracker] Post not tracked yet');
|
|
return null;
|
|
} catch (error) {
|
|
console.error('[FB Tracker] Error checking post status:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Add post to tracking
|
|
async function markPostChecked(postId, profileNumber) {
|
|
try {
|
|
console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber);
|
|
const response = await fetch(`${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);
|
|
|
|
const response = await fetch(`${API_URL}/posts`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
url: postUrl,
|
|
target_count: targetCount,
|
|
profile_number: profileNumber
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('[FB Tracker] Post added successfully:', data);
|
|
|
|
if (data && data.id) {
|
|
const checkedData = await markPostChecked(data.id, profileNumber);
|
|
await captureAndUploadScreenshot(data.id, options.postElement || null);
|
|
if (checkedData) {
|
|
return checkedData;
|
|
}
|
|
}
|
|
|
|
return data;
|
|
} else {
|
|
console.error('[FB Tracker] Failed to add post:', response.status);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('[FB Tracker] Error adding post:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeButtonLabel(button) {
|
|
const aria = button.getAttribute('aria-label');
|
|
if (aria) {
|
|
return aria.trim().toLowerCase();
|
|
}
|
|
|
|
const title = button.getAttribute('title');
|
|
if (title) {
|
|
return title.trim().toLowerCase();
|
|
}
|
|
|
|
return (button.textContent || '').trim().toLowerCase();
|
|
}
|
|
|
|
const LIKE_LABEL_KEYWORDS = ['gefällt mir', 'like', 'mag ich'];
|
|
const COMMENT_LABEL_KEYWORDS = ['kommentieren', 'comment', 'kommentar', 'antwort hinterlassen'];
|
|
const SHARE_LABEL_KEYWORDS = ['teilen', 'share', 'senden', 'posten', 'profil posten'];
|
|
const REPLY_LABEL_KEYWORDS = ['antworten', 'reply'];
|
|
|
|
const LIKE_ROLE_KEYWORDS = ['gefällt mir', 'like'];
|
|
const COMMENT_ROLE_KEYWORDS = ['comment'];
|
|
const SHARE_ROLE_KEYWORDS = ['share'];
|
|
const REPLY_ROLE_KEYWORDS = ['reply'];
|
|
|
|
function matchesKeyword(label, keywords) {
|
|
return keywords.some((keyword) => label.includes(keyword));
|
|
}
|
|
|
|
function 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
|
|
});
|
|
}
|
|
|
|
if (!hasLike || !hasComment) {
|
|
return false;
|
|
}
|
|
|
|
if (hasShare) {
|
|
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 likeButtons = document.querySelectorAll('[data-ad-rendering-role="gefällt mir_button"], [data-ad-rendering-role*="gefällt" i]');
|
|
|
|
likeButtons.forEach((likeButton) => {
|
|
const container = likeButton.closest('div[aria-posinset]');
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
if (container.getAttribute(PROCESSED_ATTR) === '1') {
|
|
return;
|
|
}
|
|
|
|
if (container.querySelector('.fb-tracker-ui')) {
|
|
return;
|
|
}
|
|
|
|
const commentButton = container.querySelector('[data-ad-rendering-role="comment_button"], [data-ad-rendering-role*="comment" i]');
|
|
const shareButton = container.querySelector('[data-ad-rendering-role="share_button"], [data-ad-rendering-role*="share" i], [data-ad-rendering-role*="teilen" i]');
|
|
|
|
if (!commentButton || !shareButton) {
|
|
return;
|
|
}
|
|
|
|
if (seen.has(container)) {
|
|
return;
|
|
}
|
|
|
|
seen.add(container);
|
|
containers.push({ container, likeButton, commentButton, shareButton });
|
|
});
|
|
|
|
return containers;
|
|
}
|
|
|
|
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 fetch(`${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;
|
|
});
|
|
}
|
|
|
|
// Create the tracking UI
|
|
async function createTrackerUI(postElement, buttonBar) {
|
|
// Check if UI already exists
|
|
if (postElement.querySelector('.fb-tracker-ui')) {
|
|
console.log('[FB Tracker] UI already exists for this post');
|
|
return;
|
|
}
|
|
|
|
const postUrl = getPostUrl(postElement);
|
|
if (!postUrl) {
|
|
console.log('[FB Tracker] No URL found for post');
|
|
return;
|
|
}
|
|
|
|
console.log('[FB Tracker] Creating tracker UI for:', postUrl);
|
|
|
|
// Create UI container
|
|
const container = document.createElement('div');
|
|
container.className = 'fb-tracker-ui';
|
|
container.style.cssText = `
|
|
padding: 12px 16px;
|
|
background-color: #f0f2f5;
|
|
border-top: 1px solid #e4e6eb;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
font-size: 14px;
|
|
`;
|
|
|
|
// Check current status
|
|
const profileNumber = await getProfileNumber();
|
|
const postData = await checkPostStatus(postUrl);
|
|
|
|
if (postData) {
|
|
const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0);
|
|
const statusText = `${checkedCount}/${postData.target_count}`;
|
|
const completed = checkedCount >= postData.target_count;
|
|
const lastCheck = Array.isArray(postData.checks) && postData.checks.length
|
|
? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE')
|
|
: null;
|
|
|
|
container.innerHTML = `
|
|
<div style="color: #65676b;">
|
|
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓ Ziel erreicht' : ''}
|
|
</div>
|
|
${lastCheck ? `<div style="color: #65676b;">Letzte Bestätigung: ${lastCheck}</div>` : ''}
|
|
`;
|
|
|
|
console.log('[FB Tracker] Showing status:', statusText);
|
|
} else {
|
|
// Post not tracked - show add UI
|
|
const selectId = `tracker-select-${Date.now()}`;
|
|
container.innerHTML = `
|
|
<label for="${selectId}" style="color: #65676b; font-weight: 500;">
|
|
Zum Tracker hinzufügen:
|
|
</label>
|
|
<select id="${selectId}" style="
|
|
padding: 6px 10px;
|
|
border: 1px solid #ccd0d5;
|
|
border-radius: 6px;
|
|
background: white;
|
|
font-size: 14px;
|
|
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>
|
|
<button class="fb-tracker-add-btn" style="
|
|
padding: 6px 16px;
|
|
background-color: #1877f2;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
">
|
|
Hinzufügen
|
|
</button>
|
|
`;
|
|
|
|
// Add click handler for the button
|
|
const addButton = container.querySelector('.fb-tracker-add-btn');
|
|
const selectElement = container.querySelector('select');
|
|
selectElement.value = '2';
|
|
|
|
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 result = await addPostToTracking(postUrl, targetCount, profileNumber, { postElement });
|
|
|
|
if (result) {
|
|
const checks = Array.isArray(result.checks) ? result.checks : [];
|
|
const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length;
|
|
const targetTotal = result.target_count || targetCount;
|
|
const statusText = `${checkedCount}/${targetTotal}`;
|
|
const completed = checkedCount >= targetTotal;
|
|
const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE') : null;
|
|
|
|
let statusHtml = `
|
|
<div style="color: #65676b;">
|
|
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓ Ziel erreicht' : ' ✓ Erfolgreich hinzugefügt'}
|
|
</div>
|
|
`;
|
|
|
|
if (lastCheck) {
|
|
statusHtml += `
|
|
<div style="color: #65676b; margin-top: 6px;">
|
|
Letzte Bestätigung: ${lastCheck}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = statusHtml;
|
|
} else {
|
|
// Error
|
|
addButton.disabled = false;
|
|
addButton.textContent = 'Fehler - Erneut versuchen';
|
|
addButton.style.backgroundColor = '#e74c3c';
|
|
}
|
|
});
|
|
|
|
console.log('[FB Tracker] UI created for new post');
|
|
}
|
|
|
|
// Insert UI below the button bar if available, otherwise append at the end
|
|
if (buttonBar && buttonBar.parentElement) {
|
|
buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling);
|
|
console.log('[FB Tracker] UI inserted after button bar');
|
|
} else {
|
|
postElement.appendChild(container);
|
|
console.log('[FB Tracker] UI inserted into article (fallback)');
|
|
}
|
|
}
|
|
|
|
// Check if article is a main post (not a comment)
|
|
function isMainPost(article, buttonBar) {
|
|
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') {
|
|
// 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-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;
|
|
}
|
|
|
|
// Find all Facebook posts on the page
|
|
function findPosts() {
|
|
console.log('[FB Tracker] Scanning for posts...');
|
|
|
|
const postContainers = findPostContainers();
|
|
console.log('[FB Tracker] Found', postContainers.length, 'candidate containers');
|
|
|
|
let processed = 0;
|
|
|
|
for (const { container } of postContainers) {
|
|
if (container.getAttribute(PROCESSED_ATTR) === '1') {
|
|
continue;
|
|
}
|
|
|
|
const buttonBar = findButtonBar(container);
|
|
if (!buttonBar) {
|
|
console.log('[FB Tracker] Skipping container without full button bar');
|
|
continue;
|
|
}
|
|
|
|
container.setAttribute(PROCESSED_ATTR, '1');
|
|
processed++;
|
|
console.log('[FB Tracker] Adding tracker to post #' + processed);
|
|
createTrackerUI(container, buttonBar);
|
|
}
|
|
|
|
console.log('[FB Tracker] Total processed posts:', processed);
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
|
|
console.log('[FB Tracker] Observer and scroll listener started');
|