Track suspended/deleted accounts and prune them from the users file after a grace period, also as standalone --cleanup. Only fetch social links when the profile description is saved, raised reddit throttle to 15/min.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const logger = require('../logger')(__filename);
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Keeps a YAML record (config.users.dead.file) of accounts found suspended or deleted, keyed by
|
||||
// username:
|
||||
//
|
||||
// SomeUser:
|
||||
// status: suspended # or 'deleted'
|
||||
// firstSeen: 2026-11-05T… # first run that observed this status - reddit exposes no date
|
||||
// lastSeen: 2026-11-05T…
|
||||
// removed: 2026-02-03T… # when it was dropped from the users file, if it was
|
||||
//
|
||||
// An account stays in the users file (and keeps being re-checked, at the cost of one request
|
||||
// per run) until it's been dead for longer than config.users.dead.retainDays[status], so a
|
||||
// lifted suspension or shadowban is noticed in time. After that it's removed from the users
|
||||
// file - but only if it came from one (--file-users), never from --users on the command line.
|
||||
// An account that turns out active again has its record dropped. Nothing is written until
|
||||
// save(), at the end of the run, since users are processed concurrently.
|
||||
async function loadRecords(file) {
|
||||
try {
|
||||
return yaml.load(await fs.readFile(file, 'utf8')) || {};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw new Error(`Could not read dead account record '${file}': ${error.message}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function findKey(records, username) {
|
||||
const lower = username.toLowerCase();
|
||||
|
||||
return Object.keys(records).find((key) => key.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
async function removeFromUsersFile(usersFile, usernames) {
|
||||
const lower = new Set([...usernames].map((name) => name.toLowerCase()));
|
||||
const contents = await fs.readFile(usersFile, 'utf8');
|
||||
// keeps comments, blank lines and order intact - only drops the exact matching entries
|
||||
const kept = contents.split('\n').filter((line) => !lower.has(line.trim().toLowerCase()));
|
||||
const tmpFile = `${usersFile}.tmp`;
|
||||
|
||||
await fs.writeFile(tmpFile, kept.join('\n'));
|
||||
await fs.rename(tmpFile, usersFile);
|
||||
}
|
||||
|
||||
async function createAccountTracker({ usersFile = null, fileUsernames = [], enabled = true } = {}) {
|
||||
const deadConfig = config.users?.dead || {};
|
||||
const file = deadConfig.file || 'users_dead.yml';
|
||||
const retainDays = deadConfig.retainDays || {};
|
||||
const fromFile = new Set(fileUsernames.map((name) => name.toLowerCase()));
|
||||
const records = enabled ? await loadRecords(file) : {};
|
||||
const removals = new Set();
|
||||
const counts = {
|
||||
suspended: 0, deleted: 0, restored: 0, removed: 0,
|
||||
};
|
||||
let changed = false;
|
||||
|
||||
function record(username, status, date = new Date()) {
|
||||
const now = date.toISOString();
|
||||
|
||||
if (!enabled || status === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = findKey(records, username);
|
||||
|
||||
if (status === 'active') {
|
||||
if (key) {
|
||||
logger.info(`Account '${username}' is active again (was ${records[key].status} since ${new Date(records[key].firstSeen).toISOString()}), dropped from dead account record`);
|
||||
|
||||
delete records[key];
|
||||
counts.restored += 1;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
counts[status] += 1;
|
||||
|
||||
const entry = key && records[key].status === status
|
||||
? { ...records[key], lastSeen: now }
|
||||
// new, or changed status (e.g. suspended -> deleted): the clock restarts
|
||||
: { status, firstSeen: now, lastSeen: now };
|
||||
|
||||
if (key && key !== username) {
|
||||
delete records[key];
|
||||
}
|
||||
|
||||
records[username] = entry;
|
||||
changed = true;
|
||||
|
||||
const deadDays = (date - new Date(entry.firstSeen)) / DAY;
|
||||
const retain = retainDays[status] ?? 90;
|
||||
|
||||
if (deadDays >= retain && fromFile.has(username.toLowerCase())) {
|
||||
removals.add(username);
|
||||
entry.removed = now;
|
||||
logger.info(`Account '${username}' has been ${status} since ${new Date(entry.firstSeen).toISOString()} (${Math.floor(deadDays)} days, retaining ${retain}), removing it from '${usersFile}'`);
|
||||
} else {
|
||||
// removed earlier, but (manually) put back in the users file since
|
||||
if (fromFile.has(username.toLowerCase())) {
|
||||
delete entry.removed;
|
||||
}
|
||||
|
||||
logger.info(`Account '${username}' is ${status} (first seen ${new Date(entry.firstSeen).toISOString()}, ${Math.floor(deadDays)} of ${retain} days before removal${fromFile.has(username.toLowerCase()) ? '' : ', not from a users file so never removed automatically'})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!enabled || !changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (removals.size && usersFile) {
|
||||
await removeFromUsersFile(usersFile, removals);
|
||||
counts.removed = removals.size;
|
||||
}
|
||||
|
||||
await fs.writeFile(file, yaml.dump(records, { sortKeys: true }));
|
||||
|
||||
logger.info(`Dead account record '${file}' updated: ${counts.suspended} suspended, ${counts.deleted} deleted, ${counts.restored} active again, ${counts.removed} removed from users file`);
|
||||
}
|
||||
|
||||
return { record, save };
|
||||
}
|
||||
|
||||
module.exports = createAccountTracker;
|
||||
+6
-35
@@ -3,40 +3,9 @@
|
||||
const Promise = require('bluebird');
|
||||
|
||||
const getIndex = require('./getIndex');
|
||||
const curateUser = require('../curate/user');
|
||||
const { fetchUser, indexUser, resolveUser } = require('./user');
|
||||
|
||||
const logger = require('../logger')(__filename);
|
||||
|
||||
async function getSocialLinks(username, reddit) {
|
||||
try {
|
||||
return await reddit.getSocialLinks(username);
|
||||
} catch (error) {
|
||||
// supplementary, not worth failing the whole user fetch over - fall back to none
|
||||
logger.warn(`Failed to fetch social links for '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(username, reddit) {
|
||||
try {
|
||||
const [user, socialLinks] = await Promise.all([
|
||||
reddit.getUser(username),
|
||||
getSocialLinks(username, reddit),
|
||||
]);
|
||||
|
||||
return curateUser(user, socialLinks);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch reddit user '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return {
|
||||
name: username,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getPostsWrap = (reddit) => function getPosts(postIds, userPosts = {}) {
|
||||
const getPostsWrap = (reddit, args) => function getPosts(postIds, userPosts = {}) {
|
||||
return Promise.reduce(postIds, (accUserPosts, postId) => Promise.resolve().then(async () => {
|
||||
const post = await reddit.getSubmission(postId);
|
||||
|
||||
@@ -69,8 +38,10 @@ const getPostsWrap = (reddit) => function getPosts(postIds, userPosts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const user = await getUser(post.author, reddit);
|
||||
const { profile, posts: indexed } = await getIndex(user);
|
||||
// a suspended/deleted author still gets their post saved, under the fallback user
|
||||
const fetched = await fetchUser(post.author, reddit);
|
||||
const { profile, posts: indexed } = await getIndex(indexUser(post.author, fetched));
|
||||
const user = await resolveUser(post.author, fetched, profile, reddit, args);
|
||||
|
||||
return {
|
||||
...accUserPosts,
|
||||
|
||||
+24
-38
@@ -4,41 +4,10 @@ const Promise = require('bluebird');
|
||||
|
||||
const getIndex = require('./getIndex');
|
||||
const getArchivePostIds = require('../archives/getArchivePostIds');
|
||||
const curateUser = require('../curate/user');
|
||||
const { fetchUser, indexUser, resolveUser } = require('./user');
|
||||
|
||||
const logger = require('../logger')(__filename);
|
||||
|
||||
async function getSocialLinks(username, reddit) {
|
||||
try {
|
||||
return await reddit.getSocialLinks(username);
|
||||
} catch (error) {
|
||||
// supplementary, not worth failing the whole user fetch over - fall back to none
|
||||
logger.warn(`Failed to fetch social links for '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(username, reddit) {
|
||||
try {
|
||||
const [user, socialLinks] = await Promise.all([
|
||||
reddit.getUser(username),
|
||||
getSocialLinks(username, reddit),
|
||||
]);
|
||||
|
||||
logger.info(`Fetched user profile for '${username}' (https://reddit.com/user/${username})`);
|
||||
|
||||
return curateUser(user, socialLinks);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch reddit user '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return {
|
||||
name: username,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// the moment before which posts will be discarded by curate/posts.js anyway (--after, or
|
||||
// --after-indexed against the existing index) - lets getSubmissions() stop paging there
|
||||
// instead of always walking reddit's entire (up to ~1000 post) listing. Without either flag,
|
||||
@@ -87,15 +56,32 @@ async function getArchivedPosts(username, posts, reddit) {
|
||||
}
|
||||
|
||||
function getUserPostsWrap(reddit, args) {
|
||||
return async function getUserPosts(usernames) {
|
||||
// tracker: see accountStatus.js - records suspended/deleted accounts (and drops them from the
|
||||
// users file once they've been dead long enough), optional
|
||||
return async function getUserPosts(usernames, tracker = null) {
|
||||
try {
|
||||
const users = await Promise.map(usernames, async (username) => {
|
||||
try {
|
||||
// sequential rather than parallel: the index (whose path may depend on the
|
||||
// user's details) decides how far back to page, and every reddit request
|
||||
// goes through the same single-concurrency limiter anyway
|
||||
const user = await getUser(username, reddit);
|
||||
const { profile, posts: indexed } = await getIndex(user);
|
||||
// sequential rather than parallel: the user decides whether there's anything to
|
||||
// fetch at all, the index (whose path may depend on the user's details)
|
||||
// decides how far back to page and whether social links are needed, and
|
||||
// every reddit request goes through the same single-concurrency limiter anyway
|
||||
const fetched = await fetchUser(username, reddit);
|
||||
|
||||
tracker?.record(username, fetched.status);
|
||||
|
||||
// nothing more to get from a suspended or deleted account - its listing is a
|
||||
// 403/404 too, and skipping it saves the rest of this user's requests
|
||||
if (fetched.status === 'suspended' || fetched.status === 'deleted') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fetched.status === 'active') {
|
||||
logger.info(`Fetched user profile for '${username}' (https://reddit.com/user/${username})`);
|
||||
}
|
||||
|
||||
const { profile, posts: indexed } = await getIndex(indexUser(username, fetched));
|
||||
const user = await resolveUser(username, fetched, profile, reddit, args);
|
||||
const posts = await getPosts(username, reddit, args, getCutoff(indexed, args));
|
||||
|
||||
if (args.archives) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
|
||||
const curateUser = require('../curate/user');
|
||||
const logger = require('../logger')(__filename);
|
||||
|
||||
// reddit tells deleted and suspended accounts apart (verified live, 2026-11):
|
||||
// - deleted: /user/x/about.json returns a JSON 404 (a shadowbanned account reportedly looks
|
||||
// the same, so a 404 isn't necessarily permanent - see accountStatus.js's grace period)
|
||||
// - suspended: about.json returns 200 with only { name, is_suspended: true, ... } - no date of
|
||||
// suspension is exposed anywhere
|
||||
// Anything else (network errors, 5xx, or the HTML bot-challenge page, which carries no
|
||||
// `status` - see reddit/client.js's requestJson()) is 'error', which must never be treated as
|
||||
// dead: a blocked or expired session would otherwise get the entire users file cleaned out.
|
||||
async function fetchUser(username, reddit) {
|
||||
try {
|
||||
const user = await reddit.getUser(username);
|
||||
|
||||
if (user.is_suspended) {
|
||||
return { status: 'suspended', user: null };
|
||||
}
|
||||
|
||||
return { status: 'active', user };
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
return { status: 'deleted', user: null };
|
||||
}
|
||||
|
||||
logger.error(`Failed to fetch reddit user '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return { status: 'error', user: null, error };
|
||||
}
|
||||
}
|
||||
|
||||
async function getSocialLinks(username, reddit) {
|
||||
try {
|
||||
return await reddit.getSocialLinks(username);
|
||||
} catch (error) {
|
||||
// supplementary, not worth failing the whole user fetch over - fall back to none
|
||||
logger.warn(`Failed to fetch social links for '${username}': ${error.message} (https://reddit.com/user/${username})`);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// social links are only ever used for the profile description file (save/profileDetails.js),
|
||||
// which is skipped once it's in the index (unless --redownload-profile) - scraping the profile
|
||||
// page for them regardless costs one throttled reddit request per user per run for nothing
|
||||
function needsSocialLinks(indexedProfile, args) {
|
||||
return Boolean(config.library.profile.description && (args.redownloadProfile || !indexedProfile?.description));
|
||||
}
|
||||
|
||||
// the user as getIndex() needs it to resolve the index path - the full curated user (minus social
|
||||
// links, which depend on the index) in case a custom library.index.file pattern uses more than
|
||||
// the name, otherwise the same fallback as resolveUser() below
|
||||
function indexUser(username, fetched) {
|
||||
return fetched.status === 'active' ? curateUser(fetched.user) : { name: username, fallback: true };
|
||||
}
|
||||
|
||||
// curated user, or the same minimal fallback as before for an account that couldn't be fetched
|
||||
// (or is suspended/deleted, when the caller still wants to proceed - e.g. a --post by them)
|
||||
async function resolveUser(username, fetched, indexedProfile, reddit, args) {
|
||||
if (fetched.status !== 'active') {
|
||||
return { name: username, fallback: true };
|
||||
}
|
||||
|
||||
const socialLinks = fetched.user.subreddit && needsSocialLinks(indexedProfile, args)
|
||||
? await getSocialLinks(username, reddit)
|
||||
: [];
|
||||
|
||||
return curateUser(fetched.user, socialLinks);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchUser,
|
||||
indexUser,
|
||||
resolveUser,
|
||||
};
|
||||
Reference in New Issue
Block a user