Skip to content

Commit b782013

Browse files
Wire batch flags into scrape/search/screenshot/codegen
Attach --input-file, --input-column and --concurrency to every scrape-style command and dispatch into the batch pipeline when --input-file is present. - createTargetAction detects batch mode, rejects --input-file combined with a positional input, and otherwise runs each input through the same body builder + SDK call with bounded concurrency. - Primary positional args are now optional so the input can come from the file instead; a genuinely missing input raises a CliUsageError. - Binary targets (screenshot) write one file per item and require -o <dir>; non-binary targets stream ndjson or write per-item JSON files. Integration tests cover flag exposure and the usage-error paths; the orchestrator is unit-tested end to end without network calls.
1 parent 6f1daca commit b782013

13 files changed

Lines changed: 437 additions & 11 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Command } from "commander";
2+
import { parseConcurrency } from "../services/resolve-concurrency.js";
3+
4+
export function attachBatchOptions(command: Command): Command {
5+
return command
6+
.option(
7+
"--input-file <path>",
8+
"Run each line/row of a .txt or .csv file as a batch item"
9+
)
10+
.option(
11+
"--input-column <name>",
12+
"Column to read inputs from when --input-file is a CSV"
13+
)
14+
.option(
15+
"--concurrency <n>",
16+
"Max requests to run in parallel in batch mode (default: 4)",
17+
parseConcurrency
18+
);
19+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
import { uniqueName } from "./unique-name.js";
8+
9+
function isBytes(value: unknown): value is Uint8Array {
10+
return value instanceof Uint8Array;
11+
}
12+
13+
export function createBinaryDirectorySink(
14+
dir: string,
15+
extension = "png"
16+
): BatchSink {
17+
mkdirSync(dir, { recursive: true });
18+
const used = new Set<string>();
19+
20+
return {
21+
write(result: BatchResult): void {
22+
const name = uniqueName(
23+
batchItemFilename(result.input, result.index),
24+
used
25+
);
26+
27+
if (result.ok && isBytes(result.data)) {
28+
writeFileSync(join(dir, `${name}.${extension}`), result.data);
29+
return;
30+
}
31+
32+
writeFileSync(
33+
join(dir, `${name}.error.json`),
34+
JSON.stringify(toBatchRecord(result), null, 2),
35+
"utf8"
36+
);
37+
},
38+
};
39+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ValidationError } from "@decodo/sdk-ts";
2+
import { DEFAULT_CONCURRENCY } from "../constants.js";
3+
4+
export function parseConcurrency(value: string): number {
5+
const parsed = Number.parseInt(value, 10);
6+
if (Number.isNaN(parsed) || parsed < 1) {
7+
throw new ValidationError("--concurrency must be a positive integer.");
8+
}
9+
return parsed;
10+
}
11+
12+
export function resolveConcurrency(value: number | undefined): number {
13+
return value ?? DEFAULT_CONCURRENCY;
14+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ValidationError } from "@decodo/sdk-ts";
2+
import { CliUsageError } from "../../platform/services/handle-cli-error.js";
3+
import type { BatchSummary } from "../types/batch-result.js";
4+
import type { BatchSink } from "../types/batch-sink.js";
5+
import { createBinaryDirectorySink } from "./binary-directory-sink.js";
6+
import { createDirectorySink } from "./directory-sink.js";
7+
import { createNdjsonStdoutSink } from "./ndjson-stdout-sink.js";
8+
import { readInputFile } from "./read-input-file.js";
9+
import { runBatch } from "./run-batch.js";
10+
11+
export interface RunBatchCommandOptions {
12+
binary?: boolean;
13+
concurrency: number;
14+
inputColumn?: string;
15+
inputFile: string;
16+
output?: string;
17+
pretty?: boolean;
18+
scrapeItem: (input: string) => Promise<unknown>;
19+
}
20+
21+
function selectSink(options: RunBatchCommandOptions): BatchSink {
22+
if (options.binary) {
23+
if (!options.output) {
24+
throw new CliUsageError(
25+
"Batch mode for binary output requires -o <dir> to write files."
26+
);
27+
}
28+
return createBinaryDirectorySink(options.output);
29+
}
30+
31+
if (options.output) {
32+
return createDirectorySink(options.output, { pretty: options.pretty });
33+
}
34+
35+
return createNdjsonStdoutSink();
36+
}
37+
38+
export async function runBatchCommand(
39+
options: RunBatchCommandOptions
40+
): Promise<BatchSummary> {
41+
const sink = selectSink(options);
42+
const items = readInputFile(options.inputFile, {
43+
inputColumn: options.inputColumn,
44+
});
45+
46+
const summary = await runBatch({
47+
items,
48+
concurrency: options.concurrency,
49+
worker: (item) => options.scrapeItem(item.input),
50+
onResult: (result) => sink.write(result),
51+
});
52+
53+
await sink.close?.();
54+
55+
if (summary.total === 0) {
56+
throw new ValidationError("Input file produced no inputs.");
57+
}
58+
59+
console.error(
60+
`Batch complete: ${summary.succeeded} succeeded, ${summary.failed} failed (${summary.total} total).`
61+
);
62+
63+
return summary;
64+
}

src/batch/types/batch-flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface BatchFlags {
2+
concurrency?: number;
3+
inputColumn?: string;
4+
inputFile?: string;
5+
}

src/scrape/commands/scrape.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { type DecodoSchema, Target, ValidationError } from "@decodo/sdk-ts";
22
import { Command } from "commander";
3+
import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js";
34
import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js";
45
import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js";
56
import type { OutputOptions } from "../../output/types/output-options.js";
7+
import { CliUsageError } from "../../platform/services/handle-cli-error.js";
68
import { resolveTarget } from "../services/resolve-target.js";
79
import { createTargetAction } from "../services/run-target-scrape.js";
810
import type { ScrapeOptions } from "../types/scrape-command.js";
@@ -32,17 +34,18 @@ export function createScrapeCommand(schema: DecodoSchema): Command {
3234
.description(
3335
"Scrape a URL with the universal target (markdown by default). Use decodo universal for --markdown, --parse, and other API flags."
3436
)
35-
.argument("<url>", "URL to scrape")
37+
.argument("[url]", "URL to scrape (omit when using --input-file)")
3638
.option("--country <code>", "Geo / country code (maps to geo)")
3739
.option("--headers <json>", "Request headers as a JSON object string")
3840
.option("--target <name>", "Scrape target override (default: universal)");
3941

4042
attachScrapeOutputOptions(command);
43+
attachBatchOptions(command);
4144

4245
return command.action(
4346
createTargetAction(Target.Universal, schema, (url, options) => {
4447
if (url === undefined) {
45-
throw new Error("Missing required URL.");
48+
throw new CliUsageError("Missing required URL.");
4649
}
4750

4851
const opts = options as ScrapeOptions & OutputOptions;

src/scrape/commands/screenshot.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { type DecodoSchema, Target } from "@decodo/sdk-ts";
22
import { Command } from "commander";
3+
import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js";
34
import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js";
5+
import { CliUsageError } from "../../platform/services/handle-cli-error.js";
46
import { resolveTarget } from "../services/resolve-target.js";
57
import { createTargetAction } from "../services/run-target-scrape.js";
68
import type { ScreenshotOptions } from "../types/screenshot-command.js";
@@ -10,21 +12,22 @@ export function createScreenshotCommand(schema: DecodoSchema): Command {
1012
.description(
1113
"Capture a PNG screenshot (universal, headless). Use decodo universal --headless png for full options."
1214
)
13-
.argument("<url>", "URL to screenshot")
15+
.argument("[url]", "URL to screenshot (omit when using --input-file)")
1416
.option("--country <code>", "Geo / country code (maps to geo)")
1517
.option("--target <name>", "Scrape target override (default: universal)");
1618

1719
attachScrapeOutputOptions(command, {
1820
outputHelp: "Write PNG to file or directory (default name: <host>.png)",
1921
});
22+
attachBatchOptions(command);
2023

2124
return command.action(
2225
createTargetAction(
2326
Target.Universal,
2427
schema,
2528
(url, options) => {
2629
if (url === undefined) {
27-
throw new Error("Missing required URL.");
30+
throw new CliUsageError("Missing required URL.");
2831
}
2932

3033
const opts = options as ScreenshotOptions;

src/scrape/commands/search.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { type DecodoSchema, Target, ValidationError } from "@decodo/sdk-ts";
22
import { Command, Option } from "commander";
3+
import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js";
34
import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js";
45
import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js";
6+
import { CliUsageError } from "../../platform/services/handle-cli-error.js";
57
import { resolveTarget } from "../services/resolve-target.js";
68
import { createTargetAction } from "../services/run-target-scrape.js";
79
import type { SearchOptions } from "../types/search-command.js";
@@ -52,7 +54,7 @@ export function createSearchCommand(schema: DecodoSchema): Command {
5254
.description(
5355
"Search the web (default: Google). Use decodo google-search or decodo bing-search for full options."
5456
)
55-
.argument("<query>", "Search query")
57+
.argument("[query]", "Search query (omit when using --input-file)")
5658
.addOption(
5759
new Option("--engine <engine>", "Search engine")
5860
.choices(["google", "bing"])
@@ -63,11 +65,12 @@ export function createSearchCommand(schema: DecodoSchema): Command {
6365
.option("--target <name>", "Scrape target override");
6466

6567
attachScrapeOutputOptions(command);
68+
attachBatchOptions(command);
6669

6770
return command.action(
6871
createTargetAction(Target.GoogleSearch, schema, (query, options) => {
6972
if (query === undefined) {
70-
throw new Error("Missing required query.");
73+
throw new CliUsageError("Missing required query.");
7174
}
7275

7376
const opts = options as SearchOptions;

src/scrape/services/command-builder.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { DecodoSchema } from "@decodo/sdk-ts";
22
import { type Command, Option } from "commander";
33
import type { JSONSchema4 } from "json-schema";
4+
import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js";
45
import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js";
56
import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js";
7+
import { CliUsageError } from "../../platform/services/handle-cli-error.js";
68
import type { TargetCommandConfig } from "../types/target-command.js";
79
import { snakeToCamel, snakeToKebab } from "./naming.js";
810
import { getPrimaryInputField } from "./primary-input.js";
@@ -81,7 +83,7 @@ export function configureTargetCommand(
8183
| undefined;
8284
const inputHelp =
8385
primarySchema?.description ?? `Primary ${primaryField} input`;
84-
command.argument("<input>", inputHelp);
86+
command.argument("[input]", inputHelp);
8587
}
8688

8789
const optionFields = Object.keys(parameterSchema?.properties ?? {}).filter(
@@ -94,6 +96,7 @@ export function configureTargetCommand(
9496
}
9597

9698
attachScrapeOutputOptions(command);
99+
attachBatchOptions(command);
97100

98101
return { target, primaryField, optionFields };
99102
}
@@ -109,7 +112,9 @@ export function buildScrapeBody(
109112

110113
if (config.primaryField) {
111114
if (input === undefined) {
112-
throw new Error(`Missing required input for ${config.primaryField}.`);
115+
throw new CliUsageError(
116+
`Missing required input for ${config.primaryField}.`
117+
);
113118
}
114119
body[config.primaryField] = input;
115120
}

src/scrape/services/run-target-scrape.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ import type { DecodoSchema, ScrapeRequest } from "@decodo/sdk-ts";
22
import type { Command } from "commander";
33
import { AuthRequiredError } from "../../auth/errors/auth-required-error.js";
44
import { resolveAuthToken } from "../../auth/services/resolve-token.js";
5+
import { resolveConcurrency } from "../../batch/services/resolve-concurrency.js";
6+
import { runBatchCommand } from "../../batch/services/run-batch-command.js";
7+
import type { BatchFlags } from "../../batch/types/batch-flags.js";
58
import { getRootOpts } from "../../cli/services/global-opts.js";
69
import { verboseLog } from "../../cli/services/verbose-log.js";
10+
import { extractPayload } from "../../output/services/extract-payload.js";
711
import { writeScrapeResponse } from "../../output/services/write-scrape-response.js";
812
import type { OutputOptions } from "../../output/types/output-options.js";
9-
import { handleCliError } from "../../platform/services/handle-cli-error.js";
13+
import {
14+
CliUsageError,
15+
handleCliError,
16+
} from "../../platform/services/handle-cli-error.js";
1017
import type {
1118
ExecuteScrapeOptions,
1219
OutputContextBuilder,
1320
ScrapeBodyBuilder,
1421
} from "../types/run-target-scrape.js";
1522
import { createDecodoClient } from "./client.js";
1623
import { buildScrapeBody, getTargetCommandConfig } from "./command-builder.js";
24+
import { extractPngFromResponse } from "./extract-png.js";
1725
import { formatScrapeRequestLog } from "./format-scrape-request-log.js";
1826

1927
async function executeScrape({
@@ -39,6 +47,47 @@ async function executeScrape({
3947
});
4048
}
4149

50+
interface ExecuteBatchOptions {
51+
binary: boolean;
52+
options: Record<string, unknown>;
53+
resolveBody: ScrapeBodyBuilder;
54+
schema: DecodoSchema;
55+
token: string;
56+
verbose: boolean;
57+
}
58+
59+
async function executeBatch({
60+
token,
61+
schema,
62+
options,
63+
resolveBody,
64+
binary,
65+
verbose,
66+
}: ExecuteBatchOptions): Promise<void> {
67+
const client = createDecodoClient(token, schema);
68+
const batch = options as BatchFlags & OutputOptions;
69+
const full = batch.full === true;
70+
71+
await runBatchCommand({
72+
inputFile: batch.inputFile as string,
73+
inputColumn: batch.inputColumn,
74+
concurrency: resolveConcurrency(batch.concurrency),
75+
output: batch.output,
76+
pretty: batch.pretty,
77+
binary,
78+
scrapeItem: async (itemInput) => {
79+
const body = resolveBody(itemInput, options);
80+
verboseLog(verbose, formatScrapeRequestLog(body));
81+
const response = await client.webScrapingApi.scrape(
82+
body as unknown as ScrapeRequest
83+
);
84+
return binary
85+
? extractPngFromResponse(response)
86+
: extractPayload(response, full);
87+
},
88+
});
89+
}
90+
4291
export function createTargetAction(
4392
target: string,
4493
schema: DecodoSchema,
@@ -60,12 +109,35 @@ export function createTargetAction(
60109
const verbose = rootOpts.verbose === true;
61110

62111
try {
112+
const batchMode = (options as BatchFlags).inputFile !== undefined;
113+
if (batchMode && input !== undefined) {
114+
throw new CliUsageError(
115+
"Cannot combine --input-file with a positional input."
116+
);
117+
}
118+
if (!batchMode && config.primaryField && input === undefined) {
119+
throw new CliUsageError(`Missing required ${config.primaryField}.`);
120+
}
121+
63122
const auth = await resolveAuthToken({ token: rootOpts.token });
64123
verboseLog(verbose, `auth source=${auth.source}`);
65124
if (!auth.token) {
66125
throw new AuthRequiredError();
67126
}
68127

128+
if (batchMode) {
129+
const outputContext = getOutputContext?.(undefined, options);
130+
await executeBatch({
131+
token: auth.token,
132+
schema,
133+
options,
134+
resolveBody,
135+
binary: outputContext?.binary?.kind === "png",
136+
verbose,
137+
});
138+
return;
139+
}
140+
69141
const body = resolveBody(input, options);
70142
verboseLog(verbose, formatScrapeRequestLog(body));
71143
const outputContext = getOutputContext?.(input, options);

0 commit comments

Comments
 (0)