Skip to content

Commit 6b4dadf

Browse files
authored
fix(seeder): replace curlFetch with Node.js CONNECT tunnel in fredFetchJson (koala73#2451)
* chore: redeploy to pick up WORLDMONITOR_VALID_KEYS fix * fix(seeder): replace curlFetch with Node.js CONNECT tunnel in fredFetchJson Seeder Railway containers use node:22-alpine (no curl). fredFetchJson was routing through curlFetch when PROXY_URL is set, causing spawnSync curl ENOENT on every FRED series — all 22 series failed silently. Fix: replace curlFetch call in fredFetchJson with a pure Node.js HTTPS-through-HTTP-proxy CONNECT tunnel using built-in http/tls/https modules. No new dependencies. curlFetch is kept for ais-relay.cjs callers (Dockerfile.relay installs curl via apk add). Root cause confirmed via logs: spawnSync curl ENOENT on all 22 FRED series. * fix(seeder): try direct first, proxy as fallback in fredFetchJson * chore(seeder): TODO to consolidate all curlFetch/proxy patterns into one helper * fix(seeders): remove curl dependency from disease-outbreaks and fear-greed Both seeders used curl as primary fetch path when PROXY_URL was set. The Decodo proxy was returning SSL_ERROR_SYSCALL causing fetch failures. Replace with native fetch() — same direct-first pattern as fredFetchJson after the FRED fix. No fallback needed: these feeds are publicly accessible, and partial failures are already handled by per-source try/catch. * fix(seeder): add User-Agent to proxy tunnel; destroy socket on CONNECT failure P1: httpsProxyFetchJson was missing User-Agent header — AGENTS.md requires it for all server-side fetches; FRED CDN/WAF may reject headless requests. P2: TCP socket left open on non-200 CONNECT response; call socket.destroy().
1 parent cc3c50c commit 6b4dadf

3 files changed

Lines changed: 106 additions & 61 deletions

File tree

scripts/_seed-utils.mjs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import { execFileSync } from 'node:child_process';
55
import { dirname, join } from 'node:path';
66
import { fileURLToPath } from 'node:url';
77
import { createRequire } from 'node:module';
8+
import * as http from 'node:http';
9+
import * as tls from 'node:tls';
10+
import * as https from 'node:https';
11+
import { promisify } from 'node:util';
12+
import { gunzip as _gunzip } from 'node:zlib';
13+
14+
const gunzip = promisify(_gunzip);
815

916
const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36';
1017
const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024; // 5MB per key
@@ -301,13 +308,22 @@ export function sleep(ms) {
301308
}
302309

303310
// ─── Proxy helpers for sources that block Railway container IPs ───
311+
// TODO: consolidate all fetch+proxy logic into a single proxyFetch(url, options) helper.
312+
// Current state: _seed-utils has fredFetchJson (fixed: direct-first, proxy fallback) and
313+
// curlFetch (curl-only, relay container). seed-disease-outbreaks.mjs and seed-fear-greed.mjs
314+
// each define their own local curlFetch that silently fails with ENOENT in seeder containers
315+
// (curl not in node:22-alpine). Each of those scripts should use a shared fetchWithProxyFallback
316+
// that tries native fetch first and falls back to httpsProxyFetchJson — same pattern as
317+
// fredFetchJson after this fix. Tracked: consolidate into one exported function.
304318
const { resolveProxyString } = createRequire(import.meta.url)('./_proxy-utils.cjs');
305319

306320
export function resolveProxy() {
307321
return resolveProxyString();
308322
}
309323

310324
// curl-based fetch; throws on non-2xx. Returns response body as string.
325+
// NOTE: requires curl binary — only available in Dockerfile.relay (apk add curl).
326+
// Do NOT call from standalone seed scripts; use fredFetchJson or httpsProxyFetchJson instead.
311327
export function curlFetch(url, proxyAuth, headers = {}) {
312328
const args = ['-sS', '--compressed', '--max-time', '15', '-L'];
313329
if (proxyAuth) args.push('-x', `http://${proxyAuth}`);
@@ -321,12 +337,84 @@ export function curlFetch(url, proxyAuth, headers = {}) {
321337
return raw.slice(0, nl);
322338
}
323339

340+
// Pure Node.js HTTPS-through-HTTP-proxy (CONNECT tunnel).
341+
// Replaces curlFetch for seeder scripts running in containers without curl.
342+
// proxyAuth format: "user:pass@host:port"
343+
async function httpsProxyFetchJson(url, proxyAuth) {
344+
const targetUrl = new URL(url);
345+
const atIdx = proxyAuth.lastIndexOf('@');
346+
const credentials = atIdx >= 0 ? proxyAuth.slice(0, atIdx) : '';
347+
const hostPort = atIdx >= 0 ? proxyAuth.slice(atIdx + 1) : proxyAuth;
348+
const colonIdx = hostPort.lastIndexOf(':');
349+
const proxyHost = hostPort.slice(0, colonIdx);
350+
const proxyPort = parseInt(hostPort.slice(colonIdx + 1), 10);
351+
352+
const connectHeaders = {};
353+
if (credentials) {
354+
connectHeaders['Proxy-Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`;
355+
}
356+
357+
const { socket } = await new Promise((resolve, reject) => {
358+
http.request({
359+
host: proxyHost, port: proxyPort,
360+
method: 'CONNECT',
361+
path: `${targetUrl.hostname}:443`,
362+
headers: connectHeaders,
363+
}).on('connect', (res, socket) => {
364+
if (res.statusCode !== 200) {
365+
socket.destroy();
366+
return reject(Object.assign(new Error(`Proxy CONNECT: ${res.statusCode}`), { status: res.statusCode }));
367+
}
368+
resolve({ socket });
369+
}).on('error', reject).end();
370+
});
371+
372+
const tlsSock = tls.connect({ socket, servername: targetUrl.hostname });
373+
await new Promise((resolve, reject) => {
374+
tlsSock.on('secureConnect', resolve);
375+
tlsSock.on('error', reject);
376+
});
377+
378+
return new Promise((resolve, reject) => {
379+
const timer = setTimeout(() => { tlsSock.destroy(); reject(new Error('FRED proxy fetch timeout')); }, 20000);
380+
const fail = (e) => { clearTimeout(timer); tlsSock.destroy(); reject(e); };
381+
382+
https.request({
383+
host: targetUrl.hostname,
384+
path: targetUrl.pathname + targetUrl.search,
385+
method: 'GET',
386+
headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'User-Agent': CHROME_UA },
387+
createConnection: () => tlsSock,
388+
}, (resp) => {
389+
clearTimeout(timer);
390+
if (resp.statusCode < 200 || resp.statusCode >= 300) {
391+
resp.resume();
392+
return reject(Object.assign(new Error(`HTTP ${resp.statusCode}`), { status: resp.statusCode }));
393+
}
394+
const chunks = [];
395+
resp.on('data', c => chunks.push(c));
396+
resp.on('end', () => {
397+
const body = Buffer.concat(chunks);
398+
const isGzip = (resp.headers['content-encoding'] || '').includes('gzip');
399+
(isGzip ? gunzip(body) : Promise.resolve(body))
400+
.then(data => { try { resolve(JSON.parse(data.toString('utf8'))); } catch (e) { fail(e); } })
401+
.catch(fail);
402+
});
403+
resp.on('error', fail);
404+
}).on('error', fail).end();
405+
});
406+
}
407+
324408
// Fetch JSON from a FRED URL, routing through proxy when available.
325409
export async function fredFetchJson(url, proxyAuth) {
326-
if (proxyAuth) return JSON.parse(curlFetch(url, proxyAuth, { Accept: 'application/json' }));
327-
const r = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) });
328-
if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
329-
return r.json();
410+
try {
411+
const r = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) });
412+
if (r.ok) return r.json();
413+
throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
414+
} catch (directErr) {
415+
if (!proxyAuth) throw directErr;
416+
return httpsProxyFetchJson(url, proxyAuth);
417+
}
330418
}
331419

