- Gefällt mir
- Antworten
diff --git a/backend/server.js b/backend/server.js index 8e3763d..c71927b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,50 @@ const MAX_POST_TEXT_LENGTH = 4000; const MIN_TEXT_HASH_LENGTH = 120; const MAX_BOOKMARK_LABEL_LENGTH = 120; const MAX_BOOKMARK_QUERY_LENGTH = 200; +const SPORTS_SCORING_DEFAULTS = { + enabled: 1, + threshold: 5, + auto_hide_enabled: 0, + weights: { + scoreline: 3, + scoreEmoji: 2, + sportEmoji: 2, + sportVerb: 1.5, + sportNoun: 2, + hashtag: 1.5, + teamToken: 2, + competition: 2, + celebration: 1, + location: 1 + } +}; +const SPORTS_SCORING_TERMS_DEFAULTS = { + nouns: [ + 'auswärtssieg', 'heimsieg', 'derbysieg', 'revanche', 'spiel', 'spieltag', 'match', 'derby', 'finale', + 'cup', 'pokal', 'liga', 'bundesliga', 'oberliga', 'kreisliga', 'bezirksliga', 'meisterschaft', + 'turnier', 'halbzeit', 'tabellenplatz', 'tabelle', 'tor', 'tore', 'treffer', 'stadion', 'arena', 'halle', + 'trainerteam', 'mannschaft', 'fans', 'fanblock', 'jugend', 'u17', 'u19', 'u15' + ], + verbs: [ + 'gewinnen', 'siegen', 'geholt', 'erkämpfen', 'erkämpft', 'erkämpfen', 'drehen', 'punkten', + 'trifft', 'treffen', 'schießt', 'schiesst', 'schießen', 'schiessen', 'verteidigen', 'stürmen', 'kämpfen' + ], + competitions: [ + 'bundesliga', 'liga', 'serie a', 'premier league', 'champions league', 'europa league', 'dfb-pokal', + 'cup', 'pokal', 'qualifikation', 'qualirunde', 'halbfinale', 'viertelfinale', 'achtelfinale', 'relegation' + ], + celebrations: [ + 'sieg', 'siege', 'auswärtssieg', 'heimsieg', 'auswärtsspiel', 'punkte', 'punkte geholt', 'man of the match', 'motm', + 'tabellenführung', 'tabellenplatz', 'tabellendritter', 'tabellenzweiter' + ], + locations: [ + 'auswärts', 'heimspiel', 'derby', 'arena', 'stadion', 'halle', 'bolle', 'bölle', 'mosel' + ], + negatives: [ + 'rezept', 'kochen', 'politik', 'wahl', 'bundestag', 'landtag', 'software', 'release', 'update', 'konzert', + 'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung' + ] +}; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { @@ -803,6 +847,21 @@ db.exec(` ); `); +db.exec(` + CREATE TABLE IF NOT EXISTS moderation_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + sports_scoring_enabled INTEGER DEFAULT ${SPORTS_SCORING_DEFAULTS.enabled}, + sports_score_threshold REAL DEFAULT ${SPORTS_SCORING_DEFAULTS.threshold}, + sports_auto_hide_enabled INTEGER DEFAULT ${SPORTS_SCORING_DEFAULTS.auto_hide_enabled}, + sports_score_weights TEXT, + sports_terms TEXT, + 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'); + db.exec(` CREATE INDEX IF NOT EXISTS idx_search_seen_posts_last_seen_at ON search_seen_posts(last_seen_at); @@ -894,6 +953,7 @@ ensureColumn('ai_credentials', 'auto_disabled_until', 'auto_disabled_until DATET ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); +ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0'); db.exec(` CREATE TABLE IF NOT EXISTS ai_usage_events ( @@ -1620,6 +1680,152 @@ function cleanupExpiredSearchPosts() { } } +function safeParseSportsWeights(raw) { + if (!raw) { + return null; + } + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw; + } + if (typeof raw !== 'string') { + return null; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (error) { + return null; + } + return null; +} + +function normalizeSportsScoreThreshold(value) { + const parsed = parseFloat(value); + if (Number.isNaN(parsed) || parsed < 0) { + return SPORTS_SCORING_DEFAULTS.threshold; + } + return Math.min(50, Math.max(0, parsed)); +} + +function normalizeSportsWeights(weights) { + const defaults = SPORTS_SCORING_DEFAULTS.weights; + const normalized = {}; + const source = (weights && typeof weights === 'object' && !Array.isArray(weights)) + ? weights + : {}; + + for (const key of Object.keys(defaults)) { + const raw = source[key]; + const parsed = typeof raw === 'number' ? raw : parseFloat(raw); + const value = Number.isFinite(parsed) ? parsed : defaults[key]; + normalized[key] = Math.max(0, Math.min(10, value)); + } + + return normalized; +} + +function normalizeSportsTerms(terms) { + const defaults = SPORTS_SCORING_TERMS_DEFAULTS; + const result = {}; + const source = (terms && typeof terms === 'object' && !Array.isArray(terms)) + ? terms + : {}; + + const normalizeList = (list, fallback) => { + const arr = Array.isArray(list) ? list : []; + const cleaned = arr + .map((entry) => { + if (typeof entry !== 'string') return ''; + return entry.trim().toLowerCase(); + }) + .filter((entry) => entry && entry.length <= 60); + const unique = Array.from(new Set(cleaned)).slice(0, 200); + if (unique.length) { + return unique; + } + return fallback.slice(); + }; + + for (const key of Object.keys(defaults)) { + result[key] = normalizeList(source[key], defaults[key]); + } + + return result; +} + +function loadModerationSettings() { + let settings = db.prepare('SELECT * FROM moderation_settings WHERE id = 1').get(); + + if (!settings) { + const serializedWeights = JSON.stringify(SPORTS_SCORING_DEFAULTS.weights); + const serializedTerms = JSON.stringify(SPORTS_SCORING_TERMS_DEFAULTS); + db.prepare(` + INSERT INTO moderation_settings (id, sports_scoring_enabled, sports_score_threshold, sports_auto_hide_enabled, sports_score_weights, sports_terms, updated_at) + VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `).run(SPORTS_SCORING_DEFAULTS.enabled, SPORTS_SCORING_DEFAULTS.threshold, SPORTS_SCORING_DEFAULTS.auto_hide_enabled, serializedWeights, serializedTerms); + settings = { + id: 1, + sports_scoring_enabled: SPORTS_SCORING_DEFAULTS.enabled, + sports_score_threshold: SPORTS_SCORING_DEFAULTS.threshold, + sports_auto_hide_enabled: SPORTS_SCORING_DEFAULTS.auto_hide_enabled, + sports_score_weights: serializedWeights, + sports_terms: serializedTerms + }; + } + + const weights = normalizeSportsWeights(safeParseSportsWeights(settings.sports_score_weights)); + const threshold = normalizeSportsScoreThreshold(settings.sports_score_threshold); + let terms = SPORTS_SCORING_TERMS_DEFAULTS; + try { + const parsedTerms = settings.sports_terms ? JSON.parse(settings.sports_terms) : null; + terms = normalizeSportsTerms(parsedTerms); + } catch (error) { + terms = SPORTS_SCORING_TERMS_DEFAULTS; + } + + return { + sports_scoring_enabled: !!settings.sports_scoring_enabled, + sports_score_threshold: threshold, + sports_auto_hide_enabled: !!settings.sports_auto_hide_enabled, + sports_score_weights: weights, + sports_terms: terms + }; +} + +function persistModerationSettings({ enabled, threshold, weights, terms, autoHide }) { + const normalizedEnabled = enabled ? 1 : 0; + const normalizedAutoHide = autoHide ? 1 : 0; + const normalizedThreshold = normalizeSportsScoreThreshold(threshold); + const normalizedWeights = normalizeSportsWeights(weights); + const serializedWeights = JSON.stringify(normalizedWeights); + const normalizedTerms = normalizeSportsTerms(terms); + const serializedTerms = JSON.stringify(normalizedTerms); + + const existing = db.prepare('SELECT id FROM moderation_settings WHERE id = 1').get(); + if (existing) { + db.prepare(` + UPDATE moderation_settings + SET sports_scoring_enabled = ?, sports_score_threshold = ?, sports_auto_hide_enabled = ?, sports_score_weights = ?, sports_terms = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(normalizedEnabled, normalizedThreshold, normalizedAutoHide, serializedWeights, serializedTerms); + } else { + db.prepare(` + INSERT INTO moderation_settings (id, sports_scoring_enabled, sports_score_threshold, sports_auto_hide_enabled, sports_score_weights, sports_terms, updated_at) + VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `).run(normalizedEnabled, normalizedThreshold, normalizedAutoHide, serializedWeights, serializedTerms); + } + + return { + sports_scoring_enabled: !!normalizedEnabled, + sports_score_threshold: normalizedThreshold, + sports_auto_hide_enabled: !!normalizedAutoHide, + sports_score_weights: normalizedWeights, + sports_terms: normalizedTerms + }; +} + function expandPhotoUrlHostVariants(url) { if (typeof url !== 'string' || !url) { return []; @@ -1826,14 +2032,14 @@ function removeSearchSeenEntries(urls) { cleanupExpiredSearchPosts(); -const selectSearchSeenStmt = db.prepare('SELECT url, seen_count, manually_hidden, first_seen_at, last_seen_at FROM search_seen_posts WHERE url = ?'); +const selectSearchSeenStmt = db.prepare('SELECT url, seen_count, manually_hidden, sports_auto_hidden, first_seen_at, last_seen_at FROM search_seen_posts WHERE url = ?'); const insertSearchSeenStmt = db.prepare(` - INSERT INTO search_seen_posts (url, seen_count, manually_hidden, first_seen_at, last_seen_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO search_seen_posts (url, seen_count, manually_hidden, sports_auto_hidden, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `); const updateSearchSeenStmt = db.prepare(` UPDATE search_seen_posts - SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP + SET seen_count = ?, manually_hidden = ?, sports_auto_hidden = ?, last_seen_at = CURRENT_TIMESTAMP WHERE url = ? `); const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); @@ -2158,7 +2364,7 @@ app.get('/api/posts/by-url', (req, res) => { app.post('/api/search-posts', (req, res) => { try { - const { url, candidates, skip_increment, force_hide } = req.body || {}; + const { url, candidates, skip_increment, force_hide, sports_auto_hide } = req.body || {}; const normalizedUrls = collectNormalizedFacebookUrls(url, candidates); if (!normalizedUrls.length) { @@ -2200,6 +2406,8 @@ app.post('/api/search-posts', (req, res) => { const targetUrl = existingUrl || normalizedUrls[0]; const existingManualHidden = existingRow ? !!existingRow.manually_hidden : false; + const existingSportsHidden = existingRow ? !!existingRow.sports_auto_hidden : false; + const sportsHideRequested = !!sports_auto_hide; if (force_hide) { const desiredCount = Math.max(existingRow ? existingRow.seen_count : 0, SEARCH_POST_HIDE_THRESHOLD); @@ -2208,36 +2416,38 @@ app.post('/api/search-posts', (req, res) => { for (const candidate of urlsToUpdate) { const row = selectSearchSeenStmt.get(candidate); const candidateCount = row ? Math.max(row.seen_count, desiredCount) : desiredCount; + const nextSportsHidden = sportsHideRequested || (row ? !!row.sports_auto_hidden : false); if (row) { - updateSearchSeenStmt.run(candidateCount, 1, candidate); + updateSearchSeenStmt.run(candidateCount, 1, nextSportsHidden ? 1 : 0, candidate); } else { - insertSearchSeenStmt.run(candidate, candidateCount, 1); + insertSearchSeenStmt.run(candidate, candidateCount, 1, nextSportsHidden ? 1 : 0); } } - return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true }); + return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true, sports_auto_hidden: sportsHideRequested || existingSportsHidden }); } if (skip_increment) { if (!existingRow) { - return res.json({ seen_count: 0, should_hide: false, manually_hidden: false }); + return res.json({ seen_count: 0, should_hide: false, manually_hidden: false, sports_auto_hidden: false }); } const seenCount = existingRow.seen_count; - const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden; - return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden }); + const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden || existingSportsHidden; + return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden, sports_auto_hidden: existingSportsHidden }); } let seenCount = existingRow ? existingRow.seen_count + 1 : 1; const manualHidden = existingManualHidden; + const sportsHidden = sportsHideRequested || existingSportsHidden; if (existingRow) { - updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, targetUrl); + updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, sportsHidden ? 1 : 0, targetUrl); } else { - insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0); + insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0, sportsHidden ? 1 : 0); } - const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden; - res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden }); + const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden || sportsHidden; + res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden, sports_auto_hidden: sportsHidden }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -3259,6 +3469,31 @@ app.put('/api/ai-settings', (req, res) => { } }); +app.get('/api/moderation-settings', (req, res) => { + try { + const settings = loadModerationSettings(); + res.json(settings); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/moderation-settings', (req, res) => { + try { + const body = req.body || {}; + const saved = persistModerationSettings({ + enabled: !!body.sports_scoring_enabled, + threshold: body.sports_score_threshold, + weights: body.sports_score_weights, + terms: body.sports_terms, + autoHide: !!body.sports_auto_hide_enabled + }); + res.json(saved); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.get('/api/hidden-settings', (req, res) => { try { const settings = loadHiddenSettings(); diff --git a/extension/content.js b/extension/content.js index 3cdb29e..3802b7e 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1,7 +1,7 @@ // Facebook Post Tracker Extension // Uses API_BASE_URL from config.js -const EXTENSION_VERSION = '1.1.0'; +const EXTENSION_VERSION = '1.2.0'; const PROCESSED_ATTR = 'data-fb-tracker-processed'; const PENDING_ATTR = 'data-fb-tracker-pending'; const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]'; @@ -155,6 +155,55 @@ const aiCredentialCache = { timestamp: 0, pending: null }; +const MODERATION_SETTINGS_CACHE_TTL = 5 * 60 * 1000; +const moderationSettingsCache = { + data: null, + timestamp: 0, + pending: null +}; +const SPORTS_SCORING_DEFAULTS = { + threshold: 5, + weights: { + scoreline: 3, + scoreEmoji: 2, + sportEmoji: 2, + sportVerb: 1.5, + sportNoun: 2, + hashtag: 1.5, + teamToken: 2, + competition: 2, + celebration: 1, + location: 1 + } +}; + +const DEFAULT_SPORT_TERMS = { + nouns: [ + 'auswärtssieg', 'heimsieg', 'derbysieg', 'revanche', 'spiel', 'spieltag', 'match', 'derby', 'finale', + 'cup', 'pokal', 'liga', 'bundesliga', 'oberliga', 'kreisliga', 'bezirksliga', 'meisterschaft', + 'turnier', 'halbzeit', 'tabellenplatz', 'tabelle', 'tor', 'tore', 'treffer', 'stadion', 'arena', 'halle', + 'trainerteam', 'mannschaft', 'fans', 'fanblock', 'jugend', 'u17', 'u19', 'u15' + ], + verbs: [ + 'gewinnen', 'siegen', 'geholt', 'erkämpfen', 'erkämpft', 'erkämpfen', 'drehen', 'punkten', + 'trifft', 'treffen', 'schießt', 'schiesst', 'schießen', 'schiessen', 'verteidigen', 'stürmen', 'kämpfen' + ], + competitions: [ + 'bundesliga', 'liga', 'serie a', 'premier league', 'champions league', 'europa league', 'dfb-pokal', + 'cup', 'pokal', 'qualifikation', 'qualirunde', 'halbfinale', 'viertelfinale', 'achtelfinale', 'relegation' + ], + celebrations: [ + 'sieg', 'siege', 'auswärtssieg', 'heimsieg', 'auswärtsspiel', 'punkte', 'punkte geholt', 'man of the match', 'motm', + 'tabellenführung', 'tabellenplatz', 'tabellendritter', 'tabellenzweiter' + ], + locations: [ + 'auswärts', 'heimspiel', 'derby', 'arena', 'stadion', 'halle', 'bolle', 'bölle', 'mosel' + ], + negatives: [ + 'rezept', 'kochen', 'politik', 'wahl', 'bundestag', 'landtag', 'software', 'release', 'update', 'konzert', + 'album', 'tour', 'podcast', 'karriere', 'job', 'stellenangebot', 'bewerbung' + ] +}; console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL); @@ -235,6 +284,100 @@ async function fetchActiveAICredentials(forceRefresh = false) { } } +function normalizeModerationSettings(payload) { + if (!payload || typeof payload !== 'object') { + return { + sports_scoring_enabled: true, + sports_score_threshold: SPORTS_SCORING_DEFAULTS.threshold, + sports_score_weights: SPORTS_SCORING_DEFAULTS.weights, + sports_terms: DEFAULT_SPORT_TERMS, + sports_auto_hide_enabled: false + }; + } + const threshold = (() => { + const parsed = parseFloat(payload.sports_score_threshold); + if (Number.isNaN(parsed) || parsed < 0) { + return SPORTS_SCORING_DEFAULTS.threshold; + } + return Math.min(50, Math.max(0, parsed)); + })(); + + const weightsSource = payload.sports_score_weights && typeof payload.sports_score_weights === 'object' + ? payload.sports_score_weights + : {}; + const normalizedWeights = { ...SPORTS_SCORING_DEFAULTS.weights }; + for (const key of Object.keys(SPORTS_SCORING_DEFAULTS.weights)) { + const raw = weightsSource[key]; + const parsed = typeof raw === 'number' ? raw : parseFloat(raw); + if (Number.isFinite(parsed)) { + normalizedWeights[key] = Math.max(0, Math.min(10, parsed)); + } + } + + const normalizeTerms = (terms) => { + const base = { ...DEFAULT_SPORT_TERMS }; + const src = terms && typeof terms === 'object' ? terms : {}; + const normalizeList = (list, fallback) => { + if (!Array.isArray(list)) return fallback; + const cleaned = list + .map((entry) => typeof entry === 'string' ? entry.trim().toLowerCase() : '') + .filter((entry) => entry); + const unique = Array.from(new Set(cleaned)).slice(0, 200); + return unique.length ? unique : fallback; + }; + return { + nouns: normalizeList(src.nouns, base.nouns), + verbs: normalizeList(src.verbs, base.verbs), + competitions: normalizeList(src.competitions, base.competitions), + celebrations: normalizeList(src.celebrations, base.celebrations), + locations: normalizeList(src.locations, base.locations), + negatives: normalizeList(src.negatives, base.negatives) + }; + }; + + return { + sports_scoring_enabled: payload.sports_scoring_enabled !== false, + sports_score_threshold: threshold, + sports_score_weights: normalizedWeights, + sports_terms: normalizeTerms(payload.sports_terms), + sports_auto_hide_enabled: !!payload.sports_auto_hide_enabled + }; +} + +async function fetchModerationSettings(forceRefresh = false) { + const now = Date.now(); + if (!forceRefresh && moderationSettingsCache.data && (now - moderationSettingsCache.timestamp < MODERATION_SETTINGS_CACHE_TTL)) { + return moderationSettingsCache.data; + } + + if (moderationSettingsCache.pending) { + try { + return await moderationSettingsCache.pending; + } catch (error) { + // fallthrough to retry + } + } + + moderationSettingsCache.pending = (async () => { + const response = await backendFetch(`${API_URL}/moderation-settings`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Moderations-Einstellungen konnten nicht geladen werden'); + } + const data = await response.json(); + const normalized = normalizeModerationSettings(data); + moderationSettingsCache.data = normalized; + moderationSettingsCache.timestamp = Date.now(); + return normalized; + })(); + + try { + return await moderationSettingsCache.pending; + } finally { + moderationSettingsCache.pending = null; + } +} + function formatAICredentialLabel(credential) { if (!credential || typeof credential !== 'object') { return 'Unbekannte AI'; @@ -658,13 +801,14 @@ async function recordSearchResultPost(primaryUrl, allUrlCandidates = [], options return null; } - const { skipIncrement = false, forceHide = false } = options || {}; + const { skipIncrement = false, forceHide = false, sportsAutoHide = false } = options || {}; const payload = { url: primaryUrl, candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [], skip_increment: !!skipIncrement, - force_hide: !!forceHide + force_hide: !!forceHide, + sports_auto_hide: !!sportsAutoHide }; const response = await backendFetch(`${API_URL}/search-posts`, { @@ -1840,15 +1984,22 @@ function extractDeadlineFromPostText(postElement) { // Check if date is valid (e.g., not 31.02.) if (date.getMonth() === month - 1 && date.getDate() === day) { const timeInfo = extractTimeAfterIndex(fullText, pattern.lastIndex); + const hasTime = Boolean(timeInfo); if (timeInfo) { date.setHours(timeInfo.hour, timeInfo.minute, 0, 0); } else if (hasInclusiveKeywordNear(fullText, matchIndex)) { date.setHours(23, 59, 0, 0); } + const hasInclusiveTime = hasInclusiveKeywordNear(fullText, matchIndex); + const recordHasTime = hasTime || hasInclusiveTime; + if (hasInclusiveTime && !hasTime) { + date.setHours(23, 59, 0, 0); + } + // Only add if date is in the future if (date > today) { - foundDates.push(date); + foundDates.push({ date, hasTime: recordHasTime }); } } } @@ -1856,13 +2007,19 @@ function extractDeadlineFromPostText(postElement) { } // Pattern for "12. Oktober" or "12 Oktober" - const monthPattern = /\b(\d{1,2})\.?\s*(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\b/gi; + const monthPattern = /\b(\d{1,2})\.?\s*(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\s*(\d{2,4})?\b/gi; let monthMatch; while ((monthMatch = monthPattern.exec(fullText)) !== null) { const day = parseInt(monthMatch[1], 10); const monthStr = monthMatch[2].toLowerCase(); const month = monthNames[monthStr]; - const year = today.getFullYear(); + let year = today.getFullYear(); + if (monthMatch[3]) { + year = parseInt(monthMatch[3], 10); + if (year < 100) { + year += 2000; + } + } const matchIndex = monthMatch.index; if (month && day >= 1 && day <= 31) { @@ -1871,30 +2028,246 @@ function extractDeadlineFromPostText(postElement) { // Check if date is valid if (date.getMonth() === month - 1 && date.getDate() === day) { const timeInfo = extractTimeAfterIndex(fullText, monthPattern.lastIndex); + const hasTime = Boolean(timeInfo); if (timeInfo) { date.setHours(timeInfo.hour, timeInfo.minute, 0, 0); } else if (hasInclusiveKeywordNear(fullText, matchIndex)) { date.setHours(23, 59, 0, 0); } + const hasInclusiveTime = hasInclusiveKeywordNear(fullText, matchIndex); + const recordHasTime = hasTime || hasInclusiveTime; + if (hasInclusiveTime && !hasTime) { + date.setHours(23, 59, 0, 0); + } + // If date has passed this year, assume next year if (date <= today) { date.setFullYear(year + 1); } - foundDates.push(date); + foundDates.push({ date, hasTime: recordHasTime }); } } } // Return the earliest future date if (foundDates.length > 0) { - foundDates.sort((a, b) => a - b); - return toDateTimeLocalString(foundDates[0]); + foundDates.sort((a, b) => { + const diff = a.date - b.date; + if (diff !== 0) { + return diff; + } + if (a.hasTime && !b.hasTime) return -1; + if (!a.hasTime && b.hasTime) return 1; + return 0; + }); + return toDateTimeLocalString(foundDates[0].date); } return null; } +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function collectKeywordMatches(keywords, text, limit = 20) { + if (!Array.isArray(keywords) || !keywords.length || !text) { + return []; + } + const found = []; + for (const keyword of keywords) { + if (!keyword) continue; + const pattern = new RegExp(`\\b${escapeRegex(keyword)}\\b`, 'gi'); + const matches = text.match(pattern); + if (matches && matches.length) { + found.push(keyword); + if (found.length >= limit) { + break; + } + } + } + return Array.from(new Set(found)); +} + +function collectRegexMatches(regex, text, limit = 20) { + if (!regex || !(regex instanceof RegExp) || !text) { + return []; + } + const matches = Array.from(text.matchAll(regex)).map((m) => m[0]); + if (!matches.length) { + return []; + } + return Array.from(new Set(matches)).slice(0, limit); +} + +function filterScorelines(candidates = []) { + const filtered = []; + for (const raw of candidates) { + const parts = raw.split(':').map((part) => part.trim()); + if (parts.length !== 2) { + continue; + } + const [a, b] = parts.map((p) => parseInt(p, 10)); + if (Number.isNaN(a) || Number.isNaN(b)) { + continue; + } + if (a < 0 || b < 0) { + continue; + } + if (a > 15 || b > 15) { + continue; + } + filtered.push(`${a}:${b}`); + } + return filtered; +} + +function evaluateSportsScore(text, moderationSettings = null) { + if (!text || typeof text !== 'string') { + return null; + } + + const normalizedText = text.toLowerCase(); + const weights = { + ...SPORTS_SCORING_DEFAULTS.weights, + ...(moderationSettings && moderationSettings.sports_score_weights ? moderationSettings.sports_score_weights : {}) + }; + const threshold = moderationSettings && typeof moderationSettings.sports_score_threshold === 'number' + ? moderationSettings.sports_score_threshold + : SPORTS_SCORING_DEFAULTS.threshold; + const terms = (() => { + const base = DEFAULT_SPORT_TERMS; + const incoming = moderationSettings && moderationSettings.sports_terms ? moderationSettings.sports_terms : null; + const normalizeList = (list, fallback) => { + if (!Array.isArray(list)) return fallback; + const cleaned = list + .map((entry) => typeof entry === 'string' ? entry.trim().toLowerCase() : '') + .filter((entry) => entry); + const unique = Array.from(new Set(cleaned)).slice(0, 200); + return unique.length ? unique : fallback; + }; + const src = incoming && typeof incoming === 'object' ? incoming : {}; + return { + nouns: normalizeList(src.nouns, base.nouns), + verbs: normalizeList(src.verbs, base.verbs), + competitions: normalizeList(src.competitions, base.competitions), + celebrations: normalizeList(src.celebrations, base.celebrations), + locations: normalizeList(src.locations, base.locations), + negatives: normalizeList(src.negatives, base.negatives) + }; + })(); + + const matchesCount = (regex) => { + if (!regex || !(regex instanceof RegExp)) { + return 0; + } + const matches = normalizedText.match(regex); + return matches ? matches.length : 0; + }; + + const applyWeight = (count, weight, label, matches = []) => { + if (!count || !weight) { + return 0; + } + const effective = Math.min(count, 5); + const gain = effective * weight; + if (matches && matches.length) { + hitDetails.push(`${label}: ${matches.slice(0, 10).join(', ')}`); + } else { + hitDetails.push(`${label} x${effective} (+${gain.toFixed(1)})`); + } + score += gain; + return gain; + }; + + const hitDetails = []; + let score = 0; + + const scorelineMatchesRaw = collectRegexMatches(/\b\d{1,2}\s*:\s*\d{1,2}\b/g, normalizedText); + const scorelineMatches = filterScorelines(scorelineMatchesRaw); + applyWeight(scorelineMatches.length, weights.scoreline, 'Ergebnis', scorelineMatches); + + const scoreEmojiMatches = collectRegexMatches(/[0-9]️⃣/g, normalizedText) + .concat(collectRegexMatches(/\+\s*\d\b/g, normalizedText)); + applyWeight(scoreEmojiMatches.length, weights.scoreEmoji, 'Punkte', scoreEmojiMatches); + + const sportEmojiMatches = collectRegexMatches(/[⚽🏐🏀🏈🎾🏉🥅🏒🏑🏓🏸🤾🏏🎽🎳🥊🥋⛳]/g, normalizedText); + applyWeight(sportEmojiMatches.length, weights.sportEmoji, 'Sport-Emoji', sportEmojiMatches); + + const verbMatches = collectKeywordMatches(terms.verbs, normalizedText); + applyWeight(verbMatches.length, weights.sportVerb, 'Sport-Verben', verbMatches); + + const nounMatches = collectKeywordMatches(terms.nouns, normalizedText); + applyWeight(nounMatches.length, weights.sportNoun, 'Sport-Vokabeln', nounMatches); + + const hashtagMatches = collectRegexMatches(/#(?:auswärtssieg|heimsieg|derbysieg|bundesliga|liga|pokal|cup|fc[a-z0-9]+|sv[a-z0-9]+|tsv[a-z0-9]+|sg[a-z0-9]+)/g, normalizedText); + applyWeight(hashtagMatches.length, weights.hashtag, 'Sport-Hashtags', hashtagMatches); + + const teamMatches = collectRegexMatches(/\b(?:fc|sv|tsv|ssv|bvb|sge|fcb|hsv|vfb|fsv|sg|scl|djk)[\s\-]?[a-zäöüß0-9]+/gi, normalizedText); + applyWeight(teamMatches.length, weights.teamToken, 'Team-Kürzel', teamMatches); + + const competitionMatches = collectKeywordMatches(terms.competitions, normalizedText); + applyWeight(competitionMatches.length, weights.competition, 'Liga/Turnier', competitionMatches); + + const celebrationMatches = collectKeywordMatches(terms.celebrations, normalizedText); + applyWeight(celebrationMatches.length, weights.celebration, 'Ergebnisbezug', celebrationMatches); + + const locationMatches = collectKeywordMatches(terms.locations, normalizedText); + applyWeight(locationMatches.length, weights.location, 'Spielort', locationMatches); + + const nonSportMatches = collectKeywordMatches(terms.negatives, normalizedText); + const nonSportHits = nonSportMatches.length; + if (nonSportHits) { + const penalty = Math.min(2, nonSportHits) * 1; + score -= penalty; + hitDetails.push(`Gegenindizien: ${nonSportMatches.slice(0, 10).join(', ')}`); + } + + const finalScore = Math.round(score * 10) / 10; + return { + score: finalScore, + threshold, + wouldHide: finalScore >= threshold, + hits: hitDetails + }; +} + +function buildSportsScoreBadge(scoreInfo) { + if (!scoreInfo) { + return null; + } + if (typeof scoreInfo.score !== 'number' || scoreInfo.score >= 0) { + return null; + } + const badge = document.createElement('span'); + const wouldHide = !!scoreInfo.wouldHide; + const bg = wouldHide ? 'rgba(245, 158, 11, 0.18)' : 'rgba(59, 130, 246, 0.12)'; + const border = wouldHide ? 'rgba(245, 158, 11, 0.5)' : 'rgba(59, 130, 246, 0.35)'; + const color = wouldHide ? '#b45309' : '#1d4ed8'; + badge.className = 'fb-tracker-score-badge'; + badge.textContent = `Sport-Score ${scoreInfo.score.toFixed(1)} / ${scoreInfo.threshold}`; + if (scoreInfo.hits && scoreInfo.hits.length) { + const lines = scoreInfo.hits.map((hit) => `• ${hit}`).join('\n'); + badge.title = `${lines}\n${wouldHide ? '≥ Schwellwert' : '< Schwellwert'}`; + } else { + badge.title = wouldHide ? 'Über Schwellwert' : 'Unter Schwellwert'; + } + badge.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: ${bg}; + border: 1px solid ${border}; + border-radius: 999px; + font-weight: 600; + color: ${color}; + font-size: 12px; + `; + return badge; +} + function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return ''; @@ -2456,8 +2829,50 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = console.log('[FB Tracker] Post #' + postNum + ' - UI created for new post'); - // Add AI button for new posts - await addAICommentButton(container, postElement); + // Add AI button for new posts + await addAICommentButton(container, postElement); + } + + let sportsScoreInfo = null; + try { + const moderationSettings = await fetchModerationSettings(); + if (moderationSettings && moderationSettings.sports_scoring_enabled !== false) { + const postTextForScore = extractPostText(postElement); + if (postTextForScore) { + sportsScoreInfo = evaluateSportsScore(postTextForScore, moderationSettings); + } + + if ( + moderationSettings.sports_auto_hide_enabled + && sportsScoreInfo + && sportsScoreInfo.wouldHide + && !isTracked + && !likedByCurrentUser + ) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would auto-hide by sports score but skipping in dialog context'); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Auto-hidden by sports score', sportsScoreInfo); + try { + await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, { forceHide: true, sportsAutoHide: true }); + } catch (error) { + console.debug('[FB Tracker] Auto-hide scoring could not persist hide state:', error); + } + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number' + ? searchTrackingInfo.seen_count + : null + }); + return; + } + } + } + } catch (error) { + console.debug('[FB Tracker] Sport-Scoring nicht verfügbar:', error); } if (isSearchResult) { @@ -2596,6 +3011,15 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = resetHover(); container.insertBefore(info, container.firstChild); + const sportsScoreBadge = buildSportsScoreBadge(sportsScoreInfo); + if (sportsScoreBadge) { + container.insertBefore(sportsScoreBadge, info.nextSibling); + } + } else { + const sportsScoreBadge = buildSportsScoreBadge(sportsScoreInfo); + if (sportsScoreBadge) { + container.insertBefore(sportsScoreBadge, container.firstChild); + } } // Insert UI - try multiple strategies to find stable insertion point @@ -3900,6 +4324,18 @@ async function setCommentText(inputElement, text, options = {}) { } } +function sanitizeAIComment(comment) { + if (!comment || typeof comment !== 'string') { + return ''; + } + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = comment; + const sanitized = tempDiv.textContent || tempDiv.innerText || ''; + + return sanitized.trim(); +} + /** * Generate AI comment for a post */ @@ -3928,7 +4364,13 @@ async function generateAIComment(postText, profileNumber, options = {}) { } const data = await response.json(); - return data.comment; + const sanitizedComment = sanitizeAIComment(data.comment); + + if (!sanitizedComment) { + throw new Error('AI-Antwort enthält keinen gültigen Text'); + } + + return sanitizedComment; } catch (error) { console.error('[FB Tracker] AI comment generation failed:', error); diff --git a/extension/manifest.json b/extension/manifest.json index 9f3353c..56d0c70 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Facebook Post Tracker", - "version": "1.1.0", + "version": "1.2.0", "description": "Track Facebook posts across multiple profiles", "permissions": [ "storage", diff --git a/fb_buttonbar.txt b/fb_buttonbar.txt deleted file mode 100644 index 1f296f0..0000000 --- a/fb_buttonbar.txt +++ /dev/null @@ -1 +0,0 @@ -
+ Analysiert Beitragstexte nach Sport-Begriffen (z.B. Fußball, Volleyball) und weist einen Score zu. + Beiträge oberhalb des Schwellwerts würden später automatisch ausgeblendet – aktuell wird nur markiert. +
+ + +