Aktueller Stand

This commit is contained in:
MDeeApp
2025-10-19 12:14:03 +02:00
parent 327a663bcf
commit 9745d38995
6 changed files with 1304 additions and 189 deletions

View File

@@ -19,7 +19,7 @@ const DEFAULT_PROFILE_NAMES = {
const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope';
const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid'];
const SEARCH_POST_HIDE_THRESHOLD = 3; const SEARCH_POST_HIDE_THRESHOLD = 2;
const SEARCH_POST_RETENTION_DAYS = 90; const SEARCH_POST_RETENTION_DAYS = 90;
const screenshotDir = path.join(__dirname, 'data', 'screenshots'); const screenshotDir = path.join(__dirname, 'data', 'screenshots');
@@ -277,10 +277,14 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams(); const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => { parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase(); const lowerKey = paramKey.toLowerCase();
if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') { const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
return; if (
} FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix))
if (lowerKey === 'hoisted_section_header_type') { || lowerKey === 'set'
|| lowerKey === 'comment_id'
|| lowerKey === 'hoisted_section_header_type'
|| isSingleUnitParam
) {
return; return;
} }
cleanedParams.append(paramKey, paramValue); cleanedParams.append(paramKey, paramValue);
@@ -394,6 +398,34 @@ db.exec(`
); );
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS post_urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
is_primary INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_post_urls_post_id
ON post_urls(post_id);
`);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_urls_primary
ON post_urls(post_id)
WHERE is_primary = 1;
`);
db.prepare(`
INSERT OR IGNORE INTO post_urls (post_id, url, is_primary)
SELECT id, url, 1
FROM posts
`).run();
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS checks ( CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1132,13 +1164,59 @@ function cleanupExpiredSearchPosts() {
} }
} }
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 protocol = parsed.protocol || 'https:';
const search = parsed.search || '';
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 normalized = normalizeFacebookPostUrl(candidateUrl);
if (normalized && normalized !== url && !variants.includes(normalized)) {
variants.push(normalized);
}
}
return variants;
} catch (error) {
return [];
}
}
function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { function collectNormalizedFacebookUrls(primaryUrl, candidates = []) {
const normalized = []; const normalized = [];
const pushNormalized = (value) => { const pushNormalized = (value, expandVariants = true) => {
const normalizedUrl = normalizeFacebookPostUrl(value); const normalizedUrl = normalizeFacebookPostUrl(value);
if (normalizedUrl && !normalized.includes(normalizedUrl)) { if (normalizedUrl && !normalized.includes(normalizedUrl)) {
normalized.push(normalizedUrl); normalized.push(normalizedUrl);
if (expandVariants) {
const photoVariants = expandPhotoUrlHostVariants(normalizedUrl);
for (const variant of photoVariants) {
pushNormalized(variant, false);
}
}
} }
}; };
@@ -1155,6 +1233,105 @@ function collectNormalizedFacebookUrls(primaryUrl, candidates = []) {
return normalized; return normalized;
} }
function collectPostAlternateUrls(primaryUrl, candidates = []) {
const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl);
if (!normalizedPrimary) {
return [];
}
const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates);
return normalized.filter(url => url !== normalizedPrimary);
}
const insertPostUrlStmt = db.prepare(`
INSERT OR IGNORE INTO post_urls (post_id, url, is_primary)
VALUES (?, ?, ?)
`);
const setPrimaryPostUrlStmt = db.prepare(`
UPDATE post_urls
SET is_primary = CASE WHEN url = ? THEN 1 ELSE 0 END
WHERE post_id = ?
`);
const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?');
const selectPostByAlternateUrlStmt = db.prepare(`
SELECT p.*
FROM post_urls pu
JOIN posts p ON p.id = pu.post_id
WHERE pu.url = ?
LIMIT 1
`);
const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?');
const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls WHERE url = ?');
const selectAlternateUrlsForPostStmt = db.prepare(`
SELECT url
FROM post_urls
WHERE post_id = ?
AND is_primary = 0
ORDER BY created_at ASC
`);
function storePostUrls(postId, primaryUrl, additionalUrls = []) {
if (!postId || !primaryUrl) {
return;
}
const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl);
if (!normalizedPrimary) {
return;
}
insertPostUrlStmt.run(postId, normalizedPrimary, 1);
setPrimaryPostUrlStmt.run(normalizedPrimary, postId);
if (Array.isArray(additionalUrls)) {
for (const candidate of additionalUrls) {
const normalized = normalizeFacebookPostUrl(candidate);
if (!normalized || normalized === normalizedPrimary) {
continue;
}
insertPostUrlStmt.run(postId, normalized, 0);
}
}
}
function findPostIdByUrl(normalizedUrl) {
if (!normalizedUrl) {
return null;
}
const primaryRow = selectPostIdByPrimaryUrlStmt.get(normalizedUrl);
if (primaryRow && primaryRow.id) {
return primaryRow.id;
}
const alternateRow = selectPostIdByAlternateUrlStmt.get(normalizedUrl);
if (alternateRow && alternateRow.post_id) {
return alternateRow.post_id;
}
return null;
}
function findPostByUrl(normalizedUrl) {
if (!normalizedUrl) {
return null;
}
const primary = selectPostByPrimaryUrlStmt.get(normalizedUrl);
if (primary) {
return primary;
}
const alternate = selectPostByAlternateUrlStmt.get(normalizedUrl);
if (alternate) {
return alternate;
}
return null;
}
function removeSearchSeenEntries(urls) { function removeSearchSeenEntries(urls) {
if (!Array.isArray(urls) || urls.length === 0) { if (!Array.isArray(urls) || urls.length === 0) {
return; return;
@@ -1191,9 +1368,6 @@ const updateSearchSeenStmt = db.prepare(`
SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP
WHERE url = ? WHERE url = ?
`); `);
const deleteSearchSeenStmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?');
const selectTrackedPostStmt = db.prepare('SELECT id FROM posts WHERE url = ?');
const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); const checkIndexes = db.prepare("PRAGMA index_list('checks')").all();
for (const idx of checkIndexes) { for (const idx of checkIndexes) {
if (idx.unique) { if (idx.unique) {
@@ -1274,6 +1448,9 @@ function mapPostRow(post) {
checked_at: sqliteTimestampToUTC(status.checked_at) checked_at: sqliteTimestampToUTC(status.checked_at)
})); }));
const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id);
const alternateUrls = alternateUrlRows.map(row => row.url);
return { return {
...post, ...post,
created_at: sqliteTimestampToUTC(post.created_at), created_at: sqliteTimestampToUTC(post.created_at),
@@ -1289,7 +1466,8 @@ function mapPostRow(post) {
created_by_profile: creatorProfile, created_by_profile: creatorProfile,
created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null,
created_by_name: creatorName, created_by_name: creatorName,
deadline_at: post.deadline_at || null deadline_at: post.deadline_at || null,
alternate_urls: alternateUrls
}; };
} }
@@ -1321,12 +1499,16 @@ app.get('/api/posts/by-url', (req, res) => {
return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' }); return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' });
} }
const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); const post = findPostByUrl(normalizedUrl);
if (!post) { if (!post) {
return res.json(null); return res.json(null);
} }
const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
if (alternates.length) {
storePostUrls(post.id, post.url, alternates);
}
res.json(mapPostRow(post)); res.json(mapPostRow(post));
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1344,16 +1526,19 @@ app.post('/api/search-posts', (req, res) => {
cleanupExpiredSearchPosts(); cleanupExpiredSearchPosts();
let isTracked = false; let trackedPost = null;
for (const candidate of normalizedUrls) { for (const candidate of normalizedUrls) {
const tracked = selectTrackedPostStmt.get(candidate); const found = findPostByUrl(candidate);
if (tracked) { if (found) {
isTracked = true; trackedPost = found;
deleteSearchSeenStmt.run(candidate); break;
} }
} }
if (isTracked) { if (trackedPost) {
const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls);
storePostUrls(trackedPost.id, trackedPost.url, alternateUrls);
removeSearchSeenEntries([trackedPost.url, ...alternateUrls]);
return res.json({ seen_count: 0, should_hide: false, tracked: true }); return res.json({ seen_count: 0, should_hide: false, tracked: true });
} }
@@ -1555,6 +1740,7 @@ app.post('/api/posts', (req, res) => {
} = req.body; } = req.body;
const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count);
const alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : [];
const normalizedUrl = normalizeFacebookPostUrl(url); const normalizedUrl = normalizeFacebookPostUrl(url);
@@ -1591,7 +1777,9 @@ app.post('/api/posts', (req, res) => {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
removeSearchSeenEntries([normalizedUrl]); const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateUrlsInput);
storePostUrls(id, normalizedUrl, alternateUrls);
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
res.json(mapPostRow(post)); res.json(mapPostRow(post));
} catch (error) { } catch (error) {
@@ -1607,6 +1795,12 @@ app.put('/api/posts/:postId', (req, res) => {
try { try {
const { postId } = req.params; const { postId } = req.params;
const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {}; const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {};
const alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : [];
const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!existingPost) {
return res.status(404).json({ error: 'Post not found' });
}
const updates = []; const updates = [];
const params = []; const params = [];
@@ -1672,9 +1866,8 @@ app.put('/api/posts/:postId', (req, res) => {
params.push(postId); params.push(postId);
const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`);
let result;
try { try {
result = stmt.run(...params); stmt.run(...params);
} catch (error) { } catch (error) {
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ error: 'Post with this URL already exists' }); return res.status(409).json({ error: 'Post with this URL already exists' });
@@ -1682,18 +1875,25 @@ app.put('/api/posts/:postId', (req, res) => {
throw error; throw error;
} }
if (result.changes === 0) {
return res.status(404).json({ error: 'Post not found' });
}
recalcCheckedCount(postId); recalcCheckedCount(postId);
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (normalizedUrlForCleanup) { const alternateCandidates = [...alternateUrlsInput];
removeSearchSeenEntries([normalizedUrlForCleanup]); if (existingPost.url && existingPost.url !== updatedPost.url) {
alternateCandidates.push(existingPost.url);
} }
const alternateUrls = collectPostAlternateUrls(updatedPost.url, alternateCandidates);
storePostUrls(updatedPost.id, updatedPost.url, alternateUrls);
const cleanupUrls = new Set([updatedPost.url]);
alternateUrls.forEach(urlValue => cleanupUrls.add(urlValue));
if (normalizedUrlForCleanup && normalizedUrlForCleanup !== updatedPost.url) {
cleanupUrls.add(normalizedUrlForCleanup);
}
removeSearchSeenEntries(Array.from(cleanupUrls));
res.json(mapPostRow(updatedPost)); res.json(mapPostRow(updatedPost));
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1785,6 +1985,32 @@ app.post('/api/posts/:postId/check', (req, res) => {
} }
}); });
app.post('/api/posts/:postId/urls', (req, res) => {
try {
const { postId } = req.params;
const { urls } = req.body || {};
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
const candidateList = Array.isArray(urls) ? urls : [];
const alternateUrls = collectPostAlternateUrls(post.url, candidateList);
storePostUrls(post.id, post.url, alternateUrls);
removeSearchSeenEntries([post.url, ...alternateUrls]);
const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
res.json({
success: true,
primary_url: post.url,
alternate_urls: storedAlternates
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Check by URL (for web interface auto-check) // Check by URL (for web interface auto-check)
app.post('/api/check-by-url', (req, res) => { app.post('/api/check-by-url', (req, res) => {
try { try {
@@ -1799,11 +2025,15 @@ app.post('/api/check-by-url', (req, res) => {
return res.status(400).json({ error: 'URL must be a valid Facebook link' }); return res.status(400).json({ error: 'URL must be a valid Facebook link' });
} }
const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); const post = findPostByUrl(normalizedUrl);
if (!post) { if (!post) {
return res.status(404).json({ error: 'Post not found' }); return res.status(404).json({ error: 'Post not found' });
} }
const alternateUrls = collectPostAlternateUrls(post.url, [normalizedUrl]);
storePostUrls(post.id, post.url, alternateUrls);
removeSearchSeenEntries([post.url, ...alternateUrls]);
// Check if deadline has passed // Check if deadline has passed
if (post.deadline_at) { if (post.deadline_at) {
const deadline = new Date(post.deadline_at); const deadline = new Date(post.deadline_at);
@@ -1970,8 +2200,8 @@ app.patch('/api/posts/:postId', (req, res) => {
const { url, is_successful } = req.body; const { url, is_successful } = req.body;
// Check if post exists // Check if post exists
const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId); const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) { if (!existingPost) {
return res.status(404).json({ error: 'Post not found' }); return res.status(404).json({ error: 'Post not found' });
} }
@@ -1989,7 +2219,15 @@ app.patch('/api/posts/:postId', (req, res) => {
// Update URL // Update URL
db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId); db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId);
removeSearchSeenEntries([normalizedUrl]);
const alternateCandidates = [];
if (existingPost.url && existingPost.url !== normalizedUrl) {
alternateCandidates.push(existingPost.url);
}
const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates);
storePostUrls(postId, normalizedUrl, alternateUrls);
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
return res.json({ success: true, url: normalizedUrl }); return res.json({ success: true, url: normalizedUrl });
} }

View File

@@ -1,21 +1,21 @@
version: '3.8' version: '3.8'
services: services:
backend: backend:
build: ./backend build: ./backend
container_name: fb-tracker-backend container_name: fb-tracker-backend
ports: ports:
- "3001:3000" - "3001:3000"
volumes: volumes:
- ./backend/server.js:/app/server.js:ro - ./backend/server.js:/app/server.js:ro
- db-data:/app/data - /opt/docker/posttracker/data:/app/data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
labels: labels:
- com.centurylinklabs.watchtower.enable=false - com.centurylinklabs.watchtower.enable=false
restart: unless-stopped restart: unless-stopped
web: web:
build: ./web build: ./web
container_name: fb-tracker-web container_name: fb-tracker-web
@@ -24,6 +24,28 @@ services:
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
labels:
- com.centurylinklabs.watchtower.enable=false
sqlite-web:
image: coleifer/sqlite-web
container_name: fb-sqlite-web
command:
- sqlite_web
- /data/tracker.db
- --host
- 0.0.0.0
- --port
- "8080"
ports:
- "8083:8080"
volumes:
- /opt/docker/posttracker/data:/data
depends_on:
- backend
restart: unless-stopped
labels:
- com.centurylinklabs.watchtower.enable=false
volumes: volumes:
db-data: db-data:

View File

@@ -486,6 +486,49 @@ function getPostUrl(postElement, postNum = '?') {
return { url: '', allCandidates: [] }; 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) // Check if post is already tracked (checks all URL candidates to avoid duplicates)
async function checkPostStatus(postUrl, allUrlCandidates = []) { async function checkPostStatus(postUrl, allUrlCandidates = []) {
try { try {
@@ -507,13 +550,30 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) {
} }
} }
console.log('[FB Tracker] Checking post status for URLs:', urlsToCheck); 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 foundPost = null;
let foundUrl = null; let foundUrl = null;
// Check each URL // Check each URL
for (const url of urlsToCheck) { for (const url of allUrlsToCheck) {
const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`); const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`);
if (response.ok) { if (response.ok) {
@@ -544,10 +604,14 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) {
} }
if (foundPost) { if (foundPost) {
const urlsForPersistence = allUrlsToCheck.filter(urlValue => urlValue && urlValue !== foundPost.url);
if (urlsForPersistence.length) {
await persistAlternatePostUrls(foundPost.id, urlsForPersistence);
}
return foundPost; return foundPost;
} }
console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.length, 'URLs)'); console.log('[FB Tracker] Post not tracked yet (checked', allUrlsToCheck.length, 'URLs)');
return null; return null;
} catch (error) { } catch (error) {
console.error('[FB Tracker] Error checking post status:', error); console.error('[FB Tracker] Error checking post status:', error);
@@ -615,6 +679,29 @@ async function updatePostUrl(postId, newUrl) {
} }
} }
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 // Add post to tracking
async function markPostChecked(postId, profileNumber) { async function markPostChecked(postId, profileNumber) {
try { try {
@@ -663,6 +750,8 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
} }
} }
const alternateCandidates = Array.isArray(options && options.candidates) ? options.candidates : [];
const normalizedUrl = normalizeFacebookPostUrl(postUrl); const normalizedUrl = normalizeFacebookPostUrl(postUrl);
if (!normalizedUrl) { if (!normalizedUrl) {
console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl); console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl);
@@ -676,6 +765,10 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
created_by_profile: profileNumber created_by_profile: profileNumber
}; };
if (alternateCandidates.length) {
payload.alternate_urls = alternateCandidates;
}
if (createdByName) { if (createdByName) {
payload.created_by_name = createdByName; payload.created_by_name = createdByName;
} }
@@ -697,11 +790,7 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
console.log('[FB Tracker] Post added successfully:', data); console.log('[FB Tracker] Post added successfully:', data);
if (data && data.id) { if (data && data.id) {
const checkedData = await markPostChecked(data.id, profileNumber);
await captureAndUploadScreenshot(data.id, options.postElement || null); await captureAndUploadScreenshot(data.id, options.postElement || null);
if (checkedData) {
return checkedData;
}
} }
return data; return data;
@@ -1690,6 +1779,7 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams(); const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => { parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase(); const lowerKey = paramKey.toLowerCase();
const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
if ( if (
lowerKey.startsWith('__cft__') lowerKey.startsWith('__cft__')
|| lowerKey.startsWith('__tn__') || lowerKey.startsWith('__tn__')
@@ -1698,6 +1788,7 @@ function normalizeFacebookPostUrl(rawValue) {
|| lowerKey === 'set' || lowerKey === 'set'
|| lowerKey === 'comment_id' || lowerKey === 'comment_id'
|| lowerKey === 'hoisted_section_header_type' || lowerKey === 'hoisted_section_header_type'
|| isSingleUnitParam
) { ) {
return; return;
} }
@@ -1721,6 +1812,127 @@ function normalizeFacebookPostUrl(rawValue) {
return formatted.replace(/[?&]$/, ''); 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;
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 // Create the tracking UI
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) { async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
// Normalize to top-level post container if nested element passed // Normalize to top-level post container if nested element passed
@@ -1789,6 +2001,8 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
container.id = 'fb-tracker-ui-post-' + postNum; container.id = 'fb-tracker-ui-post-' + postNum;
container.setAttribute('data-post-num', postNum); container.setAttribute('data-post-num', postNum);
container.setAttribute('data-post-url', encodedUrl); container.setAttribute('data-post-url', encodedUrl);
container.dataset.isFeedHome = isFeedHome ? '1' : '0';
container.dataset.isDialogContext = isDialogContext ? '1' : '0';
container.style.cssText = ` container.style.cssText = `
padding: 6px 12px; padding: 6px 12px;
background-color: #f0f2f5; background-color: #f0f2f5;
@@ -1894,111 +2108,21 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
} }
if (postData) { if (postData) {
const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0); const renderResult = await renderTrackedStatus({
const statusText = `${checkedCount}/${postData.target_count}`; container,
const completed = checkedCount >= postData.target_count; postElement,
const lastCheck = Array.isArray(postData.checks) && postData.checks.length postData,
? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) profileNumber,
: null; isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
// Check if deadline has passed if (renderResult && renderResult.hidden) {
const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; return;
const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : '';
// Check if current profile can check this post
const canCurrentProfileCheck = postData.next_required_profile === profileNumber;
const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber);
if (isFeedHome && isCurrentProfileDone) {
if (isDialogContext) {
console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context');
} else {
console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)');
hidePostElement(postElement);
processedPostUrls.set(encodedUrl, {
element: postElement,
createdAt: Date.now(),
hidden: true,
searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null
});
return;
}
} }
let statusHtml = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}${expiredText}
</div>
${lastCheck ? `<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${lastCheck}</div>` : ''}
`;
// Add check button if current profile can check and not expired
if (canCurrentProfileCheck && !isExpired && !completed) {
statusHtml += `
<button class="fb-tracker-check-btn" style="
padding: 4px 12px;
background-color: #42b72a;
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
margin-left: 8px;
">
✓ Bestätigen
</button>
`;
} else if (isCurrentProfileDone) {
statusHtml += `
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
✓ Von dir bestätigt
</div>
`;
}
container.innerHTML = statusHtml;
// Add AI button
await addAICommentButton(container, postElement);
// Add event listener for check button
const checkBtn = container.querySelector('.fb-tracker-check-btn');
if (checkBtn) {
checkBtn.addEventListener('click', async () => {
checkBtn.disabled = true;
checkBtn.textContent = 'Wird bestätigt...';
const result = await markPostChecked(postData.id, profileNumber);
if (result) {
const newCheckedCount = result.checked_count ?? checkedCount + 1;
const newStatusText = `${newCheckedCount}/${postData.target_count}`;
const newCompleted = newCheckedCount >= postData.target_count;
const newLastCheck = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
container.innerHTML = `
<div style="color: #65676b; white-space: nowrap;">
<strong>Tracker:</strong> ${newStatusText}${newCompleted ? ' ✓' : ''}
</div>
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">Letzte: ${newLastCheck}</div>
<div style="color: #42b72a; font-size: 12px; white-space: nowrap; margin-left: 8px;">
✓ Von dir bestätigt
</div>
`;
// Re-add AI button after update
await addAICommentButton(container, postElement);
} else {
checkBtn.disabled = false;
checkBtn.textContent = 'Fehler - Erneut versuchen';
checkBtn.style.backgroundColor = '#e74c3c';
}
});
}
console.log('[FB Tracker] Showing status:', statusText);
} else { } else {
// Post not tracked - show add UI // Post not tracked - show add UI
const selectId = `tracker-select-${Date.now()}`; const selectId = `tracker-select-${Date.now()}`;
@@ -2072,37 +2196,28 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, { const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
postElement, postElement,
deadline: deadlineValue deadline: deadlineValue,
candidates: postUrlData.allCandidates
}); });
if (result) { if (result) {
const checks = Array.isArray(result.checks) ? result.checks : []; const renderOutcome = await renderTrackedStatus({
const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length; container,
const targetTotal = result.target_count || targetCount; postElement,
const statusText = `${checkedCount}/${targetTotal}`; postData: result,
const completed = checkedCount >= targetTotal; profileNumber,
const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null; isFeedHome,
isDialogContext,
manualHideInfo,
encodedUrl,
postNum
});
let statusHtml = ` if (renderOutcome && renderOutcome.hidden) {
<div style="color: #65676b; white-space: nowrap;"> return;
<strong>Tracker:</strong> ${statusText}${completed ? ' ✓' : ''}
</div>
`;
if (lastCheck) {
statusHtml += `
<div style="color: #65676b; font-size: 12px; white-space: nowrap;">
Letzte: ${lastCheck}
</div>
`;
} }
container.innerHTML = statusHtml; return;
if (deadlineInput) {
deadlineInput.value = '';
}
await addAICommentButton(container, postElement);
} else { } else {
// Error // Error
addButton.disabled = false; addButton.disabled = false;
@@ -3667,6 +3782,77 @@ async function addAICommentButton(container, postElement) {
window.removeEventListener('resize', repositionDropdown); 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) => { const handleOutsideClick = (event) => {
if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) { if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) {
closeDropdown(); closeDropdown();
@@ -4134,6 +4320,7 @@ async function addAICommentButton(container, postElement) {
throwIfCancelled(); throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info'); showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000); restoreIdle('📋 Kopiert', 2000);
await confirmParticipationAfterAI(profileNumber);
return; return;
} }
@@ -4148,11 +4335,13 @@ async function addAICommentButton(container, postElement) {
if (success) { if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success'); showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000); restoreIdle('✓ Eingefügt', 2000);
await confirmParticipationAfterAI(profileNumber);
} else { } else {
await navigator.clipboard.writeText(comment); await navigator.clipboard.writeText(comment);
throwIfCancelled(); throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info'); showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000); restoreIdle('📋 Kopiert', 2000);
await confirmParticipationAfterAI(profileNumber);
} }
} catch (error) { } catch (error) {
const cancelled = aiContext.cancelled || isCancellationError(error); const cancelled = aiContext.cancelled || isCancellationError(error);

View File

@@ -25,7 +25,7 @@ const PROFILE_NAMES = {
4: 'Profil 4', 4: 'Profil 4',
5: 'Profil 5' 5: 'Profil 5'
}; };
function apiFetch(url, options = {}) { function apiFetch(url, options = {}) {
const config = { const config = {
...options, ...options,
@@ -68,13 +68,26 @@ const autoRefreshToggle = document.getElementById('autoRefreshToggle');
const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval'); const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval');
const sortModeSelect = document.getElementById('sortMode'); const sortModeSelect = document.getElementById('sortMode');
const sortDirectionToggle = document.getElementById('sortDirectionToggle'); const sortDirectionToggle = document.getElementById('sortDirectionToggle');
const bookmarkPanelToggle = document.getElementById('bookmarkPanelToggle');
const bookmarkPanel = document.getElementById('bookmarkPanel');
const bookmarkPanelClose = document.getElementById('bookmarkPanelClose');
const bookmarksList = document.getElementById('bookmarksList');
const bookmarkForm = document.getElementById('bookmarkForm');
const bookmarkNameInput = document.getElementById('bookmarkName');
const bookmarkQueryInput = document.getElementById('bookmarkQuery');
const bookmarkCancelBtn = document.getElementById('bookmarkCancelBtn');
const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings'; const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings';
const SORT_SETTINGS_KEY = 'trackerSortSettings'; const SORT_SETTINGS_KEY = 'trackerSortSettings';
const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode'; const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode';
const FACEBOOK_TRACKING_PARAMS = ['__cft__', '__tn__', '__eep__', 'mibextid']; const FACEBOOK_TRACKING_PARAMS = ['__cft__', '__tn__', '__eep__', 'mibextid', 'set', 'comment_id', 'hoisted_section_header_type'];
const VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']); const VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']);
const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' }; const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' };
const BOOKMARKS_STORAGE_KEY = 'trackerSearchBookmarks';
const BOOKMARKS_BASE_URL = 'https://www.facebook.com/search/top';
const BOOKMARK_WINDOW_DAYS = 28;
const DEFAULT_BOOKMARKS = [];
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
let autoRefreshTimer = null; let autoRefreshTimer = null;
let autoRefreshSettings = { let autoRefreshSettings = {
@@ -89,6 +102,8 @@ let manualPostEditingId = null;
let manualPostModalLastFocus = null; let manualPostModalLastFocus = null;
let manualPostModalPreviousOverflow = ''; let manualPostModalPreviousOverflow = '';
let activeDeadlinePicker = null; let activeDeadlinePicker = null;
let bookmarkPanelVisible = false;
let bookmarkOutsideHandler = null;
const INITIAL_POST_LIMIT = 10; const INITIAL_POST_LIMIT = 10;
const POST_LOAD_INCREMENT = 10; const POST_LOAD_INCREMENT = 10;
@@ -222,6 +237,429 @@ function persistSortStorage(storage) {
} }
} }
function normalizeCustomBookmark(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
const query = typeof entry.query === 'string' ? entry.query.trim() : '';
if (!query) {
return null;
}
const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : query;
const id = typeof entry.id === 'string' && entry.id ? entry.id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
return {
id,
label,
query,
type: 'custom'
};
}
function loadCustomBookmarks() {
try {
const raw = localStorage.getItem(BOOKMARKS_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.map(normalizeCustomBookmark).filter(Boolean);
} catch (error) {
console.warn('Konnte Bookmarks nicht laden:', error);
return [];
}
}
function saveCustomBookmarks(bookmarks) {
try {
const sanitized = Array.isArray(bookmarks)
? bookmarks.map(normalizeCustomBookmark).filter(Boolean)
: [];
localStorage.setItem(BOOKMARKS_STORAGE_KEY, JSON.stringify(sanitized));
} catch (error) {
console.warn('Konnte Bookmarks nicht speichern:', error);
}
}
function formatFacebookDateParts(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const monthLabel = `${year}-${month}`;
const dayLabel = `${year}-${month}-${day}`;
return {
year: String(year),
monthLabel,
dayLabel
};
}
function buildBookmarkFiltersParam() {
const y = String(new Date().getFullYear()); // "2025"
// start_month = Monat von (heute - BOOKMARK_WINDOW_DAYS), auf Monatsanfang (ohne Padding)
const windowAgo = new Date();
windowAgo.setDate(windowAgo.getDate() - BOOKMARK_WINDOW_DAYS);
const startMonthNum = windowAgo.getMonth() + 1; // 1..12
const startMonthLabel = `${y}-${startMonthNum}`; // z.B. "2025-9"
const startDayLabel = `${startMonthLabel}-1`; // z.B. "2025-9-1"
// Ende = Jahresende (ohne Padding), Jahre immer aktuelles Jahr als String
const endMonthLabel = `${y}-12`;
const endDayLabel = `${y}-12-31`;
// Reihenfolge wie gewünscht: top_tab zuerst, dann rp_creation_time
const filtersPayload = {
'top_tab_recent_posts:0': JSON.stringify({
name: 'top_tab_recent_posts',
args: ''
}),
'rp_creation_time:0': JSON.stringify({
name: 'creation_time',
args: JSON.stringify({
start_year: y, // als String
start_month: startMonthLabel,
end_year: y, // als String
end_month: endMonthLabel,
start_day: startDayLabel,
end_day: endDayLabel
})
})
};
const serialized = JSON.stringify(filtersPayload);
// Rohes Base64 zurückgeben (kein encodeURIComponent!)
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(serialized);
} else if (typeof btoa === 'function') {
return btoa(serialized);
} else if (typeof Buffer !== 'undefined') {
return Buffer.from(serialized, 'utf8').toString('base64');
} else {
console.warn('Base64 nicht verfügbar, gebe JSON zurück (wird von URLSearchParams encodet).');
return serialized;
}
}
/*
function buildBookmarkFiltersParam() {
const y = String(new Date().getFullYear()); // "2025"
// WICHTIG: Schlüssel-Reihenfolge wie im 'soll' → top_tab zuerst
const filtersPayload = {
'top_tab_recent_posts:0': JSON.stringify({
name: 'top_tab_recent_posts',
args: ''
}),
'rp_creation_time:0': JSON.stringify({
name: 'creation_time',
args: JSON.stringify({
start_year: y, // als String
start_month: `${y}-1`, // ohne Padding
end_year: y, // als String
end_month: `${y}-12`,
start_day: `${y}-1-1`,
end_day: `${y}-12-31`
})
})
};
const serialized = JSON.stringify(filtersPayload);
// Base64 OHNE URL-Encode zurückgeben
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(serialized);
} else if (typeof btoa === 'function') {
return btoa(serialized);
} else if (typeof Buffer !== 'undefined') {
return Buffer.from(serialized, 'utf8').toString('base64');
} else {
console.warn('Base64 nicht verfügbar, gebe JSON zurück (wird von URLSearchParams encodet).');
return serialized; // kein encodeURIComponent!
}
}
*/
function buildBookmarkSearchUrl(query) {
const trimmed = typeof query === 'string' ? query.trim() : '';
if (!trimmed) {
return null;
}
const searchUrl = new URL(BOOKMARKS_BASE_URL);
searchUrl.searchParams.set('q', trimmed);
searchUrl.searchParams.set('filters', buildBookmarkFiltersParam());
return searchUrl.toString();
}
function buildBookmarkSearchQueries(baseQuery) {
const trimmed = typeof baseQuery === 'string' ? baseQuery.trim() : '';
if (!trimmed) {
return [...BOOKMARK_SUFFIXES];
}
return BOOKMARK_SUFFIXES.map((suffix) => `${trimmed} ${suffix}`.trim());
}
function openBookmark(bookmark) {
if (!bookmark) {
return;
}
const queries = buildBookmarkSearchQueries(bookmark.query);
if (!queries.length) {
queries.push('');
}
queries.forEach((searchTerm) => {
const url = buildBookmarkSearchUrl(searchTerm);
if (url) {
window.open(url, '_blank', 'noopener');
}
});
}
function removeBookmark(bookmarkId) {
if (!bookmarkId) {
return;
}
const current = loadCustomBookmarks();
const next = current.filter((bookmark) => bookmark.id !== bookmarkId);
if (next.length === current.length) {
return;
}
saveCustomBookmarks(next);
renderBookmarks();
}
function renderBookmarks() {
if (!bookmarksList) {
return;
}
bookmarksList.innerHTML = '';
const items = [...DEFAULT_BOOKMARKS, ...loadCustomBookmarks()];
const staticDefault = {
id: 'default-empty',
label: 'Gewinnspiel / gewinnen / verlosen',
query: '',
type: 'default'
};
items.unshift(staticDefault);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'bookmark-empty';
empty.textContent = 'Noch keine Bookmarks vorhanden.';
empty.setAttribute('role', 'listitem');
bookmarksList.appendChild(empty);
return;
}
items.forEach((bookmark) => {
const item = document.createElement('div');
item.className = 'bookmark-item';
item.setAttribute('role', 'listitem');
const button = document.createElement('button');
button.type = 'button';
button.className = 'bookmark-button';
const label = bookmark.label || bookmark.query;
button.textContent = label;
const searchVariants = buildBookmarkSearchQueries(bookmark.query);
if (searchVariants.length) {
button.title = searchVariants.map((variant) => `${variant}`).join('\n');
} else {
button.title = `Suche nach "${bookmark.query}" (letzte 4 Wochen)`;
}
button.addEventListener('click', () => openBookmark(bookmark));
item.appendChild(button);
if (bookmark.type === 'custom') {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'bookmark-remove-btn';
removeBtn.setAttribute('aria-label', `${bookmark.label || bookmark.query} entfernen`);
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
removeBookmark(bookmark.id);
});
item.appendChild(removeBtn);
}
bookmarksList.appendChild(item);
});
}
function resetBookmarkForm() {
if (!bookmarkForm) {
return;
}
bookmarkForm.reset();
if (bookmarkNameInput) {
bookmarkNameInput.value = '';
}
if (bookmarkQueryInput) {
bookmarkQueryInput.value = '';
}
}
function ensureBookmarkOutsideHandler() {
if (bookmarkOutsideHandler) {
return bookmarkOutsideHandler;
}
bookmarkOutsideHandler = (event) => {
if (!bookmarkPanelVisible) {
return;
}
const target = event.target;
const insidePanel = bookmarkPanel && bookmarkPanel.contains(target);
const onToggle = bookmarkPanelToggle && bookmarkPanelToggle.contains(target);
if (!insidePanel && !onToggle) {
toggleBookmarkPanel(false);
}
};
return bookmarkOutsideHandler;
}
function removeBookmarkOutsideHandler() {
if (!bookmarkOutsideHandler) {
return;
}
document.removeEventListener('mousedown', bookmarkOutsideHandler, true);
document.removeEventListener('focusin', bookmarkOutsideHandler);
}
function toggleBookmarkPanel(forceVisible) {
if (!bookmarkPanel || !bookmarkPanelToggle) {
return;
}
const shouldShow = typeof forceVisible === 'boolean' ? forceVisible : !bookmarkPanelVisible;
if (shouldShow === bookmarkPanelVisible) {
return;
}
bookmarkPanelVisible = shouldShow;
bookmarkPanel.hidden = !bookmarkPanelVisible;
bookmarkPanel.setAttribute('aria-hidden', bookmarkPanelVisible ? 'false' : 'true');
bookmarkPanelToggle.setAttribute('aria-expanded', bookmarkPanelVisible ? 'true' : 'false');
if (bookmarkPanelVisible) {
renderBookmarks();
resetBookmarkForm();
if (bookmarkQueryInput) {
window.requestAnimationFrame(() => {
bookmarkQueryInput.focus();
});
}
const handler = ensureBookmarkOutsideHandler();
window.requestAnimationFrame(() => {
document.addEventListener('mousedown', handler, true);
document.addEventListener('focusin', handler);
});
} else {
resetBookmarkForm();
removeBookmarkOutsideHandler();
if (bookmarkPanelToggle) {
bookmarkPanelToggle.focus();
}
}
}
function handleBookmarkSubmit(event) {
event.preventDefault();
if (!bookmarkForm) {
return;
}
const query = bookmarkQueryInput ? bookmarkQueryInput.value.trim() : '';
const name = bookmarkNameInput ? bookmarkNameInput.value.trim() : '';
if (!query) {
resetBookmarkForm();
if (bookmarkQueryInput) {
bookmarkQueryInput.focus();
}
return;
}
const customBookmarks = loadCustomBookmarks();
const normalizedQuery = query.toLowerCase();
const existingIndex = customBookmarks.findIndex((bookmark) => bookmark.query.toLowerCase() === normalizedQuery);
const nextBookmark = {
id: existingIndex >= 0 ? customBookmarks[existingIndex].id : `custom-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
label: name || query,
query,
type: 'custom'
};
if (existingIndex >= 0) {
customBookmarks[existingIndex] = nextBookmark;
} else {
customBookmarks.push(nextBookmark);
}
saveCustomBookmarks(customBookmarks);
renderBookmarks();
resetBookmarkForm();
}
function initializeBookmarks() {
if (!bookmarksList) {
return;
}
renderBookmarks();
if (bookmarkPanel) {
bookmarkPanel.setAttribute('aria-hidden', 'true');
}
if (bookmarkPanelToggle) {
bookmarkPanelToggle.addEventListener('click', () => {
toggleBookmarkPanel();
});
}
if (bookmarkPanelClose) {
bookmarkPanelClose.addEventListener('click', () => {
toggleBookmarkPanel(false);
});
}
if (bookmarkCancelBtn) {
bookmarkCancelBtn.addEventListener('click', () => {
resetBookmarkForm();
if (bookmarkQueryInput) {
bookmarkQueryInput.focus();
}
});
}
if (bookmarkForm) {
bookmarkForm.addEventListener('submit', handleBookmarkSubmit);
}
}
function getSortSettingsPageKey() { function getSortSettingsPageKey() {
try { try {
const path = window.location.pathname; const path = window.location.pathname;
@@ -532,7 +970,9 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams(); const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => { parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase(); const lowerKey = paramKey.toLowerCase();
if (FACEBOOK_TRACKING_PARAMS.some((trackingKey) => lowerKey.startsWith(trackingKey))) { const isTrackingParam = FACEBOOK_TRACKING_PARAMS.some((trackingKey) => lowerKey.startsWith(trackingKey));
const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
if (isTrackingParam || isSingleUnitParam) {
return; return;
} }
cleanedParams.append(paramKey, paramValue); cleanedParams.append(paramKey, paramValue);
@@ -2842,6 +3282,12 @@ document.addEventListener('keydown', (event) => {
return; return;
} }
if (bookmarkPanelVisible) {
event.preventDefault();
toggleBookmarkPanel(false);
return;
}
if (screenshotModal && screenshotModal.classList.contains('open')) { if (screenshotModal && screenshotModal.classList.contains('open')) {
if (screenshotModalZoomed) { if (screenshotModalZoomed) {
resetScreenshotZoom(); resetScreenshotZoom();
@@ -2858,6 +3304,7 @@ window.addEventListener('resize', () => {
}); });
// Initialize // Initialize
initializeBookmarks();
loadAutoRefreshSettings(); loadAutoRefreshSettings();
initializeTabFromUrl(); initializeTabFromUrl();
loadSortMode(); loadSortMode();

View File

@@ -14,9 +14,36 @@
<header> <header>
<div class="header-main"> <div class="header-main">
<h1>📋 Post Tracker</h1> <h1>📋 Post Tracker</h1>
<div style="display: flex; gap: 10px;"> <div class="header-links">
<a href="?view=dashboard" class="btn btn-secondary">Dashboard</a> <a href="?view=dashboard" class="btn btn-secondary">Dashboard</a>
<a href="settings.html" class="btn btn-secondary">⚙️ Einstellungen</a> <a href="settings.html" class="btn btn-secondary">⚙️ Einstellungen</a>
<div class="bookmark-inline">
<button type="button" class="btn btn-secondary bookmark-inline__toggle" id="bookmarkPanelToggle" aria-expanded="false" aria-controls="bookmarkPanel">🔖 Bookmarks</button>
<div id="bookmarkPanel" class="bookmark-panel" role="dialog" aria-modal="false" hidden>
<div class="bookmark-panel__header">
<h2 class="bookmark-panel__title">🔖 Bookmarks</h2>
<button type="button" class="bookmark-panel__close" id="bookmarkPanelClose" aria-label="Schließen">×</button>
</div>
<div id="bookmarksList" class="bookmark-list" role="list" aria-live="polite"></div>
<form id="bookmarkForm" class="bookmark-form" autocomplete="off">
<div class="bookmark-form__fields">
<label class="bookmark-form__field">
<span>Titel</span>
<input type="text" id="bookmarkName" maxlength="40" placeholder="Optionaler Titel">
</label>
<label class="bookmark-form__field">
<span>Keyword *</span>
<input type="text" id="bookmarkQuery" required placeholder="z.B. gewinnspiel">
</label>
</div>
<div class="bookmark-form__actions">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" class="btn btn-secondary" id="bookmarkCancelBtn">Zurücksetzen</button>
</div>
<p class="bookmark-form__hint">Öffnet für das Keyword drei Suchen (… Gewinnspiel / … gewinnen / … verlosen) mit Filter auf die letzten 4 Wochen.</p>
</form>
</div>
</div>
<button type="button" class="btn btn-primary" id="openManualPostModalBtn">Beitrag hinzufügen</button> <button type="button" class="btn btn-primary" id="openManualPostModalBtn">Beitrag hinzufügen</button>
</div> </div>
</div> </div>

View File

@@ -35,6 +35,13 @@ header {
gap: 12px; gap: 12px;
} }
.header-links {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.header-controls { .header-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1061,6 +1068,166 @@ h1 {
cursor: not-allowed; cursor: not-allowed;
} }
.bookmark-inline {
position: relative;
display: inline-flex;
align-items: center;
margin: 0;
}
.bookmark-inline__toggle {
padding: 8px 14px;
font-size: 14px;
}
.bookmark-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(420px, 90vw);
background: #ffffff;
border-radius: 10px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
padding: 16px;
z-index: 20;
border: 1px solid rgba(229, 231, 235, 0.8);
}
.bookmark-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.bookmark-panel__title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.bookmark-panel__close {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: #4b5563;
padding: 2px 6px;
}
.bookmark-panel__close:hover,
.bookmark-panel__close:focus {
color: #ef4444;
}
.bookmark-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bookmark-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #f0f2f5;
border-radius: 999px;
}
.bookmark-button {
border: none;
background: transparent;
color: #1d2129;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
font-size: 14px;
}
.bookmark-button:hover,
.bookmark-button:focus {
text-decoration: underline;
}
.bookmark-button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.bookmark-remove-btn {
border: none;
background: transparent;
color: #7f8186;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.bookmark-remove-btn:hover,
.bookmark-remove-btn:focus {
color: #c0392b;
}
.bookmark-form {
margin-top: 12px;
border-top: 1px solid #e4e6eb;
padding-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.bookmark-form__fields {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.bookmark-form__field {
display: flex;
flex-direction: column;
flex: 1 1 220px;
gap: 6px;
}
.bookmark-form__field span {
font-size: 13px;
color: #65676b;
}
.bookmark-form__field input {
border: 1px solid #d0d3d9;
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
}
.bookmark-form__actions {
display: flex;
gap: 10px;
}
.bookmark-form__hint {
margin: 0;
font-size: 12px;
color: #65676b;
}
.bookmark-empty {
font-size: 14px;
color: #65676b;
background: #f0f2f5;
border-radius: 8px;
padding: 12px;
}
.screenshot-modal { .screenshot-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1184,6 +1351,16 @@ h1 {
padding: 14px; padding: 14px;
} }
.header-main {
flex-direction: column;
align-items: flex-start;
}
.header-links {
width: 100%;
justify-content: flex-start;
}
.post-card { .post-card {
padding-left: 20px; padding-left: 20px;
} }
@@ -1222,4 +1399,19 @@ h1 {
.btn { .btn {
width: 100%; width: 100%;
} }
.bookmark-inline {
display: block;
margin-bottom: 10px;
}
.bookmark-panel {
position: static;
width: 100%;
margin-top: 10px;
}
.bookmark-form__actions {
flex-direction: column;
}
} }