← back to Homesonspec
collectors/common/src/adapter.ts
82 lines
import { createHash } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { ExtractedRecord } from "@homesonspec/schemas";
/**
* The contract every builder collector implements. v1 ships fixture mode
* only — live fetching arrives later as a second FetchContext implementation
* that honors the source registry's rate limits and permissions. Extraction
* is pure and deterministic: same bytes in, same records out.
*/
export interface RawPage {
url: string;
retrievedAt: string; // ISO
contentType: string;
body: Buffer;
contentHash: string; // sha256 hex
}
export interface SourceRegistryEntry {
key: string;
collectionMethod: "FEED" | "CRAWL" | "MANUAL" | "FIXTURE" | "SYNTHETIC";
rateLimitRpm?: number | null;
mediaRights: "NONE" | "LINK_ONLY" | "LICENSED";
}
export interface FetchContext {
mode: "fixture" | "live";
fixtureDir?: string; // fixtures/raw-pages/<key>/
registry: SourceRegistryEntry;
}
export interface ExtractionOutput {
records: ExtractedRecord[];
errors: { url: string; reason: string }[];
}
export interface SourceAdapter {
/** Must equal the SourceRegistry.key row this adapter collects for. */
key: string;
/** Stamped as extractor_version on every evidence + staged record. */
version: string;
fetch(ctx: FetchContext): AsyncIterable<RawPage>;
extract(page: RawPage): ExtractionOutput;
/**
* Optional LLM-assist hook (terminology mapping, incentive parsing).
* v1 is identity. Whatever this returns still faces the deterministic
* validators — normalization can never bless a record into publishing.
*/
normalize?(record: ExtractedRecord): Promise<ExtractedRecord>;
}
export function sha256(buffer: Buffer | string): string {
return createHash("sha256").update(buffer).digest("hex");
}
/**
* Shared fixture fetcher: yields every .html/.json file in the adapter's
* fixture directory as a RawPage. The "url" is reconstructed from a
* `<!-- source-url: ... -->` comment on line 1 when present, else a
* fixture:// pseudo-URL — keeping evidence traceable even in fixture mode.
*/
export async function* fetchFixtures(ctx: FetchContext): AsyncIterable<RawPage> {
if (ctx.mode !== "fixture" || !ctx.fixtureDir) {
throw new Error("fetchFixtures requires mode=fixture and a fixtureDir");
}
const files = (await readdir(ctx.fixtureDir)).filter((f) => /\.(html|json)$/.test(f)).sort();
for (const file of files) {
const body = await readFile(join(ctx.fixtureDir, file));
const firstLine = body.toString("utf8").split("\n", 1)[0] ?? "";
const match = firstLine.match(/source-url:\s*(\S+)/);
yield {
url: match?.[1] ?? `fixture://${ctx.registry.key}/${file}`,
retrievedAt: new Date().toISOString(),
contentType: file.endsWith(".json") ? "application/json" : "text/html",
body,
contentHash: sha256(body),
};
}
}