diff --git a/backend/server.js b/backend/server.js
index c02feb4..5d04a2e 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -19,7 +19,7 @@ const DEFAULT_PROFILE_NAMES = {
const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope';
const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
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 screenshotDir = path.join(__dirname, 'data', 'screenshots');
@@ -277,10 +277,14 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase();
- if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') {
- return;
- }
- if (lowerKey === 'hoisted_section_header_type') {
+ const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
+ if (
+ FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix))
+ || lowerKey === 'set'
+ || lowerKey === 'comment_id'
+ || lowerKey === 'hoisted_section_header_type'
+ || isSingleUnitParam
+ ) {
return;
}
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(`
CREATE TABLE IF NOT EXISTS checks (
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 = []) {
const normalized = [];
- const pushNormalized = (value) => {
+ const pushNormalized = (value, expandVariants = true) => {
const normalizedUrl = normalizeFacebookPostUrl(value);
if (normalizedUrl && !normalized.includes(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;
}
+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) {
if (!Array.isArray(urls) || urls.length === 0) {
return;
@@ -1191,9 +1368,6 @@ const updateSearchSeenStmt = db.prepare(`
SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP
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();
for (const idx of checkIndexes) {
if (idx.unique) {
@@ -1274,6 +1448,9 @@ function mapPostRow(post) {
checked_at: sqliteTimestampToUTC(status.checked_at)
}));
+ const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id);
+ const alternateUrls = alternateUrlRows.map(row => row.url);
+
return {
...post,
created_at: sqliteTimestampToUTC(post.created_at),
@@ -1289,7 +1466,8 @@ function mapPostRow(post) {
created_by_profile: creatorProfile,
created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null,
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' });
}
- const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl);
-
+ const post = findPostByUrl(normalizedUrl);
if (!post) {
return res.json(null);
}
+ const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
+ if (alternates.length) {
+ storePostUrls(post.id, post.url, alternates);
+ }
+
res.json(mapPostRow(post));
} catch (error) {
res.status(500).json({ error: error.message });
@@ -1344,16 +1526,19 @@ app.post('/api/search-posts', (req, res) => {
cleanupExpiredSearchPosts();
- let isTracked = false;
+ let trackedPost = null;
for (const candidate of normalizedUrls) {
- const tracked = selectTrackedPostStmt.get(candidate);
- if (tracked) {
- isTracked = true;
- deleteSearchSeenStmt.run(candidate);
+ const found = findPostByUrl(candidate);
+ if (found) {
+ trackedPost = found;
+ 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 });
}
@@ -1555,6 +1740,7 @@ app.post('/api/posts', (req, res) => {
} = req.body;
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);
@@ -1591,7 +1777,9 @@ app.post('/api/posts', (req, res) => {
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));
} catch (error) {
@@ -1607,6 +1795,12 @@ app.put('/api/posts/:postId', (req, res) => {
try {
const { postId } = req.params;
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 params = [];
@@ -1672,9 +1866,8 @@ app.put('/api/posts/:postId', (req, res) => {
params.push(postId);
const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`);
- let result;
try {
- result = stmt.run(...params);
+ stmt.run(...params);
} catch (error) {
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
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;
}
- if (result.changes === 0) {
- return res.status(404).json({ error: 'Post not found' });
- }
-
recalcCheckedCount(postId);
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
- if (normalizedUrlForCleanup) {
- removeSearchSeenEntries([normalizedUrlForCleanup]);
+ const alternateCandidates = [...alternateUrlsInput];
+ 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));
} catch (error) {
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)
app.post('/api/check-by-url', (req, res) => {
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' });
}
- const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl);
+ const post = findPostByUrl(normalizedUrl);
if (!post) {
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
if (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;
// Check if post exists
- const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId);
- if (!post) {
+ const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
+ if (!existingPost) {
return res.status(404).json({ error: 'Post not found' });
}
@@ -1989,7 +2219,15 @@ app.patch('/api/posts/:postId', (req, res) => {
// Update URL
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 });
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 04e9c2a..00ae598 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,21 +1,21 @@
-version: '3.8'
-
-services:
- backend:
- build: ./backend
- container_name: fb-tracker-backend
- ports:
- - "3001:3000"
- volumes:
- - ./backend/server.js:/app/server.js:ro
- - db-data:/app/data
- environment:
- - NODE_ENV=production
- - PORT=3000
- labels:
- - com.centurylinklabs.watchtower.enable=false
- restart: unless-stopped
-
+version: '3.8'
+
+services:
+ backend:
+ build: ./backend
+ container_name: fb-tracker-backend
+ ports:
+ - "3001:3000"
+ volumes:
+ - ./backend/server.js:/app/server.js:ro
+ - /opt/docker/posttracker/data:/app/data
+ environment:
+ - NODE_ENV=production
+ - PORT=3000
+ labels:
+ - com.centurylinklabs.watchtower.enable=false
+ restart: unless-stopped
+
web:
build: ./web
container_name: fb-tracker-web
@@ -24,6 +24,28 @@ services:
depends_on:
- backend
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:
- db-data:
\ No newline at end of file
+ db-data:
diff --git a/extension/content.js b/extension/content.js
index 3dc7945..3fa90f1 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -486,6 +486,49 @@ function getPostUrl(postElement, postNum = '?') {
return { url: '', allCandidates: [] };
}
+function expandPhotoUrlHostVariants(url) {
+ if (typeof url !== 'string' || !url) {
+ return [];
+ }
+
+ try {
+ const parsed = new URL(url);
+ const hostname = parsed.hostname.toLowerCase();
+ if (!hostname.endsWith('facebook.com')) {
+ return [];
+ }
+
+ const pathname = parsed.pathname.toLowerCase();
+ if (!pathname.startsWith('/photo')) {
+ return [];
+ }
+
+ const search = parsed.search || '';
+ const protocol = parsed.protocol || 'https:';
+ const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com'];
+ const variants = [];
+
+ for (const candidateHost of hosts) {
+ if (candidateHost === hostname) {
+ continue;
+ }
+ const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`;
+ const normalizedVariant = normalizeFacebookPostUrl(candidateUrl);
+ if (
+ normalizedVariant
+ && normalizedVariant !== url
+ && !variants.includes(normalizedVariant)
+ ) {
+ variants.push(normalizedVariant);
+ }
+ }
+
+ return variants;
+ } catch (error) {
+ return [];
+ }
+}
+
// Check if post is already tracked (checks all URL candidates to avoid duplicates)
async function checkPostStatus(postUrl, allUrlCandidates = []) {
try {
@@ -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 foundUrl = null;
// 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)}`);
if (response.ok) {
@@ -544,10 +604,14 @@ async function checkPostStatus(postUrl, allUrlCandidates = []) {
}
if (foundPost) {
+ const urlsForPersistence = allUrlsToCheck.filter(urlValue => urlValue && urlValue !== foundPost.url);
+ if (urlsForPersistence.length) {
+ await persistAlternatePostUrls(foundPost.id, urlsForPersistence);
+ }
return foundPost;
}
- console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.length, 'URLs)');
+ console.log('[FB Tracker] Post not tracked yet (checked', allUrlsToCheck.length, 'URLs)');
return null;
} catch (error) {
console.error('[FB Tracker] Error checking post status:', error);
@@ -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
async function markPostChecked(postId, profileNumber) {
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);
if (!normalizedUrl) {
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
};
+ if (alternateCandidates.length) {
+ payload.alternate_urls = alternateCandidates;
+ }
+
if (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);
if (data && data.id) {
- const checkedData = await markPostChecked(data.id, profileNumber);
await captureAndUploadScreenshot(data.id, options.postElement || null);
- if (checkedData) {
- return checkedData;
- }
}
return data;
@@ -1690,6 +1779,7 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
const lowerKey = paramKey.toLowerCase();
+ const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit';
if (
lowerKey.startsWith('__cft__')
|| lowerKey.startsWith('__tn__')
@@ -1698,6 +1788,7 @@ function normalizeFacebookPostUrl(rawValue) {
|| lowerKey === 'set'
|| lowerKey === 'comment_id'
|| lowerKey === 'hoisted_section_header_type'
+ || isSingleUnitParam
) {
return;
}
@@ -1721,6 +1812,127 @@ function normalizeFacebookPostUrl(rawValue) {
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 = `
+
+ Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText}
+
+ ${lastCheck ? `Letzte: ${lastCheck}
` : ''}
+ `;
+
+ if (canCurrentProfileCheck && !isExpired && !completed) {
+ statusHtml += `
+
+ `;
+ } else if (isCurrentProfileDone) {
+ statusHtml += `
+
+ ✓ Von dir bestätigt
+
+ `;
+ }
+
+ 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
async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) {
// 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.setAttribute('data-post-num', postNum);
container.setAttribute('data-post-url', encodedUrl);
+ container.dataset.isFeedHome = isFeedHome ? '1' : '0';
+ container.dataset.isDialogContext = isDialogContext ? '1' : '0';
container.style.cssText = `
padding: 6px 12px;
background-color: #f0f2f5;
@@ -1894,111 +2108,21 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
}
if (postData) {
- const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0);
- const statusText = `${checkedCount}/${postData.target_count}`;
- const completed = checkedCount >= postData.target_count;
- const lastCheck = Array.isArray(postData.checks) && postData.checks.length
- ? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })
- : null;
+ const renderResult = await renderTrackedStatus({
+ container,
+ postElement,
+ postData,
+ profileNumber,
+ isFeedHome,
+ isDialogContext,
+ manualHideInfo,
+ encodedUrl,
+ postNum
+ });
- // Check if deadline has passed
- const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false;
- const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : '';
-
- // Check if current profile can check this post
- const canCurrentProfileCheck = postData.next_required_profile === profileNumber;
- const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber);
-
- if (isFeedHome && isCurrentProfileDone) {
- if (isDialogContext) {
- console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context');
- } else {
- console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)');
- hidePostElement(postElement);
- processedPostUrls.set(encodedUrl, {
- element: postElement,
- createdAt: Date.now(),
- hidden: true,
- searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null
- });
- return;
- }
+ if (renderResult && renderResult.hidden) {
+ return;
}
-
- let statusHtml = `
-
- Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText}
-
- ${lastCheck ? `Letzte: ${lastCheck}
` : ''}
- `;
-
- // Add check button if current profile can check and not expired
- if (canCurrentProfileCheck && !isExpired && !completed) {
- statusHtml += `
-
- `;
- } else if (isCurrentProfileDone) {
- statusHtml += `
-
- ✓ Von dir bestätigt
-
- `;
- }
-
- 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 = `
-
- Tracker: ${newStatusText}${newCompleted ? ' ✓' : ''}
-
- Letzte: ${newLastCheck}
-
- ✓ Von dir bestätigt
-
- `;
-
- // Re-add AI button after update
- await addAICommentButton(container, postElement);
- } else {
- checkBtn.disabled = false;
- checkBtn.textContent = 'Fehler - Erneut versuchen';
- checkBtn.style.backgroundColor = '#e74c3c';
- }
- });
- }
-
- console.log('[FB Tracker] Showing status:', statusText);
} else {
// Post not tracked - show add UI
const selectId = `tracker-select-${Date.now()}`;
@@ -2072,37 +2196,28 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, {
postElement,
- deadline: deadlineValue
+ deadline: deadlineValue,
+ candidates: postUrlData.allCandidates
});
if (result) {
- const checks = Array.isArray(result.checks) ? result.checks : [];
- const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length;
- const targetTotal = result.target_count || targetCount;
- const statusText = `${checkedCount}/${targetTotal}`;
- const completed = checkedCount >= targetTotal;
- const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null;
+ const renderOutcome = await renderTrackedStatus({
+ container,
+ postElement,
+ postData: result,
+ profileNumber,
+ isFeedHome,
+ isDialogContext,
+ manualHideInfo,
+ encodedUrl,
+ postNum
+ });
- let statusHtml = `
-
- Tracker: ${statusText}${completed ? ' ✓' : ''}
-
- `;
-
- if (lastCheck) {
- statusHtml += `
-
- Letzte: ${lastCheck}
-
- `;
+ if (renderOutcome && renderOutcome.hidden) {
+ return;
}
- container.innerHTML = statusHtml;
- if (deadlineInput) {
- deadlineInput.value = '';
- }
-
- await addAICommentButton(container, postElement);
+ return;
} else {
// Error
addButton.disabled = false;
@@ -3667,6 +3782,77 @@ async function addAICommentButton(container, postElement) {
window.removeEventListener('resize', repositionDropdown);
};
+ const getDecodedPostUrl = () => {
+ const raw = encodedPostUrl || (container && container.getAttribute('data-post-url'));
+ if (!raw) {
+ return null;
+ }
+ try {
+ return decodeURIComponent(raw);
+ } catch (error) {
+ console.warn('[FB Tracker] Konnte Post-URL nicht dekodieren:', error);
+ return null;
+ }
+ };
+
+ const confirmParticipationAfterAI = async (profileNumber) => {
+ try {
+ if (!container) {
+ return;
+ }
+
+ const effectiveProfile = profileNumber || await getProfileNumber();
+ const decodedUrl = getDecodedPostUrl();
+ const isFeedHomeFlag = container.dataset.isFeedHome === '1';
+ const isDialogFlag = container.dataset.isDialogContext === '1';
+ const postNumValue = container.getAttribute('data-post-num') || '?';
+ const encodedUrlValue = container.getAttribute('data-post-url') || '';
+
+ let latestData = null;
+ let postId = container.dataset.postId || '';
+
+ if (postId) {
+ latestData = await markPostChecked(postId, effectiveProfile);
+ if (!latestData && decodedUrl) {
+ const refreshed = await checkPostStatus(decodedUrl);
+ if (refreshed && refreshed.id) {
+ container.dataset.postId = refreshed.id;
+ latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed;
+ }
+ }
+ } else if (decodedUrl) {
+ const refreshed = await checkPostStatus(decodedUrl);
+ if (refreshed && refreshed.id) {
+ container.dataset.postId = refreshed.id;
+ latestData = await markPostChecked(refreshed.id, effectiveProfile) || refreshed;
+ }
+ }
+
+ if (!latestData && decodedUrl) {
+ const fallbackStatus = await checkPostStatus(decodedUrl);
+ if (fallbackStatus) {
+ latestData = fallbackStatus;
+ }
+ }
+
+ if (latestData) {
+ await renderTrackedStatus({
+ container,
+ postElement,
+ postData: latestData,
+ profileNumber: effectiveProfile,
+ isFeedHome: isFeedHomeFlag,
+ isDialogContext: isDialogFlag,
+ manualHideInfo: null,
+ encodedUrl: encodedUrlValue,
+ postNum: postNumValue
+ });
+ }
+ } catch (error) {
+ console.error('[FB Tracker] Auto-confirm nach AI fehlgeschlagen:', error);
+ }
+ };
+
const handleOutsideClick = (event) => {
if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) {
closeDropdown();
@@ -4134,6 +4320,7 @@ async function addAICommentButton(container, postElement) {
throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
+ await confirmParticipationAfterAI(profileNumber);
return;
}
@@ -4148,11 +4335,13 @@ async function addAICommentButton(container, postElement) {
if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000);
+ await confirmParticipationAfterAI(profileNumber);
} else {
await navigator.clipboard.writeText(comment);
throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
+ await confirmParticipationAfterAI(profileNumber);
}
} catch (error) {
const cancelled = aiContext.cancelled || isCancellationError(error);
diff --git a/web/app.js b/web/app.js
index 003be28..f08fa10 100644
--- a/web/app.js
+++ b/web/app.js
@@ -25,7 +25,7 @@ const PROFILE_NAMES = {
4: 'Profil 4',
5: 'Profil 5'
};
-
+
function apiFetch(url, options = {}) {
const config = {
...options,
@@ -68,13 +68,26 @@ const autoRefreshToggle = document.getElementById('autoRefreshToggle');
const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval');
const sortModeSelect = document.getElementById('sortMode');
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 SORT_SETTINGS_KEY = 'trackerSortSettings';
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 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 autoRefreshSettings = {
@@ -89,6 +102,8 @@ let manualPostEditingId = null;
let manualPostModalLastFocus = null;
let manualPostModalPreviousOverflow = '';
let activeDeadlinePicker = null;
+let bookmarkPanelVisible = false;
+let bookmarkOutsideHandler = null;
const INITIAL_POST_LIMIT = 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() {
try {
const path = window.location.pathname;
@@ -532,7 +970,9 @@ function normalizeFacebookPostUrl(rawValue) {
const cleanedParams = new URLSearchParams();
parsed.searchParams.forEach((paramValue, paramKey) => {
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;
}
cleanedParams.append(paramKey, paramValue);
@@ -2842,6 +3282,12 @@ document.addEventListener('keydown', (event) => {
return;
}
+ if (bookmarkPanelVisible) {
+ event.preventDefault();
+ toggleBookmarkPanel(false);
+ return;
+ }
+
if (screenshotModal && screenshotModal.classList.contains('open')) {
if (screenshotModalZoomed) {
resetScreenshotZoom();
@@ -2858,6 +3304,7 @@ window.addEventListener('resize', () => {
});
// Initialize
+initializeBookmarks();
loadAutoRefreshSettings();
initializeTabFromUrl();
loadSortMode();
diff --git a/web/index.html b/web/index.html
index 039ab9c..dd2295f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -14,9 +14,36 @@
📋 Post Tracker
-
+
diff --git a/web/style.css b/web/style.css
index 0b2276f..223e01e 100644
--- a/web/style.css
+++ b/web/style.css
@@ -35,6 +35,13 @@ header {
gap: 12px;
}
+.header-links {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
.header-controls {
display: flex;
flex-wrap: wrap;
@@ -1061,6 +1068,166 @@ h1 {
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 {
position: fixed;
inset: 0;
@@ -1184,6 +1351,16 @@ h1 {
padding: 14px;
}
+ .header-main {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .header-links {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
.post-card {
padding-left: 20px;
}
@@ -1222,4 +1399,19 @@ h1 {
.btn {
width: 100%;
}
+
+ .bookmark-inline {
+ display: block;
+ margin-bottom: 10px;
+ }
+
+ .bookmark-panel {
+ position: static;
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .bookmark-form__actions {
+ flex-direction: column;
+ }
}