From b6f8572aaea12f3ce0dd954e40138656fe18b562 Mon Sep 17 00:00:00 2001 From: Meik Date: Tue, 7 Apr 2026 16:54:24 +0200 Subject: [PATCH] Exempt repeat AI comments on same post --- backend/server.js | 111 +++++++++++++++++++++++++++++++++++----- extension/content.js | 74 +++++++++++++++++++++------ extension/manifest.json | 2 +- 3 files changed, 155 insertions(+), 32 deletions(-) diff --git a/backend/server.js b/backend/server.js index 216f093..4954129 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1171,11 +1171,36 @@ function getAIAutoCommentOldestEventSince(profileNumber, sinceIso) { `).get(normalizedProfileNumber, sinceIso) || null; } -function buildAIAutoCommentRateLimitStatus(profileNumber, settings = getAIAutoCommentRateLimitSettings(), now = new Date()) { +function sanitizeAIAutoCommentPostKey(value) { + if (typeof value !== 'string') { + return null; + } + const trimmed = truncateString(value.trim(), 1000); + return trimmed || null; +} + +function hasAIAutoCommentEventForPost(profileNumber, postKey) { + const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + const normalizedPostKey = sanitizeAIAutoCommentPostKey(postKey); + if (!normalizedProfileNumber || !normalizedPostKey) { + return false; + } + const row = db.prepare(` + SELECT 1 + FROM ai_auto_comment_rate_limit_events + WHERE profile_number = ? + AND post_key = ? + LIMIT 1 + `).get(normalizedProfileNumber, normalizedPostKey); + return Boolean(row); +} + +function buildAIAutoCommentRateLimitStatus(profileNumber, settings = getAIAutoCommentRateLimitSettings(), now = new Date(), options = {}) { const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (!normalizedProfileNumber) { return null; } + const normalizedPostKey = sanitizeAIAutoCommentPostKey(options.postKey); clearExpiredAIAutoCommentProfileCooldown(normalizedProfileNumber, now); @@ -1192,12 +1217,13 @@ function buildAIAutoCommentRateLimitStatus(profileNumber, settings = getAIAutoCo const lastEvent = getAIAutoCommentLatestEvent(normalizedProfileNumber); const profileState = getAIAutoCommentProfileState(normalizedProfileNumber); const activeHours = getAIAutoCommentActiveHoursState(settings, now); + const samePostExempt = Boolean(normalizedPostKey && hasAIAutoCommentEventForPost(normalizedProfileNumber, normalizedPostKey)); let blocked = false; let blockedReason = null; let blockedUntil = null; - if (settings.enabled) { + if (settings.enabled && !samePostExempt) { const blockingCandidates = []; const addBlockingCandidate = (reason, untilIso) => { if (!untilIso) { @@ -1282,6 +1308,7 @@ function buildAIAutoCommentRateLimitStatus(profileNumber, settings = getAIAutoCo blocked, blocked_reason: blockedReason, blocked_until: blockedUntil, + same_post_exempt: samePostExempt, cooldown_until: profileState?.cooldown_until || null, cooldown_reason: profileState?.cooldown_reason || null, usage: { @@ -1313,15 +1340,18 @@ function listAIAutoCommentRateLimitStatuses(settings = getAIAutoCommentRateLimit )); } -function checkAIAutoCommentActionAvailability(profileNumber, settings = getAIAutoCommentRateLimitSettings()) { +function checkAIAutoCommentActionAvailability(profileNumber, settings = getAIAutoCommentRateLimitSettings(), postKey = null) { const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (!normalizedProfileNumber) { return { ok: false, status: null }; } + const normalizedPostKey = sanitizeAIAutoCommentPostKey(postKey); const check = db.transaction(() => { purgeOldAIAutoCommentRateLimitEvents(); - const currentStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, new Date()); + const currentStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, new Date(), { + postKey: normalizedPostKey + }); if (!currentStatus || currentStatus.blocked) { return { ok: false, @@ -1345,11 +1375,12 @@ function checkAIAutoCommentActionAvailability(profileNumber, settings = getAIAut return check(); } -function recordAIAutoCommentAction(profileNumber, settings = getAIAutoCommentRateLimitSettings(), occurredAt = new Date()) { +function recordAIAutoCommentAction(profileNumber, settings = getAIAutoCommentRateLimitSettings(), occurredAt = new Date(), postKey = null) { const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); if (!normalizedProfileNumber) { return null; } + const normalizedPostKey = sanitizeAIAutoCommentPostKey(postKey); const eventDate = occurredAt instanceof Date && !Number.isNaN(occurredAt.getTime()) ? occurredAt @@ -1357,12 +1388,19 @@ function recordAIAutoCommentAction(profileNumber, settings = getAIAutoCommentRat const record = db.transaction(() => { purgeOldAIAutoCommentRateLimitEvents(eventDate); + if (normalizedPostKey && hasAIAutoCommentEventForPost(normalizedProfileNumber, normalizedPostKey)) { + return buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, eventDate, { + postKey: normalizedPostKey + }); + } db.prepare(` - INSERT INTO ai_auto_comment_rate_limit_events (profile_number, created_at) - VALUES (?, ?) - `).run(normalizedProfileNumber, eventDate.toISOString()); + INSERT INTO ai_auto_comment_rate_limit_events (profile_number, post_key, created_at) + VALUES (?, ?, ?) + `).run(normalizedProfileNumber, normalizedPostKey, eventDate.toISOString()); - return buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, eventDate); + return buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, settings, eventDate, { + postKey: normalizedPostKey + }); }); return record(); @@ -2159,6 +2197,7 @@ db.exec(` CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_number INTEGER NOT NULL, + post_key TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); @@ -2655,8 +2694,13 @@ ensureColumn('ai_auto_comment_rate_limit_settings', 'burst_limit', 'burst_limit ensureColumn('ai_auto_comment_rate_limit_settings', 'cooldown_minutes', 'cooldown_minutes INTEGER NOT NULL DEFAULT 15'); ensureColumn('ai_auto_comment_rate_limit_settings', 'active_hours_start', 'active_hours_start TEXT'); ensureColumn('ai_auto_comment_rate_limit_settings', 'active_hours_end', 'active_hours_end TEXT'); +ensureColumn('ai_auto_comment_rate_limit_events', 'post_key', 'post_key TEXT'); ensureColumn('ai_auto_comment_rate_limit_profile_state', 'cooldown_until', 'cooldown_until DATETIME'); ensureColumn('ai_auto_comment_rate_limit_profile_state', 'cooldown_reason', 'cooldown_reason TEXT'); +db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_auto_comment_rate_limit_events_profile_post + ON ai_auto_comment_rate_limit_events(profile_number, post_key); +`); ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); @@ -7294,6 +7338,26 @@ app.get('/api/ai-settings', (req, res) => { } }); +app.get('/api/ai/auto-comment-rate-limit-status', (req, res) => { + try { + const profileNumber = sanitizeProfileNumber(req.query.profileNumber); + if (!profileNumber) { + return res.status(400).json({ error: 'profileNumber is required' }); + } + + const status = buildAIAutoCommentRateLimitStatus( + profileNumber, + getAIAutoCommentRateLimitSettings(), + new Date(), + { postKey: req.query.postKey } + ); + + res.json({ status }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.put('/api/ai-settings', (req, res) => { try { const { active_credential_id, prompt_prefix, enabled, rate_limit_settings } = req.body; @@ -7933,10 +7997,12 @@ app.post('/api/ai/generate-comment', async (req, res) => { }; let autoCommentRateLimitStatus = null; + let autoCommentRateLimitGlobalStatus = null; try { - const { postText, profileNumber, preferredCredentialId } = requestBody; + const { postText, profileNumber, preferredCredentialId, postKey } = requestBody; const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); + const normalizedPostKey = sanitizeAIAutoCommentPostKey(postKey); if (!postText) { return respondWithTrackedError(400, 'postText is required'); @@ -7975,8 +8041,16 @@ app.post('/api/ai/generate-comment', async (req, res) => { } const limitCheckStartedMs = timingStart(); - const availability = checkAIAutoCommentActionAvailability(normalizedProfileNumber, autoCommentRateLimitSettings); + const availability = checkAIAutoCommentActionAvailability( + normalizedProfileNumber, + autoCommentRateLimitSettings, + normalizedPostKey + ); autoCommentRateLimitStatus = availability.status || null; + autoCommentRateLimitGlobalStatus = buildAIAutoCommentRateLimitStatus( + normalizedProfileNumber, + autoCommentRateLimitSettings + ); if (!availability.ok && autoCommentRateLimitStatus && autoCommentRateLimitStatus.blocked) { timingEnd('profileLimitCheckMs', limitCheckStartedMs); const blockedUntilText = autoCommentRateLimitStatus.blocked_until @@ -8079,7 +8153,12 @@ app.post('/api/ai/generate-comment', async (req, res) => { autoCommentRateLimitStatus = recordAIAutoCommentAction( normalizedProfileNumber, autoCommentRateLimitSettings, - new Date() + new Date(), + normalizedPostKey + ); + autoCommentRateLimitGlobalStatus = buildAIAutoCommentRateLimitStatus( + normalizedProfileNumber, + autoCommentRateLimitSettings ); } @@ -8097,7 +8176,8 @@ app.post('/api/ai/generate-comment', async (req, res) => { usedCredential: credential.name, usedCredentialId: credential.id, attempts: attemptDetails, - autoCommentRateLimit: autoCommentRateLimitStatus + autoCommentRateLimit: autoCommentRateLimitStatus, + autoCommentRateLimitGlobal: autoCommentRateLimitGlobalStatus }, totalDurationMs: backendTimings.totalMs }); @@ -8112,6 +8192,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { attempts: attemptDetails, rateLimitInfo: rateInfo || null, autoCommentRateLimitStatus, + autoCommentRateLimitGlobalStatus, traceId, flowId, timings: { @@ -8129,6 +8210,7 @@ app.post('/api/ai/generate-comment', async (req, res) => { if (cooldownDecision) { setAIAutoCommentProfileCooldown(normalizedProfileNumber, cooldownDecision); autoCommentRateLimitStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, autoCommentRateLimitSettings); + autoCommentRateLimitGlobalStatus = buildAIAutoCommentRateLimitStatus(normalizedProfileNumber, autoCommentRateLimitSettings); } } credentialTimingDetails.push({ @@ -8170,7 +8252,8 @@ app.post('/api/ai/generate-comment', async (req, res) => { attempts: error && error.attempts ? error.attempts : null, responseMeta: { attempts: error && error.attempts ? error.attempts : null, - autoCommentRateLimit: autoCommentRateLimitStatus + autoCommentRateLimit: autoCommentRateLimitStatus, + autoCommentRateLimitGlobal: autoCommentRateLimitGlobalStatus } } ); diff --git a/extension/content.js b/extension/content.js index caed12f..6e268bf 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.2.2'; +const EXTENSION_VERSION = '1.2.3'; 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"]'; @@ -6509,7 +6509,8 @@ async function generateAIComment(postText, profileNumber, options = {}) { maxAttempts = 3, flowId = null, source = 'extension-ai-button', - returnMeta = false + returnMeta = false, + postKey = null } = options; const normalizedFlowId = typeof flowId === 'string' && flowId.trim() ? flowId.trim() @@ -6524,6 +6525,9 @@ async function generateAIComment(postText, profileNumber, options = {}) { if (typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)) { basePayload.preferredCredentialId = preferredCredentialId; } + if (typeof postKey === 'string' && postKey.trim()) { + basePayload.postKey = postKey.trim(); + } const requestAttempts = []; let lastError = null; @@ -6605,7 +6609,8 @@ async function generateAIComment(postText, profileNumber, options = {}) { flowId: effectiveFlowId, requestAttempts, backendTimings: data.timings && data.timings.backend ? data.timings.backend : null, - autoCommentRateLimitStatus: data.autoCommentRateLimitStatus || null + autoCommentRateLimitStatus: data.autoCommentRateLimitStatus || null, + autoCommentRateLimitGlobalStatus: data.autoCommentRateLimitGlobalStatus || null }; return returnMeta ? result : sanitizedComment; } @@ -6664,6 +6669,27 @@ async function generateAIComment(postText, profileNumber, options = {}) { throw finalError; } +async function fetchAIAutoCommentRateLimitStatus(profileNumber, options = {}) { + const normalizedProfile = parseInt(profileNumber, 10); + if (!normalizedProfile) { + return null; + } + + const params = new URLSearchParams(); + params.set('profileNumber', String(normalizedProfile)); + if (typeof options.postKey === 'string' && options.postKey.trim()) { + params.set('postKey', options.postKey.trim()); + } + + const response = await backendFetch(`${API_URL}/ai/auto-comment-rate-limit-status?${params.toString()}`); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch AI rate limit status'); + } + + return data && data.status ? data.status : null; +} + async function handleSelectionAIRequest(selectionText, sendResponse) { try { const normalizedSelection = normalizeSelectionText(selectionText); @@ -6869,6 +6895,20 @@ async function addAICommentButton(container, postElement) { : 'Generiere automatisch einen passenden Kommentar'; }; + const getCurrentPostKey = () => { + const raw = encodedPostUrl || (container && container.getAttribute('data-post-url')); + if (!raw) { + return null; + } + try { + const decoded = decodeURIComponent(raw); + return normalizeFacebookPostUrl(decoded) || decoded; + } catch (error) { + console.warn('[FB Tracker] Konnte Post-Key nicht lesen:', error); + return null; + } + }; + const buildBlockedButtonTitle = (status) => { if (!status || !status.blocked) { return getDefaultButtonTitle(); @@ -7024,8 +7064,10 @@ async function addAICommentButton(container, postElement) { applyAvailabilityState(null); return null; } - const settings = await fetchAISettings(forceRefresh); - const status = getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber); + const status = await fetchAIAutoCommentRateLimitStatus(profileNumber, { + forceRefresh, + postKey: getCurrentPostKey() + }); applyAvailabilityState(status); return status; } catch (error) { @@ -7040,7 +7082,7 @@ async function addAICommentButton(container, postElement) { return await rateLimitRefreshPromise; }; - const handleSharedAISettingsUpdate = async (settings) => { + const handleSharedAISettingsUpdate = async () => { if (!wrapper.isConnected) { clearBlockedCountdown(); aiAvailabilitySubscribers.delete(handleSharedAISettingsUpdate); @@ -7048,13 +7090,7 @@ async function addAICommentButton(container, postElement) { } try { - const profileNumber = await fetchBackendProfileNumber(); - if (!profileNumber) { - applyAvailabilityState(null); - return; - } - const status = getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber); - applyAvailabilityState(status); + await refreshAvailabilityState(true); } catch (error) { console.warn('[FB Tracker] Failed to apply shared AI settings update:', error); } @@ -7841,7 +7877,8 @@ async function addAICommentButton(container, postElement) { preferredCredentialId, flowId: flowTrace.flowId, source: flowTrace.source, - returnMeta: true + returnMeta: true, + postKey: getCurrentPostKey() }); endPhase('aiRequestMs', { traceId: aiResult.traceId || null, @@ -7849,9 +7886,12 @@ async function addAICommentButton(container, postElement) { }); mergeTraceInfo(aiResult); if (aiResult.autoCommentRateLimitStatus) { - const nextSettings = upsertAIAutoCommentRateLimitStatus(aiSettingsCache.data, aiResult.autoCommentRateLimitStatus); - applyAISettingsSnapshot(nextSettings, { timestamp: Date.now(), broadcast: true }); applyAvailabilityState(aiResult.autoCommentRateLimitStatus); + const globalRateLimitStatus = aiResult.autoCommentRateLimitGlobalStatus || null; + if (globalRateLimitStatus) { + const nextSettings = upsertAIAutoCommentRateLimitStatus(aiSettingsCache.data, globalRateLimitStatus); + applyAISettingsSnapshot(nextSettings, { timestamp: Date.now(), broadcast: true }); + } } else { void refreshAvailabilityState(true, profileNumber); } @@ -7969,7 +8009,7 @@ async function addAICommentButton(container, postElement) { if (error && error.status === 429) { const syncedStatus = await refreshAvailabilityState(true); - if (syncedStatus) { + if (syncedStatus && !syncedStatus.same_post_exempt) { const nextSettings = upsertAIAutoCommentRateLimitStatus(aiSettingsCache.data, syncedStatus); applyAISettingsSnapshot(nextSettings, { timestamp: Date.now(), broadcast: true }); } diff --git a/extension/manifest.json b/extension/manifest.json index 3f79e45..4b292d6 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Facebook Post Tracker", - "version": "1.2.2", + "version": "1.2.3", "description": "Track Facebook posts across multiple profiles", "permissions": [ "storage",