332420
// ---------------------------------------------------------------------------

scripts/seed-disease-outbreaks.mjs

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { execFileSync } from 'node:child_process';
4-
import { loadEnvFile, CHROME_UA, runSeed, resolveProxy } from './_seed-utils.mjs';
3+
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
54
import { extractCountryCode } from './shared/geo-extract.mjs';
65

76
loadEnvFile(import.meta.url);
@@ -42,22 +41,6 @@ const TGH_LOOKBACK_DAYS = 90;
4241

4342
const RSS_MAX_BYTES = 500_000; // guard against oversized responses before regex
4443

45-
const _proxyAuth = resolveProxy();
46-
47-
// curl-based fetch for sources that block Railway IPs (e.g. Outbreak News Today).
48-
function curlFetch(url, extraHeaders = {}) {
49-
const args = ['-sS', '--compressed', '--max-time', '15', '-L'];
50-
if (_proxyAuth) args.push('-x', `http://${_proxyAuth}`);
51-
args.push('-H', `User-Agent: ${CHROME_UA}`);
52-
for (const [k, v] of Object.entries(extraHeaders)) args.push('-H', `${k}: ${v}`);
53-
args.push('-w', '\n%{http_code}');
54-
args.push(url);
55-
const raw = execFileSync('curl', args, { encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] });
56-
const nl = raw.lastIndexOf('\n');
57-
const httpStatus = parseInt(raw.slice(nl + 1).trim(), 10);
58-
if (httpStatus < 200 || httpStatus >= 300) throw Object.assign(new Error(`HTTP ${httpStatus}`), { status: httpStatus });
59-
return raw.slice(0, nl);
60-
}
6144

