feat: grow new accounts by commenting on active threads
A new Reddit account cannot start chats until Reddit considers it established, and nothing in the app previously did anything about that. Account growth posts a small number of AI-written comments per day on active threads in an operator-chosen list of communities, per account, so the history that lifts the hold actually accumulates. Growth is account-scoped rather than part of the shared outreach funnel: karma, comment history and pacing all belong to one identity. It runs as the lowest-priority work in the account runtime, after inbound replies, and deliberately keeps running while an account is held out of outbound messaging. Comments publish without operator review, so the guards are the feature: - Both screening stages fail closed. A deterministic gate rejects links, contact solicitation, user tags, filler and any text that reveals the generator; the classifier must actively approve with confidence, and an outage stalls growth rather than publishing unreviewed text. - Thread text reaches the model only as fenced untrusted data, since Reddit posts do contain instructions aimed at automated readers. - The daily allowance is adaptive, not configured: it starts at two per day and rises only while comments survive and earn upvotes. - Posted comments are swept at ~15m/2h/24h with one batched info.json request, plus a single signed-out probe that detects a comment the public cannot see. Two consecutive removals, an invisible comment, or a captcha challenge pauses the account until an operator clears it. A failed probe never pauses anything. - One comment per thread across every owned account, enforced by a unique index, because two of our accounts on one post is the easiest pattern to correlate. - A comment interrupted mid-post is abandoned, never retried: we cannot know whether the write landed and a duplicate cannot be withdrawn. - Reddit's refusals are authoritative — a ban disables that community for that account, a throttle backs off at the scope Reddit named, and a signed-out response invalidates the session. Comments are submitted through the account's own browser session against the old.reddit form endpoint, which returns machine-readable error codes and needs no credentials beyond the cookies already imported. API-first: /api/growth exposes state, settings, the community list and pause/resume, all verified against a local instance. Route responses are parsed with the shared contracts in tests so the server-side mirror cannot drift from what the dashboard expects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14d92e14ee
commit
1ac6ad22ad
46 files changed
+7500
-5
No files matched your search
@@ -116,6 +116,15 @@ export interface RedditPost {
|
||||
over_18: boolean;
|
||||
spoiler: boolean;
|
||||
link_flair_text?: string | null;
|
||||
// Listing fields the account grower needs to judge whether a thread can and
|
||||
// should be commented on. Optional because the outreach paths never read them.
|
||||
num_comments?: number;
|
||||
locked?: boolean;
|
||||
stickied?: boolean;
|
||||
archived?: boolean;
|
||||
pinned?: boolean;
|
||||
distinguished?: string | null;
|
||||
removed_by_category?: string | null;
|
||||
}
|
||||
|
||||
interface RedditListingChild {
|
||||
@@ -387,6 +396,88 @@ export class RedditApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a `hot` listing without the outreach validity filters.
|
||||
*
|
||||
* The account grower wants threads with traction rather than image or chat
|
||||
* posts, and does its own screening, so this deliberately returns the listing
|
||||
* close to raw — only authorless and bot-authored posts are dropped.
|
||||
*/
|
||||
async getHotPosts(subreddit: string, limit = 25): Promise<RedditPost[]> {
|
||||
try {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
const response = await this.scheduleRequest(() => this.client.get(`/r/${subreddit}/hot.json`, {
|
||||
params: { limit, raw_json: 1 },
|
||||
headers,
|
||||
}));
|
||||
|
||||
const posts: RedditPost[] = response.data.data.children
|
||||
.map((child: { data: RedditPost }) => child.data)
|
||||
.filter((post: RedditPost) => (
|
||||
post.author !== '[deleted]' && post.author !== 'AutoModerator'
|
||||
));
|
||||
|
||||
this.logger.debug(`Fetched ${posts.length} hot posts from r/${subreddit}`);
|
||||
this.consecutiveListing403s = 0;
|
||||
return posts;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) throw new RedditSubredditUnavailableError(subreddit);
|
||||
if (error.response?.status === 403) {
|
||||
this.consecutiveListing403s += 1;
|
||||
if (this.consecutiveListing403s >= 2) {
|
||||
const cooldownMs = 120_000;
|
||||
this.requestScheduler.extendCooldown(cooldownMs);
|
||||
this.logger.warn(
|
||||
`Reddit is temporarily denying listing requests (consecutive HTTP 403s); pausing for ${cooldownMs / 1000}s`,
|
||||
);
|
||||
throw new RedditRateLimitError(cooldownMs);
|
||||
}
|
||||
throw new RedditAccessDeniedError(subreddit);
|
||||
}
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
}
|
||||
this.logger.error(`Error fetching hot posts from r/${subreddit}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level comment bodies already on a thread, so a generated comment does
|
||||
* not restate a point somebody else has made.
|
||||
*/
|
||||
async getThreadTopComments(subreddit: string, postId: string, limit = 8): Promise<string[]> {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
try {
|
||||
const response = await this.scheduleRequest(() => this.client.get(
|
||||
`/r/${subreddit}/comments/${postId}.json`,
|
||||
{ params: { limit, depth: 1, sort: 'top', raw_json: 1 }, headers },
|
||||
));
|
||||
const listings = Array.isArray(response.data) ? response.data : [];
|
||||
const commentListing = listings[1] as { data?: { children?: unknown } } | undefined;
|
||||
const children = commentListing?.data?.children;
|
||||
if (!Array.isArray(children)) return [];
|
||||
return children
|
||||
.map((child) => (child as { data?: { body?: unknown } })?.data?.body)
|
||||
.filter((body): body is string => typeof body === 'string')
|
||||
.filter((body) => body !== '[removed]' && body !== '[deleted]')
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
// Context is a nice-to-have; never fail a comment because it is missing.
|
||||
this.logger.warn(`Failed to read existing comments for ${postId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
|
||||
// Skip deleted/removed authors
|
||||
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { RedditRequestScheduler } from './request-coordinator.js';
|
||||
|
||||
const OLD_REDDIT = 'https://old.reddit.com';
|
||||
const WWW_REDDIT = 'https://www.reddit.com';
|
||||
|
||||
/**
|
||||
* The outcome of one attempted comment. Every branch exists because Reddit
|
||||
* reports it distinctly and it needs different handling: retry, drop the
|
||||
* thread, drop the subreddit, refresh the session, or stop the account.
|
||||
*/
|
||||
export type CommentPostResult =
|
||||
/** The comment exists on Reddit. Irreversible. */
|
||||
| { status: 'posted'; fullname: string; permalink: string | null }
|
||||
/** Reddit asked us to slow down. `scope` says whether it is the whole account or one community. */
|
||||
| { status: 'rate-limited'; scope: 'account' | 'subreddit'; retryAfterMs: number; reason: string }
|
||||
/** The thread will never accept a comment; abandon this candidate. */
|
||||
| { status: 'thread-closed'; reason: string }
|
||||
/** This account may not comment in this community; stop polling it. */
|
||||
| { status: 'not-allowed'; reason: string }
|
||||
/** The session is no longer signed in; the account needs a fresh import. */
|
||||
| { status: 'session-invalid'; reason: string }
|
||||
/** Reddit is challenging the account. Treated as a hard stop, never retried. */
|
||||
| { status: 'blocked'; reason: string }
|
||||
/** Anything else: transient, safe to retry later. */
|
||||
| { status: 'failed'; reason: string };
|
||||
|
||||
export interface RedditCommentState {
|
||||
fullname: string;
|
||||
score: number | null;
|
||||
/** Removed by a moderator, automod, or deleted. */
|
||||
removed: boolean;
|
||||
/** Reddit did not return the comment at all. */
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface HttpTextResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP seam. The production implementation runs inside the account's
|
||||
* own Playwright context so Reddit sees the same session, TLS fingerprint and
|
||||
* cookies as a human using that profile; tests supply a fake.
|
||||
*/
|
||||
export interface RedditWriteTransport {
|
||||
get(url: string): Promise<HttpTextResponse>;
|
||||
postForm(
|
||||
url: string,
|
||||
form: Record<string, string>,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<HttpTextResponse>;
|
||||
}
|
||||
|
||||
/** Signed-out fetch, used to tell whether a comment is publicly visible. */
|
||||
export type AnonymousFetcher = (url: string) => Promise<HttpTextResponse>;
|
||||
|
||||
export interface RedditCommentClientDependencies {
|
||||
scheduler?: RedditRequestScheduler;
|
||||
anonymousFetch?: AnonymousFetcher;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const passthroughScheduler: RedditRequestScheduler = {
|
||||
schedule: async <T>(request: () => Promise<T>) => await request(),
|
||||
extendCooldown: () => undefined,
|
||||
};
|
||||
|
||||
/** Reddit error codes that mean the thread itself is finished. */
|
||||
const THREAD_CLOSED_CODES = new Set([
|
||||
'THREAD_LOCKED',
|
||||
'TOO_OLD',
|
||||
'DELETED_COMMENT',
|
||||
'DELETED_LINK',
|
||||
'DELETED_THING',
|
||||
]);
|
||||
|
||||
/** Codes that mean this account is not welcome in this community. */
|
||||
const NOT_ALLOWED_CODES = new Set([
|
||||
'SUBREDDIT_NOTALLOWED',
|
||||
'BANNED_FROM_SUBREDDIT',
|
||||
'USER_BLOCKED',
|
||||
'NOT_WHITELISTED_BY_EPIO_LICENSE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Posts comments as one Reddit account and reports what happened to them.
|
||||
*
|
||||
* Uses the old.reddit form endpoint rather than the current web app's private
|
||||
* GraphQL calls: it is stable, it returns machine-readable error codes, and it
|
||||
* needs nothing beyond the cookies already imported for the account.
|
||||
*/
|
||||
export class RedditCommentClient {
|
||||
private modhash: string | null = null;
|
||||
private readonly scheduler: RedditRequestScheduler;
|
||||
private readonly anonymousFetch: AnonymousFetcher | null;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly transport: RedditWriteTransport,
|
||||
private readonly logger: Logger,
|
||||
dependencies: RedditCommentClientDependencies = {},
|
||||
) {
|
||||
this.scheduler = dependencies.scheduler ?? passthroughScheduler;
|
||||
this.anonymousFetch = dependencies.anonymousFetch ?? null;
|
||||
this.now = dependencies.now ?? Date.now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one top-level comment to a thread.
|
||||
*
|
||||
* `threadFullname` must be a `t3_` post fullname. A `posted` result is
|
||||
* irreversible, so callers must record it before doing anything else.
|
||||
*/
|
||||
async postComment(
|
||||
threadFullname: string,
|
||||
subreddit: string,
|
||||
text: string,
|
||||
): Promise<CommentPostResult> {
|
||||
if (!/^t3_[a-z0-9]+$/i.test(threadFullname)) {
|
||||
return { status: 'failed', reason: `"${threadFullname}" is not a post fullname` };
|
||||
}
|
||||
if (!text.trim()) {
|
||||
return { status: 'failed', reason: 'Refusing to post an empty comment' };
|
||||
}
|
||||
|
||||
const modhash = await this.ensureModhash();
|
||||
if (!modhash) {
|
||||
return { status: 'session-invalid', reason: 'Reddit did not return a modhash for this session' };
|
||||
}
|
||||
|
||||
let response: HttpTextResponse;
|
||||
try {
|
||||
response = await this.scheduler.schedule(() => this.transport.postForm(
|
||||
`${OLD_REDDIT}/api/comment`,
|
||||
{ thing_id: threadFullname, text, api_type: 'json', uh: modhash },
|
||||
{ 'X-Modhash': modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
));
|
||||
} catch (error) {
|
||||
return { status: 'failed', reason: errorMessage(error) };
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// A 403 here is ambiguous between a dead session and a community ban, so
|
||||
// drop the cached modhash and let the next attempt re-derive identity.
|
||||
this.modhash = null;
|
||||
return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
status: 'rate-limited',
|
||||
scope: 'account',
|
||||
retryAfterMs: 10 * 60_000,
|
||||
reason: 'Reddit returned HTTP 429 for the comment',
|
||||
};
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { status: 'failed', reason: `Reddit returned HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const payload = parseJson(response.body);
|
||||
if (!payload) {
|
||||
return { status: 'failed', reason: `Reddit returned an unreadable comment response (HTTP ${response.status})` };
|
||||
}
|
||||
|
||||
const errors = readErrors(payload);
|
||||
if (errors.length > 0) return this.classifyError(errors[0], subreddit);
|
||||
|
||||
const created = readCreatedComment(payload);
|
||||
if (!created) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: 'Reddit accepted the comment but returned no comment identity',
|
||||
};
|
||||
}
|
||||
this.logger.info(`Posted growth comment ${created.fullname} to r/${subreddit}`);
|
||||
return { status: 'posted', fullname: created.fullname, permalink: created.permalink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Current score and removal state for previously posted comments, from the
|
||||
* account's own authenticated view. Batched because Reddit accepts up to 100
|
||||
* fullnames per request and a sweep should cost one request, not one each.
|
||||
*/
|
||||
async fetchCommentStates(fullnames: readonly string[]): Promise<RedditCommentState[]> {
|
||||
const ids = [...new Set(fullnames)].filter((id) => /^t1_[a-z0-9]+$/i.test(id));
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length > 100) throw new Error('Reddit accepts at most 100 fullnames per info lookup');
|
||||
|
||||
const url = `${WWW_REDDIT}/api/info.json?id=${ids.join(',')}&raw_json=1`;
|
||||
const response = await this.scheduler.schedule(() => this.transport.get(url));
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Reddit returned HTTP ${response.status} for comment info`);
|
||||
}
|
||||
const found = readListing(parseJson(response.body));
|
||||
return ids.map((fullname) => {
|
||||
const entry = found.get(fullname.toLowerCase());
|
||||
if (!entry) return { fullname, score: null, removed: false, missing: true };
|
||||
return { fullname, score: entry.score, removed: entry.removed, missing: false };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these comments a signed-out visitor can see.
|
||||
*
|
||||
* This is the shadowban and silent-removal test: a comment the account itself
|
||||
* can see but the public cannot has achieved nothing, and continuing to
|
||||
* comment from that account only deepens the hole.
|
||||
*/
|
||||
async fetchPubliclyVisible(fullnames: readonly string[]): Promise<Set<string>> {
|
||||
const ids = [...new Set(fullnames)].filter((id) => /^t1_[a-z0-9]+$/i.test(id));
|
||||
if (ids.length === 0 || !this.anonymousFetch) return new Set(ids);
|
||||
if (ids.length > 100) throw new Error('Reddit accepts at most 100 fullnames per info lookup');
|
||||
|
||||
const url = `${WWW_REDDIT}/api/info.json?id=${ids.join(',')}&raw_json=1`;
|
||||
const response = await this.scheduler.schedule(() => this.anonymousFetch!(url));
|
||||
if (response.status !== 200) {
|
||||
// Never conclude "shadowbanned" from a failed probe; that would pause a
|
||||
// healthy account on a network blip.
|
||||
throw new Error(`Anonymous Reddit lookup returned HTTP ${response.status}`);
|
||||
}
|
||||
const found = readListing(parseJson(response.body));
|
||||
return new Set(ids.filter((id) => {
|
||||
const entry = found.get(id.toLowerCase());
|
||||
return entry !== undefined && !entry.removed;
|
||||
}));
|
||||
}
|
||||
|
||||
/** Forces the next request to re-derive the session's modhash. */
|
||||
invalidateSession(): void {
|
||||
this.modhash = null;
|
||||
}
|
||||
|
||||
private async ensureModhash(): Promise<string | null> {
|
||||
if (this.modhash) return this.modhash;
|
||||
try {
|
||||
const response = await this.scheduler.schedule(
|
||||
() => this.transport.get(`${OLD_REDDIT}/api/me.json`),
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
this.logger.warn(`Reddit returned HTTP ${response.status} for the session identity check`);
|
||||
return null;
|
||||
}
|
||||
const payload = parseJson(response.body);
|
||||
const data = payload && typeof payload === 'object'
|
||||
? (payload as { data?: { modhash?: unknown } }).data
|
||||
: undefined;
|
||||
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
|
||||
if (!modhash) return null;
|
||||
this.modhash = modhash;
|
||||
return modhash;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to read the Reddit session modhash:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private classifyError(
|
||||
error: { code: string; message: string },
|
||||
subreddit: string,
|
||||
): CommentPostResult {
|
||||
const code = error.code.toUpperCase();
|
||||
const reason = error.message.trim() || code;
|
||||
|
||||
if (code === 'RATELIMIT') {
|
||||
const retryAfterMs = parseRetryAfterMs(error.message) ?? 10 * 60_000;
|
||||
this.scheduler.extendCooldown(retryAfterMs);
|
||||
return { status: 'rate-limited', scope: 'account', retryAfterMs, reason };
|
||||
}
|
||||
if (code === 'SUBREDDIT_RATELIMIT') {
|
||||
return {
|
||||
status: 'rate-limited',
|
||||
scope: 'subreddit',
|
||||
retryAfterMs: parseRetryAfterMs(error.message) ?? 60 * 60_000,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
if (code === 'QUOTA_FILLED') {
|
||||
return { status: 'rate-limited', scope: 'account', retryAfterMs: 6 * 60 * 60_000, reason };
|
||||
}
|
||||
if (THREAD_CLOSED_CODES.has(code)) {
|
||||
return { status: 'thread-closed', reason };
|
||||
}
|
||||
if (NOT_ALLOWED_CODES.has(code)) {
|
||||
return { status: 'not-allowed', reason: `${reason} (r/${subreddit})` };
|
||||
}
|
||||
if (code === 'USER_REQUIRED') {
|
||||
this.modhash = null;
|
||||
return { status: 'session-invalid', reason };
|
||||
}
|
||||
if (code === 'BAD_CAPTCHA' || code === 'CAPTCHA_REQUIRED') {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reason: `Reddit is challenging this account with a captcha (${reason})`,
|
||||
};
|
||||
}
|
||||
return { status: 'failed', reason: `${code}: ${reason}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the HTTP calls inside a page of the account's own browser context, so
|
||||
* the request carries that profile's cookies and looks like the signed-in user.
|
||||
*/
|
||||
export interface EvaluatingPage {
|
||||
evaluate<Result, Argument>(
|
||||
fn: (argument: Argument) => Promise<Result>,
|
||||
argument: Argument,
|
||||
): Promise<Result>;
|
||||
}
|
||||
|
||||
export function createBrowserWriteTransport(page: EvaluatingPage): RedditWriteTransport {
|
||||
return {
|
||||
// The page already carries the profile's cookies and user agent, so the
|
||||
// request needs no explicit identity of its own.
|
||||
get: (url) => page.evaluate(async ({ target }) => {
|
||||
const response = await fetch(target, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url }),
|
||||
|
||||
postForm: (url, form, headers) => page.evaluate(async ({ target, fields, extraHeaders }) => {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...extraHeaders },
|
||||
body,
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url, fields: form, extraHeaders: headers ?? {} }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Signed-out fetcher used for the public-visibility probe. */
|
||||
export function createAnonymousFetcher(userAgent: string): AnonymousFetcher {
|
||||
return async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': userAgent, Accept: 'application/json' },
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reddit phrases comment throttling as prose ("try again in 9 minutes"), so the
|
||||
* wait has to be read out of the message rather than a header.
|
||||
*/
|
||||
export function parseRetryAfterMs(message: string): number | null {
|
||||
const match = /(\d+(?:\.\d+)?)\s*(second|minute|hour)s?/i.exec(message);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
const unit = match[2].toLowerCase();
|
||||
const multiplier = unit === 'hour' ? 3_600_000 : unit === 'minute' ? 60_000 : 1_000;
|
||||
return Math.round(value * multiplier);
|
||||
}
|
||||
|
||||
function readErrors(payload: unknown): Array<{ code: string; message: string }> {
|
||||
const json = (payload as { json?: { errors?: unknown } })?.json;
|
||||
const errors = json?.errors;
|
||||
if (!Array.isArray(errors)) return [];
|
||||
return errors.flatMap((entry) => {
|
||||
if (Array.isArray(entry)) {
|
||||
const [code, message] = entry;
|
||||
if (typeof code !== 'string') return [];
|
||||
return [{ code, message: typeof message === 'string' ? message : '' }];
|
||||
}
|
||||
if (typeof entry === 'string') return [{ code: entry, message: '' }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function readCreatedComment(payload: unknown): { fullname: string; permalink: string | null } | null {
|
||||
const things = (payload as { json?: { data?: { things?: unknown } } })?.json?.data?.things;
|
||||
if (!Array.isArray(things)) return null;
|
||||
for (const thing of things) {
|
||||
const data = (thing as { data?: Record<string, unknown> })?.data;
|
||||
if (!data) continue;
|
||||
const name = typeof data.name === 'string' && data.name
|
||||
? data.name
|
||||
: typeof data.id === 'string' && data.id
|
||||
? (data.id.startsWith('t1_') ? data.id : `t1_${data.id}`)
|
||||
: null;
|
||||
if (!name) continue;
|
||||
const permalink = typeof data.permalink === 'string' ? data.permalink : null;
|
||||
return { fullname: name, permalink };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readListing(payload: unknown): Map<string, { score: number | null; removed: boolean }> {
|
||||
const found = new Map<string, { score: number | null; removed: boolean }>();
|
||||
const children = (payload as { data?: { children?: unknown } })?.data?.children;
|
||||
if (!Array.isArray(children)) return found;
|
||||
for (const child of children) {
|
||||
const data = (child as { data?: Record<string, unknown> })?.data;
|
||||
const name = typeof data?.name === 'string' ? data.name : null;
|
||||
if (!name) continue;
|
||||
const body = typeof data?.body === 'string' ? data.body : '';
|
||||
const removed = body === '[removed]'
|
||||
|| body === '[deleted]'
|
||||
|| (data?.banned_by !== null && data?.banned_by !== undefined)
|
||||
|| data?.removed === true;
|
||||
found.set(name.toLowerCase(), {
|
||||
score: typeof data?.score === 'number' ? data.score : null,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseJson(body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in new issue
Block a user