File size: 10,288 Bytes
a875dec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/** Source collection shared by the CLI, experiments and downstream applications. */
import {lookup} from 'node:dns/promises';
import {isIP} from 'node:net';
import {sha256} from './runtime.ts';

export type Evidence = {
  id: string; url: string; title: string; text: string; fetched_at: string;
  published_at: string | null; publication_basis: 'source_metadata' | 'user_attested' | 'unknown';
  content_sha256: string; snapshot_sha256: string;
  kind: 'page' | 'structured' | 'search_snippet' | 'fixture';
};
export type SourceInput = {url: string; title?: string; published_at?: string};
export type ResearchOptions = {
  sources?: SourceInput[]; queries?: string[]; search?: {endpoint: string; apiKey?: string; kind: 'searxng' | 'brave'};
  maxSources?: number; maxCharsPerSource?: number; asOf?: string; historical?: boolean;
  fetch?: typeof fetch; resolve?: typeof lookup;
};
export type EvidencePacket = {schema_version: 1; collected_at: string; sources: Evidence[]; failures: {url: string; error: string}[]; sha256: string};

function forbiddenIP(ip: string): boolean {
  const s = ip.toLowerCase().replace(/^::ffff:/, '');
  if (s.includes(':')) return s === '::' || s === '::1' || /^f[cd]|^fe[89ab]|^ff/.test(s);
  const n = s.split('.').map(Number);
  return n[0] === 0 || n[0] === 10 || n[0] === 127 || n[0] >= 224 ||
    (n[0] === 169 && n[1] === 254) || (n[0] === 172 && n[1] >= 16 && n[1] <= 31) ||
    (n[0] === 192 && n[1] === 168) || (n[0] === 100 && n[1] >= 64 && n[1] <= 127);
}
export async function publicSourceUrl(value: string, resolve: typeof lookup = lookup): Promise<URL> {
  const url = new URL(value);
  const host = url.hostname.replace(/^\[|\]$/g, '');
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password ||
      /(^|\.)(localhost|local|internal)$/.test(host) || (url.port && !['80', '443'].includes(url.port)))
    throw new Error('Evidence URL must be a public HTTP(S) source without credentials');
  const addresses = isIP(host) ? [{address: host}] : await resolve(host, {all: true});
  if (!addresses.length || addresses.some(item => forbiddenIP(item.address))) throw new Error('Private/reserved evidence address rejected');
  return url;
}
export async function fetchSourceBytes(value: string, options: Pick<ResearchOptions, 'fetch' | 'resolve'> = {}): Promise<{url: string; bytes: Buffer; contentType: string}> {
  let url = await publicSourceUrl(value, options.resolve);
  for (let redirects = 0; redirects <= 4; redirects++) {
    const response = await (options.fetch ?? fetch)(url, {redirect: 'manual', signal: AbortSignal.timeout(25000),
      headers: {'User-Agent': 'Vaticinus-Open-Forecast/0.2 (+https://github.com/vaticinus/vati)', Accept: 'text/html,application/json,text/plain,application/xml'}});
    if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
      await response.body?.cancel(); url = await publicSourceUrl(new URL(response.headers.get('location')!, url).href, options.resolve); continue;
    }
    if (!response.ok) { await response.body?.cancel(); throw new Error(`Source HTTP ${response.status}`); }
    const type = response.headers.get('content-type') ?? '';
    if (/pdf|image|video|audio|octet-stream/.test(type)) { await response.body?.cancel(); throw new Error('Use a text/JSON/XML source or provide a dated extracted packet'); }
    if (!response.body) throw new Error('Source returned no body');
    const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0;
    try {
      for (;;) { const {done, value: chunk} = await reader.read(); if (done) break;
        size += chunk.length; if (size > 2_000_000) throw new Error('Source exceeds 2 MB limit'); chunks.push(chunk); }
    } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
    return {url: url.href, bytes: Buffer.concat(chunks), contentType: type};
  }
  throw new Error('Too many source redirects');
}
function unescapeText(text: string): string {
  return text.replace(/&#(\d+);/g, (_, n) => Number(n) <= 0x10ffff ? String.fromCodePoint(Number(n)) : '')
    .replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&apos;|&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ');
}
export function extractPage(text: string): {text: string; title: string; published_at: string | null} {
  const title = unescapeText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text)?.[1] ?? '').trim();
  let published_at: string | null = null;
  // Only explicit publication metadata, never Last-Modified or the retrieval clock.
  for (const tag of text.match(/<meta\b[^>]*>/gi) ?? []) {
    if (!/(?:property|name)\s*=\s*["'](?:article:published_time|datePublished|citation_publication_date)["']/i.test(tag)) continue;
    const raw = /content\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1];
    if (raw && Number.isFinite(Date.parse(raw))) published_at = new Date(raw).toISOString();
  }
  const clean = unescapeText(text.replace(/<(script|style|nav|footer)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
    .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n').replace(/<[^>]+>/g, ' ').replace(/[\t ]+/g, ' ').replace(/\n\s*\n/g, '\n')).trim();
  return {title, text: clean, published_at};
}
export function validateEvidence(source: Evidence, asOf: string, historical = false): void {
  if (!source.url || !source.text || !source.title || !Number.isFinite(Date.parse(source.fetched_at))) throw new Error('Incomplete evidence source');
  if (!Number.isFinite(Date.parse(asOf)) || Date.parse(source.fetched_at) > Date.parse(asOf)) throw new Error('Evidence capture is after the information cutoff');
  if (source.content_sha256 !== sha256(source.text)) throw new Error(`Evidence text hash mismatch: ${source.id}`);
  if (!/^[a-f0-9]{64}$/.test(source.snapshot_sha256)) throw new Error('Evidence needs a raw snapshot hash');
  if (source.published_at && (!Number.isFinite(Date.parse(source.published_at)) || Date.parse(source.published_at) > Date.parse(asOf)))
    throw new Error(`Source publication is invalid or after the information cutoff: ${source.id}`);
  if (historical && (!source.published_at || Date.parse(source.fetched_at) > Date.parse(asOf)))
    throw new Error('Historical replay requires a dated snapshot captured by the issue cutoff; a date on a current page is insufficient');
}
export function packet(sources: Evidence[], failures: EvidencePacket['failures'] = []): EvidencePacket {
  return {schema_version: 1, collected_at: new Date().toISOString(), sources, failures,
    sha256: sha256(JSON.stringify(sources))};
}
/** Query the caller's selected search service. Search results remain discovery, not fetched evidence. */
export async function discover(queries: string[], config: NonNullable<ResearchOptions['search']>, request = fetch): Promise<SourceInput[]> {
  const base = new URL(config.endpoint);
  if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw new Error('Search endpoint must use HTTPS or an explicit local service');
  const found: SourceInput[] = [];
  for (const query of queries.slice(0, 3)) {
    const url = new URL(base); url.searchParams.set('q', query);
    if (config.kind === 'searxng') url.searchParams.set('format', 'json');
    else url.searchParams.set('count', '5');
    const response = await request(url, {signal: AbortSignal.timeout(20000), headers: config.kind === 'brave' ? {'X-Subscription-Token': config.apiKey ?? ''} : {}});
    if (!response.ok) throw new Error(`Search HTTP ${response.status}`);
    const data = await response.json() as any;
    const rows = config.kind === 'brave' ? data.web?.results : data.results;
    for (const row of (Array.isArray(rows) ? rows : []).slice(0, 5)) {
      if (typeof row.url === 'string' && !found.some(s => s.url === row.url)) found.push({url: row.url, title: row.title});
    }
  }
  return found;
}
export async function collectEvidence(options: ResearchOptions): Promise<EvidencePacket> {
  if (options.historical) throw new Error('Live retrieval cannot reconstruct a historical packet; load archived snapshots instead');
  const failures: EvidencePacket['failures'] = [], candidates = [...(options.sources ?? [])];
  if (options.search && options.queries?.length) {
    try { candidates.push(...await discover(options.queries, options.search, options.fetch)); }
    catch (e) { failures.push({url: options.search.endpoint, error: String(e).replace(options.search.apiKey ?? '\0', '[REDACTED]')}); }
  }
  const seen = new Set<string>(), sources: Evidence[] = [];
  for (const input of candidates.slice(0, 30)) {
    if (seen.has(input.url) || sources.length >= (options.maxSources ?? 6)) continue;
    seen.add(input.url);
    try {
      const response = await fetchSourceBytes(input.url, options), raw = response.bytes.toString('utf8');
      const extracted = response.contentType.includes('html') ? extractPage(raw) : {text: raw, title: input.title ?? new URL(input.url).hostname, published_at: null};
      const text = extracted.text.slice(0, options.maxCharsPerSource ?? 12000);
      if (text.length < 40) throw new Error('Source has insufficient readable text');
      const published_at = input.published_at ?? extracted.published_at;
      const source: Evidence = {id: sha256(response.url + '\n' + text).slice(0, 20), url: response.url,
        title: input.title ?? (extracted.title || new URL(response.url).hostname), text,
        fetched_at: new Date().toISOString(), published_at,
        publication_basis: input.published_at ? 'user_attested' : published_at ? 'source_metadata' : 'unknown',
        content_sha256: sha256(text), snapshot_sha256: sha256(response.bytes), kind: 'page'};
      validateEvidence(source, options.asOf ?? new Date().toISOString());
      if (!sources.some(s => s.content_sha256 === source.content_sha256)) sources.push(source);
    } catch (e) { failures.push({url: input.url, error: String(e)}); }
  }
  return packet(sources, failures);
}
export function formatEvidence(value: EvidencePacket): string {
  return value.sources.map(s => `[${s.id}] ${s.title}\nURL: ${s.url}\nPublished: ${s.published_at ?? 'unknown; do not assume a publication date'} (${s.publication_basis})\nCaptured: ${s.fetched_at}\n${s.text}`).join('\n\n');
}