weiter
This commit is contained in:
@@ -4,6 +4,7 @@ const Database = require('better-sqlite3');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -21,6 +22,8 @@ const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||
const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid'];
|
||||
const SEARCH_POST_HIDE_THRESHOLD = 2;
|
||||
const SEARCH_POST_RETENTION_DAYS = 90;
|
||||
const MAX_POST_TEXT_LENGTH = 4000;
|
||||
const MIN_TEXT_HASH_LENGTH = 120;
|
||||
|
||||
const screenshotDir = path.join(__dirname, 'data', 'screenshots');
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
@@ -65,6 +68,53 @@ const dbPath = path.join(__dirname, 'data', 'tracker.db');
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const info = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!info.some((row) => row.name === column)) {
|
||||
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run();
|
||||
}
|
||||
}
|
||||
|
||||
ensureColumn('posts', 'post_text', 'post_text TEXT');
|
||||
ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT');
|
||||
ensureColumn('posts', 'content_key', 'content_key TEXT');
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_content_key
|
||||
ON posts(content_key)
|
||||
`);
|
||||
|
||||
const updateContentKeyStmt = db.prepare('UPDATE posts SET content_key = ? WHERE id = ?');
|
||||
const updatePostTextColumnsStmt = db.prepare('UPDATE posts SET post_text = ?, post_text_hash = ? WHERE id = ?');
|
||||
|
||||
const postsMissingKey = db.prepare(`
|
||||
SELECT id, url
|
||||
FROM posts
|
||||
WHERE content_key IS NULL OR content_key = ''
|
||||
`).all();
|
||||
|
||||
for (const entry of postsMissingKey) {
|
||||
const normalizedUrl = normalizeFacebookPostUrl(entry.url);
|
||||
const key = extractFacebookContentKey(normalizedUrl);
|
||||
if (key) {
|
||||
updateContentKeyStmt.run(key, entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
const postsMissingHash = db.prepare(`
|
||||
SELECT id, post_text
|
||||
FROM posts
|
||||
WHERE post_text IS NOT NULL
|
||||
AND TRIM(post_text) <> ''
|
||||
AND (post_text_hash IS NULL OR post_text_hash = '')
|
||||
`).all();
|
||||
|
||||
for (const entry of postsMissingHash) {
|
||||
const normalizedText = normalizePostText(entry.post_text);
|
||||
const hash = computePostTextHash(normalizedText);
|
||||
updatePostTextColumnsStmt.run(normalizedText, hash, entry.id);
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
if (!header || typeof header !== 'string') {
|
||||
return {};
|
||||
@@ -242,6 +292,30 @@ function normalizeCreatorName(value) {
|
||||
return trimmed.slice(0, 160);
|
||||
}
|
||||
|
||||
function normalizePostText(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text = value.replace(/\s+/g, ' ').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.length > MAX_POST_TEXT_LENGTH) {
|
||||
text = text.slice(0, MAX_POST_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function computePostTextHash(text) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function normalizeFacebookPostUrl(rawValue) {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return null;
|
||||
@@ -274,6 +348,10 @@ function normalizeFacebookPostUrl(rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parsed.hostname = 'www.facebook.com';
|
||||
parsed.protocol = 'https:';
|
||||
parsed.port = '';
|
||||
|
||||
const cleanedParams = new URLSearchParams();
|
||||
parsed.searchParams.forEach((paramValue, paramKey) => {
|
||||
const lowerKey = paramKey.toLowerCase();
|
||||
@@ -307,6 +385,88 @@ function normalizeFacebookPostUrl(rawValue) {
|
||||
return formatted.replace(/[?&]$/, '');
|
||||
}
|
||||
|
||||
function extractFacebookContentKey(normalizedUrl) {
|
||||
if (!normalizedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedUrl);
|
||||
const pathnameRaw = parsed.pathname || '/';
|
||||
const pathname = pathnameRaw.replace(/\/+$/, '') || '/';
|
||||
const lowerPath = pathname.toLowerCase();
|
||||
const params = parsed.searchParams;
|
||||
|
||||
const reelMatch = lowerPath.match(/^\/reel\/([^/]+)/);
|
||||
if (reelMatch) {
|
||||
return `reel:${reelMatch[1]}`;
|
||||
}
|
||||
|
||||
const watchId = params.get('v') || params.get('video_id');
|
||||
if ((lowerPath === '/watch' || lowerPath === '/watch/') && watchId) {
|
||||
return `video:${watchId}`;
|
||||
}
|
||||
if (lowerPath === '/video.php' && watchId) {
|
||||
return `video:${watchId}`;
|
||||
}
|
||||
|
||||
const photoId = params.get('fbid');
|
||||
if ((lowerPath === '/photo.php' || lowerPath === '/photo') && photoId) {
|
||||
return `photo:${photoId}`;
|
||||
}
|
||||
|
||||
const storyFbid = params.get('story_fbid');
|
||||
if (storyFbid) {
|
||||
const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || '';
|
||||
return `story:${ownerId}:${storyFbid}`;
|
||||
}
|
||||
|
||||
const groupPostMatch = lowerPath.match(/^\/groups\/([^/]+)\/posts\/([^/]+)/);
|
||||
if (groupPostMatch) {
|
||||
return `group-post:${groupPostMatch[1]}:${groupPostMatch[2]}`;
|
||||
}
|
||||
|
||||
const groupPermalinkMatch = lowerPath.match(/^\/groups\/([^/]+)\/permalink\/([^/]+)/);
|
||||
if (groupPermalinkMatch) {
|
||||
return `group-post:${groupPermalinkMatch[1]}:${groupPermalinkMatch[2]}`;
|
||||
}
|
||||
|
||||
const pagePostMatch = lowerPath.match(/^\/([^/]+)\/posts\/([^/]+)/);
|
||||
if (pagePostMatch) {
|
||||
return `profile-post:${pagePostMatch[1]}:${pagePostMatch[2]}`;
|
||||
}
|
||||
|
||||
const pageVideoMatch = lowerPath.match(/^\/([^/]+)\/videos\/([^/]+)/);
|
||||
if (pageVideoMatch) {
|
||||
return `video:${pageVideoMatch[2]}`;
|
||||
}
|
||||
|
||||
const pagePhotoMatch = lowerPath.match(/^\/([^/]+)\/photos\/[^/]+\/([^/]+)/);
|
||||
if (pagePhotoMatch) {
|
||||
return `photo:${pagePhotoMatch[2]}`;
|
||||
}
|
||||
|
||||
if (lowerPath === '/' && storyFbid) {
|
||||
const ownerId = params.get('id') || '';
|
||||
return `story:${ownerId}:${storyFbid}`;
|
||||
}
|
||||
|
||||
if ((lowerPath === '/permalink.php' || lowerPath === '/story.php') && storyFbid) {
|
||||
const ownerId = params.get('id') || '';
|
||||
return `story:${ownerId}:${storyFbid}`;
|
||||
}
|
||||
|
||||
const sortedParams = Array.from(params.entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.sort()
|
||||
.join('&');
|
||||
|
||||
return `generic:${lowerPath}?${sortedParams}`;
|
||||
} catch (error) {
|
||||
return `generic:${normalizedUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredProfiles(targetCount) {
|
||||
const count = clampTargetCount(targetCount);
|
||||
return Array.from({ length: count }, (_, index) => index + 1);
|
||||
@@ -393,6 +553,12 @@ db.exec(`
|
||||
target_count INTEGER NOT NULL,
|
||||
checked_count INTEGER DEFAULT 0,
|
||||
screenshot_path TEXT,
|
||||
created_by_profile INTEGER,
|
||||
created_by_name TEXT,
|
||||
deadline_at DATETIME,
|
||||
post_text TEXT,
|
||||
post_text_hash TEXT,
|
||||
content_key TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_change DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -415,17 +581,9 @@ db.exec(`
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_urls_primary
|
||||
ON post_urls(post_id)
|
||||
WHERE is_primary = 1;
|
||||
DROP INDEX IF EXISTS idx_post_urls_primary;
|
||||
`);
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO post_urls (post_id, url, is_primary)
|
||||
SELECT id, url, 1
|
||||
FROM posts
|
||||
`).run();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -497,13 +655,6 @@ db.exec(`
|
||||
ON search_seen_posts(last_seen_at);
|
||||
`);
|
||||
|
||||
const ensureColumn = (table, column, definition) => {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!columns.some(col => col.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
|
||||
}
|
||||
};
|
||||
|
||||
ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0');
|
||||
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
|
||||
ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER');
|
||||
@@ -588,6 +739,8 @@ function normalizeExistingPostUrls() {
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(cleaned, row.id);
|
||||
const updatedKey = extractFacebookContentKey(cleaned);
|
||||
updateContentKeyStmt.run(updatedKey || null, row.id);
|
||||
updatedCount += 1;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
@@ -605,6 +758,42 @@ function normalizeExistingPostUrls() {
|
||||
|
||||
normalizeExistingPostUrls();
|
||||
|
||||
function normalizeExistingPostUrlMappings() {
|
||||
const rows = db.prepare('SELECT id, url FROM post_urls').all();
|
||||
let updated = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const normalized = normalizeFacebookPostUrl(row.url);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized === row.url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE post_urls SET url = ? WHERE id = ?').run(normalized, row.id);
|
||||
updated += 1;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
db.prepare('DELETE FROM post_urls WHERE id = ?').run(row.id);
|
||||
removed += 1;
|
||||
} else {
|
||||
console.warn(`Failed to normalize post_urls entry ${row.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated || removed) {
|
||||
console.log(`Normalized post_urls entries: updated ${updated}, removed ${removed}`);
|
||||
}
|
||||
}
|
||||
|
||||
normalizeExistingPostUrlMappings();
|
||||
db.prepare('DELETE FROM post_urls WHERE url IN (SELECT url FROM posts)').run();
|
||||
|
||||
function truncateString(value, maxLength) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
@@ -1239,19 +1428,22 @@ function collectPostAlternateUrls(primaryUrl, candidates = []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryKey = extractFacebookContentKey(normalizedPrimary);
|
||||
const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates);
|
||||
return normalized.filter(url => url !== normalizedPrimary);
|
||||
|
||||
return normalized.filter((url) => {
|
||||
if (url === normalizedPrimary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateKey = extractFacebookContentKey(url);
|
||||
return candidateKey && candidateKey === primaryKey;
|
||||
});
|
||||
}
|
||||
|
||||
const insertPostUrlStmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO post_urls (post_id, url, is_primary)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const setPrimaryPostUrlStmt = db.prepare(`
|
||||
UPDATE post_urls
|
||||
SET is_primary = CASE WHEN url = ? THEN 1 ELSE 0 END
|
||||
WHERE post_id = ?
|
||||
VALUES (?, ?, 0)
|
||||
`);
|
||||
|
||||
const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?');
|
||||
@@ -1268,9 +1460,9 @@ const selectAlternateUrlsForPostStmt = db.prepare(`
|
||||
SELECT url
|
||||
FROM post_urls
|
||||
WHERE post_id = ?
|
||||
AND is_primary = 0
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?');
|
||||
|
||||
function storePostUrls(postId, primaryUrl, additionalUrls = []) {
|
||||
if (!postId || !primaryUrl) {
|
||||
@@ -1282,8 +1474,7 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) {
|
||||
return;
|
||||
}
|
||||
|
||||
insertPostUrlStmt.run(postId, normalizedPrimary, 1);
|
||||
setPrimaryPostUrlStmt.run(normalizedPrimary, postId);
|
||||
const primaryKey = extractFacebookContentKey(normalizedPrimary);
|
||||
|
||||
if (Array.isArray(additionalUrls)) {
|
||||
for (const candidate of additionalUrls) {
|
||||
@@ -1291,6 +1482,16 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) {
|
||||
if (!normalized || normalized === normalizedPrimary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateKey = extractFacebookContentKey(normalized);
|
||||
if (!candidateKey || candidateKey !== primaryKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPostId = findPostIdByUrl(normalized);
|
||||
if (existingPostId && existingPostId !== postId) {
|
||||
continue;
|
||||
}
|
||||
insertPostUrlStmt.run(postId, normalized, 0);
|
||||
}
|
||||
}
|
||||
@@ -1398,6 +1599,24 @@ function mapPostRow(post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let postContentKey = post.content_key;
|
||||
if (!postContentKey) {
|
||||
const normalizedUrl = normalizeFacebookPostUrl(post.url);
|
||||
postContentKey = extractFacebookContentKey(normalizedUrl);
|
||||
if (postContentKey) {
|
||||
updateContentKeyStmt.run(postContentKey, post.id);
|
||||
post.content_key = postContentKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (post.post_text && (!post.post_text_hash || !post.post_text_hash.trim())) {
|
||||
const normalizedPostText = normalizePostText(post.post_text);
|
||||
const hash = computePostTextHash(normalizedPostText);
|
||||
post.post_text = normalizedPostText;
|
||||
post.post_text_hash = hash;
|
||||
updatePostTextColumnsStmt.run(normalizedPostText, hash, post.id);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1467,7 +1686,10 @@ function mapPostRow(post) {
|
||||
created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null,
|
||||
created_by_name: creatorName,
|
||||
deadline_at: post.deadline_at || null,
|
||||
alternate_urls: alternateUrls
|
||||
alternate_urls: alternateUrls,
|
||||
post_text: post.post_text || null,
|
||||
post_text_hash: post.post_text_hash || null,
|
||||
content_key: post.content_key || postContentKey || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1736,7 +1958,8 @@ app.post('/api/posts', (req, res) => {
|
||||
created_by_profile,
|
||||
created_by_name,
|
||||
profile_number,
|
||||
deadline_at
|
||||
deadline_at,
|
||||
post_text
|
||||
} = req.body;
|
||||
|
||||
const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count);
|
||||
@@ -1754,6 +1977,31 @@ app.post('/api/posts', (req, res) => {
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
const normalizedPostText = normalizePostText(post_text);
|
||||
const postTextHash = computePostTextHash(normalizedPostText);
|
||||
const contentKey = extractFacebookContentKey(normalizedUrl);
|
||||
const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash;
|
||||
|
||||
if (useTextHashDedup) {
|
||||
let existingByHash = selectPostByTextHashStmt.get(postTextHash);
|
||||
if (existingByHash) {
|
||||
const alternateCandidates = [normalizedUrl, ...alternateUrlsInput];
|
||||
const alternateUrls = collectPostAlternateUrls(existingByHash.url, alternateCandidates);
|
||||
storePostUrls(existingByHash.id, existingByHash.url, alternateUrls);
|
||||
|
||||
const cleanupSet = new Set([existingByHash.url, normalizedUrl, ...alternateUrls]);
|
||||
removeSearchSeenEntries(Array.from(cleanupSet));
|
||||
|
||||
if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) {
|
||||
updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id);
|
||||
touchPost(existingByHash.id);
|
||||
existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id);
|
||||
}
|
||||
|
||||
return res.json(mapPostRow(existingByHash));
|
||||
}
|
||||
}
|
||||
|
||||
let creatorProfile = sanitizeProfileNumber(created_by_profile);
|
||||
if (!creatorProfile) {
|
||||
creatorProfile = sanitizeProfileNumber(profile_number) || null;
|
||||
@@ -1770,10 +2018,35 @@ app.post('/api/posts', (req, res) => {
|
||||
const creatorDisplayName = normalizeCreatorName(created_by_name);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path, created_by_profile, created_by_name, deadline_at, last_change)
|
||||
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
INSERT INTO posts (
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
target_count,
|
||||
checked_count,
|
||||
screenshot_path,
|
||||
created_by_profile,
|
||||
created_by_name,
|
||||
deadline_at,
|
||||
post_text,
|
||||
post_text_hash,
|
||||
content_key,
|
||||
last_change
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
stmt.run(id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline);
|
||||
stmt.run(
|
||||
id,
|
||||
normalizedUrl,
|
||||
title || '',
|
||||
validatedTargetCount,
|
||||
creatorProfile,
|
||||
creatorDisplayName,
|
||||
normalizedDeadline,
|
||||
normalizedPostText,
|
||||
postTextHash,
|
||||
contentKey || null
|
||||
);
|
||||
|
||||
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
||||
|
||||
@@ -1794,7 +2067,15 @@ app.post('/api/posts', (req, res) => {
|
||||
app.put('/api/posts/:postId', (req, res) => {
|
||||
try {
|
||||
const { postId } = req.params;
|
||||
const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {};
|
||||
const {
|
||||
target_count,
|
||||
title,
|
||||
created_by_profile,
|
||||
created_by_name,
|
||||
deadline_at,
|
||||
url,
|
||||
post_text
|
||||
} = req.body || {};
|
||||
const alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : [];
|
||||
const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
|
||||
@@ -1805,6 +2086,7 @@ app.put('/api/posts/:postId', (req, res) => {
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let normalizedUrlForCleanup = null;
|
||||
let updatedContentKey = null;
|
||||
|
||||
if (typeof target_count !== 'undefined') {
|
||||
const validatedTargetCount = validateTargetCount(target_count);
|
||||
@@ -1856,6 +2138,19 @@ app.put('/api/posts/:postId', (req, res) => {
|
||||
updates.push('url = ?');
|
||||
params.push(normalizedUrl);
|
||||
normalizedUrlForCleanup = normalizedUrl;
|
||||
const newContentKey = extractFacebookContentKey(normalizedUrl);
|
||||
updates.push('content_key = ?');
|
||||
params.push(newContentKey || null);
|
||||
updatedContentKey = newContentKey || null;
|
||||
}
|
||||
|
||||
if (typeof post_text !== 'undefined') {
|
||||
const normalizedPostText = normalizePostText(post_text);
|
||||
const postTextHash = computePostTextHash(normalizedPostText);
|
||||
updates.push('post_text = ?');
|
||||
params.push(normalizedPostText);
|
||||
updates.push('post_text_hash = ?');
|
||||
params.push(postTextHash);
|
||||
}
|
||||
|
||||
if (!updates.length) {
|
||||
@@ -2217,8 +2512,9 @@ app.patch('/api/posts/:postId', (req, res) => {
|
||||
return res.status(409).json({ error: 'URL already used by another post' });
|
||||
}
|
||||
|
||||
const contentKey = extractFacebookContentKey(normalizedUrl);
|
||||
// Update URL
|
||||
db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId);
|
||||
db.prepare('UPDATE posts SET url = ?, content_key = ? WHERE id = ?').run(normalizedUrl, contentKey || null, postId);
|
||||
|
||||
const alternateCandidates = [];
|
||||
if (existingPost.url && existingPost.url !== normalizedUrl) {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"]';
|
||||
const API_URL = `${API_BASE_URL}/api`;
|
||||
const WEBAPP_BASE_URL = API_BASE_URL.replace(/\/+$/, '');
|
||||
const MAX_SELECTION_LENGTH = 5000;
|
||||
const postSelectionCache = new WeakMap();
|
||||
const LAST_SELECTION_MAX_AGE = 5000;
|
||||
@@ -148,6 +149,28 @@ const aiCredentialCache = {
|
||||
|
||||
console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL);
|
||||
|
||||
function ensureTrackerActionsContainer(container) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionsContainer = container.querySelector('.fb-tracker-actions-end');
|
||||
if (actionsContainer && actionsContainer.isConnected) {
|
||||
return actionsContainer;
|
||||
}
|
||||
|
||||
actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'fb-tracker-actions-end';
|
||||
actionsContainer.style.cssText = `
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
container.appendChild(actionsContainer);
|
||||
return actionsContainer;
|
||||
}
|
||||
|
||||
function backendFetch(url, options = {}) {
|
||||
const config = {
|
||||
...options,
|
||||
@@ -743,6 +766,15 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
|
||||
createdByName = extractAuthorName(options.postElement) || null;
|
||||
}
|
||||
|
||||
let postText = null;
|
||||
if (options && options.postElement) {
|
||||
try {
|
||||
postText = extractPostText(options.postElement) || null;
|
||||
} catch (error) {
|
||||
console.debug('[FB Tracker] Failed to extract post text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let deadlineIso = null;
|
||||
if (options && typeof options.deadline === 'string' && options.deadline.trim()) {
|
||||
const parsedDeadline = new Date(options.deadline.trim());
|
||||
@@ -778,6 +810,10 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options =
|
||||
payload.deadline_at = deadlineIso;
|
||||
}
|
||||
|
||||
if (postText) {
|
||||
payload.post_text = postText;
|
||||
}
|
||||
|
||||
const response = await backendFetch(`${API_URL}/posts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1941,6 +1977,61 @@ async function renderTrackedStatus({
|
||||
|
||||
container.innerHTML = statusHtml;
|
||||
|
||||
if (postData.id) {
|
||||
const actionsContainer = ensureTrackerActionsContainer(container);
|
||||
if (actionsContainer) {
|
||||
const webAppUrl = (() => {
|
||||
try {
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/`;
|
||||
const url = new URL('', baseUrl);
|
||||
url.searchParams.set('tab', 'all');
|
||||
url.searchParams.set('postId', String(postData.id));
|
||||
if (postData.url) {
|
||||
url.searchParams.set('postUrl', postData.url);
|
||||
}
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
console.debug('[FB Tracker] Failed to build WebApp URL, falling back to base:', error);
|
||||
return `${WEBAPP_BASE_URL}/?tab=all`;
|
||||
}
|
||||
})();
|
||||
|
||||
let webAppLink = actionsContainer.querySelector('.fb-tracker-webapp-link');
|
||||
if (!webAppLink) {
|
||||
webAppLink = document.createElement('a');
|
||||
webAppLink.className = 'fb-tracker-webapp-link';
|
||||
webAppLink.target = '_blank';
|
||||
webAppLink.rel = 'noopener noreferrer';
|
||||
webAppLink.setAttribute('aria-label', 'In der Webapp anzeigen');
|
||||
webAppLink.title = 'In der Webapp anzeigen';
|
||||
webAppLink.textContent = '📋';
|
||||
webAppLink.style.cssText = `
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
`;
|
||||
webAppLink.addEventListener('mouseenter', () => {
|
||||
webAppLink.style.backgroundColor = 'rgba(0, 0, 0, 0.08)';
|
||||
webAppLink.style.transform = 'translateY(-1px)';
|
||||
});
|
||||
webAppLink.addEventListener('mouseleave', () => {
|
||||
webAppLink.style.backgroundColor = 'transparent';
|
||||
webAppLink.style.transform = 'translateY(0)';
|
||||
});
|
||||
actionsContainer.insertBefore(webAppLink, actionsContainer.firstChild);
|
||||
}
|
||||
webAppLink.href = webAppUrl;
|
||||
}
|
||||
}
|
||||
|
||||
await addAICommentButton(container, postElement);
|
||||
|
||||
const checkBtn = container.querySelector('.fb-tracker-check-btn');
|
||||
@@ -3716,6 +3807,11 @@ async function addAICommentButton(container, postElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionsContainer = ensureTrackerActionsContainer(container);
|
||||
if (!actionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPostUrl = container && container.getAttribute('data-post-url')
|
||||
? container.getAttribute('data-post-url')
|
||||
: null;
|
||||
@@ -3723,7 +3819,6 @@ async function addAICommentButton(container, postElement) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'fb-tracker-ai-wrapper';
|
||||
wrapper.style.cssText = `
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
@@ -3792,7 +3887,7 @@ async function addAICommentButton(container, postElement) {
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(dropdownButton);
|
||||
wrapper.appendChild(dropdown);
|
||||
container.appendChild(wrapper);
|
||||
actionsContainer.appendChild(wrapper);
|
||||
|
||||
const baseButtonText = button.textContent;
|
||||
|
||||
|
||||
BIN
tracker.db
Normal file
BIN
tracker.db
Normal file
Binary file not shown.
196
web/app.js
196
web/app.js
@@ -1,17 +1,35 @@
|
||||
const API_URL = 'https://fb.srv.medeba-media.de/api';
|
||||
|
||||
// Check if we should redirect to dashboard
|
||||
(function checkViewRouting() {
|
||||
let initialViewParam = null;
|
||||
|
||||
// Normalize incoming routing parameters without leaving the index view
|
||||
(function normalizeViewRouting() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const view = params.get('view');
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === 'dashboard') {
|
||||
// Remove view parameter and keep other params
|
||||
initialViewParam = view;
|
||||
params.delete('view');
|
||||
const remainingParams = params.toString();
|
||||
window.location.href = 'dashboard.html' + (remainingParams ? '?' + remainingParams : '');
|
||||
const remaining = params.toString();
|
||||
const newUrl = `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Konnte view-Parameter nicht verarbeiten:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
let focusPostIdParam = null;
|
||||
let focusPostUrlParam = null;
|
||||
let focusNormalizedUrl = '';
|
||||
let focusHandled = false;
|
||||
let initialTabOverride = null;
|
||||
let focusTabAdjusted = null;
|
||||
|
||||
let currentProfile = 1;
|
||||
let currentTab = 'pending';
|
||||
let posts = [];
|
||||
@@ -89,6 +107,30 @@ const BOOKMARK_WINDOW_DAYS = 28;
|
||||
const DEFAULT_BOOKMARKS = [];
|
||||
const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen'];
|
||||
|
||||
function initializeFocusParams() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const postIdParam = params.get('postId');
|
||||
const postUrlParam = params.get('postUrl');
|
||||
|
||||
if (postIdParam && postIdParam.trim()) {
|
||||
focusPostIdParam = postIdParam.trim();
|
||||
initialTabOverride = initialTabOverride || 'all';
|
||||
}
|
||||
|
||||
if (postUrlParam && postUrlParam.trim()) {
|
||||
focusPostUrlParam = postUrlParam.trim();
|
||||
const normalized = normalizeFacebookPostUrl(focusPostUrlParam);
|
||||
focusNormalizedUrl = normalized || focusPostUrlParam;
|
||||
initialTabOverride = initialTabOverride || 'all';
|
||||
}
|
||||
focusHandled = false;
|
||||
focusTabAdjusted = null;
|
||||
} catch (error) {
|
||||
console.warn('Konnte Fokus-Parameter nicht verarbeiten:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let autoRefreshTimer = null;
|
||||
let autoRefreshSettings = {
|
||||
enabled: true,
|
||||
@@ -882,18 +924,28 @@ function setTab(tab, { updateUrl = true } = {}) {
|
||||
}
|
||||
|
||||
function initializeTabFromUrl() {
|
||||
let tabResolved = false;
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tabParam = params.get('tab');
|
||||
if (tabParam === 'all' || tabParam === 'pending' || tabParam === 'expired') {
|
||||
currentTab = tabParam;
|
||||
tabResolved = true;
|
||||
} else if (initialTabOverride) {
|
||||
currentTab = initialTabOverride;
|
||||
tabResolved = true;
|
||||
} else if (initialViewParam === 'dashboard') {
|
||||
currentTab = 'pending';
|
||||
tabResolved = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Konnte Tab-Parameter nicht auslesen:', error);
|
||||
}
|
||||
|
||||
updateTabButtons();
|
||||
if (tabResolved) {
|
||||
updateTabInUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeadlineInput(value) {
|
||||
@@ -2225,6 +2277,100 @@ async function normalizeLoadedPostUrls() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
function doesPostMatchFocus(post) {
|
||||
if (!post) {
|
||||
return false;
|
||||
}
|
||||
if (focusPostIdParam && String(post.id) === focusPostIdParam) {
|
||||
return true;
|
||||
}
|
||||
if (focusNormalizedUrl && post.url) {
|
||||
const candidateNormalized = normalizeFacebookPostUrl(post.url) || post.url;
|
||||
return candidateNormalized === focusNormalizedUrl;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveFocusTargetInfo(items) {
|
||||
if (!Array.isArray(items) || (!focusPostIdParam && !focusNormalizedUrl)) {
|
||||
return { index: -1, post: null };
|
||||
}
|
||||
const index = items.findIndex(({ post }) => doesPostMatchFocus(post));
|
||||
return {
|
||||
index,
|
||||
post: index !== -1 && items[index] ? items[index].post : null
|
||||
};
|
||||
}
|
||||
|
||||
function clearFocusParamsFromUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
let changed = false;
|
||||
if (url.searchParams.has('postId')) {
|
||||
url.searchParams.delete('postId');
|
||||
changed = true;
|
||||
}
|
||||
if (url.searchParams.has('postUrl')) {
|
||||
url.searchParams.delete('postUrl');
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
const newQuery = url.searchParams.toString();
|
||||
const newUrl = `${url.pathname}${newQuery ? `?${newQuery}` : ''}${url.hash}`;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Konnte Fokus-Parameter nicht aus URL entfernen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightPostCard(post) {
|
||||
if (!post || focusHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.getElementById(`post-${post.id}`);
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.classList.add('post-card--highlight');
|
||||
|
||||
const hadTabIndex = card.hasAttribute('tabindex');
|
||||
if (!hadTabIndex) {
|
||||
card.setAttribute('tabindex', '-1');
|
||||
card.dataset.fbTrackerTempTabindex = '1';
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} catch (error) {
|
||||
console.warn('Konnte Karte nicht scrollen:', error);
|
||||
}
|
||||
try {
|
||||
card.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// ignore focus errors
|
||||
}
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
card.classList.remove('post-card--highlight');
|
||||
if (card.dataset.fbTrackerTempTabindex === '1') {
|
||||
card.removeAttribute('tabindex');
|
||||
delete card.dataset.fbTrackerTempTabindex;
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
focusPostIdParam = null;
|
||||
focusPostUrlParam = null;
|
||||
focusNormalizedUrl = '';
|
||||
focusHandled = true;
|
||||
focusTabAdjusted = null;
|
||||
clearFocusParamsFromUrl();
|
||||
}
|
||||
|
||||
// Render posts
|
||||
function renderPosts() {
|
||||
hideLoading();
|
||||
@@ -2245,6 +2391,9 @@ function renderPosts() {
|
||||
}));
|
||||
|
||||
const sortedItems = [...postItems].sort(comparePostItems);
|
||||
const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl))
|
||||
? sortedItems.find((item) => doesPostMatchFocus(item.post))
|
||||
: null;
|
||||
|
||||
let filteredItems = sortedItems;
|
||||
|
||||
@@ -2275,6 +2424,38 @@ function renderPosts() {
|
||||
});
|
||||
}
|
||||
|
||||
if (!focusHandled && focusCandidateEntry && !searchActive) {
|
||||
const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post));
|
||||
if (!candidateVisibleInCurrentTab) {
|
||||
let desiredTab = 'all';
|
||||
if (focusCandidateEntry.status.isExpired || focusCandidateEntry.status.isComplete) {
|
||||
desiredTab = 'expired';
|
||||
} else if (focusCandidateEntry.status.canCurrentProfileCheck && !focusCandidateEntry.status.isExpired && !focusCandidateEntry.status.isComplete) {
|
||||
desiredTab = 'pending';
|
||||
}
|
||||
|
||||
if (currentTab !== desiredTab && focusTabAdjusted !== desiredTab) {
|
||||
focusTabAdjusted = desiredTab;
|
||||
setTab(desiredTab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (desiredTab !== 'all' && currentTab !== 'all' && focusTabAdjusted !== 'all') {
|
||||
focusTabAdjusted = 'all';
|
||||
setTab('all');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const focusTargetInfo = resolveFocusTargetInfo(filteredItems);
|
||||
if (focusTargetInfo.index !== -1) {
|
||||
const requiredVisible = focusTargetInfo.index + 1;
|
||||
if (requiredVisible > getVisibleCount(currentTab)) {
|
||||
setVisibleCount(currentTab, requiredVisible);
|
||||
}
|
||||
}
|
||||
|
||||
updateFilteredCount(currentTab, filteredItems.length);
|
||||
|
||||
const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab));
|
||||
@@ -2346,6 +2527,10 @@ function renderPosts() {
|
||||
|
||||
observeLoadMoreElement(loadMoreContainer, currentTab);
|
||||
}
|
||||
|
||||
if (!focusHandled && focusTargetInfo.index !== -1 && focusTargetInfo.post) {
|
||||
requestAnimationFrame(() => highlightPostCard(focusTargetInfo.post));
|
||||
}
|
||||
}
|
||||
|
||||
function attachPostEventHandlers(post, status) {
|
||||
@@ -3306,6 +3491,7 @@ window.addEventListener('resize', () => {
|
||||
// Initialize
|
||||
initializeBookmarks();
|
||||
loadAutoRefreshSettings();
|
||||
initializeFocusParams();
|
||||
initializeTabFromUrl();
|
||||
loadSortMode();
|
||||
resetManualPostForm();
|
||||
|
||||
@@ -466,6 +466,31 @@ h1 {
|
||||
border-left: 4px solid #059669;
|
||||
}
|
||||
|
||||
.post-card--highlight {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 4px rgba(102, 126, 234, 0.35);
|
||||
animation: post-card-highlight-pulse 1.4s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes post-card-highlight-pulse {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 0 rgba(102, 126, 234, 0.45);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 8px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 0 rgba(102, 126, 234, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user