374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const Bottleneck = require('bottleneck');
|
|
const cheerio = require('cheerio');
|
|
const config = require('config');
|
|
|
|
const { fetch } = require('../proxy');
|
|
const { version } = require('../../package.json');
|
|
const logger = require('../logger')(__filename);
|
|
|
|
const BASE_URL = 'https://www.reddit.com';
|
|
const MAX_RETRIES = 5;
|
|
|
|
// deliberately much heavier throttling than the old OAuth limiter (src/limiter.js reddit: 30/min) -
|
|
// this is unauthenticated/public access and getting flagged as a bot defeats the point
|
|
const limiter = new Bottleneck({
|
|
reservoir: config.reddit.throttle?.reservoir ?? 6,
|
|
reservoirRefreshAmount: config.reddit.throttle?.reservoir ?? 6,
|
|
reservoirRefreshInterval: 60000,
|
|
maxConcurrent: 1,
|
|
minTime: config.reddit.throttle?.minTime ?? 1500,
|
|
});
|
|
|
|
// parses a Netscape/Mozilla-format cookie jar dump (domain, includeSubdomains, path,
|
|
// secure, expiry, name, value - tab separated) into a `name=value; name2=value2` header
|
|
function parseCookieJar(raw) {
|
|
const now = Date.now() / 1000;
|
|
|
|
return raw
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith('#'))
|
|
.map((line) => line.split('\t'))
|
|
.filter((fields) => fields.length === 7)
|
|
.filter(([, , , , expiry]) => expiry === '0' || Number(expiry) > now)
|
|
.filter(([domain]) => domain.replace(/^\./, '').endsWith('reddit.com'))
|
|
.map(([, , , , , name, value]) => `${name}=${value}`)
|
|
.join('; ');
|
|
}
|
|
|
|
function soonestExpiry(raw) {
|
|
const expiries = raw
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith('#'))
|
|
.map((line) => line.split('\t'))
|
|
.filter((fields) => fields.length === 7)
|
|
.map(([, , , , expiry]) => Number(expiry))
|
|
.filter((expiry) => expiry > 0);
|
|
|
|
return expiries.length ? Math.min(...expiries) : null;
|
|
}
|
|
|
|
let cachedCookieHeader;
|
|
|
|
function cookieHeader() {
|
|
if (cachedCookieHeader === undefined) {
|
|
cachedCookieHeader = config.reddit.cookies
|
|
? parseCookieJar(config.reddit.cookies)
|
|
: (config.reddit.cookie || null);
|
|
|
|
logger.info(cachedCookieHeader
|
|
? `Using ${cachedCookieHeader.split(';').length} reddit cookie(s) for authenticated requests`
|
|
: 'No reddit cookies configured, requesting unauthenticated');
|
|
|
|
if (config.reddit.cookies) {
|
|
const expiry = soonestExpiry(config.reddit.cookies);
|
|
const hoursLeft = expiry ? (expiry - Date.now() / 1000) / 3600 : null;
|
|
|
|
if (hoursLeft !== null && hoursLeft < 48) {
|
|
logger.warn(`Soonest-expiring reddit cookie expires in ${hoursLeft.toFixed(1)}h (${new Date(expiry * 1000).toISOString()}) - refresh config/cookies.js before then`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cachedCookieHeader;
|
|
}
|
|
|
|
function headers(accept = 'application/json') {
|
|
const requestHeaders = {
|
|
'user-agent': config.reddit.userAgent || `ripunzel/${version}`,
|
|
accept,
|
|
};
|
|
|
|
const cookie = cookieHeader();
|
|
|
|
if (cookie) {
|
|
requestHeaders.cookie = cookie;
|
|
}
|
|
|
|
return requestHeaders;
|
|
}
|
|
|
|
function buildUrl(path, params) {
|
|
const url = new URL(`${BASE_URL}${path}`);
|
|
|
|
Object.entries({ raw_json: 1, ...params }).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
return url;
|
|
}
|
|
|
|
async function wait(seconds) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, seconds * 1000);
|
|
});
|
|
}
|
|
|
|
// shared by requestJson() and requestHtml() below - handles the actual throttled network
|
|
// call plus retrying on a network error or a 429/503, regardless of what kind of response
|
|
// the caller ultimately wants back.
|
|
//
|
|
// Only the actual throttled network call belongs inside limiter.schedule() - this has
|
|
// maxConcurrent: 1, and every retry path below used to recurse back into this function
|
|
// (and so back into limiter.schedule()) from *inside* that same callback. Bottleneck won't
|
|
// start a second job while the first is still running, and the first can't finish until
|
|
// its awaited recursive call starts - a guaranteed deadlock the instant a retry was ever
|
|
// needed (e.g. a single 429), freezing every reddit request behind it for good. Retrying
|
|
// now happens here, after the scheduled call has actually returned and freed its slot.
|
|
async function fetchWithRetry(path, params, attempt, accept) {
|
|
const url = buildUrl(path, params);
|
|
let res;
|
|
|
|
try {
|
|
// config.proxy.retryFlipped: once this request has already failed once, retry it via
|
|
// whichever of proxied/direct is NOT what it'd normally get - see proxy.js
|
|
res = await limiter.schedule(async () => fetch(url.toString(), {
|
|
headers: headers(accept),
|
|
proxyFlip: config.proxy.retryFlipped && attempt > 0,
|
|
}));
|
|
} catch (error) {
|
|
// fetch() has no timeout of its own beyond undici's fairly generous defaults (see
|
|
// proxy.js) - this catches both that and genuine network errors (DNS, connection
|
|
// refused/reset, etc.), neither of which was retried before
|
|
if (attempt >= MAX_RETRIES) {
|
|
throw new Error(`Failed to reach reddit after ${attempt} retries for '${path}': ${error.message}`);
|
|
}
|
|
|
|
logger.warn(`Failed to reach reddit for '${path}', retrying: ${error.message}`);
|
|
|
|
return fetchWithRetry(path, params, attempt + 1, accept);
|
|
}
|
|
|
|
if (res.status === 429 || res.status === 503) {
|
|
if (attempt >= MAX_RETRIES) {
|
|
throw new Error(`Reddit rate-limited request after ${attempt} retries (${res.status}) for '${path}'`);
|
|
}
|
|
|
|
const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
|
|
|
|
logger.warn(`Rate limited by reddit (${res.status}) for '${path}', retrying in ${retryAfter}s`);
|
|
|
|
await wait(retryAfter);
|
|
|
|
return fetchWithRetry(path, params, attempt + 1, accept);
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
async function requestJson(path, params = {}, attempt = 0) {
|
|
const res = await fetchWithRetry(path, params, attempt, 'application/json');
|
|
const contentType = res.headers.get('content-type') || '';
|
|
|
|
if (!contentType.includes('json')) {
|
|
// reddit serves an HTML interstitial/block page instead of JSON when it decides
|
|
// the request looks like a bot - this is a distinct failure mode from a genuine
|
|
// 4xx and should be logged/handled separately rather than treated as "bad request"
|
|
const hint = cookieHeader() ? ' - session cookie may have expired, refresh config/cookies.js' : ' - likely blocked or interstitial page (consider setting config.reddit.cookies)';
|
|
|
|
throw new Error(`Reddit returned non-JSON response (${res.status}, content-type '${contentType || 'none'}') for '${path}'${hint}`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
const message = data?.message || data?.reason || JSON.stringify(data).slice(0, 200);
|
|
|
|
throw new Error(`Reddit returned ${res.status} for '${path}': ${message}`);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// unlike requestJson(), this is for reddit's rendered profile pages - there's no public JSON
|
|
// endpoint for a user's social links (the "Social Links" widget on their profile), they're
|
|
// only present in the page's HTML (see getSocialLinks() below)
|
|
async function requestHtml(path, params = {}, attempt = 0) {
|
|
const res = await fetchWithRetry(path, params, attempt, 'text/html');
|
|
const contentType = res.headers.get('content-type') || '';
|
|
|
|
if (!contentType.includes('html')) {
|
|
const hint = cookieHeader() ? ' - session cookie may have expired, refresh config/cookies.js' : ' - likely blocked or interstitial page (consider setting config.reddit.cookies)';
|
|
|
|
throw new Error(`Reddit returned non-HTML response (${res.status}, content-type '${contentType || 'none'}') for '${path}'${hint}`);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Reddit returned ${res.status} for '${path}'`);
|
|
}
|
|
|
|
return res.text();
|
|
}
|
|
|
|
// reddit's "Social Links" profile widget (the row of Twitter/Instagram/OnlyFans/etc. buttons
|
|
// under a user's bio) has no public JSON endpoint - the structured API for it
|
|
// (/r/u_{username}/api/widgets.json) requires OAuth, which this project deliberately doesn't
|
|
// use (see reddit.throttle above). The rendered profile page embeds the same data anyway, as
|
|
// JSON inside each link's `data-faceplate-tracking-context` attribute, and that page is
|
|
// accessible with the same cookies as everything else here.
|
|
function parseSocialLinks(html) {
|
|
const $ = cheerio.load(html);
|
|
const links = [];
|
|
|
|
$('[data-faceplate-tracking-context]').each((index, el) => {
|
|
try {
|
|
const { social_link: link } = JSON.parse($(el).attr('data-faceplate-tracking-context'));
|
|
|
|
if (link) {
|
|
links.push(link);
|
|
}
|
|
} catch (error) {
|
|
// not a social-link tracker, or a shape we don't recognize - ignore
|
|
}
|
|
});
|
|
|
|
return links
|
|
.sort((a, b) => a.position - b.position)
|
|
.map(({ type, name, url }) => ({ type, name, url }));
|
|
}
|
|
|
|
async function getSocialLinks(username) {
|
|
const html = await requestHtml(`/user/${username}/`);
|
|
|
|
return parseSocialLinks(html);
|
|
}
|
|
|
|
async function getUser(username) {
|
|
const data = await requestJson(`/user/${username}/about.json`);
|
|
|
|
return data.data;
|
|
}
|
|
|
|
async function getSubmissions(username, { sort = 'new', limit = Infinity } = {}) {
|
|
const submissions = [];
|
|
let after = null;
|
|
|
|
do {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const data = await requestJson(`/user/${username}/submitted.json`, {
|
|
sort,
|
|
limit: Math.min(100, Number.isFinite(limit) ? limit - submissions.length : 100),
|
|
after,
|
|
});
|
|
|
|
const page = data.data.children.map((child) => child.data);
|
|
|
|
submissions.push(...page);
|
|
after = data.data.after;
|
|
} while (after && submissions.length < limit);
|
|
|
|
return submissions;
|
|
}
|
|
|
|
// reddit's raw comment listing is a tree of {kind, data} "things": kind 't1' is a comment
|
|
// (whose own `replies` field is either '' or a nested Listing of more things), kind 'more'
|
|
// is a stub meaning "N additional comment IDs exist here, fetch them separately".
|
|
function flattenThings(things) {
|
|
const comments = [];
|
|
const moreIds = [];
|
|
|
|
things.forEach((thing) => {
|
|
if (thing.kind === 't1') {
|
|
comments.push(thing.data);
|
|
|
|
if (thing.data.replies?.data?.children) {
|
|
const nested = flattenThings(thing.data.replies.data.children);
|
|
|
|
comments.push(...nested.comments);
|
|
moreIds.push(...nested.moreIds);
|
|
}
|
|
} else if (thing.kind === 'more' && thing.data.id !== '_') {
|
|
moreIds.push(...thing.data.children);
|
|
}
|
|
});
|
|
|
|
return { comments, moreIds };
|
|
}
|
|
|
|
async function moreChildren(linkId, ids) {
|
|
const unique = [...new Set(ids)];
|
|
const results = [];
|
|
|
|
for (let offset = 0; offset < unique.length; offset += 100) {
|
|
const chunk = unique.slice(offset, offset + 100);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const data = await requestJson('/api/morechildren.json', {
|
|
api_type: 'json',
|
|
link_id: linkId,
|
|
children: chunk.join(','),
|
|
limit_children: false,
|
|
});
|
|
|
|
results.push(...(data.json?.data?.things || []));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// re-fetches 'more' stubs and re-attaches them by parent_id; morechildren can itself return
|
|
// further 'more' stubs on very deep threads, so this recurses with a depth cap rather than
|
|
// looping forever
|
|
async function resolveMore(moreIds, linkId, depth = 0) {
|
|
if (!moreIds.length) {
|
|
return [];
|
|
}
|
|
|
|
if (depth >= 4) {
|
|
logger.warn(`Gave up resolving ${moreIds.length} deeply-nested 'more comments' stubs for '${linkId}' after ${depth} rounds`);
|
|
|
|
return [];
|
|
}
|
|
|
|
const more = await moreChildren(linkId, moreIds);
|
|
const resolvedComments = more.filter((thing) => thing.kind === 't1').map((thing) => thing.data);
|
|
const nestedMoreIds = more.filter((thing) => thing.kind === 'more').flatMap((thing) => thing.data.children);
|
|
|
|
return [...resolvedComments, ...await resolveMore(nestedMoreIds, linkId, depth + 1)];
|
|
}
|
|
|
|
function buildCommentTree(comments, parentId) {
|
|
return comments
|
|
.filter((comment) => comment.parent_id === parentId)
|
|
.map((comment) => ({
|
|
...comment,
|
|
replies: buildCommentTree(comments, `t1_${comment.id}`),
|
|
}));
|
|
}
|
|
|
|
// post metadata only - cheap, no comment-tree resolution (limit: 0 asks reddit to skip
|
|
// returning comments too, but we ignore the second listing element regardless)
|
|
async function getSubmission(id) {
|
|
const [postListing] = await requestJson(`/comments/${id}.json`, { limit: 0 });
|
|
|
|
return postListing.data.children[0].data;
|
|
}
|
|
|
|
// post + fully-resolved comment tree, incl. 'more comments' stubs - expensive on busy
|
|
// threads (each round of stubs is its own throttled request), only use where the full
|
|
// thread is actually needed
|
|
async function getSubmissionWithComments(id) {
|
|
const linkId = `t3_${id}`;
|
|
const [postListing, commentsListing] = await requestJson(`/comments/${id}.json`);
|
|
const post = postListing.data.children[0].data;
|
|
|
|
const { comments, moreIds } = flattenThings(commentsListing.data.children);
|
|
const resolved = [...comments, ...await resolveMore(moreIds, linkId)];
|
|
|
|
return { post, comments: buildCommentTree(resolved, linkId) };
|
|
}
|
|
|
|
module.exports = {
|
|
requestJson,
|
|
getUser,
|
|
getSocialLinks,
|
|
getSubmissions,
|
|
getSubmission,
|
|
getSubmissionWithComments,
|
|
};
|