From 677eac2632f04f0db89ccf177f13c8e7298f31f4 Mon Sep 17 00:00:00 2001 From: Meik Date: Mon, 29 Dec 2025 19:45:08 +0100 Subject: [PATCH] aktueller stand --- backend/server.js | 809 +++++++++++++++++++++++++++++++------------ extension/content.js | 572 ++++++++++++++++++++++++++++-- web/app.js | 481 +++++++++++++++++++++++-- web/index.html | 62 ++++ web/settings.js | 101 ++++++ web/style.css | 135 ++++++++ 6 files changed, 1888 insertions(+), 272 deletions(-) diff --git a/backend/server.js b/backend/server.js index 37a603c..1348e57 100644 --- a/backend/server.js +++ b/backend/server.js @@ -25,6 +25,7 @@ const SEARCH_POST_HIDE_THRESHOLD = 2; const SEARCH_POST_RETENTION_DAYS = 90; const MAX_POST_TEXT_LENGTH = 4000; const MIN_TEXT_HASH_LENGTH = 120; +const MIN_SIMILAR_TEXT_LENGTH = 60; const MAX_BOOKMARK_LABEL_LENGTH = 120; const MAX_BOOKMARK_QUERY_LENGTH = 200; const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160; @@ -96,6 +97,10 @@ const SPORTS_SCORING_TERMS_DEFAULTS = { 'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung' ] }; +const SIMILARITY_DEFAULTS = { + text_threshold: 0.85, + image_distance_threshold: 6 +}; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { @@ -282,6 +287,8 @@ function ensureColumn(table, column, definition) { ensureColumn('posts', 'post_text', 'post_text TEXT'); ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT'); ensureColumn('posts', 'content_key', 'content_key TEXT'); +ensureColumn('posts', 'first_image_hash', 'first_image_hash TEXT'); +ensureColumn('posts', 'first_image_url', 'first_image_url TEXT'); db.exec(` CREATE INDEX IF NOT EXISTS idx_posts_content_key @@ -692,6 +699,61 @@ function computePostTextHash(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); } +function tokenizeSimilarityText(text) { + if (!text) { + return []; + } + const tokens = text.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []; + return tokens.filter(token => token.length > 1); +} + +function computeTextSimilarity(a, b) { + if (!a || !b) { + return 0; + } + const tokensA = tokenizeSimilarityText(a); + const tokensB = tokenizeSimilarityText(b); + if (!tokensA.length || !tokensB.length) { + return 0; + } + const setA = new Set(tokensA); + const setB = new Set(tokensB); + let intersection = 0; + for (const token of setA) { + if (setB.has(token)) { + intersection += 1; + } + } + const union = setA.size + setB.size - intersection; + if (union <= 0) { + return 0; + } + return intersection / union; +} + +function hammingDistanceHex(a, b) { + if (!a || !b) { + return null; + } + const left = String(a).toLowerCase().replace(/^0x/, ''); + const right = String(b).toLowerCase().replace(/^0x/, ''); + if (left.length !== right.length) { + return null; + } + let xor; + try { + xor = BigInt(`0x${left}`) ^ BigInt(`0x${right}`); + } catch (error) { + return null; + } + let distance = 0; + while (xor > 0n) { + distance += Number(xor & 1n); + xor >>= 1n; + } + return distance; +} + function normalizeBookmarkQuery(value) { if (typeof value !== 'string') { return null; @@ -1186,6 +1248,43 @@ function buildProfileStatuses(requiredProfiles, checks) { }; } +function buildCompletedProfileSet(rows) { + const completedSet = new Set(); + rows.forEach((row) => { + const profileNumber = sanitizeProfileNumber(row.profile_number); + if (profileNumber) { + completedSet.add(profileNumber); + } + }); + return completedSet; +} + +function countUniqueProfileChecks(checks) { + const uniqueProfiles = new Set(); + checks.forEach((check) => { + const profileNumber = sanitizeProfileNumber(check.profile_number); + if (profileNumber) { + uniqueProfiles.add(profileNumber); + } + }); + return uniqueProfiles.size; +} + +function shouldEnforceProfileOrder({ post, requiredProfiles, completedSet, ignoreOrder = false }) { + if (ignoreOrder) { + return false; + } + if (!post || !requiredProfiles.length) { + return false; + } + const isExpired = post.deadline_at ? new Date(post.deadline_at) < new Date() : false; + if (isExpired) { + return false; + } + const isComplete = requiredProfiles.every(profile => completedSet.has(profile)); + return !isComplete; +} + function recalcCheckedCount(postId) { const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId); if (!post) { @@ -1195,7 +1294,7 @@ function recalcCheckedCount(postId) { const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses } = buildProfileStatuses(requiredProfiles, checks); - const checkedCount = statuses.filter(status => status.status === 'done').length; + const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks)); const updates = []; const params = []; @@ -1234,6 +1333,8 @@ db.exec(` post_text TEXT, post_text_hash TEXT, content_key TEXT, + first_image_hash TEXT, + first_image_url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_change DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -1346,6 +1447,15 @@ db.exec(` ); `); +db.exec(` + CREATE TABLE IF NOT EXISTS similarity_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + text_threshold REAL DEFAULT ${SIMILARITY_DEFAULTS.text_threshold}, + image_distance_threshold INTEGER DEFAULT ${SIMILARITY_DEFAULTS.image_distance_threshold}, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + ensureColumn('moderation_settings', 'sports_terms', 'sports_terms TEXT'); ensureColumn('moderation_settings', 'sports_auto_hide_enabled', 'sports_auto_hide_enabled INTEGER DEFAULT 0'); @@ -2037,6 +2147,7 @@ function extractRateLimitInfo(response, provider) { } const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; +const AI_COMMENT_RETRY_LIMIT = 5; function determineAutoDisable(error) { if (!error) { @@ -3284,6 +3395,64 @@ function persistModerationSettings({ enabled, threshold, weights, terms, autoHid }; } +function normalizeSimilarityTextThreshold(value) { + const parsed = parseFloat(value); + if (Number.isNaN(parsed)) { + return SIMILARITY_DEFAULTS.text_threshold; + } + return Math.min(0.99, Math.max(0.5, parsed)); +} + +function normalizeSimilarityImageDistance(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + return SIMILARITY_DEFAULTS.image_distance_threshold; + } + return Math.min(64, Math.max(0, parsed)); +} + +function loadSimilaritySettings() { + let settings = db.prepare('SELECT * FROM similarity_settings WHERE id = 1').get(); + if (!settings) { + db.prepare(` + INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at) + VALUES (1, ?, ?, CURRENT_TIMESTAMP) + `).run(SIMILARITY_DEFAULTS.text_threshold, SIMILARITY_DEFAULTS.image_distance_threshold); + settings = { + text_threshold: SIMILARITY_DEFAULTS.text_threshold, + image_distance_threshold: SIMILARITY_DEFAULTS.image_distance_threshold + }; + } + + return { + text_threshold: normalizeSimilarityTextThreshold(settings.text_threshold), + image_distance_threshold: normalizeSimilarityImageDistance(settings.image_distance_threshold) + }; +} + +function persistSimilaritySettings({ textThreshold, imageDistanceThreshold }) { + const normalizedText = normalizeSimilarityTextThreshold(textThreshold); + const normalizedImage = normalizeSimilarityImageDistance(imageDistanceThreshold); + const existing = db.prepare('SELECT id FROM similarity_settings WHERE id = 1').get(); + if (existing) { + db.prepare(` + UPDATE similarity_settings + SET text_threshold = ?, image_distance_threshold = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(normalizedText, normalizedImage); + } else { + db.prepare(` + INSERT INTO similarity_settings (id, text_threshold, image_distance_threshold, updated_at) + VALUES (1, ?, ?, CURRENT_TIMESTAMP) + `).run(normalizedText, normalizedImage); + } + + return { + text_threshold: normalizedText, + image_distance_threshold: normalizedImage + }; +} + function expandPhotoUrlHostVariants(url) { if (typeof url !== 'string' || !url) { return []; @@ -3396,6 +3565,10 @@ const selectAlternateUrlsForPostStmt = db.prepare(` ORDER BY created_at ASC `); const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?'); +const selectPostsForSimilarityStmt = db.prepare(` + SELECT id, url, title, created_by_name, created_at, post_text, first_image_hash + FROM posts +`); const selectChecksForPostStmt = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?'); const updateCheckPostStmt = db.prepare('UPDATE checks SET post_id = ? WHERE id = ?'); const updateCheckTimestampStmt = db.prepare('UPDATE checks SET checked_at = ? WHERE id = ?'); @@ -3576,7 +3749,7 @@ function mapPostRow(post) { const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); - const checkedCount = statuses.filter(status => status.status === 'done').length; + const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks)); const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) ? `/api/posts/${post.id}/screenshot` @@ -3645,7 +3818,9 @@ function mapPostRow(post) { alternate_urls: alternateUrls, post_text: post.post_text || null, post_text_hash: post.post_text_hash || null, - content_key: post.content_key || postContentKey || null + content_key: post.content_key || postContentKey || null, + first_image_hash: post.first_image_hash || null, + first_image_url: post.first_image_url || null }; } @@ -4607,6 +4782,106 @@ app.get('/api/posts/by-url', (req, res) => { } }); +app.post('/api/posts/similar', (req, res) => { + try { + const { url, post_text, first_image_hash } = req.body || {}; + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return res.status(400).json({ error: 'url must be a valid Facebook link' }); + } + + const existing = findPostByUrl(normalizedUrl); + if (existing) { + return res.json({ match: null }); + } + + const settings = loadSimilaritySettings(); + const normalizedText = normalizePostText(post_text); + const textEligible = normalizedText && normalizedText.length >= MIN_SIMILAR_TEXT_LENGTH; + const normalizedImageHash = typeof first_image_hash === 'string' + ? first_image_hash.trim().toLowerCase() + : null; + const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash)) + ? normalizedImageHash + : null; + + let bestText = null; + let bestImage = null; + + const rows = selectPostsForSimilarityStmt.all(); + for (const row of rows) { + if (!row) { + continue; + } + if (textEligible && row.post_text) { + const candidateText = normalizePostText(row.post_text); + if (candidateText) { + const score = computeTextSimilarity(normalizedText, candidateText); + if (!bestText || score > bestText.score) { + bestText = { post: row, score }; + } + } + } + if (cleanedImageHash && row.first_image_hash) { + const distance = hammingDistanceHex(cleanedImageHash, row.first_image_hash); + if (distance !== null && (!bestImage || distance < bestImage.distance)) { + bestImage = { post: row, distance }; + } + } + } + + const textMatch = bestText && bestText.score >= settings.text_threshold ? bestText : null; + const imageMatch = bestImage && bestImage.distance <= settings.image_distance_threshold ? bestImage : null; + + if (!textMatch && !imageMatch) { + return res.json({ match: null }); + } + + const imageScore = (match) => match ? 1 - (match.distance / 64) : 0; + let selected = textMatch || imageMatch; + let reason = textMatch ? 'text' : 'image'; + let textScore = textMatch ? textMatch.score : null; + let imageDistance = imageMatch ? imageMatch.distance : null; + + if (textMatch && imageMatch) { + if (textMatch.post.id === imageMatch.post.id) { + selected = textMatch; + reason = 'text+image'; + } else { + const textScoreValue = textMatch.score; + const imageScoreValue = imageScore(imageMatch); + if (imageScoreValue > textScoreValue) { + selected = imageMatch; + reason = 'image'; + } else { + selected = textMatch; + reason = 'text'; + } + } + } + + const matchPost = selected.post; + const responsePayload = { + match: { + id: matchPost.id, + url: matchPost.url, + title: matchPost.title, + created_by_name: matchPost.created_by_name, + created_at: sqliteTimestampToUTC(matchPost.created_at) + }, + similarity: { + text: textScore, + image_distance: imageDistance + }, + reason + }; + + res.json(responsePayload); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.post('/api/search-posts', (req, res) => { try { const { url, candidates, skip_increment, force_hide, sports_auto_hide } = req.body || {}; @@ -4861,6 +5136,15 @@ app.post('/api/posts', (req, res) => { const normalizedPostText = normalizePostText(post_text); const postTextHash = computePostTextHash(normalizedPostText); const contentKey = extractFacebookContentKey(normalizedUrl); + const normalizedImageHash = typeof req.body.first_image_hash === 'string' + ? req.body.first_image_hash.trim().toLowerCase() + : null; + const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash)) + ? normalizedImageHash + : null; + const normalizedImageUrl = typeof req.body.first_image_url === 'string' + ? normalizeFacebookPostUrl(req.body.first_image_url) || req.body.first_image_url.trim() + : null; const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash; if (useTextHashDedup) { @@ -4912,9 +5196,11 @@ app.post('/api/posts', (req, res) => { post_text, post_text_hash, content_key, + first_image_hash, + first_image_url, last_change ) - VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run( id, @@ -4926,7 +5212,9 @@ app.post('/api/posts', (req, res) => { normalizedDeadline, normalizedPostText, postTextHash, - contentKey || null + contentKey || null, + cleanedImageHash || null, + normalizedImageUrl || null ); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); @@ -5084,7 +5372,7 @@ app.put('/api/posts/:postId', (req, res) => { app.post('/api/posts/:postId/check', (req, res) => { try { const { postId } = req.params; - const { profile_number } = req.body; + const { profile_number, ignore_order } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { @@ -5113,9 +5401,14 @@ app.post('/api/posts/:postId/check', (req, res) => { profileValue = storedProfile || requiredProfiles[0]; } - if (!requiredProfiles.includes(profileValue)) { - return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); - } + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); + const completedSet = buildCompletedProfileSet(completedRows); + const shouldEnforceOrder = shouldEnforceProfileOrder({ + post, + requiredProfiles, + completedSet, + ignoreOrder: !!ignore_order + }); const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' @@ -5129,16 +5422,9 @@ app.post('/api/posts/:postId/check', (req, res) => { // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; - if (requiredProfiles.length > 0 && !isCreator) { + if (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { - const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); - const completedSet = new Set( - completedRows - .map(row => sanitizeProfileNumber(row.profile_number)) - .filter(Boolean) - ); - const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ @@ -5168,7 +5454,7 @@ 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 { urls, skip_content_key_check, first_image_hash, first_image_url } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { @@ -5176,10 +5462,37 @@ app.post('/api/posts/:postId/urls', (req, res) => { } const candidateList = Array.isArray(urls) ? urls : []; - const alternateUrls = collectPostAlternateUrls(post.url, candidateList); - storePostUrls(post.id, post.url, alternateUrls); + let alternateUrls = []; + const skipContentKey = !!skip_content_key_check; + if (skipContentKey) { + const normalized = collectNormalizedFacebookUrls(post.url, candidateList); + alternateUrls = normalized.filter(url => url !== post.url); + } else { + alternateUrls = collectPostAlternateUrls(post.url, candidateList); + } + storePostUrls(post.id, post.url, alternateUrls, { skipContentKeyCheck: skipContentKey }); removeSearchSeenEntries([post.url, ...alternateUrls]); + const normalizedImageHash = typeof first_image_hash === 'string' + ? first_image_hash.trim().toLowerCase() + : null; + const cleanedImageHash = (normalizedImageHash && /^[0-9a-f]{16}$/.test(normalizedImageHash)) + ? normalizedImageHash + : null; + const normalizedImageUrl = typeof first_image_url === 'string' + ? normalizeFacebookPostUrl(first_image_url) || first_image_url.trim() + : null; + + if ((!post.first_image_hash && cleanedImageHash) || (!post.first_image_url && normalizedImageUrl)) { + db.prepare(` + UPDATE posts + SET first_image_hash = COALESCE(first_image_hash, ?), + first_image_url = COALESCE(first_image_url, ?), + last_change = CURRENT_TIMESTAMP + WHERE id = ? + `).run(cleanedImageHash || null, normalizedImageUrl || null, post.id); + } + const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); if (alternateUrls.length) { touchPost(post.id, 'alternate-urls'); @@ -5197,7 +5510,7 @@ app.post('/api/posts/:postId/urls', (req, res) => { // Check by URL (for web interface auto-check) app.post('/api/check-by-url', (req, res) => { try { - const { url, profile_number } = req.body; + const { url, profile_number, ignore_order } = req.body || {}; if (!url) { return res.status(400).json({ error: 'URL is required' }); @@ -5239,9 +5552,14 @@ app.post('/api/check-by-url', (req, res) => { profileValue = storedProfile || requiredProfiles[0]; } - if (!requiredProfiles.includes(profileValue)) { - return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); - } + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); + const completedSet = buildCompletedProfileSet(completedRows); + const shouldEnforceOrder = shouldEnforceProfileOrder({ + post, + requiredProfiles, + completedSet, + ignoreOrder: !!ignore_order + }); const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' @@ -5255,16 +5573,9 @@ app.post('/api/check-by-url', (req, res) => { // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; - if (requiredProfiles.length > 0 && !isCreator) { + if (shouldEnforceOrder && requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { - const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); - const completedSet = new Set( - completedRows - .map(row => sanitizeProfileNumber(row.profile_number)) - .filter(Boolean) - ); - const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ @@ -5293,7 +5604,7 @@ app.post('/api/check-by-url', (req, res) => { app.post('/api/posts/:postId/profile-status', (req, res) => { try { const { postId } = req.params; - const { profile_number, status } = req.body || {}; + const { profile_number, status, ignore_order } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { @@ -5331,16 +5642,18 @@ app.post('/api/posts/:postId/profile-status', (req, res) => { // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; - if (!isCreator) { + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); + const completedSet = buildCompletedProfileSet(completedRows); + const shouldEnforceOrder = shouldEnforceProfileOrder({ + post, + requiredProfiles, + completedSet, + ignoreOrder: !!ignore_order + }); + + if (shouldEnforceOrder && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { - const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); - const completedSet = new Set( - completedRows - .map(row => sanitizeProfileNumber(row.profile_number)) - .filter(Boolean) - ); - const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ @@ -5518,6 +5831,8 @@ app.post('/api/posts/merge', (req, res) => { const mergedTitle = (primaryPost.title && primaryPost.title.trim()) ? primaryPost.title : (secondaryPost.title || null); + const mergedImageHash = primaryPost.first_image_hash || secondaryPost.first_image_hash || null; + const mergedImageUrl = primaryPost.first_image_url || secondaryPost.first_image_url || null; const mergeTransaction = db.transaction(() => { // Move checks from secondary to primary (one per profile) @@ -5566,7 +5881,7 @@ app.post('/api/posts/merge', (req, res) => { db.prepare(` UPDATE posts SET url = ?, content_key = ?, target_count = ?, created_by_name = ?, created_by_profile = ?, - deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, last_change = CURRENT_TIMESTAMP + deadline_at = ?, title = ?, post_text = ?, post_text_hash = ?, first_image_hash = ?, first_image_url = ?, last_change = CURRENT_TIMESTAMP WHERE id = ? `).run( normalizedPrimaryUrl, @@ -5578,6 +5893,8 @@ app.post('/api/posts/merge', (req, res) => { mergedTitle, normalizedMergedPostText, mergedPostTextHash, + mergedImageHash, + mergedImageUrl, primary_post_id ); @@ -5916,6 +6233,28 @@ app.put('/api/moderation-settings', (req, res) => { } }); +app.get('/api/similarity-settings', (req, res) => { + try { + const settings = loadSimilaritySettings(); + res.json(settings); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/similarity-settings', (req, res) => { + try { + const body = req.body || {}; + const saved = persistSimilaritySettings({ + textThreshold: body.text_threshold, + imageDistanceThreshold: body.image_distance_threshold + }); + res.json(saved); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.get('/api/hidden-settings', (req, res) => { try { const settings = loadHiddenSettings(); @@ -5955,6 +6294,26 @@ function sanitizeAIComment(text) { // Strip leading label noise some models prepend (e.g. "**Kommentar**, **Inhalt**:") const markerPattern = /^(?:\s*(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[,;:.\-–—`'"]*\s*)+/i; cleaned = cleaned.replace(markerPattern, ''); + cleaned = cleaned.replace(/```+/g, ''); + cleaned = cleaned.replace(/\*\*[^*]+\*\*/g, ''); + cleaned = cleaned.replace(/`@([^`]+)`/g, '@$1'); + cleaned = cleaned.replace(/`([^`]+)`/g, '$1'); + + const lines = cleaned.split(/\r?\n/); + while (lines.length) { + const line = lines[0].trim(); + if (!line) { + lines.shift(); + continue; + } + const labelLine = /^(?:\*\*|__)?\s*(kommentar|inhalt|text|content)\s*(?:\*\*|__)?\s*[:\-–—]*\s*$/i; + if (labelLine.test(line)) { + lines.shift(); + continue; + } + break; + } + cleaned = lines.join('\n').replace(/\s*,\s*/g, ', ').replace(/,+\s*$/g, ''); // Clean up AI output: drop hidden tags, replace dashes, normalize spacing. return cleaned @@ -5967,209 +6326,229 @@ function sanitizeAIComment(text) { return prevIsWord && nextIsWord ? match : ', '; }) .replace(/^[\s,;:.\-–—!?\u00a0"'`]+/, '') + .replace(/,+$/g, '') .replace(/\s{2,}/g, ' ') .trim(); } +function shouldRetryAIComment(text) { + if (!text || typeof text !== 'string') { + return false; + } + const lower = text.toLowerCase(); + const hasCommentOrCharacter = lower.includes('comment') || lower.includes('character'); + const hasLengthOrCount = lower.includes('length') || lower.includes('count'); + return hasCommentOrCharacter && hasLengthOrCount; +} + async function tryGenerateComment(credential, promptPrefix, postText) { const provider = credential.provider; const apiKey = credential.api_key; const model = credential.model; - let comment = ''; let lastResponse = null; try { - if (provider === 'gemini') { - const modelName = model || 'gemini-2.0-flash-exp'; - const prompt = promptPrefix + postText; + for (let attempt = 1; attempt <= AI_COMMENT_RETRY_LIMIT; attempt += 1) { + let comment = ''; - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, - { + if (provider === 'gemini') { + const modelName = model || 'gemini-2.0-flash-exp'; + const prompt = promptPrefix + postText; + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: prompt }] + }] + }) + } + ); + + lastResponse = response; + + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore + } + } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`Gemini API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; + } + + const data = await response.json(); + comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + } else if (provider === 'openai') { + const modelName = model || 'gpt-3.5-turbo'; + const prompt = promptPrefix + postText; + + const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); + const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; + + const headers = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(endpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ - contents: [{ - parts: [{ text: prompt }] - }] + model: modelName, + messages: [{ role: 'user', content: prompt }], + max_tokens: 150 }) - } - ); + }); - lastResponse = response; + lastResponse = response; - if (!response.ok) { - let errorPayload = null; - let message = response.statusText; - try { - errorPayload = await response.json(); - message = errorPayload?.error?.message || message; - } catch (jsonError) { + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; try { - const textBody = await response.text(); - if (textBody) { - message = textBody; + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore } - } catch (textError) { - // ignore } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`OpenAI API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; } - const rateInfo = extractRateLimitInfo(response, provider); - const error = new Error(`Gemini API error: ${message}`); - error.status = response.status; + const data = await response.json(); + comment = data.choices?.[0]?.message?.content || ''; + } else if (provider === 'claude') { + const modelName = model || 'claude-3-5-haiku-20241022'; + const prompt = promptPrefix + postText; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: modelName, + max_tokens: 150, + messages: [{ role: 'user', content: prompt }] + }) + }); + + lastResponse = response; + + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore + } + } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`Claude API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; + } + + const data = await response.json(); + comment = data.content?.[0]?.text || ''; + } else { + throw new Error(`Unsupported AI provider: ${provider}`); + } + + if (shouldRetryAIComment(comment)) { + if (attempt < AI_COMMENT_RETRY_LIMIT) { + continue; + } + const error = new Error('AI response contains forbidden comment length/count metadata'); error.provider = provider; - error.apiError = errorPayload; - if (rateInfo.retryAfterSeconds !== undefined) { - error.retryAfterSeconds = rateInfo.retryAfterSeconds; - } - if (rateInfo.rateLimitResetAt) { - error.rateLimitResetAt = rateInfo.rateLimitResetAt; - } - if (rateInfo.rateLimitRemaining !== undefined) { - error.rateLimitRemaining = rateInfo.rateLimitRemaining; - } - error.rateLimitHeaders = rateInfo.headers; throw error; } - const data = await response.json(); - comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const rateInfo = extractRateLimitInfo(lastResponse, provider); + rateInfo.status = lastResponse ? lastResponse.status : null; - } else if (provider === 'openai') { - const modelName = model || 'gpt-3.5-turbo'; - const prompt = promptPrefix + postText; - - const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); - const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; - - const headers = { - 'Content-Type': 'application/json' + return { + comment: sanitizeAIComment(comment), + rateInfo }; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; - } - - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ - model: modelName, - messages: [{ role: 'user', content: prompt }], - max_tokens: 150 - }) - }); - - lastResponse = response; - - if (!response.ok) { - let errorPayload = null; - let message = response.statusText; - try { - errorPayload = await response.json(); - message = errorPayload?.error?.message || message; - } catch (jsonError) { - try { - const textBody = await response.text(); - if (textBody) { - message = textBody; - } - } catch (textError) { - // ignore - } - } - - const rateInfo = extractRateLimitInfo(response, provider); - const error = new Error(`OpenAI API error: ${message}`); - error.status = response.status; - error.provider = provider; - error.apiError = errorPayload; - if (rateInfo.retryAfterSeconds !== undefined) { - error.retryAfterSeconds = rateInfo.retryAfterSeconds; - } - if (rateInfo.rateLimitResetAt) { - error.rateLimitResetAt = rateInfo.rateLimitResetAt; - } - if (rateInfo.rateLimitRemaining !== undefined) { - error.rateLimitRemaining = rateInfo.rateLimitRemaining; - } - error.rateLimitHeaders = rateInfo.headers; - throw error; - } - - const data = await response.json(); - comment = data.choices?.[0]?.message?.content || ''; - - } else if (provider === 'claude') { - const modelName = model || 'claude-3-5-haiku-20241022'; - const prompt = promptPrefix + postText; - - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01' - }, - body: JSON.stringify({ - model: modelName, - max_tokens: 150, - messages: [{ role: 'user', content: prompt }] - }) - }); - - lastResponse = response; - - if (!response.ok) { - let errorPayload = null; - let message = response.statusText; - try { - errorPayload = await response.json(); - message = errorPayload?.error?.message || message; - } catch (jsonError) { - try { - const textBody = await response.text(); - if (textBody) { - message = textBody; - } - } catch (textError) { - // ignore - } - } - - const rateInfo = extractRateLimitInfo(response, provider); - const error = new Error(`Claude API error: ${message}`); - error.status = response.status; - error.provider = provider; - error.apiError = errorPayload; - if (rateInfo.retryAfterSeconds !== undefined) { - error.retryAfterSeconds = rateInfo.retryAfterSeconds; - } - if (rateInfo.rateLimitResetAt) { - error.rateLimitResetAt = rateInfo.rateLimitResetAt; - } - if (rateInfo.rateLimitRemaining !== undefined) { - error.rateLimitRemaining = rateInfo.rateLimitRemaining; - } - error.rateLimitHeaders = rateInfo.headers; - throw error; - } - - const data = await response.json(); - comment = data.content?.[0]?.text || ''; - - } else { - throw new Error(`Unsupported AI provider: ${provider}`); } - - const rateInfo = extractRateLimitInfo(lastResponse, provider); - rateInfo.status = lastResponse ? lastResponse.status : null; - - return { - comment: sanitizeAIComment(comment), - rateInfo - }; } catch (error) { if (error && !error.provider) { error.provider = provider; diff --git a/extension/content.js b/extension/content.js index c1dfa63..aa0a1ec 100644 --- a/extension/content.js +++ b/extension/content.js @@ -42,6 +42,32 @@ function isOnReelsPage() { } } +function maybeRedirectPageReelsToMain() { + try { + const { location } = window; + const pathname = location && location.pathname; + if (typeof pathname !== 'string') { + return false; + } + const match = pathname.match(/^\/([^/]+)\/reels\/?$/i); + if (!match) { + return false; + } + const pageSlug = match[1]; + if (!pageSlug) { + return false; + } + const targetUrl = `${location.origin}/${pageSlug}/`; + if (location.href === targetUrl) { + return false; + } + location.replace(targetUrl); + return true; + } catch (error) { + return false; + } +} + let debugLoggingEnabled = false; const originalConsoleLog = console.log.bind(console); @@ -705,6 +731,140 @@ function expandPhotoUrlHostVariants(url) { } } +async function fetchPostByUrl(url) { + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return null; + } + const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(normalizedUrl)}`); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data && data.id ? data : null; +} + +async function fetchPostById(postId) { + if (!postId) { + return null; + } + try { + const response = await backendFetch(`${API_URL}/posts`); + if (!response.ok) { + return null; + } + const posts = await response.json(); + if (!Array.isArray(posts)) { + return null; + } + return posts.find(post => post && post.id === postId) || null; + } catch (error) { + return null; + } +} + +async function buildSimilarityPayload(postElement) { + let postText = null; + try { + postText = extractPostText(postElement) || null; + } catch (error) { + console.debug('[FB Tracker] Failed to extract post text for similarity:', error); + } + const imageInfo = await getFirstPostImageInfo(postElement); + return { + postText, + firstImageHash: imageInfo.hash, + firstImageUrl: imageInfo.url + }; +} + +async function findSimilarPost({ url, postText, firstImageHash }) { + if (!url) { + return null; + } + if (!postText && !firstImageHash) { + return null; + } + try { + const payload = { + url, + post_text: postText || null, + first_image_hash: firstImageHash || null + }; + const response = await backendFetch(`${API_URL}/posts/similar`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data && data.match ? data : null; + } catch (error) { + console.warn('[FB Tracker] Similarity check failed:', error); + return null; + } +} + +function shortenInline(text, maxLength = 64) { + if (!text) { + return ''; + } + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3)}...`; +} + +function formatSimilarityLabel(similarity) { + if (!similarity || !similarity.match) { + return ''; + } + const match = similarity.match; + const base = match.title || match.created_by_name || match.url || 'Beitrag'; + const details = []; + if (similarity.similarity && typeof similarity.similarity.text === 'number') { + details.push(`Text ${Math.round(similarity.similarity.text * 100)}%`); + } + if (similarity.similarity && typeof similarity.similarity.image_distance === 'number') { + details.push(`Bild Δ${similarity.similarity.image_distance}`); + } + const detailText = details.length ? ` (${details.join(', ')})` : ''; + return `Ähnlich zu: ${shortenInline(base, 64)}${detailText}`; +} + +async function attachUrlToExistingPost(postId, urls, payload = {}) { + if (!postId) { + return false; + } + try { + const body = { + urls: Array.isArray(urls) ? urls : [], + skip_content_key_check: true + }; + if (payload.firstImageHash) { + body.first_image_hash = payload.firstImageHash; + } + if (payload.firstImageUrl) { + body.first_image_url = payload.firstImageUrl; + } + const response = await backendFetch(`${API_URL}/posts/${postId}/urls`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + return response.ok; + } catch (error) { + console.warn('[FB Tracker] Failed to attach URL to existing post:', error); + return false; + } +} + // Check if post is already tracked (checks all URL candidates to avoid duplicates) async function checkPostStatus(postUrl, allUrlCandidates = []) { try { @@ -880,15 +1040,20 @@ async function persistAlternatePostUrls(postId, urls = []) { } // Add post to tracking -async function markPostChecked(postId, profileNumber) { +async function markPostChecked(postId, profileNumber, options = {}) { try { + const ignoreOrder = options && options.ignoreOrder === true; + const returnError = options && options.returnError === true; console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber); const response = await backendFetch(`${API_URL}/posts/${postId}/check`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ profile_number: profileNumber }) + body: JSON.stringify({ + profile_number: profileNumber, + ignore_order: ignoreOrder + }) }); if (response.ok) { @@ -898,15 +1063,21 @@ async function markPostChecked(postId, profileNumber) { } if (response.status === 409) { - console.log('[FB Tracker] Post already checked by this profile'); - return null; + const payload = await response.json().catch(() => ({})); + const message = payload && payload.error ? payload.error : 'Beitrag kann aktuell nicht bestätigt werden.'; + console.log('[FB Tracker] Post check blocked:', message); + return returnError ? { error: message, status: response.status } : null; } console.error('[FB Tracker] Failed to mark post as checked:', response.status); - return null; + return returnError + ? { error: 'Beitrag konnte nicht bestätigt werden.', status: response.status } + : null; } catch (error) { console.error('[FB Tracker] Error marking post as checked:', error); - return null; + return (options && options.returnError) + ? { error: 'Beitrag konnte nicht bestätigt werden.', status: 0 } + : null; } } @@ -920,7 +1091,9 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = } let postText = null; - if (options && options.postElement) { + if (options && typeof options.postText === 'string') { + postText = options.postText; + } else if (options && options.postElement) { try { postText = extractPostText(options.postElement) || null; } catch (error) { @@ -967,6 +1140,13 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = payload.post_text = postText; } + if (options && typeof options.firstImageHash === 'string' && options.firstImageHash.trim()) { + payload.first_image_hash = options.firstImageHash.trim(); + } + if (options && typeof options.firstImageUrl === 'string' && options.firstImageUrl.trim()) { + payload.first_image_url = options.firstImageUrl.trim(); + } + const response = await backendFetch(`${API_URL}/posts`, { method: 'POST', headers: { @@ -1667,6 +1847,18 @@ async function captureElementScreenshot(element) { const endY = Math.min(documentHeight, elementBottom + verticalMargin); const baseDocTop = Math.max(0, elementTop - verticalMargin); + const restoreScrollPosition = () => { + window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' }); + if (document.documentElement) { + document.documentElement.scrollTop = originalScrollY; + document.documentElement.scrollLeft = originalScrollX; + } + if (document.body) { + document.body.scrollTop = originalScrollY; + document.body.scrollLeft = originalScrollX; + } + }; + try { let iteration = 0; let targetScroll = startY; @@ -1723,7 +1915,9 @@ async function captureElementScreenshot(element) { } } } finally { - window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' }); + restoreScrollPosition(); + await delay(0); + restoreScrollPosition(); } if (!segments.length) { @@ -1845,6 +2039,125 @@ async function maybeDownscaleScreenshot(imageData) { } } +function isLikelyPostImage(img) { + if (!img) { + return false; + } + const src = img.currentSrc || img.src || ''; + if (!src) { + return false; + } + if (src.startsWith('data:')) { + return false; + } + const lowerSrc = src.toLowerCase(); + if (lowerSrc.includes('emoji') || lowerSrc.includes('static.xx') || lowerSrc.includes('sprite')) { + return false; + } + const width = img.naturalWidth || img.width || 0; + const height = img.naturalHeight || img.height || 0; + if (width < 120 || height < 120) { + return false; + } + return true; +} + +function waitForImageLoad(img, timeoutMs = 1500) { + return new Promise((resolve) => { + if (!img) { + resolve(false); + return; + } + if (img.complete && img.naturalWidth > 0) { + resolve(true); + return; + } + let resolved = false; + const finish = (value) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + img.addEventListener('load', () => { + clearTimeout(timer); + finish(true); + }, { once: true }); + img.addEventListener('error', () => { + clearTimeout(timer); + finish(false); + }, { once: true }); + }); +} + +function buildDHashFromPixels(imageData) { + if (!imageData || !imageData.data) { + return null; + } + const { data } = imageData; + const bits = []; + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const leftIndex = ((y * 9) + x) * 4; + const rightIndex = ((y * 9) + x + 1) * 4; + const left = 0.299 * data[leftIndex] + 0.587 * data[leftIndex + 1] + 0.114 * data[leftIndex + 2]; + const right = 0.299 * data[rightIndex] + 0.587 * data[rightIndex + 1] + 0.114 * data[rightIndex + 2]; + bits.push(left > right ? 1 : 0); + } + } + let hex = ''; + for (let i = 0; i < bits.length; i += 4) { + const value = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3]; + hex += value.toString(16); + } + return hex.padStart(16, '0'); +} + +async function computeDHashFromUrl(imageUrl) { + if (!imageUrl) { + return null; + } + try { + const response = await fetch(imageUrl); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = 9; + canvas.height = 8; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + ctx.drawImage(bitmap, 0, 0, 9, 8); + const imageData = ctx.getImageData(0, 0, 9, 8); + return buildDHashFromPixels(imageData); + } catch (error) { + return null; + } +} + +async function getFirstPostImageInfo(postElement) { + if (!postElement) { + return { hash: null, url: null }; + } + const images = Array.from(postElement.querySelectorAll('img')).filter(isLikelyPostImage); + for (const img of images.slice(0, 5)) { + const loaded = await waitForImageLoad(img); + if (!loaded) { + continue; + } + const src = img.currentSrc || img.src; + const hash = await computeDHashFromUrl(src); + if (hash) { + return { hash, url: src }; + } + } + return { hash: null, url: null }; +} + function getStickyHeaderHeight() { try { const banner = document.querySelector('[role="banner"], header[role="banner"]'); @@ -1939,6 +2252,9 @@ function extractDeadlineFromPostText(postElement) { const extractTimeAfterIndex = (text, index) => { const tail = text.slice(index, index + 80); + if (/^\s*(?:-|–|—|bis)\s*\d{1,2}\.\d{1,2}\./i.test(tail)) { + return null; + } const timeMatch = /^\s*(?:\(|\[)?\s*(?:[,;:-]|\b)?\s*(?:um|ab|bis|gegen|spätestens)?\s*(?:den|dem|am)?\s*(?:ca\.?)?\s*(\d{1,2})(?:[:.](\d{2}))?\s*(?:uhr|h)?\s*(?:\)|\])?/i.exec(tail); if (!timeMatch) { return null; @@ -1950,6 +2266,9 @@ function extractDeadlineFromPostText(postElement) { if (Number.isNaN(hour) || Number.isNaN(minute)) { return null; } + if (hour === 24 && minute === 0) { + return { hour: 23, minute: 59 }; + } if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { return null; } @@ -1963,6 +2282,22 @@ function extractDeadlineFromPostText(postElement) { }; const foundDates = []; + const rangePattern = /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\s*(?:-|–|—|bis)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/i; + const rangeMatch = rangePattern.exec(fullText); + if (rangeMatch) { + const endDay = parseInt(rangeMatch[4], 10); + const endMonth = parseInt(rangeMatch[5], 10); + let endYear = parseInt(rangeMatch[6], 10); + if (endYear < 100) { + endYear += 2000; + } + if (endMonth >= 1 && endMonth <= 12 && endDay >= 1 && endDay <= 31) { + const endDate = new Date(endYear, endMonth - 1, endDay, 0, 0, 0, 0); + if (endDate.getMonth() === endMonth - 1 && endDate.getDate() === endDay && endDate > today) { + return toDateTimeLocalString(endDate); + } + } + } for (const pattern of patterns) { let match; @@ -2404,7 +2739,19 @@ async function renderTrackedStatus({ : null; const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; - const canCurrentProfileCheck = postData.next_required_profile === profileNumber; + const requiredProfiles = Array.isArray(postData.required_profiles) && postData.required_profiles.length + ? postData.required_profiles + .map((value) => { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + return null; + } + return Math.min(5, Math.max(1, parsed)); + }) + .filter(Boolean) + : Array.from({ length: Math.max(1, Math.min(5, parseInt(postData.target_count, 10) || 1)) }, (_, index) => index + 1); + const isCurrentProfileRequired = requiredProfiles.includes(profileNumber); + const canCurrentProfileCheck = isCurrentProfileRequired && postData.next_required_profile === profileNumber; const isCurrentProfileDone = checks.some(check => check.profile_number === profileNumber); if (isFeedHome && isCurrentProfileDone) { @@ -2434,16 +2781,23 @@ async function renderTrackedStatus({ ${lastCheck ? `
Letzte: ${lastCheck}
` : ''} `; - if (canCurrentProfileCheck && !isExpired && !completed) { + if (!isExpired && !completed && !isCurrentProfileDone && isCurrentProfileRequired) { + const checkButtonEnabled = canCurrentProfileCheck; + const buttonColor = checkButtonEnabled ? '#42b72a' : '#f39c12'; + const cursorStyle = 'pointer'; + const buttonTitle = checkButtonEnabled + ? 'Beitrag bestätigen' + : 'Wartet auf vorherige Profile'; + statusHtml += ` - + Öffnen + + +
+
Lade Beiträge...
+ +
@@ -1181,6 +1213,36 @@ + +
+

Ähnlichkeits-Erkennung

+

+ Steuert, ab wann Posts als ähnlich gelten (Text-Ähnlichkeit oder Bild-Ähnlichkeit). +

+ +
+
+ + +

+ Je höher der Wert, desto strenger wird Text-Ähnlichkeit bewertet. +

+
+ +
+ + +

+ Kleiner Wert = strenger (0 = identischer Hash, 64 = komplett unterschiedlich). +

+
+ +
+ +
+
+
+

Versteckte Beiträge bereinigen

diff --git a/web/settings.js b/web/settings.js index 3407911..1b4f4c9 100644 --- a/web/settings.js +++ b/web/settings.js @@ -72,6 +72,10 @@ let moderationSettings = { sports_terms: {}, sports_auto_hide_enabled: false }; +let similaritySettings = { + text_threshold: 0.85, + image_distance_threshold: 6 +}; function handleUnauthorized(response) { if (response && response.status === 401) { @@ -405,6 +409,84 @@ async function saveModerationSettings(event, { silent = false } = {}) { } } +function normalizeSimilarityTextThresholdInput(value) { + const parsed = parseFloat(value); + if (Number.isNaN(parsed)) { + return 0.85; + } + return Math.min(0.99, Math.max(0.5, parsed)); +} + +function normalizeSimilarityImageThresholdInput(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + return 6; + } + return Math.min(64, Math.max(0, parsed)); +} + +function applySimilaritySettingsUI() { + const textInput = document.getElementById('similarityTextThreshold'); + const imageInput = document.getElementById('similarityImageThreshold'); + if (textInput) { + textInput.value = similaritySettings.text_threshold ?? 0.85; + } + if (imageInput) { + imageInput.value = similaritySettings.image_distance_threshold ?? 6; + } +} + +async function loadSimilaritySettings() { + const res = await apiFetch(`${API_URL}/similarity-settings`); + if (!res.ok) throw new Error('Konnte Ähnlichkeits-Einstellungen nicht laden'); + const data = await res.json(); + similaritySettings = { + text_threshold: normalizeSimilarityTextThresholdInput(data.text_threshold), + image_distance_threshold: normalizeSimilarityImageThresholdInput(data.image_distance_threshold) + }; + applySimilaritySettingsUI(); +} + +async function saveSimilaritySettings(event, { silent = false } = {}) { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + const textInput = document.getElementById('similarityTextThreshold'); + const imageInput = document.getElementById('similarityImageThreshold'); + const textThreshold = textInput + ? normalizeSimilarityTextThresholdInput(textInput.value) + : similaritySettings.text_threshold; + const imageThreshold = imageInput + ? normalizeSimilarityImageThresholdInput(imageInput.value) + : similaritySettings.image_distance_threshold; + + try { + const res = await apiFetch(`${API_URL}/similarity-settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text_threshold: textThreshold, + image_distance_threshold: imageThreshold + }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Speichern'); + } + similaritySettings = await res.json(); + applySimilaritySettingsUI(); + if (!silent) { + showSuccess('✅ Ähnlichkeitsregeln gespeichert'); + } + return true; + } catch (err) { + if (!silent) { + showError('❌ ' + err.message); + } + return false; + } +} + function shorten(text, maxLength = 80) { if (typeof text !== 'string') { return ''; @@ -1041,6 +1123,7 @@ async function saveAllSettings(event) { saveSettings(null, { silent: true }), saveHiddenSettings(null, { silent: true }), saveModerationSettings(null, { silent: true }), + saveSimilaritySettings(null, { silent: true }), saveAllFriends({ silent: true }) ]); @@ -1208,12 +1291,30 @@ if (sportsScoringToggle && sportsScoreInput) { } } +const similarityForm = document.getElementById('similaritySettingsForm'); +if (similarityForm) { + const textInput = document.getElementById('similarityTextThreshold'); + const imageInput = document.getElementById('similarityImageThreshold'); + if (textInput) { + textInput.addEventListener('blur', () => { + textInput.value = normalizeSimilarityTextThresholdInput(textInput.value); + }); + } + if (imageInput) { + imageInput.addEventListener('blur', () => { + imageInput.value = normalizeSimilarityImageThresholdInput(imageInput.value); + }); + } + similarityForm.addEventListener('submit', (e) => saveSimilaritySettings(e)); +} + // Initialize Promise.all([ loadCredentials(), loadSettings(), loadHiddenSettings(), loadModerationSettings(), + loadSimilaritySettings(), loadProfileFriends() ]).catch(err => showError(err.message)); })(); diff --git a/web/style.css b/web/style.css index 8e90afc..3d1931f 100644 --- a/web/style.css +++ b/web/style.css @@ -589,6 +589,141 @@ h1 { margin: 0 4px; } +.posts-bulk-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + justify-content: space-between; + margin-top: 12px; +} + +.bulk-actions { + display: inline-flex; + align-items: center; + gap: 8px; + background: #f8fafc; + border: 1px solid #e5e7eb; + padding: 8px 10px; + border-radius: 12px; +} + +.bulk-actions label { + color: #6b7280; + font-size: 13px; +} + +.bulk-actions select { + background: #ffffff; + border: 1px solid #e5e7eb; + color: #111827; + border-radius: 10px; + padding: 8px 10px; +} + +.auto-open-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #6b7280; +} + +.auto-open-toggle input { + width: 16px; + height: 16px; +} + +.bulk-status { + font-size: 13px; + color: #6b7280; +} + +.bulk-status--error { + color: #dc2626; +} + +.auto-open-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(circle at 20% 20%, rgba(37, 99, 235, 0.12), transparent 42%), + radial-gradient(circle at 80% 30%, rgba(6, 182, 212, 0.12), transparent 38%), + rgba(15, 23, 42, 0.6); + z-index: 30; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.auto-open-overlay.visible { + opacity: 1; + pointer-events: auto; +} + +.auto-open-overlay__panel { + width: min(940px, 100%); + background: linear-gradient(150deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.96)); + border-radius: 22px; + padding: 38px 42px 40px; + box-shadow: 0 32px 90px rgba(15, 23, 42, 0.4); + border: 1px solid rgba(255, 255, 255, 0.6); + text-align: center; + cursor: pointer; +} + +.auto-open-overlay__badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.12); + color: #0f172a; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 12px; +} + +.auto-open-overlay__timer { + display: flex; + align-items: baseline; + justify-content: center; + gap: 12px; + margin: 18px 0 8px; + color: #0f172a; +} + +.auto-open-overlay__count { + font-size: clamp(72px, 12vw, 120px); + line-height: 1; + font-weight: 700; + letter-spacing: -0.02em; +} + +.auto-open-overlay__unit { + font-size: 22px; + color: #6b7280; +} + +.auto-open-overlay__text { + margin: 0 auto; + color: #334155; + max-width: 700px; + font-size: 18px; +} + +.auto-open-overlay__hint { + margin: 12px 0 0; + color: #475569; + font-size: 15px; +} + .posts-load-more { display: flex; justify-content: center;