Add support for disabling broken profiles

This commit is contained in:
2026-04-12 19:23:34 +02:00
parent ad673f29ad
commit 9b329e513a
5 changed files with 566 additions and 59 deletions

View File

@@ -31,6 +31,9 @@ const DEFAULT_PROFILE_NAMES = {
4: 'Profil 4',
5: 'Profil 5'
};
const DEFAULT_ACTIVE_PROFILES = Object.freeze(
Array.from({ length: MAX_PROFILES }, (_unused, index) => index + 1)
);
const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope';
const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid'];
@@ -687,17 +690,27 @@ function setScopedProfileNumber(scopeId, profileNumber) {
`).run(scopeId, profileNumber);
}
function clampTargetCount(value) {
function normalizeProfileLimit(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 1) {
return MAX_PROFILES;
}
return Math.min(MAX_PROFILES, parsed);
}
function clampTargetCount(value, maxProfiles = MAX_PROFILES) {
const maxAllowed = normalizeProfileLimit(maxProfiles);
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return 1;
}
return Math.min(MAX_PROFILES, Math.max(1, parsed));
return Math.min(maxAllowed, Math.max(1, parsed));
}
function validateTargetCount(value) {
function validateTargetCount(value, maxProfiles = MAX_PROFILES) {
const maxAllowed = normalizeProfileLimit(maxProfiles);
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) {
if (Number.isNaN(parsed) || parsed < 1 || parsed > maxAllowed) {
return null;
}
return parsed;
@@ -711,6 +724,120 @@ function sanitizeProfileNumber(value) {
return parsed;
}
function normalizeProfileNumberList(raw) {
let source = raw;
if (typeof source === 'string') {
const trimmed = source.trim();
if (!trimmed) {
return [];
}
try {
source = JSON.parse(trimmed);
} catch (error) {
source = trimmed.split(',');
}
}
if (!Array.isArray(source)) {
return [];
}
const seen = new Set();
const profiles = [];
source.forEach((value) => {
const profileNumber = sanitizeProfileNumber(value);
if (!profileNumber || seen.has(profileNumber)) {
return;
}
seen.add(profileNumber);
profiles.push(profileNumber);
});
return profiles.sort((a, b) => a - b);
}
function getDefaultActiveProfiles() {
return [...DEFAULT_ACTIVE_PROFILES];
}
function loadProfileSettings() {
let settings = db.prepare('SELECT * FROM profile_settings WHERE id = 1').get();
let activeProfiles = settings ? normalizeProfileNumberList(settings.active_profiles) : [];
if (!activeProfiles.length) {
activeProfiles = getDefaultActiveProfiles();
const serialized = JSON.stringify(activeProfiles);
const existing = db.prepare('SELECT id FROM profile_settings WHERE id = 1').get();
if (existing) {
db.prepare(`
UPDATE profile_settings
SET active_profiles = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`).run(serialized);
} else {
db.prepare(`
INSERT INTO profile_settings (id, active_profiles, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
`).run(serialized);
}
settings = db.prepare('SELECT * FROM profile_settings WHERE id = 1').get();
}
const inactiveProfiles = getDefaultActiveProfiles().filter(profileNumber => !activeProfiles.includes(profileNumber));
return {
updated_at: settings && settings.updated_at ? settings.updated_at : null,
active_profiles: activeProfiles,
inactive_profiles: inactiveProfiles,
max_target_count: activeProfiles.length
};
}
function saveProfileSettings(input = {}) {
const activeProfiles = normalizeProfileNumberList(input.active_profiles);
if (!activeProfiles.length) {
throw new Error('Mindestens ein Profil muss aktiv bleiben.');
}
const serialized = JSON.stringify(activeProfiles);
const existing = db.prepare('SELECT id FROM profile_settings WHERE id = 1').get();
if (existing) {
db.prepare(`
UPDATE profile_settings
SET active_profiles = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`).run(serialized);
} else {
db.prepare(`
INSERT INTO profile_settings (id, active_profiles, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
`).run(serialized);
}
return loadProfileSettings();
}
function getActiveProfiles(settings = null) {
const source = settings && Array.isArray(settings.active_profiles)
? settings.active_profiles
: loadProfileSettings().active_profiles;
const activeProfiles = normalizeProfileNumberList(source);
return activeProfiles.length ? activeProfiles : getDefaultActiveProfiles();
}
function isActiveProfileNumber(profileNumber, settings = null) {
const sanitized = sanitizeProfileNumber(profileNumber);
if (!sanitized) {
return false;
}
return getActiveProfiles(settings).includes(sanitized);
}
function getSelectableScopedProfileNumber(scopeId, settings = null) {
const profileNumber = getScopedProfileNumber(scopeId);
return isActiveProfileNumber(profileNumber, settings) ? profileNumber : null;
}
function applyProfileVariantTemplates(text, profileNumber) {
if (typeof text !== 'string' || !text) {
return text || '';
@@ -1950,8 +2077,10 @@ function extractFacebookContentKey(normalizedUrl) {
}
function getRequiredProfiles(targetCount) {
const count = clampTargetCount(targetCount);
return Array.from({ length: count }, (_, index) => index + 1);
const profileSettings = loadProfileSettings();
const activeProfiles = getActiveProfiles(profileSettings);
const count = clampTargetCount(targetCount, activeProfiles.length);
return activeProfiles.slice(0, count);
}
function buildProfileStatuses(requiredProfiles, checks) {
@@ -2004,11 +2133,14 @@ function buildCompletedProfileSet(rows) {
return completedSet;
}
function countUniqueProfileChecks(checks) {
function countUniqueProfileChecks(checks, allowedProfiles = null) {
const allowedSet = Array.isArray(allowedProfiles) && allowedProfiles.length
? new Set(allowedProfiles)
: null;
const uniqueProfiles = new Set();
checks.forEach((check) => {
const profileNumber = sanitizeProfileNumber(check.profile_number);
if (profileNumber) {
if (profileNumber && (!allowedSet || allowedSet.has(profileNumber))) {
uniqueProfiles.add(profileNumber);
}
});
@@ -2039,7 +2171,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 = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks, requiredProfiles));
const updates = [];
const params = [];
@@ -2170,6 +2302,14 @@ db.exec(`
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS profile_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_profiles TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -2678,6 +2818,7 @@ ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0');
ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER');
ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT');
ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0');
ensureColumn('profile_settings', 'active_profiles', 'active_profiles TEXT');
ensureColumn('ai_auto_comment_rate_limit_settings', 'enabled', 'enabled INTEGER NOT NULL DEFAULT 1');
ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_minute', 'requests_per_minute INTEGER NOT NULL DEFAULT 2');
ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_hour', 'requests_per_hour INTEGER NOT NULL DEFAULT 20');
@@ -4905,7 +5046,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 = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks));
const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks, requiredProfiles));
const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null;
const screenshotPath = screenshotFile && fs.existsSync(screenshotFile)
? `/api/posts/${post.id}/screenshot`
@@ -6146,10 +6287,34 @@ app.delete('/api/search-posts', (req, res) => {
}
});
app.get('/api/profile-settings', (req, res) => {
try {
const settings = loadProfileSettings();
res.json(settings);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/profile-settings', (req, res) => {
try {
const { active_profiles } = req.body || {};
if (!Array.isArray(active_profiles)) {
return res.status(400).json({ error: 'active_profiles must be an array' });
}
const saved = saveProfileSettings({ active_profiles });
res.json(saved);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/api/profile-state', (req, res) => {
try {
const scopeId = req.profileScope;
const profileNumber = getScopedProfileNumber(scopeId);
const profileSettings = loadProfileSettings();
const profileNumber = getSelectableScopedProfileNumber(scopeId, profileSettings);
res.json({ profile_number: profileNumber || null });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -6163,13 +6328,13 @@ app.post('/api/profile-state', (req, res) => {
return res.status(400).json({ error: 'profile_number is required' });
}
const parsed = parseInt(profile_number, 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) {
return res.status(400).json({ error: 'profile_number must be between 1 and 5' });
const profileSettings = loadProfileSettings();
const sanitized = sanitizeProfileNumber(profile_number);
if (!sanitized || !isActiveProfileNumber(sanitized, profileSettings)) {
return res.status(400).json({ error: 'profile_number must reference an active profile' });
}
const scopeId = req.profileScope;
const sanitized = sanitizeProfileNumber(parsed) || 1;
setScopedProfileNumber(scopeId, sanitized);
res.json({ profile_number: sanitized });
@@ -6277,7 +6442,11 @@ app.post('/api/posts', (req, res) => {
post_text
} = req.body;
const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count);
const activeProfiles = getActiveProfiles();
const validatedTargetCount = validateTargetCount(
typeof target_count === 'undefined' ? 1 : target_count,
activeProfiles.length
);
const alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : [];
const normalizedUrl = normalizeFacebookPostUrl(url);
@@ -6287,7 +6456,7 @@ app.post('/api/posts', (req, res) => {
}
if (!validatedTargetCount) {
return res.status(400).json({ error: 'target_count must be between 1 and 5' });
return res.status(400).json({ error: `target_count must be between 1 and ${activeProfiles.length}` });
}
const id = uuidv4();
@@ -6417,11 +6586,12 @@ app.put('/api/posts/:postId', (req, res) => {
const params = [];
let normalizedUrlForCleanup = null;
let updatedContentKey = null;
const activeProfiles = getActiveProfiles();
if (typeof target_count !== 'undefined') {
const validatedTargetCount = validateTargetCount(target_count);
const validatedTargetCount = validateTargetCount(target_count, activeProfiles.length);
if (!validatedTargetCount) {
return res.status(400).json({ error: 'target_count must be between 1 and 5' });
return res.status(400).json({ error: `target_count must be between 1 and ${activeProfiles.length}` });
}
updates.push('target_count = ?');
params.push(validatedTargetCount);
@@ -6546,6 +6716,7 @@ app.post('/api/posts/:postId/check', (req, res) => {
}
}
const profileSettings = loadProfileSettings();
const requiredProfiles = getRequiredProfiles(post.target_count);
let didChange = false;
if (post.target_count !== requiredProfiles.length) {
@@ -6554,11 +6725,18 @@ app.post('/api/posts/:postId/check', (req, res) => {
didChange = true;
}
let profileValue = sanitizeProfileNumber(profile_number) || getScopedProfileNumber(req.profileScope);
let profileValue = sanitizeProfileNumber(profile_number);
if (!profileValue) {
profileValue = getSelectableScopedProfileNumber(req.profileScope, profileSettings);
}
if (!profileValue) {
return res.status(400).json({ error: 'Profil muss zuerst ausgewählt werden.' });
}
if (!requiredProfiles.includes(profileValue)) {
return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder 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({
@@ -6696,6 +6874,7 @@ app.post('/api/check-by-url', (req, res) => {
}
}
const profileSettings = loadProfileSettings();
const requiredProfiles = getRequiredProfiles(post.target_count);
let didChange = false;
if (post.target_count !== requiredProfiles.length) {
@@ -6704,11 +6883,18 @@ app.post('/api/check-by-url', (req, res) => {
didChange = true;
}
let profileValue = sanitizeProfileNumber(profile_number) || getScopedProfileNumber(req.profileScope);
let profileValue = sanitizeProfileNumber(profile_number);
if (!profileValue) {
profileValue = getSelectableScopedProfileNumber(req.profileScope, profileSettings);
}
if (!profileValue) {
return res.status(400).json({ error: 'Profil muss zuerst ausgewählt werden.' });
}
if (!requiredProfiles.includes(profileValue)) {
return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder 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({
@@ -6790,7 +6976,7 @@ app.post('/api/posts/:postId/profile-status', (req, res) => {
}
if (!requiredProfiles.includes(profileValue)) {
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder für diesen Beitrag nicht erforderlich.' });
}
const normalizedStatus = status === 'done' ? 'done' : 'pending';
@@ -6967,9 +7153,10 @@ app.post('/api/posts/merge', (req, res) => {
return primaryPost.deadline_at || secondaryPost.deadline_at || null;
})();
const activeProfileLimit = getActiveProfiles().length;
const mergedTargetCount = Math.max(
validateTargetCount(primaryPost.target_count) || 1,
validateTargetCount(secondaryPost.target_count) || 1
clampTargetCount(primaryPost.target_count, activeProfileLimit),
clampTargetCount(secondaryPost.target_count, activeProfileLimit)
);
const mergedPostText = (primaryPost.post_text && primaryPost.post_text.trim())