Merge branch 'codex/production-intelligence-terminal' into codex/issue-3-acled-diagnostics-auth-tests
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 50s

This commit is contained in:
2026-05-17 14:45:19 +00:00
16 changed files with 546 additions and 145 deletions

View File

@@ -1,14 +1,15 @@
// Reddit social sentiment intelligence
// Reddit now requires OAuth for API access (public JSON API returns 403).
// Gracefully degrades when not authenticated.
// To enable: register an app at https://www.reddit.com/prefs/apps/ and set
// REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env
// 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',
@@ -17,48 +18,95 @@ const SUBREDDITS = [
'commodities',
];
// Get OAuth token using client credentials flow (application-only)
async function getToken() {
const clientId = process.env.REDDIT_CLIENT_ID;
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
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(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://www.reddit.com/api/v1/access_token', {
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': 'Crucix/1.0 intelligence-engine',
'User-Agent': USER_AGENT,
},
body: 'grant_type=client_credentials',
});
if (!res.ok) return null;
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();
return data.access_token || null;
} catch {
return null;
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,
};
}
}
// Fetch hot posts — tries OAuth first, then falls back to public endpoint
export async function getHot(subreddit, opts = {}) {
const { limit = 10, token = null } = opts;
if (token) {
// Use OAuth endpoint
return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'Crucix/1.0 intelligence-engine',
},
});
if (!token) {
return {
status: 'no_credentials',
error: 'reddit_oauth_required',
message: 'Reddit source requires OAuth; unauthenticated reddit.com .json scraping is disabled',
};
}
// Try public endpoint (may 403)
return safeFetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=${limit}&raw_json=1`, {
headers: { 'User-Agent': 'Crucix/1.0 intelligence-engine' },
return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
source: 'Reddit',
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': USER_AGENT,
},
});
}
@@ -74,29 +122,46 @@ function compactPost(child) {
};
}
export async function briefing() {
const token = await getToken();
export async function briefing(opts = {}) {
const {
env = process.env,
subreddits = SUBREDDITS,
delayMs = 1000,
fetchImpl = globalThis.fetch,
} = opts;
const tokenResult = await getToken({ env, fetchImpl });
if (!token && !process.env.REDDIT_CLIENT_ID) {
if (!tokenResult.ok) {
return {
source: 'Reddit',
timestamp: new Date().toISOString(),
status: 'no_key',
message: 'Reddit requires OAuth. Register at https://www.reddit.com/prefs/apps/ (script type), set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env',
status: tokenResult.status,
error: tokenResult.error,
message: tokenResult.message,
missing: tokenResult.missing || [],
};
}
const subredditResults = {};
for (const sub of SUBREDDITS) {
const result = await getHot(sub, { limit: 10, token });
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);
await delay(token ? 1000 : 2000);
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,
};
}