6245
function stableHash(str) {
6346
let h = 0;
@@ -131,19 +114,14 @@ async function fetchWhoDonApi() {
131114
}
132115
}
133116

134-
async function fetchRssItems(url, sourceName, useCurl = false) {
117+
async function fetchRssItems(url, sourceName) {
135118
try {
136-
let xml;
137-
if (useCurl) {
138-
xml = curlFetch(url, { Accept: 'application/rss+xml, application/xml, text/xml' });
139-
} else {
140-
const resp = await fetch(url, {
141-
headers: { Accept: 'application/rss+xml, application/xml, text/xml', 'User-Agent': CHROME_UA },
142-
signal: AbortSignal.timeout(15000),
143-
});
144-
if (!resp.ok) { console.warn(`[Disease] ${sourceName} HTTP ${resp.status}`); return []; }
145-
xml = await resp.text();
146-
}
119+
const resp = await fetch(url, {
120+
headers: { Accept: 'application/rss+xml, application/xml, text/xml', 'User-Agent': CHROME_UA },
121+
signal: AbortSignal.timeout(15000),
122+
});
123+
if (!resp.ok) { console.warn(`[Disease] ${sourceName} HTTP ${resp.status}`); return []; }
124+
const xml = await resp.text();
147125
const bounded = xml.length > RSS_MAX_BYTES ? xml.slice(0, RSS_MAX_BYTES) : xml;
148126
const items = [];
149127
const itemRe = /<item>([\s\S]*?)<\/item>/g;
@@ -268,7 +246,7 @@ async function fetchDiseaseOutbreaks() {
268246
const [whoItems, cdcItems, outbreakNewsItems, tghItems] = await Promise.all([
269247
fetchWhoDonApi(),
270248
fetchRssItems(CDC_FEED, 'CDC'),
271-
fetchRssItems(OUTBREAK_NEWS_FEED, 'Outbreak News Today', true),
249+
fetchRssItems(OUTBREAK_NEWS_FEED, 'Outbreak News Today'),
272250
fetchThinkGlobalHealth(),
273251
]);
274252
console.log(`[Disease] Sources: WHO=${whoItems.length} CDC=${cdcItems.length} ONT=${outbreakNewsItems.length} TGH=${tghItems.length}`);

scripts/seed-fear-greed.mjs

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,8 @@
11
#!/usr/bin/env node
22

3-
import { loadEnvFile, CHROME_UA, runSeed, readSeedSnapshot, sleep, resolveProxy } from './_seed-utils.mjs';
4-
import { execFileSync } from 'child_process';
3+
import { loadEnvFile, CHROME_UA, runSeed, readSeedSnapshot, sleep } from './_seed-utils.mjs';
54
loadEnvFile(import.meta.url);
65

7-
const _proxyAuth = resolveProxy();
8-
9-
// curl-based fetch for sources that block Railway IPs (Yahoo Finance).
10-
// Returns response body as string; throws on non-2xx.
11-
function curlFetch(url, headers = {}) {
12-
const args = ['-sS', '--compressed', '--max-time', '15', '-L'];
13-
if (_proxyAuth) args.push('-x', `http://${_proxyAuth}`);
14-
for (const [k, v] of Object.entries(headers)) args.push('-H', `${k}: ${v}`);
15-
args.push('-w', '\n%{http_code}');
16-
args.push(url);
17-
const raw = execFileSync('curl', args, { encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] });
18-
const nl = raw.lastIndexOf('\n');
19-
const status = parseInt(raw.slice(nl + 1).trim(), 10);
20-
if (status < 200 || status >= 300) throw Object.assign(new Error(`HTTP ${status}`), { status });
21-
return raw.slice(0, nl);
22-
}
23-
246
const FEAR_GREED_KEY = 'market:fear-greed:v1';
257
const FEAR_GREED_TTL = 64800; // 18h = 3x 6h interval
268

@@ -33,13 +15,10 @@ async function fetchYahooSymbol(symbol) {
3315
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=1y`;
3416
const headers = { 'User-Agent': CHROME_UA, Accept: 'application/json' };
3517
try {
36-
// Use curl+proxy when available — Railway container IPs are periodically blocked by Yahoo.
37-
const text = _proxyAuth
38-
? curlFetch(url, headers)
39-
: await fetch(url, { headers, signal: AbortSignal.timeout(10_000) }).then(r => {
40-
if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
41-
return r.text();
42-
});
18+
const text = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) }).then(r => {
19+
if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
20+
return r.text();
21+
});
4322
const data = JSON.parse(text);
4423
const result = data?.chart?.result?.[0];
4524
if (!result) return null;

0 commit comments

Comments
 (0)