603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
import type { Logger } from '../utils/logger.js';
|
|
import type { RedditRequestScheduler } from './request-coordinator.js';
|
|
|
|
/**
|
|
* Every authenticated call is made against this one origin so the page issuing
|
|
* it is same-origin: a cross-origin fetch would not send the session cookies,
|
|
* and a page still on `about:blank` cannot reach Reddit at all.
|
|
*/
|
|
export const REDDIT_WRITE_ORIGIN = 'https://old.reddit.com';
|
|
/** The public view, only ever fetched signed out from Node, so CORS never applies. */
|
|
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 };
|
|
|
|
/** Result of a single upvote. A failed/uncertain request is never retried. */
|
|
export type VoteResult =
|
|
| { status: 'submitted' }
|
|
| { status: 'rate-limited'; retryAfterMs: number; reason: string }
|
|
| { status: 'session-invalid'; reason: string }
|
|
| { status: 'blocked'; reason: string }
|
|
| { status: 'failed'; reason: string };
|
|
|
|
/** Why a modhash lookup failed, so the caller can pick a proportionate response. */
|
|
type ModhashResult =
|
|
| { ok: true; modhash: string }
|
|
| { ok: false; kind: 'unreachable' | 'signed-out'; 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;
|
|
/**
|
|
* Names and domains of the cookies visible to Reddit, never their values.
|
|
* Included in the log when Reddit reports a signed-out session, because the
|
|
* usual cause is cookies scoped to a host that this origin cannot see.
|
|
*/
|
|
describeCookies?: () => Promise<string>;
|
|
}
|
|
|
|
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;
|
|
private readonly describeCookies: (() => Promise<string>) | null;
|
|
|
|
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;
|
|
this.describeCookies = dependencies.describeCookies ?? null;
|
|
}
|
|
|
|
/**
|
|
* 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.ok) {
|
|
// Only Reddit actually reporting a signed-out session may invalidate the
|
|
// account's auth; anything else is transient and simply retried.
|
|
return modhash.kind === 'signed-out'
|
|
? { status: 'session-invalid', reason: modhash.reason }
|
|
: { status: 'failed', reason: modhash.reason };
|
|
}
|
|
|
|
let response: HttpTextResponse;
|
|
try {
|
|
response = await this.scheduler.schedule(() => this.transport.postForm(
|
|
`${REDDIT_WRITE_ORIGIN}/api/comment`,
|
|
{ thing_id: threadFullname, text, api_type: 'json', uh: modhash.modhash },
|
|
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
));
|
|
} catch (error) {
|
|
return { status: 'failed', reason: errorMessage(error) };
|
|
}
|
|
|
|
if (response.status === 403) {
|
|
// Reddit's anti-abuse interstitial is an HTML 403. It says nothing about
|
|
// whether we are signed in, so it must not invalidate the account.
|
|
if (!looksLikeJson(response.body)) {
|
|
return { status: 'failed', reason: blockPageReason(response) };
|
|
}
|
|
// A JSON 403 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 };
|
|
}
|
|
|
|
/** Submit one upvote through the same authenticated browser session as comments. */
|
|
async upvote(thingFullname: string): Promise<VoteResult> {
|
|
if (!/^t[13]_[a-z0-9]+$/i.test(thingFullname)) {
|
|
return { status: 'failed', reason: `"${thingFullname}" is not a post or comment fullname` };
|
|
}
|
|
const modhash = await this.ensureModhash();
|
|
if (!modhash.ok) return modhash.kind === 'signed-out'
|
|
? { status: 'session-invalid', reason: modhash.reason }
|
|
: { status: 'failed', reason: modhash.reason };
|
|
|
|
let response: HttpTextResponse;
|
|
try {
|
|
response = await this.scheduler.schedule(() => this.transport.postForm(
|
|
`${REDDIT_WRITE_ORIGIN}/api/vote`,
|
|
{ id: thingFullname, dir: '1', api_type: 'json', uh: modhash.modhash },
|
|
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
));
|
|
} catch (error) {
|
|
return { status: 'failed', reason: errorMessage(error) };
|
|
}
|
|
if (response.status === 429) {
|
|
const retryAfterMs = 10 * 60_000;
|
|
this.scheduler.extendCooldown(retryAfterMs);
|
|
return { status: 'rate-limited', retryAfterMs, reason: 'Reddit returned HTTP 429 for the vote' };
|
|
}
|
|
if (response.status === 403) {
|
|
if (!looksLikeJson(response.body)) return { status: 'blocked', reason: blockPageReason(response) };
|
|
this.modhash = null;
|
|
return { status: 'session-invalid', reason: 'Reddit rejected the vote with HTTP 403' };
|
|
}
|
|
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 vote response (HTTP ${response.status})` };
|
|
const errors = readErrors(payload);
|
|
if (errors.length > 0) {
|
|
const classified = this.classifyError(errors[0], 'the selected community');
|
|
if (classified.status === 'rate-limited') return { status: 'rate-limited', retryAfterMs: classified.retryAfterMs, reason: classified.reason };
|
|
if (classified.status === 'session-invalid' || classified.status === 'blocked') return classified;
|
|
return { status: 'failed', reason: 'reason' in classified ? classified.reason : 'Reddit rejected the vote' };
|
|
}
|
|
this.logger.info(`Submitted Reddit upvote for ${thingFullname}`);
|
|
return { status: 'submitted' };
|
|
}
|
|
|
|
/**
|
|
* 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 = `${REDDIT_WRITE_ORIGIN}/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;
|
|
}
|
|
|
|
/**
|
|
* Resolves this session's modhash, distinguishing "we could not reach Reddit"
|
|
* from "Reddit says this session is not signed in".
|
|
*
|
|
* The distinction matters beyond growth: a `session-invalid` result
|
|
* invalidates the account's shared auth context, which would disrupt outreach
|
|
* and chat for that account. A network or browser failure must never do that.
|
|
*/
|
|
private async ensureModhash(): Promise<ModhashResult> {
|
|
if (this.modhash) return { ok: true, modhash: this.modhash };
|
|
let response: HttpTextResponse;
|
|
try {
|
|
response = await this.scheduler.schedule(
|
|
() => this.transport.get(`${REDDIT_WRITE_ORIGIN}/api/me.json`),
|
|
);
|
|
} catch (error) {
|
|
this.logger.warn('Could not reach Reddit to read the session modhash:', error);
|
|
return {
|
|
ok: false,
|
|
kind: 'unreachable',
|
|
reason: `Could not reach Reddit to read the session modhash: ${errorMessage(error)}`,
|
|
};
|
|
}
|
|
|
|
// A non-JSON body is Reddit refusing to serve us at all — an anti-abuse
|
|
// interstitial or error page. Reading that as "signed out" would invalidate
|
|
// a perfectly good session and disrupt outreach, so it stays retryable.
|
|
if (!looksLikeJson(response.body)) {
|
|
this.logger.warn(`Growth: ${blockPageReason(response)}`);
|
|
return { ok: false, kind: 'unreachable', reason: blockPageReason(response) };
|
|
}
|
|
if (response.status === 401 || response.status === 403) {
|
|
return {
|
|
ok: false,
|
|
kind: 'signed-out',
|
|
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
|
|
};
|
|
}
|
|
if (response.status !== 200) {
|
|
this.logger.warn(`Reddit returned HTTP ${response.status} for the session identity check`);
|
|
return {
|
|
ok: false,
|
|
kind: 'unreachable',
|
|
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
// Reddit answered with real JSON and no modhash, so it genuinely does not
|
|
// recognise this session. The cookie summary is the fastest way to see
|
|
// why — most often they are scoped to a host this origin cannot read.
|
|
const cookies = await this.describeSessionCookies();
|
|
this.logger.warn(
|
|
`Growth: Reddit does not recognise this session on ${REDDIT_WRITE_ORIGIN}. Cookies visible: ${cookies}`,
|
|
);
|
|
return {
|
|
ok: false,
|
|
kind: 'signed-out',
|
|
reason: `Reddit did not return a modhash for this session (cookies visible: ${cookies})`,
|
|
};
|
|
}
|
|
this.modhash = modhash;
|
|
return { ok: true, modhash };
|
|
}
|
|
|
|
private async describeSessionCookies(): Promise<string> {
|
|
if (!this.describeCookies) return 'not inspected';
|
|
try {
|
|
return await this.describeCookies();
|
|
} catch (error) {
|
|
return `could not be read (${errorMessage(error)})`;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
url(): string;
|
|
goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise<unknown>;
|
|
evaluate<Result, Argument>(
|
|
fn: (argument: Argument) => Promise<Result>,
|
|
argument: Argument,
|
|
): Promise<Result>;
|
|
}
|
|
|
|
export function isRedditWriteOrigin(value: string): boolean {
|
|
try {
|
|
return new URL(value).origin === REDDIT_WRITE_ORIGIN;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function createBrowserWriteTransport(page: EvaluatingPage): RedditWriteTransport {
|
|
/**
|
|
* Parks the page on the Reddit origin before it fetches anything.
|
|
*
|
|
* A freshly opened page sits on `about:blank`, whose opaque origin cannot
|
|
* reach Reddit at all — the fetch fails outright with "Failed to fetch". Any
|
|
* other origin would make these calls cross-origin, so the session cookies
|
|
* would be withheld even if CORS allowed the request. Being on the same
|
|
* origin is what makes them authenticated.
|
|
*/
|
|
const ensureOrigin = async (): Promise<void> => {
|
|
if (isRedditWriteOrigin(page.url())) return;
|
|
await page.goto(REDDIT_WRITE_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
};
|
|
|
|
return {
|
|
// The page already carries the profile's cookies and user agent, so the
|
|
// request needs no explicit identity of its own.
|
|
get: async (url) => {
|
|
await ensureOrigin();
|
|
return 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: async (url, form, headers) => {
|
|
await ensureOrigin();
|
|
return 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;
|
|
}
|
|
|
|
/** Reddit serves JSON for real answers and HTML when it is blocking or erroring. */
|
|
function looksLikeJson(body: string): boolean {
|
|
const start = body.trimStart();
|
|
return start.startsWith('{') || start.startsWith('[');
|
|
}
|
|
|
|
function blockPageReason(response: HttpTextResponse): string {
|
|
return `Reddit served a non-JSON HTTP ${response.status} response `
|
|
+ `(${response.body.length} bytes), which is an anti-abuse block page rather than a session verdict`;
|
|
}
|
|
|
|
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);
|
|
}
|