Skip to content

Commit c5dd667

Browse files
Batch output sinks (ndjson stdout + per-item files)
Add the two batch output destinations on top of the executor: - createNdjsonStdoutSink: one JSON record per line on stdout, regardless of TTY (the batch default). - createDirectorySink: one <name>.json file per item into a directory (created if needed); names derived from the input URL slug or row index via batchItemFilename, deduped on collision. Both share toBatchRecord so a record looks identical whether streamed or written to a file. Successes carry `result`, failures carry `error`.
1 parent 53af17f commit c5dd667

8 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const MAX_SLUG_LENGTH = 100;
2+
3+
function slugify(value: string): string {
4+
return value
5+
.replace(/[^a-zA-Z0-9.-]+/g, "-")
6+
.replace(/^-+|-+$/g, "")
7+
.slice(0, MAX_SLUG_LENGTH);
8+
}
9+
10+
/**
11+
* Derive a filesystem-safe base name (no extension) for a batch item. URLs are
12+
* slugged from host + path; anything else falls back to the row index.
13+
*/
14+
export function batchItemFilename(input: string, index: number): string {
15+
try {
16+
const url = new URL(input);
17+
const slug = slugify(`${url.hostname}${url.pathname}`);
18+
if (slug.length > 0) {
19+
return slug;
20+
}
21+
} catch {
22+
// Not a URL — fall back to the row index below.
23+
}
24+
25+
return `item-${index}`;
26+
}

