Skip to content

Commit c88ee0a

Browse files
committed
fix(scripts): carnegie slug follows the org merge; prove-didi-auth reaches deployed stacks
Three script changes carried over from the reach-edu corpus work, committed together because they all serve that research run. The carnegie slug correction follows the triage decision recorded in the reach-edu submodule ("corporation folds into foundation — the incomplete rebrand resolves to one org"). The fetch manifest still pointed at the retired carnegie-corporation slug, which would have filed the captured source under a funder folder that no longer exists. prove-didi-auth.mjs gains prod-session auth. The retype flow could only run against local dev, because a deployed id.didi.sh emails the magic link instead of handing back a dev_token. It now prefers a DIDI_SESSION cookie when one is supplied and falls back to the dev-mode magic-link flow otherwise, so a domain retype can be driven against a deployed stack — with WS_URL pointed at that deployment so the file move lands on its volume rather than localhost. jina-fetch-mega-gifts-sources.mjs is a new manifest-from-CSV sibling of jina-fetch-urls.mjs for the 2026-07-28 mega-gifts-by-topic run. It captures every unique source_url into corpus/inbox/ per the capture-first rule, carries the row's strategy mapping as frontmatter, and records funder aboutness as a suggestion only — filing into funders/<slug>/ stays a deliberate operator step. Bot-walled bodies and HTTP-blocked fetches both land in inbox/gated/ as stubs, so a wanted URL is never silently dropped just because the fetch failed. Files changed: - scripts/jina-fetch-urls.mjs - scripts/prove-didi-auth.mjs - scripts/jina-fetch-mega-gifts-sources.mjs (new) Claude-Session: https://claude.ai/code/session_019a8tSPbFdvF1pKtADnWyDg
1 parent 41c1087 commit c88ee0a

3 files changed

Lines changed: 268 additions & 3 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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); });

scripts/jina-fetch-urls.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const DRY_RUN = process.argv.includes('--dry-run');
3131
// (the filesystem can only carry one folder; the DB/graph carries both).
3232
const FIRST_PARTY = 'reach-edu-first-party';
3333
const MANIFEST = [
34-
{ url: 'https://reach.edu/blog/reach-university-secures-2m-grant-carnegie-corporation-of-new-york', bucket: 'carnegie-corporation', related_funder_slug: 'carnegie-corporation' },
34+
{ url: 'https://reach.edu/blog/reach-university-secures-2m-grant-carnegie-corporation-of-new-york', bucket: 'carnegie-foundation', related_funder_slug: 'carnegie-foundation' },
3535
{ url: 'https://reach.edu/blog/inside-higher-ed-a-college-for-health-care-apprentices', bucket: FIRST_PARTY },
3636
{ url: 'https://reach.edu/blog/work-shift-a-pioneer-of-apprenticeship-degrees-steps-into-healthcare', bucket: FIRST_PARTY },
3737
{ url: 'https://reach.edu/blog/philanthropy-roundtable-turning-the-workplace-into-the-new-learning-place-with-reach-university', bucket: FIRST_PARTY, related_funder_slug: 'stand-together-trust' },

scripts/prove-didi-auth.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,30 @@ const step = (msg) => console.log(`\n\x1b[1m== ${msg}\x1b[0m`);
3535

3636
// ── RETYPE MODE — one-off domain.retype invocation ─────────────────────────
3737
// Moves a domain from one type to another (DB + filesystem, all clients on
38-
// the row). Usage:
38+
// the row).
39+
//
40+
// Local dev (id service in dev mode auto-issues the magic link):
3941
// RETYPE=1 RETYPE_SLUG=consumer-immunology RETYPE_FROM=strategy \
4042
// RETYPE_TO=thesis node scripts/prove-didi-auth.mjs mpstaton@gmail.com
43+
//
44+
// Against a DEPLOYED stack (prod id.didi.sh emails the link — it won't hand
45+
// back a dev_token — so pass a real didi_session cookie grabbed from the
46+
// browser via DIDI_SESSION, and point WS_URL at the deployed workspace so the
47+
// FILE MOVE happens on that deployment's volume, not localhost):
48+
// RETYPE=1 RETYPE_SLUG=wearables-and-somatic-markers RETYPE_FROM=strategy \
49+
// RETYPE_TO=thesis WS_URL=wss://ws.augment.didi.sh/ws \
50+
// DIDI_SESSION='<paste didi_session JWT>' node scripts/prove-didi-auth.mjs
4151
if (process.env.RETYPE === '1') {
4252
const slug = process.env.RETYPE_SLUG;
4353
const from_type = process.env.RETYPE_FROM;
4454
const to_type = process.env.RETYPE_TO;
4555
if (!slug || !from_type || !to_type) fail('RETYPE_SLUG, RETYPE_FROM, RETYPE_TO are all required');
4656

4757
step(`RETYPE 1. sign in`);
48-
const jwt = await signInAs(EMAIL);
58+
// Prefer a directly-supplied session cookie (the only way to auth against a
59+
// prod id service, which does not return dev_tokens); fall back to the
60+
// dev-mode magic-link flow when DIDI_SESSION is unset.
61+
const jwt = process.env.DIDI_SESSION ?? (await signInAs(EMAIL));
4962

5063
step(`RETYPE 2. domain.retype ${from_type}:${slug}${to_type}`);
5164
const frame = await wsInvoke(

0 commit comments

Comments
 (0)