|
| 1 | +#!/usr/bin/env node |
| 2 | +// ============================================================================ |
| 3 | +// ingest-mega-gifts-by-topic.mjs |
| 4 | +// |
| 5 | +// Files the mega-gifts-by-topic CSV (operator ask 2026-07-28) into the |
| 6 | +// canonical layer, three guarantees per row: |
| 7 | +// 1. the funder EXISTS as an organization (alias-aware match first — the |
| 8 | +// bhef/donorstrust lesson — mint only on a true miss, long-form slug) |
| 9 | +// 2. the source article is registered on the funder's org corpus |
| 10 | +// (organization.corpus.add — dedup-by-URL server-side) |
| 11 | +// 3. the source article is registered on each strategy corpus named in |
| 12 | +// strategy_slugs (source.add — slugs VALIDATED against the live |
| 13 | +// domain list, never fabricated) |
| 14 | +// |
| 15 | +// DB-side only, deliberately: another session captured the article bodies |
| 16 | +// into the inbox; the disk merge into folders is the triage lane's job |
| 17 | +// (and merged sources must never be source.fetch'd — registry gotcha). |
| 18 | +// |
| 19 | +// Usage: |
| 20 | +// set -a; source ./.env; set +a |
| 21 | +// node scripts/ingest-mega-gifts-by-topic.mjs [--csv <path>] [--live] |
| 22 | +// ============================================================================ |
| 23 | + |
| 24 | +import { createRequire } from 'node:module'; |
| 25 | +import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'; |
| 26 | +import { resolve, join } from 'node:path'; |
| 27 | + |
| 28 | +const requireScripts = createRequire(new URL('./package.json', import.meta.url)); |
| 29 | +const requireServices = createRequire(new URL('../services/social-search/package.json', import.meta.url)); |
| 30 | +const { Surreal } = requireScripts('surrealdb'); |
| 31 | +const { connect } = requireServices('@nats-io/transport-node'); |
| 32 | + |
| 33 | +const args = { csv: 'clients/reach-edu/outputs/2026-07-28_mega-gifts-by-topic/mega-gifts-by-topic.csv', client: 'reach-edu', live: false }; |
| 34 | +for (let i = 2; i < process.argv.length; i += 1) { |
| 35 | + const k = process.argv[i]; |
| 36 | + if (k === '--csv') args.csv = process.argv[++i]; |
| 37 | + else if (k === '--client') args.client = process.argv[++i]; |
| 38 | + else if (k === '--live') args.live = true; |
| 39 | +} |
| 40 | + |
| 41 | +// ---- csv parse ---------------------------------------------------------------- |
| 42 | +function parseCsv(path) { |
| 43 | + const text = readFileSync(path, 'utf8'); |
| 44 | + const rows = []; |
| 45 | + let row = [], cell = '', inQ = false; |
| 46 | + for (let i = 0; i < text.length; i += 1) { |
| 47 | + const c = text[i]; |
| 48 | + if (inQ) { |
| 49 | + if (c === '"' && text[i + 1] === '"') { cell += '"'; i += 1; } |
| 50 | + else if (c === '"') inQ = false; |
| 51 | + else cell += c; |
| 52 | + } else if (c === '"') inQ = true; |
| 53 | + else if (c === ',') { row.push(cell); cell = ''; } |
| 54 | + else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } |
| 55 | + else if (c !== '\r') cell += c; |
| 56 | + } |
| 57 | + if (row.length > 1) rows.push(row); |
| 58 | + const [h, ...data] = rows; |
| 59 | + return data.filter((r) => r.length > 1).map((r) => Object.fromEntries(h.map((c, i) => [c, (r[i] ?? '').trim()]))); |
| 60 | +} |
| 61 | +const rows = parseCsv(resolve(args.csv)); |
| 62 | +console.log(`rows: ${rows.length}`); |
| 63 | + |
| 64 | +// ---- org matching (the export script's alias-aware family) -------------------- |
| 65 | +const normName = (s) => String(s ?? '').replace(/\(.*?\)/g, ' ').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); |
| 66 | + |
| 67 | +const db = new Surreal(); |
| 68 | +await db.connect(process.env.SURREAL_URL); |
| 69 | +await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS }); |
| 70 | +await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB }); |
| 71 | +const orgs = (await db.query( |
| 72 | + `SELECT slug, complete_name, conventional_name, aliases FROM organizations WHERE client_access CONTAINS $client;`, |
| 73 | + { client: args.client }, |
| 74 | +))?.[0] ?? []; |
| 75 | +await db.close(); |
| 76 | + |
| 77 | +const byNorm = new Map(); |
| 78 | +for (const o of orgs) { |
| 79 | + for (const cand of [o.complete_name, o.conventional_name, o.slug.replace(/-/g, ' '), ...(o.aliases ?? [])]) { |
| 80 | + const n = normName(cand); |
| 81 | + if (n && !byNorm.has(n)) byNorm.set(n, o.slug); |
| 82 | + } |
| 83 | +} |
| 84 | +const orgNorms = Array.from(byNorm.keys()); |
| 85 | +function matchOrg(name) { |
| 86 | + const variants = new Set([normName(name)]); |
| 87 | + for (const part of String(name).split('/')) variants.add(normName(part)); |
| 88 | + for (const v of Array.from(variants)) { |
| 89 | + const toks = v.split(' '); |
| 90 | + for (let d = 1; d <= 4 && toks.length - d >= 1; d += 1) variants.add(toks.slice(0, toks.length - d).join(' ')); |
| 91 | + } |
| 92 | + for (const v of variants) if (byNorm.has(v)) return byNorm.get(v); |
| 93 | + for (const v of variants) { |
| 94 | + const minLen = v.includes(' ') ? 5 : 8; |
| 95 | + if (v.length < minLen) continue; |
| 96 | + const hits = orgNorms.filter((n) => n.startsWith(v + ' ')); |
| 97 | + if (hits.length === 1) return byNorm.get(hits[0]); |
| 98 | + } |
| 99 | + return null; |
| 100 | +} |
| 101 | + |
| 102 | +// ---- NATS --------------------------------------------------------------------- |
| 103 | +const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' }); |
| 104 | +const req = async (s, b, t = 30_000) => |
| 105 | + JSON.parse(new TextDecoder().decode((await nc.request(s, JSON.stringify(b), { timeout: t })).data)); |
| 106 | + |
| 107 | +// ---- strategy validation (never fabricate a domain slug) ---------------------- |
| 108 | +const dl = await req('domain.list.requested', { client_slug: args.client }); |
| 109 | +const liveStrategies = new Set((dl.domains ?? []).filter((d) => (d.domain_type ?? d.type) === 'strategy').map((d) => d.domain_slug ?? d.slug)); |
| 110 | + |
| 111 | +// ---- overrides — operator-ruled resolutions for every tricky cell (multi- |
| 112 | +// funder consortia split to components; person-anchored giving mapped to |
| 113 | +// the ruled vehicle orgs; false-match traps pinned). Reviewed 2026-07-28. |
| 114 | +const OVERRIDES = { |
| 115 | + 'New York State': [{ mint: 'New York State' }], // the new-york-times prefix trap |
| 116 | + 'U.S. Department of Labor': [{ slug: 'us-department-of-labor' }], |
| 117 | + 'Bill & Melinda Gates Foundation': [{ slug: 'the-gates-foundation' }], |
| 118 | + 'Carnegie Foundation for the Advancement of Teaching and College Board': [ |
| 119 | + { slug: 'carnegie-foundation-for-the-advancement-of-teaching' }, { slug: 'college-board' }], |
| 120 | + 'Connect Humanity / Microsoft / Appalachian Community Capital': [ |
| 121 | + { mint: 'Connect Humanity' }, { slug: 'microsoft' }, { mint: 'Appalachian Community Capital' }], |
| 122 | + 'Microsoft, OpenAI, Anthropic': [{ slug: 'microsoft' }, { mint: 'OpenAI' }, { mint: 'Anthropic' }], |
| 123 | + 'Ballmer Group, Gates Foundation, Stand Together, Valhalla Foundation, John Overdeck': [ |
| 124 | + { slug: 'ballmer-group' }, { slug: 'the-gates-foundation' }, { mint: 'Stand Together' }, |
| 125 | + { mint: 'Valhalla Foundation' }, { skip: 'John Overdeck — person, no ruled vehicle yet' }], |
| 126 | + 'Ford, MacArthur, Mellon, Omidyar, Lumina, Doris Duke, Kapor, Mozilla, Packard, Siegel Family Endowment': [ |
| 127 | + { mint: 'Ford Foundation' }, { mint: 'MacArthur Foundation' }, { mint: 'Mellon Foundation' }, |
| 128 | + { mint: 'Omidyar Network' }, { slug: 'lumina-foundation' }, { mint: 'Doris Duke Foundation' }, |
| 129 | + { mint: 'Kapor Foundation' }, { mint: 'Mozilla Foundation' }, { mint: 'Packard Foundation' }, |
| 130 | + { mint: 'Siegel Family Endowment' }], |
| 131 | + 'ADQ + Gates Foundation': [{ mint: 'ADQ' }, { slug: 'the-gates-foundation' }], |
| 132 | + 'Wells Fargo / Wells Fargo Foundation': [{ mint: 'Wells Fargo Foundation' }], |
| 133 | + 'David M. Rubenstein / Library of Congress': [{ slug: 'declaration-partners' }], |
| 134 | + 'Ballmer Group and Ralph C. Wilson Jr. Foundation': [ |
| 135 | + { slug: 'ballmer-group' }, { mint: 'Ralph C. Wilson Jr. Foundation' }], |
| 136 | + 'Commonwealth of Pennsylvania (Shapiro Administration)': [{ mint: 'Commonwealth of Pennsylvania' }], |
| 137 | + 'ProLiteracy (Nora Roberts Foundation-backed)': [{ mint: 'ProLiteracy' }], |
| 138 | + 'Goldman Sachs (10,000 Small Businesses)': [{ mint: 'Goldman Sachs' }], |
| 139 | + 'Invest Appalachia (multiple foundation LPs)': [{ mint: 'Invest Appalachia' }], |
| 140 | +}; |
| 141 | + |
| 142 | +// ---- census ------------------------------------------------------------------- |
| 143 | +const knownSlugs = new Set(orgs.map((o) => o.slug)); |
| 144 | +const funders = new Map(); // funder cell -> { targets: [{slug}|{mint}|{skip}], rows: [] } |
| 145 | +const badStrategies = new Set(); |
| 146 | +const badOverrideSlugs = new Set(); |
| 147 | +for (const r of rows) { |
| 148 | + let f = funders.get(r.funder); |
| 149 | + if (!f) { |
| 150 | + let targets; |
| 151 | + if (OVERRIDES[r.funder]) { |
| 152 | + targets = OVERRIDES[r.funder]; |
| 153 | + for (const t of targets) if (t.slug && !knownSlugs.has(t.slug)) badOverrideSlugs.add(t.slug); |
| 154 | + } else { |
| 155 | + const m = matchOrg(r.funder); |
| 156 | + targets = m ? [{ slug: m }] : [{ mint: r.funder }]; |
| 157 | + } |
| 158 | + f = { targets, rows: [] }; |
| 159 | + funders.set(r.funder, f); |
| 160 | + } |
| 161 | + f.rows.push(r); |
| 162 | + for (const s of r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)) { |
| 163 | + if (!liveStrategies.has(s)) badStrategies.add(s); |
| 164 | + } |
| 165 | +} |
| 166 | +if (badOverrideSlugs.size) { |
| 167 | + console.log(`✗ override references unknown slugs (aborting): ${[...badOverrideSlugs].join(', ')}`); |
| 168 | + await nc.drain(); |
| 169 | + process.exit(1); |
| 170 | +} |
| 171 | +const mintNames = new Set(); |
| 172 | +let matchedCount = 0, overrideCount = 0, skipCount = 0; |
| 173 | +for (const [name, f] of funders) { |
| 174 | + const via = OVERRIDES[name] ? 'override' : 'match'; |
| 175 | + if (OVERRIDES[name]) overrideCount += 1; |
| 176 | + for (const t of f.targets) { |
| 177 | + if (t.mint) mintNames.add(t.mint); |
| 178 | + else if (t.skip) skipCount += 1; |
| 179 | + else matchedCount += 1; |
| 180 | + } |
| 181 | + console.log(` ${via === 'override' ? '⊙' : '='} ${name} → ${f.targets.map((t) => t.slug ?? (t.mint ? 'MINT:' + t.mint : 'SKIP')).join(' + ')}`); |
| 182 | +} |
| 183 | +console.log(`funders: ${funders.size} distinct cells — ${matchedCount} slug targets, ${mintNames.size} distinct mints, ${skipCount} skips, ${overrideCount} override cells`); |
| 184 | +if (badStrategies.size) { |
| 185 | + console.log(`✗ UNKNOWN strategy slugs (aborting — never fabricate): ${[...badStrategies].join(', ')}`); |
| 186 | + await nc.drain(); |
| 187 | + process.exit(1); |
| 188 | +} |
| 189 | +console.log(`strategies referenced: all valid (${[...new Set(rows.flatMap((r) => r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)))].join(', ')})`); |
| 190 | +console.log(`distinct source urls: ${new Set(rows.map((r) => r.source_url)).size}`); |
| 191 | + |
| 192 | +if (!args.live) { |
| 193 | + console.log('\nDRY-RUN — nothing written. Re-run with --live.'); |
| 194 | + await nc.drain(); |
| 195 | + process.exit(0); |
| 196 | +} |
| 197 | + |
| 198 | +// ---- execute ------------------------------------------------------------------ |
| 199 | +// 1. Mint missing funders once each (deduped across cells). |
| 200 | +const mintedSlug = new Map(); |
| 201 | +for (const name of mintNames) { |
| 202 | + const r = await req('person.affiliate.requested', { org_action: 'create', org_name: name, client: args.client, source: 'mega-gifts-by-topic' }); |
| 203 | + if (r.ok) { mintedSlug.set(name, r.org_slug); console.log(` minted ${name} → ${r.org_slug}`); } |
| 204 | + else console.log(` ✗ mint ${name}: ${r.error}`); |
| 205 | +} |
| 206 | +const slugsFor = (cell) => |
| 207 | + (funders.get(cell)?.targets ?? []) |
| 208 | + .map((t) => t.slug ?? (t.mint ? mintedSlug.get(t.mint) : null)) |
| 209 | + .filter(Boolean); |
| 210 | + |
| 211 | +// Inbox index — the other session captured article bodies here; after |
| 212 | +// registering, the Surreal uuids get stamped into each file's frontmatter |
| 213 | +// (operator ask 2026-07-28) so the triage merge becomes a lookup. |
| 214 | +const INBOX = resolve(`clients/${args.client}/corpus/inbox`); |
| 215 | +const normUrl = (u) => String(u ?? '').toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '').replace(/#.*$/, ''); |
| 216 | +const inboxByUrl = new Map(); |
| 217 | +for (const dir of [INBOX, join(INBOX, 'gated')]) { |
| 218 | + if (!existsSync(dir)) continue; |
| 219 | + for (const f of readdirSync(dir)) { |
| 220 | + if (!f.endsWith('.md')) continue; |
| 221 | + const path = join(dir, f); |
| 222 | + const head = readFileSync(path, 'utf8').slice(0, 2000); |
| 223 | + const url = /^(?:exact_url|url): *"?([^"\n]+)"?/m.exec(head)?.[1]; |
| 224 | + if (url) inboxByUrl.set(normUrl(url), path); |
| 225 | + } |
| 226 | +} |
| 227 | +console.log(`inbox files indexed by url: ${inboxByUrl.size}`); |
| 228 | +function stampFrontmatter(path, sets) { |
| 229 | + let text = readFileSync(path, 'utf8'); |
| 230 | + const end = text.indexOf('\n---', 4); |
| 231 | + if (!text.startsWith('---') || end === -1) return false; |
| 232 | + let fm = text.slice(0, end + 1); |
| 233 | + const body = text.slice(end + 1); |
| 234 | + for (const [key, value] of Object.entries(sets)) { |
| 235 | + if (value === undefined || value === null || value === '') continue; |
| 236 | + const line = `${key}: ${JSON.stringify(String(value))}`; |
| 237 | + const re = new RegExp(`^${key}: .*$`, 'm'); |
| 238 | + fm = re.test(fm) ? fm.replace(re, line) : fm + line + '\n'; |
| 239 | + } |
| 240 | + writeFileSync(path, fm + body); |
| 241 | + return true; |
| 242 | +} |
| 243 | + |
| 244 | +// 2 + 3. Per row: org corpus + strategy corpora, then frontmatter stamps. |
| 245 | +let orgAdds = 0, stratAdds = 0, fails = 0, stamped = 0, noFile = 0; |
| 246 | +const stampsByUrl = new Map(); // url -> { source_uuid, org_slugs: [], content_uuid } |
| 247 | +for (const r of rows) { |
| 248 | + const stamp = stampsByUrl.get(r.source_url) ?? { org_slugs: [] }; |
| 249 | + for (const slug of slugsFor(r.funder)) { |
| 250 | + if (!r.source_url) continue; |
| 251 | + const a = await req('organization.corpus.add.requested', { org_slug: slug, url: r.source_url, client: args.client }); |
| 252 | + if (a.ok) { |
| 253 | + orgAdds += 1; |
| 254 | + stamp.org_slugs.push(slug); |
| 255 | + const cid = /u"([0-9a-f-]+)"/.exec(String(a.entry?.content_id ?? ''))?.[1]; |
| 256 | + if (cid && !stamp.content_uuid) stamp.content_uuid = cid; |
| 257 | + } else { fails += 1; console.log(` ✗ corpus ${slug} ← ${r.source_url.slice(0, 60)}: ${a.error}`); } |
| 258 | + } |
| 259 | + for (const s of r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)) { |
| 260 | + const sa = await req('source.add.requested', { url: r.source_url, domain_type: 'strategy', domain_slug: s, client_slug: args.client }); |
| 261 | + if (sa.ok) { |
| 262 | + stratAdds += 1; |
| 263 | + if (sa.source?.source_uuid && !stamp.source_uuid) stamp.source_uuid = sa.source.source_uuid; |
| 264 | + } else { fails += 1; console.log(` ✗ source.add ${s} ← ${r.source_url.slice(0, 60)}: ${sa.error}`); } |
| 265 | + } |
| 266 | + stampsByUrl.set(r.source_url, stamp); |
| 267 | +} |
| 268 | +for (const [url, s] of stampsByUrl) { |
| 269 | + const file = inboxByUrl.get(normUrl(url)); |
| 270 | + if (!file) { noFile += 1; continue; } |
| 271 | + const ok = stampFrontmatter(file, { |
| 272 | + source_uuid: s.source_uuid, |
| 273 | + content_uuid: s.content_uuid, |
| 274 | + org_slugs: s.org_slugs.length ? [...new Set(s.org_slugs)].join(', ') : undefined, |
| 275 | + }); |
| 276 | + if (ok) stamped += 1; |
| 277 | +} |
| 278 | +console.log(`\norg corpus adds: ${orgAdds} · strategy source adds: ${stratAdds} · failures: ${fails}`); |
| 279 | +console.log(`frontmatter stamped: ${stamped} inbox files · ${noFile} urls with no inbox capture (gated or never fetched)`); |
| 280 | +console.log('NOTE: DB registration + uuid stamps only. The disk merge into folders is the triage lane (and merged sources must NOT be re-fetched).'); |
| 281 | +await nc.drain(); |
0 commit comments