src/batch/services/batch-record.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { BatchResult } from "../types/batch-result.js";
2+
3+
export interface BatchRecord {
4+
error?: { class: string; message: string };
5+
index: number;
6+
input: string;
7+
result?: unknown;
8+
}
9+
10+
/**
11+
* Shape a settled batch result into the record that is streamed to stdout or
12+
* written per item. Successes carry `result`; failures carry `error`.
13+
*/
14+
export function toBatchRecord(result: BatchResult): BatchRecord {
15+
if (result.ok) {
16+
return { index: result.index, input: result.input, result: result.data };
17+
}
18+
19+
return { index: result.index, input: result.input, error: result.error };
20+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { mkdirSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { BatchResult } from "../types/batch-result.js";
4+
import type { BatchSink } from "../types/batch-sink.js";
5+
import { batchItemFilename } from "./batch-item-filename.js";
6+
import { toBatchRecord } from "./batch-record.js";
7+
8+
export interface DirectorySinkOptions {
9+
pretty?: boolean;
10+
}
11+
12+
function uniqueName(base: string, used: Set<string>): string {
13+
if (!used.has(base)) {
14+
used.add(base);
15+
return base;
16+
}
17+
18+
let suffix = 2;
19+
let candidate = `${base}-${suffix}`;
20+
while (used.has(candidate)) {
21+
suffix++;
22+
candidate = `${base}-${suffix}`;
23+
}
24+
used.add(candidate);
25+
return candidate;
26+
}
27+
28+
/**
29+
* Per-item sink: writes one `<name>.json` file per result into `dir` (created
30+
* if needed). File names come from the input URL slug or row index, deduped on
31+
* collision. Each file holds the same record that the stdout sink would emit.
32+
*/
33+
export function createDirectorySink(
34+
dir: string,
35+
options: DirectorySinkOptions = {}
36+
): BatchSink {
37+
mkdirSync(dir, { recursive: true });
38+
const used = new Set<string>();
39+
const indent = options.pretty ? 2 : undefined;
40+
41+
return {
42+
write(result: BatchResult): void {
43+
const base = batchItemFilename(result.input, result.index);
44+
const name = uniqueName(base, used);
45+
const record = toBatchRecord(result);
46+
writeFileSync(
47+
join(dir, `${name}.json`),
48+
JSON.stringify(record, null, indent),
49+
"utf8"
50+
);
51+
},
52+
};
53+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { BatchResult } from "../types/batch-result.js";
2+
import type { BatchSink } from "../types/batch-sink.js";
3+
import { toBatchRecord } from "./batch-record.js";
4+
5+
/**
6+
* Batch default sink: one JSON record per line on stdout, regardless of TTY.
7+
*/
8+
export function createNdjsonStdoutSink(): BatchSink {
9+
return {
10+
write(result: BatchResult): void {
11+
process.stdout.write(`${JSON.stringify(toBatchRecord(result))}\n`);
12+
},
13+
};
14+
}

src/batch/types/batch-sink.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { BatchResult } from "./batch-result.js";
2+
3+
/** Destination for batch results — stdout (ndjson) or a directory of files. */
4+
export interface BatchSink {
5+
close?(): void | Promise<void>;
6+
write(result: BatchResult): void | Promise<void>;
7+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from "vitest";
2+
import { batchItemFilename } from "../../src/batch/services/batch-item-filename.js";
3+
4+
describe("batchItemFilename", () => {
5+
it("slugs a URL from host and path, dropping the query", () => {
6+
expect(batchItemFilename("https://example.com/a/b?x=1", 0)).toBe(
7+
"example.com-a-b"
8+
);
9+
});
10+
11+
it("falls back to the row index for non-URL input", () => {
12+
expect(batchItemFilename("how to scrape", 3)).toBe("item-3");
13+
});
14+
15+
it("truncates very long slugs", () => {
16+
const long = `https://example.com/${"a".repeat(300)}`;
17+
expect(batchItemFilename(long, 0).length).toBeLessThanOrEqual(100);
18+
});
19+
});

tests/batch/directory-sink.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { createDirectorySink } from "../../src/batch/services/directory-sink.js";
6+
7+
let dir: string;
8+
9+
beforeEach(() => {
10+
dir = mkdtempSync(join(tmpdir(), "batch-dir-sink-"));
11+
});
12+
13+
afterEach(() => {
14+
rmSync(dir, { recursive: true, force: true });
15+
});
16+
17+
describe("createDirectorySink", () => {
18+
it("writes one record file per item, named from the input", () => {
19+
const outDir = join(dir, "out");
20+
const sink = createDirectorySink(outDir);
21+
22+
sink.write({ index: 0, input: "https://a.com/x", ok: true, data: "A" });
23+
sink.write({
24+
index: 1,
25+
input: "https://b.com",
26+
ok: false,
27+
error: { class: "TimeoutError", message: "timed out" },
28+
});
29+
30+
const files = readdirSync(outDir).sort();
31+
expect(files).toEqual(["a.com-x.json", "b.com.json"]);
32+
33+
const success = JSON.parse(
34+
readFileSync(join(outDir, "a.com-x.json"), "utf8")
35+
);
36+
expect(success).toEqual({
37+
index: 0,
38+
input: "https://a.com/x",
39+
result: "A",
40+
});
41+
42+
const failure = JSON.parse(
43+
readFileSync(join(outDir, "b.com.json"), "utf8")
44+
);
45+
expect(failure).toEqual({
46+
index: 1,
47+
input: "https://b.com",
48+
error: { class: "TimeoutError", message: "timed out" },
49+
});
50+
});
51+
52+
it("dedupes colliding names with a numeric suffix", () => {
53+
const sink = createDirectorySink(dir);
54+
55+
sink.write({ index: 0, input: "not a url", ok: true, data: 1 });
56+
sink.write({ index: 0, input: "also not a url", ok: true, data: 2 });
57+
58+
const files = readdirSync(dir).sort();
59+
expect(files).toEqual(["item-0-2.json", "item-0.json"]);
60+
});
61+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createNdjsonStdoutSink } from "../../src/batch/services/ndjson-stdout-sink.js";
3+
4+
afterEach(() => {
5+
vi.restoreAllMocks();
6+
});
7+
8+
describe("createNdjsonStdoutSink", () => {
9+
it("writes one JSON line per result to stdout", () => {
10+
const lines: string[] = [];
11+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
12+
lines.push(String(chunk));
13+
return true;
14+
});
15+
16+
const sink = createNdjsonStdoutSink();
17+
sink.write({ index: 0, input: "https://a.com", ok: true, data: { ok: 1 } });
18+
sink.write({
19+
index: 1,
20+
input: "https://b.com",
21+
ok: false,
22+
error: { class: "ValidationError", message: "nope" },
23+
});
24+
25+
expect(lines).toHaveLength(2);
26+
expect(lines[0].endsWith("\n")).toBe(true);
27+
expect(JSON.parse(lines[0])).toEqual({
28+
index: 0,
29+
input: "https://a.com",
30+
result: { ok: 1 },
31+
});
32+
expect(JSON.parse(lines[1])).toEqual({
33+
index: 1,
34+
input: "https://b.com",
35+
error: { class: "ValidationError", message: "nope" },
36+
});
37+
});
38+
});

0 commit comments

Comments
 (0)