|
| 1 | +#!/usr/bin/env node |
| 2 | +// One-off Jina fetcher for the 2026-07-28 mega-gifts-by-topic research run. |
| 3 | +// Sibling of jina-fetch-urls.mjs, but manifest-from-CSV: reads |
| 4 | +// clients/reach-edu/outputs/2026-07-28_mega-gifts-by-topic/mega-gifts-by-topic.csv |
| 5 | +// and captures every unique source_url into corpus/inbox/ (capture-first per |
| 6 | +// corpus AGENTS.md), carrying the row's strategy mapping as top-level |
| 7 | +// `strategy_slugs` frontmatter plus a `topic` lane field. Funder aboutness is |
| 8 | +// recorded as extra_metadata.suggested_funder_slug for the later triage pass — |
| 9 | +// filing into funders/<slug>/ stays a deliberate operator step. |
| 10 | +// |
| 11 | +// Gated discipline: HTTP-blocked fetches AND "successful" bot-wall bodies |
| 12 | +// (Access Denied / Just a moment…) land in inbox/gated/ as stubs — URL still |
| 13 | +// wanted, only the fetch failed. |
| 14 | +// |
| 15 | +// Usage: |
| 16 | +// node scripts/jina-fetch-mega-gifts-sources.mjs --limit 2 # smoke test |
| 17 | +// node scripts/jina-fetch-mega-gifts-sources.mjs # full run |
| 18 | + |
| 19 | +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; |
| 20 | +import { join, dirname } from 'node:path'; |
| 21 | +import { fileURLToPath } from 'node:url'; |
| 22 | + |
| 23 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 24 | +const REPO_ROOT = join(__dirname, '..'); |
| 25 | +const CLIENT_ID = 'reach-edu'; |
| 26 | +const CLIENT_ROOT = join(REPO_ROOT, 'clients', CLIENT_ID); |
| 27 | +const CORPUS_ROOT = join(CLIENT_ROOT, 'corpus'); |
| 28 | +const CSV_PATH = join(CLIENT_ROOT, 'outputs', '2026-07-28_mega-gifts-by-topic', 'mega-gifts-by-topic.csv'); |
| 29 | +const RESEARCH_RUN = 'outputs/2026-07-28_mega-gifts-by-topic'; |
| 30 | +const JINA_BASE = 'https://r.jina.ai/'; |
| 31 | +const limitArg = process.argv.indexOf('--limit'); |
| 32 | +const LIMIT = limitArg !== -1 ? Number(process.argv[limitArg + 1]) : Infinity; |
| 33 | + |
| 34 | +const WALL_TITLE = /access denied|just a moment|attention required|are you a robot|verify you are|cloudflare|forbidden|page not found|error 40\d/i; |
| 35 | +const GATED_STATUSES = new Set([401, 402, 403, 404, 451]); |
| 36 | + |
| 37 | +function parseCsv(text) { |
| 38 | + const rows = []; |
| 39 | + let field = '', row = [], inQuotes = false; |
| 40 | + for (let i = 0; i < text.length; i++) { |
| 41 | + const c = text[i]; |
| 42 | + if (inQuotes) { |
| 43 | + if (c === '"') { |
| 44 | + if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; |
| 45 | + } else field += c; |
| 46 | + } else if (c === '"') inQuotes = true; |
| 47 | + else if (c === ',') { row.push(field); field = ''; } |
| 48 | + else if (c === '\n' || c === '\r') { |
| 49 | + if (c === '\r' && text[i + 1] === '\n') i++; |
| 50 | + row.push(field); field = ''; |
| 51 | + if (row.length > 1 || row[0] !== '') rows.push(row); |
| 52 | + row = []; |
| 53 | + } else field += c; |
| 54 | + } |
| 55 | + if (field !== '' || row.length) { row.push(field); rows.push(row); } |
| 56 | + const header = rows.shift(); |
| 57 | + return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))); |
| 58 | +} |
| 59 | + |
| 60 | +function slugify(s) { |
| 61 | + return s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '') |
| 62 | + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/g, ''); |
| 63 | +} |
| 64 | + |
| 65 | +function yamlString(s) { |
| 66 | + return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; |
| 67 | +} |
| 68 | + |
| 69 | +async function loadJinaKey() { |
| 70 | + if (process.env.JINA_API_KEY) return process.env.JINA_API_KEY; |
| 71 | + try { |
| 72 | + const env = await readFile(join(REPO_ROOT, '.env'), 'utf8'); |
| 73 | + const m = env.match(/^JINA_API_KEY=(.+)$/m); |
| 74 | + if (m) return m[1].trim().replace(/^["']|["']$/g, ''); |
| 75 | + } catch {} |
| 76 | + return null; |
| 77 | +} |
| 78 | + |
| 79 | +// Recursive exact_url scan so inbox/gated/ and strategies/*/sources/ count as "already captured". |
| 80 | +async function existingUrls(dir = CORPUS_ROOT, urls = new Set()) { |
| 81 | + let entries; |
| 82 | + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return urls; } |
| 83 | + for (const e of entries) { |
| 84 | + const p = join(dir, e.name); |
| 85 | + if (e.isDirectory()) await existingUrls(p, urls); |
| 86 | + else if (e.name.endsWith('.md')) { |
| 87 | + const raw = await readFile(p, 'utf8'); |
| 88 | + const m = raw.match(/^exact_url:\s*"?(.+?)"?\s*$/m); |
| 89 | + if (m) urls.add(m[1].trim()); |
| 90 | + } |
| 91 | + } |
| 92 | + return urls; |
| 93 | +} |
| 94 | + |
| 95 | +function parsePreamble(markdown) { |
| 96 | + const out = {}; |
| 97 | + for (const raw of markdown.split('\n').slice(0, 30)) { |
| 98 | + const line = raw.trim(); |
| 99 | + if (/^Markdown Content:/i.test(line)) break; |
| 100 | + if (line === '') continue; |
| 101 | + const m = line.match(/^([A-Za-z][A-Za-z0-9 _-]{0,40}):\s+(.+)$/); |
| 102 | + if (m && m[2].trim() !== '') out[m[1].trim()] = m[2].trim(); |
| 103 | + } |
| 104 | + return out; |
| 105 | +} |
| 106 | + |
| 107 | +function extractTitle(markdown, url) { |
| 108 | + for (const line of markdown.split('\n', 10)) { |
| 109 | + const m = line.match(/^Title:\s*(.+?)\s*$/i); |
| 110 | + if (m) return m[1].trim(); |
| 111 | + } |
| 112 | + for (const line of markdown.split('\n', 10)) { |
| 113 | + const m = line.match(/^#\s+(.+?)\s*$/); |
| 114 | + if (m) return m[1].trim(); |
| 115 | + } |
| 116 | + return url; |
| 117 | +} |
| 118 | + |
| 119 | +async function fetchOnce(url, key) { |
| 120 | + const fetched_at = new Date().toISOString(); |
| 121 | + const headers = { Accept: 'text/markdown' }; |
| 122 | + if (key) headers.Authorization = `Bearer ${key}`; |
| 123 | + const res = await fetch(JINA_BASE + url, { headers }); |
| 124 | + if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status} ${res.statusText}`, fetched_at }; |
| 125 | + const markdown = await res.text(); |
| 126 | + if (!markdown.trim()) return { ok: false, status: res.status, error: 'empty body', fetched_at }; |
| 127 | + const preamble = parsePreamble(markdown); |
| 128 | + const published_at = preamble['Published Time'] |
| 129 | + ? (Number.isNaN(new Date(preamble['Published Time']).getTime()) ? null : new Date(preamble['Published Time']).toISOString()) |
| 130 | + : null; |
| 131 | + return { ok: true, markdown, fetched_at, title: extractTitle(markdown, url), published_at, status: res.status }; |
| 132 | +} |
| 133 | + |
| 134 | +async function fetchWithRetry(url, key) { |
| 135 | + let backoff = 2000; |
| 136 | + for (let i = 0; i < 3; i++) { |
| 137 | + const r = await fetchOnce(url, key); |
| 138 | + if (r.ok || r.status !== 429) return r; |
| 139 | + if (i < 2) { await new Promise((res) => setTimeout(res, backoff)); backoff *= 2; } |
| 140 | + } |
| 141 | + return { ok: false, status: 429, error: '429 after retries', fetched_at: new Date().toISOString() }; |
| 142 | +} |
| 143 | + |
| 144 | +function buildFrontmatter(item, r, { gated, title }) { |
| 145 | + const lines = ['---']; |
| 146 | + lines.push(`title: ${yamlString(title)}`); |
| 147 | + lines.push(`exact_url: ${yamlString(item.url)}`); |
| 148 | + lines.push(`fetched_at: ${r.fetched_at}`); |
| 149 | + if (r.published_at) lines.push(`published_at: ${yamlString(r.published_at)}`); |
| 150 | + lines.push(`client_id: ${yamlString(CLIENT_ID)}`); |
| 151 | + lines.push(`funder_slug: "inbox"`); |
| 152 | + lines.push(`record_id: null`); |
| 153 | + lines.push(`response_id: null`); |
| 154 | + lines.push(`pack_id: "inbox"`); |
| 155 | + lines.push(`topic: ${yamlString(item.topics.join('|'))}`); |
| 156 | + lines.push(`strategy_slugs: [${item.strategySlugs.map((s) => yamlString(s)).join(', ')}]`); |
| 157 | + lines.push('tags: []'); |
| 158 | + lines.push(`inbox_status: ${yamlString(gated ? 'gated' : 'pending')}`); |
| 159 | + lines.push(`captured_at: ${r.fetched_at}`); |
| 160 | + lines.push(`captured_from: "mega-gifts-by-topic-research"`); |
| 161 | + lines.push(`captured_note: ${yamlString(`Source of a gift/initiative row in ${RESEARCH_RUN}`)}`); |
| 162 | + lines.push(`captured_session_id: null`); |
| 163 | + lines.push(`triaged_at: null`); |
| 164 | + lines.push(`triaged_to: null`); |
| 165 | + lines.push(`triaged_by: null`); |
| 166 | + lines.push(`triaged_note: null`); |
| 167 | + lines.push('extra_metadata:'); |
| 168 | + lines.push(` jina_status: ${yamlString(String(r.status ?? 'n/a'))}`); |
| 169 | + lines.push(` content_length_bytes: ${yamlString(String(r.markdown ? r.markdown.length : 0))}`); |
| 170 | + if (!r.ok || gated) lines.push(` fetch_error: ${yamlString(r.error ?? 'bot-wall body')}`); |
| 171 | + lines.push(` ingest_method: "manual-jina-cli"`); |
| 172 | + lines.push(` ingested_by: "claude-code"`); |
| 173 | + lines.push(` research_output: ${yamlString(RESEARCH_RUN)}`); |
| 174 | + lines.push(` suggested_funder_slug: ${yamlString(item.suggestedFunderSlug)}`); |
| 175 | + lines.push(` funder_name: ${yamlString(item.funder)}`); |
| 176 | + lines.push(` recipient_or_initiative: ${yamlString(item.recipient)}`); |
| 177 | + lines.push(` gift_year: ${yamlString(item.year)}`); |
| 178 | + lines.push(` amount_display: ${yamlString(item.amount)}`); |
| 179 | + lines.push(` gift_type: ${yamlString(item.giftType)}`); |
| 180 | + lines.push('---'); |
| 181 | + return lines.join('\n'); |
| 182 | +} |
| 183 | + |
| 184 | +async function writeUnique(baseDir, datePart, slug, content) { |
| 185 | + await mkdir(baseDir, { recursive: true }); |
| 186 | + let filename = `${datePart}_${slug}.md`; |
| 187 | + let target = join(baseDir, filename); |
| 188 | + let tries = 0; |
| 189 | + while (await readFile(target).then(() => true).catch(() => false)) { |
| 190 | + const suffix = Math.random().toString(36).slice(2, 6); |
| 191 | + filename = `${datePart}_${slug}_${suffix}.md`; |
| 192 | + target = join(baseDir, filename); |
| 193 | + if (++tries > 8) break; |
| 194 | + } |
| 195 | + await writeFile(target, content, 'utf8'); |
| 196 | + return filename; |
| 197 | +} |
| 198 | + |
| 199 | +async function main() { |
| 200 | + const key = await loadJinaKey(); |
| 201 | + if (!key) console.warn('⚠ No JINA_API_KEY found — trying free tier (lower rate limits).'); |
| 202 | + const rows = parseCsv(await readFile(CSV_PATH, 'utf8')); |
| 203 | + |
| 204 | + // Group rows by URL; union strategy slugs + topics across rows sharing a source. |
| 205 | + const byUrl = new Map(); |
| 206 | + for (const row of rows) { |
| 207 | + const url = row.source_url.trim(); |
| 208 | + if (!byUrl.has(url)) { |
| 209 | + byUrl.set(url, { |
| 210 | + url, |
| 211 | + topics: [], strategySlugs: [], |
| 212 | + funder: row.funder, recipient: row.recipient_or_initiative, |
| 213 | + year: row.year, amount: row.amount_display, giftType: row.gift_type, |
| 214 | + suggestedFunderSlug: slugify(row.funder), |
| 215 | + }); |
| 216 | + } |
| 217 | + const item = byUrl.get(url); |
| 218 | + if (!item.topics.includes(row.topic)) item.topics.push(row.topic); |
| 219 | + for (const s of row.strategy_slugs.split('|')) { |
| 220 | + if (s && !item.strategySlugs.includes(s)) item.strategySlugs.push(s); |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + const seen = await existingUrls(); |
| 225 | + let written = 0, gatedCount = 0, skipped = 0, processed = 0; |
| 226 | + |
| 227 | + for (const item of byUrl.values()) { |
| 228 | + if (processed >= LIMIT) break; |
| 229 | + if (seen.has(item.url)) { console.log(`SKIP (dupe) ${item.url}`); skipped++; continue; } |
| 230 | + processed++; |
| 231 | + const r = await fetchWithRetry(item.url, key); |
| 232 | + const wallBody = r.ok && (WALL_TITLE.test(r.title) || r.markdown.length < 900); |
| 233 | + const gated = !r.ok || wallBody; |
| 234 | + const title = r.ok ? r.title : `Fetch failed (${r.error})`; |
| 235 | + const fm = buildFrontmatter(item, r, { gated, title }); |
| 236 | + const body = r.ok ? r.markdown.trim() : `Fetch failed: ${r.error}. URL still wanted — re-fetch later.`; |
| 237 | + const slug = slugify(r.ok ? r.title : item.url) || slugify(item.url); |
| 238 | + const dir = gated ? join(CORPUS_ROOT, 'inbox', 'gated') : join(CORPUS_ROOT, 'inbox'); |
| 239 | + const filename = await writeUnique(dir, r.fetched_at.slice(0, 10), slug, `${fm}\n${body}\n`); |
| 240 | + seen.add(item.url); |
| 241 | + if (gated) { |
| 242 | + console.log(`GATED [inbox/gated] ${filename} (${r.error ?? `wall body ${r.markdown.length}b "${r.title}"`})`); |
| 243 | + gatedCount++; |
| 244 | + } else { |
| 245 | + console.log(`WROTE [inbox] ${filename} (${r.markdown.length}b, pub ${r.published_at ?? 'n/a'}, strategies ${item.strategySlugs.join('|')})`); |
| 246 | + written++; |
| 247 | + } |
| 248 | + } |
| 249 | + console.log(`\nDone. written=${written} gated=${gatedCount} skipped=${skipped} of ${byUrl.size} unique urls`); |
| 250 | +} |
| 251 | + |
| 252 | +main().catch((e) => { console.error(e); process.exit(1); }); |
0 commit comments