173 lines
4.8 KiB
JavaScript
173 lines
4.8 KiB
JavaScript
// Reddit social sentiment intelligence.
|
|
// Reddit API access requires OAuth. Runtime sweeps intentionally do not use
|
|
// unauthenticated reddit.com .json scraping because it is unreliable and not
|
|
// acceptable for production operation.
|
|
|
|
import { safeFetch } from '../utils/fetch.mjs';
|
|
import '../utils/env.mjs';
|
|
|
|
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
|
const USER_AGENT = 'Crucix/2.0 intelligence-engine';
|
|
|
|
const SUBREDDITS = [
|
|
'worldnews',
|
|
'geopolitics',
|
|
'economics',
|
|
'wallstreetbets',
|
|
'commodities',
|
|
];
|
|
|
|
export function getRedditConfig(env = process.env) {
|
|
const clientId = env.REDDIT_CLIENT_ID || '';
|
|
const clientSecret = env.REDDIT_CLIENT_SECRET || '';
|
|
const missing = [];
|
|
if (!clientId) missing.push('REDDIT_CLIENT_ID');
|
|
if (!clientSecret) missing.push('REDDIT_CLIENT_SECRET');
|
|
return {
|
|
clientId,
|
|
clientSecret,
|
|
configured: missing.length === 0,
|
|
missing,
|
|
};
|
|
}
|
|
|
|
function credentialsMessage(missing) {
|
|
return `Reddit requires OAuth. Register a script app at https://www.reddit.com/prefs/apps/ and set ${missing.join(' and ')} in .env`;
|
|
}
|
|
|
|
export async function getToken({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
const config = getRedditConfig(env);
|
|
if (!config.configured) {
|
|
return {
|
|
ok: false,
|
|
status: 'no_credentials',
|
|
missing: config.missing,
|
|
error: 'missing_reddit_oauth_credentials',
|
|
message: credentialsMessage(config.missing),
|
|
};
|
|
}
|
|
|
|
try {
|
|
const auth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
|
|
const res = await fetchImpl('https://www.reddit.com/api/v1/access_token', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${auth}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
body: 'grant_type=client_credentials',
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '');
|
|
return {
|
|
ok: false,
|
|
status: 'auth_failed',
|
|
error: `reddit_oauth_http_${res.status}`,
|
|
message: `Reddit OAuth token request failed with HTTP ${res.status}`,
|
|
detail: body.slice(0, 200),
|
|
};
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (!data.access_token) {
|
|
return {
|
|
ok: false,
|
|
status: 'auth_failed',
|
|
error: 'reddit_oauth_missing_access_token',
|
|
message: 'Reddit OAuth token response did not include an access token',
|
|
};
|
|
}
|
|
return { ok: true, status: 'ok', token: data.access_token };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
status: 'auth_failed',
|
|
error: 'reddit_oauth_request_failed',
|
|
message: e.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function getHot(subreddit, opts = {}) {
|
|
const { limit = 10, token = null } = opts;
|
|
|
|
if (!token) {
|
|
return {
|
|
status: 'no_credentials',
|
|
error: 'reddit_oauth_required',
|
|
message: 'Reddit source requires OAuth; unauthenticated reddit.com .json scraping is disabled',
|
|
};
|
|
}
|
|
|
|
return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
|
|
source: 'Reddit',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
}
|
|
|
|
function compactPost(child) {
|
|
const d = child?.data;
|
|
if (!d) return null;
|
|
return {
|
|
title: d.title,
|
|
score: d.score ?? 0,
|
|
comments: d.num_comments ?? 0,
|
|
url: d.url,
|
|
created: d.created_utc ? new Date(d.created_utc * 1000).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
export async function briefing(opts = {}) {
|
|
const {
|
|
env = process.env,
|
|
subreddits = SUBREDDITS,
|
|
delayMs = 1000,
|
|
fetchImpl = globalThis.fetch,
|
|
} = opts;
|
|
const tokenResult = await getToken({ env, fetchImpl });
|
|
|
|
if (!tokenResult.ok) {
|
|
return {
|
|
source: 'Reddit',
|
|
timestamp: new Date().toISOString(),
|
|
status: tokenResult.status,
|
|
error: tokenResult.error,
|
|
message: tokenResult.message,
|
|
missing: tokenResult.missing || [],
|
|
};
|
|
}
|
|
|
|
const subredditResults = {};
|
|
const errors = [];
|
|
for (const sub of subreddits) {
|
|
const result = await getHot(sub, { limit: 10, token: tokenResult.token });
|
|
if (result?.error) {
|
|
errors.push({ subreddit: sub, error: result.error });
|
|
subredditResults[sub] = [];
|
|
if (delayMs > 0) await delay(delayMs);
|
|
continue;
|
|
}
|
|
const children = result?.data?.children || [];
|
|
subredditResults[sub] = children.map(compactPost).filter(Boolean);
|
|
if (delayMs > 0) await delay(delayMs);
|
|
}
|
|
|
|
return {
|
|
source: 'Reddit',
|
|
timestamp: new Date().toISOString(),
|
|
status: errors.length > 0 ? 'degraded' : 'ok',
|
|
...(errors.length > 0 ? { error: 'reddit_subreddit_fetch_failed', errors } : {}),
|
|
subreddits: subredditResults,
|
|
};
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith('reddit.mjs')) {
|
|
const data = await briefing();
|
|
console.log(JSON.stringify(data, null, 2));
|
|
}
|