webscraping-ai-cli 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command10 } from "commander";
4
+ import { Command as Command12 } from "commander";
5
5
  import { readFileSync } from "fs";
6
6
  import { dirname as dirname3, resolve as resolve2 } from "path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -67,24 +67,100 @@ function maskApiKey(key) {
67
67
 
68
68
  // src/lib/output.ts
69
69
  import { promises as fs2 } from "fs";
70
- async function emit(value, opts = {}) {
70
+
71
+ // src/lib/stdin.ts
72
+ import readline from "readline";
73
+ function urlIsStdin(url) {
74
+ return url === "-";
75
+ }
76
+ async function* readUrlsFromStdin() {
77
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
78
+ for await (const raw of rl) {
79
+ const line = raw.trim();
80
+ if (line === "" || line.startsWith("#")) continue;
81
+ yield line;
82
+ }
83
+ }
84
+ async function* readQueriesFromStdin(input = process.stdin) {
85
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
86
+ for await (const line of rl) {
87
+ if (line.trim() === "") continue;
88
+ yield line;
89
+ }
90
+ }
91
+ async function* urlIterator(arg) {
92
+ if (urlIsStdin(arg)) {
93
+ yield* readUrlsFromStdin();
94
+ return;
95
+ }
96
+ yield arg;
97
+ }
98
+ async function* queryIterator(arg) {
99
+ if (urlIsStdin(arg)) {
100
+ yield* readQueriesFromStdin();
101
+ return;
102
+ }
103
+ yield arg;
104
+ }
105
+
106
+ // src/lib/output.ts
107
+ async function emit(value, opts = {}, write = {}) {
71
108
  const text = stringify(value, opts);
72
109
  if (opts.output && opts.output.trim() !== "") {
73
- await fs2.writeFile(opts.output, text + (text.endsWith("\n") ? "" : "\n"));
110
+ const data = text + (text.endsWith("\n") ? "" : "\n");
111
+ if (write.append) await fs2.appendFile(opts.output, data);
112
+ else await fs2.writeFile(opts.output, data);
74
113
  return;
75
114
  }
76
115
  process.stdout.write(text);
77
116
  if (!text.endsWith("\n")) process.stdout.write("\n");
78
117
  }
118
+ function createEmitter(opts = {}) {
119
+ let first = true;
120
+ return async (value) => {
121
+ const append = !first;
122
+ first = false;
123
+ await emit(value, opts, { append });
124
+ };
125
+ }
79
126
  function stringify(value, opts = {}) {
80
127
  if (typeof value === "string") return value;
81
128
  const pretty = opts.pretty ?? process.stdout.isTTY ?? false;
82
129
  return JSON.stringify(value, null, pretty ? 2 : 0);
83
130
  }
131
+ async function openEmitter(opts, arg) {
132
+ if (urlIsStdin(arg) && opts.output && opts.output.trim() !== "") {
133
+ await fs2.writeFile(opts.output, "");
134
+ return (value) => emit(value, opts, { append: true });
135
+ }
136
+ return createEmitter(opts);
137
+ }
84
138
 
85
139
  // src/lib/sdk.ts
86
140
  import { WebScrapingAI } from "webscraping-ai";
141
+
142
+ // src/lib/redact.ts
143
+ var secrets = /* @__PURE__ */ new Set();
144
+ function registerSecret(value) {
145
+ if (value && value.trim() !== "") secrets.add(value);
146
+ }
147
+ var API_KEY_PARAM = /(api_key=)[^&\s"'<>]*/gi;
148
+ function redactSecrets(text) {
149
+ let out = text.replace(API_KEY_PARAM, "$1[REDACTED]");
150
+ const candidates = new Set(secrets);
151
+ const env = process.env.WEBSCRAPING_AI_API_KEY?.trim();
152
+ if (env) candidates.add(env);
153
+ for (const secret of candidates) {
154
+ for (const form of /* @__PURE__ */ new Set([secret, encodeURIComponent(secret)])) {
155
+ out = out.split(form).join("[REDACTED]");
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ // src/lib/sdk.ts
87
162
  function createClient(options) {
163
+ registerSecret(options.apiKey);
88
164
  const inner = new WebScrapingAI({
89
165
  apiKey: options.apiKey,
90
166
  timeoutMs: options.requestTimeoutMs
@@ -107,6 +183,10 @@ function createClient(options) {
107
183
  return (o) => target.question({ ...o, ...tag });
108
184
  case "fields":
109
185
  return (o) => target.fields({ ...o, ...tag });
186
+ case "serp":
187
+ return (o) => target.serp({ ...o, params: { ...o.params, from_cli: true } });
188
+ case "data":
189
+ return (o) => target.data({ ...o, params: { ...o.params, from_cli: true } });
110
190
  case "account":
111
191
  return value.bind(target);
112
192
  default:
@@ -177,19 +257,66 @@ function buildQuestionOptions(url, question, flags, resolved) {
177
257
  function buildFieldsOptions(url, fields, flags, resolved) {
178
258
  return { url, fields, ...buildCommonOptions(flags, resolved) };
179
259
  }
260
+ function buildSerpOptions(query, flags = {}) {
261
+ if (typeof query !== "string" || query.trim() === "") {
262
+ throw new Error("serp requires a non-empty query");
263
+ }
264
+ const out = { q: query };
265
+ if (flags.engine !== void 0) out.engine = flags.engine;
266
+ if (flags.gl) out.gl = flags.gl;
267
+ if (flags.hl) out.hl = flags.hl;
268
+ if (flags.page !== void 0) {
269
+ if (!Number.isSafeInteger(flags.page) || flags.page < 1) {
270
+ throw new Error(`--page must be an integer >= 1, got: ${flags.page}`);
271
+ }
272
+ out.page = flags.page;
273
+ }
274
+ return out;
275
+ }
276
+ var RESERVED_DATA_PARAMS = /* @__PURE__ */ new Set(["api_key", "url", "from_cli", "__proto__"]);
277
+ var NAMED_DATA_PARAMS = {
278
+ country: "--country",
279
+ transcript: "--transcript",
280
+ transcript_language: "--transcript-language"
281
+ };
282
+ function parseDataParams(raw = []) {
283
+ const out = /* @__PURE__ */ Object.create(null);
284
+ for (const entry of raw) {
285
+ const eq = entry.indexOf("=");
286
+ if (eq <= 0) throw new Error(`--param must be key=value, got: ${entry}`);
287
+ const key = entry.slice(0, eq);
288
+ if (RESERVED_DATA_PARAMS.has(key)) {
289
+ throw new Error(`--param must not set "${key}"`);
290
+ }
291
+ if (Object.hasOwn(NAMED_DATA_PARAMS, key)) {
292
+ throw new Error(`--param must not set "${key}"; use ${NAMED_DATA_PARAMS[key]}`);
293
+ }
294
+ if (Object.hasOwn(out, key)) {
295
+ throw new Error(`--param "${key}" given more than once`);
296
+ }
297
+ out[key] = entry.slice(eq + 1);
298
+ }
299
+ return { ...out };
300
+ }
301
+ function buildDataOptions(url, flags = {}) {
302
+ if (typeof url !== "string" || url.trim() === "") {
303
+ throw new Error("data requires a non-empty url");
304
+ }
305
+ const out = { url };
306
+ if (flags.country) out.country = flags.country;
307
+ if (flags.transcript !== void 0) out.transcript = flags.transcript;
308
+ if (flags.transcriptLanguage) out.transcript_language = flags.transcriptLanguage;
309
+ const params = parseDataParams(flags.param);
310
+ if (Object.keys(params).length > 0) out.params = params;
311
+ return out;
312
+ }
180
313
 
181
314
  // src/lib/options.ts
182
315
  import { promises as fs3 } from "fs";
183
316
  import { Option } from "commander";
184
317
  function addCommonScrapeOptions(cmd) {
185
- return cmd.option("-k, --api-key <key>", "API key (overrides $WEBSCRAPING_AI_API_KEY and config file)").option("-o, --output <file>", "write result to FILE instead of stdout").option("--pretty", "pretty-print JSON output (default on TTY)").addOption(
186
- new Option("--no-pretty", "force single-line JSON output").conflicts("pretty")
187
- ).option("--js", "execute JS via a headless browser (default: enabled)").option("--no-js", "disable JS execution (faster, cheaper)").addOption(
188
- new Option("--proxy <type>", "proxy pool").choices([
189
- "datacenter",
190
- "residential",
191
- "stealth"
192
- ])
318
+ return cmd.option("-k, --api-key <key>", "API key (overrides $WEBSCRAPING_AI_API_KEY and config file)").option("-o, --output <file>", "write result to FILE instead of stdout").option("--pretty", "pretty-print JSON output (default on TTY)").option("--no-pretty", "force single-line JSON output").option("--js", "execute JS via a headless browser (default: enabled)").option("--no-js", "disable JS execution (faster, cheaper)").addOption(
319
+ new Option("--proxy <type>", "proxy pool").choices(["datacenter", "residential", "stealth"])
193
320
  ).option("--country <code>", "two-letter proxy country code, e.g. us, gb, de").addOption(
194
321
  new Option("--device <type>", "device emulation").choices(["desktop", "mobile", "tablet"])
195
322
  ).option("--timeout <ms>", "page-retrieval timeout in ms (max 30000)", integerParser).option("--js-timeout <ms>", "JS rendering time in ms after load (max 20000)", integerParser).option("--wait-for <selector>", "CSS selector to wait for before returning content").option(
@@ -197,6 +324,9 @@ function addCommonScrapeOptions(cmd) {
197
324
  "HTTP headers to forward to the target page; JSON object or @file.json"
198
325
  ).option("--js-script <code>", "custom JS to evaluate on the target page").option("--custom-proxy <url>", "custom proxy URL: http://user:pass@host:port").option("--error-on-404", "return error on 404").option("--error-on-redirect", "return error on redirect");
199
326
  }
327
+ function isPositiveIntegerString(value) {
328
+ return /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value));
329
+ }
200
330
  function integerParser(value) {
201
331
  const n = Number.parseInt(value, 10);
202
332
  if (!Number.isFinite(n)) throw new Error(`expected integer, got: ${value}`);
@@ -233,27 +363,6 @@ function parseJsonObject(text, label) {
233
363
  return out;
234
364
  }
235
365
 
236
- // src/lib/stdin.ts
237
- import readline from "readline";
238
- function urlIsStdin(url) {
239
- return url === "-";
240
- }
241
- async function* readUrlsFromStdin() {
242
- const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
243
- for await (const raw of rl) {
244
- const line = raw.trim();
245
- if (line === "" || line.startsWith("#")) continue;
246
- yield line;
247
- }
248
- }
249
- async function* urlIterator(arg) {
250
- if (urlIsStdin(arg)) {
251
- yield* readUrlsFromStdin();
252
- return;
253
- }
254
- yield arg;
255
- }
256
-
257
366
  // src/commands/ask.ts
258
367
  function askCommand() {
259
368
  const cmd = new Command2("ask").description("Ask an AI question about a page (wraps /ai/question)").argument("<url>", "page URL (or `-` to read URLs from stdin)").requiredOption("-q, --question <text>", "the question / instructions for the model").addOption(new Option2("--format <fmt>", "response wrapping").choices(["text", "json"]));
@@ -263,10 +372,11 @@ function askCommand() {
263
372
  const apiKey = await resolveApiKey({ flag: opts.apiKey });
264
373
  const headers = await parseHeaders(opts.headers);
265
374
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
375
+ const write = await openEmitter(opts, url);
266
376
  for await (const target of urlIterator(url)) {
267
377
  const sdkOptions = buildQuestionOptions(target, opts.question, opts, { headers });
268
378
  const result = await client.question(sdkOptions);
269
- await emit(result, opts);
379
+ await write(result);
270
380
  }
271
381
  });
272
382
  return cmd;
@@ -286,8 +396,10 @@ function authCommand() {
286
396
  const persisted = await readPersistedConfig();
287
397
  const env = process.env.WEBSCRAPING_AI_API_KEY;
288
398
  if (env && env.trim() !== "") {
289
- process.stdout.write(`Active key (env WEBSCRAPING_AI_API_KEY): ${maskApiKey(env.trim())}
290
- `);
399
+ process.stdout.write(
400
+ `Active key (env WEBSCRAPING_AI_API_KEY): ${maskApiKey(env.trim())}
401
+ `
402
+ );
291
403
  }
292
404
  if (persisted.apiKey && persisted.apiKey.trim() !== "") {
293
405
  process.stdout.write(
@@ -301,16 +413,125 @@ function authCommand() {
301
413
  });
302
414
  cmd.command("clear").description("Remove the stored API key file").action(async () => {
303
415
  const removed = await clearPersistedConfig();
304
- process.stdout.write(removed ? `Removed ${configPath()}
305
- ` : "No stored API key to remove.\n");
416
+ process.stdout.write(
417
+ removed ? `Removed ${configPath()}
418
+ ` : "No stored API key to remove.\n"
419
+ );
306
420
  });
307
421
  return cmd;
308
422
  }
309
423
 
310
- // src/commands/extract.ts
424
+ // src/commands/data.ts
311
425
  import { Command as Command4 } from "commander";
426
+
427
+ // src/lib/errors.ts
428
+ import {
429
+ APIConnectionError,
430
+ APIError,
431
+ APITimeoutError,
432
+ AuthenticationError,
433
+ BadRequestError,
434
+ GatewayTimeoutError,
435
+ PaymentRequiredError,
436
+ RateLimitError,
437
+ ServerError,
438
+ WebScrapingAIError
439
+ } from "webscraping-ai";
440
+ var EXIT_CODES = {
441
+ ok: 0,
442
+ generic: 1,
443
+ usage: 2,
444
+ badRequest: 3,
445
+ auth: 4,
446
+ paymentRequired: 5,
447
+ rateLimit: 6,
448
+ serverError: 7,
449
+ timeout: 8,
450
+ connection: 9
451
+ };
452
+ function exitCodeFor(err) {
453
+ if (err instanceof BadRequestError) return EXIT_CODES.badRequest;
454
+ if (err instanceof AuthenticationError) return EXIT_CODES.auth;
455
+ if (err instanceof PaymentRequiredError) return EXIT_CODES.paymentRequired;
456
+ if (err instanceof RateLimitError) return EXIT_CODES.rateLimit;
457
+ if (err instanceof ServerError) return EXIT_CODES.serverError;
458
+ if (err instanceof GatewayTimeoutError) return EXIT_CODES.timeout;
459
+ if (err instanceof APITimeoutError) return EXIT_CODES.timeout;
460
+ if (err instanceof APIConnectionError) return EXIT_CODES.connection;
461
+ if (err instanceof APIError) return EXIT_CODES.serverError;
462
+ if (err instanceof WebScrapingAIError) return EXIT_CODES.generic;
463
+ return EXIT_CODES.generic;
464
+ }
465
+ function formatError(err) {
466
+ return redactSecrets(rawMessage(err));
467
+ }
468
+ function rawMessage(err) {
469
+ if (err instanceof APIError) {
470
+ const parts = [err.message];
471
+ if (err.status) parts.push(`(HTTP ${err.status})`);
472
+ return parts.join(" ");
473
+ }
474
+ if (err instanceof Error) return err.message;
475
+ return String(err);
476
+ }
477
+
478
+ // src/commands/data.ts
479
+ function collect(value, previous = []) {
480
+ return [...previous, value];
481
+ }
482
+ function dataCommand() {
483
+ const cmd = new Command4("data").description(
484
+ "Structured JSON for a page on a supported site, e.g. YouTube, TikTok, X, LinkedIn, Instagram, Reddit (wraps /data, 15 credits/request; more sites are added server-side, an unsupported URL or page type gets a 400, not charged, listing what is supported)"
485
+ ).argument("<url>", "page URL (or `-` to read URLs from stdin, one per line)").option(
486
+ "--country <code>",
487
+ "two-letter country code of the proxy used to fetch the page (default: us)"
488
+ ).option(
489
+ "--transcript",
490
+ "YouTube videos only: also fetch the transcript into data.transcript (null without matching captions; a failed transcript fetch fails the request with a 500, not charged)"
491
+ ).option(
492
+ "--transcript-language <code>",
493
+ "caption language to pick, e.g. en or de (default: English, then the first track)"
494
+ ).option(
495
+ "--param <key=value>",
496
+ "extra site-specific query parameter sent as-is (repeatable)",
497
+ collect
498
+ ).option("-k, --api-key <key>", "API key (overrides $WEBSCRAPING_AI_API_KEY and config file)").option("-o, --output <file>", "write result to FILE instead of stdout").option("--pretty", "pretty-print JSON output (default on TTY)").option("--no-pretty", "force single-line JSON output");
499
+ cmd.action(async (url, opts) => {
500
+ if (!urlIsStdin(url) && url.trim() === "") {
501
+ cmd.error("error: data requires a non-empty url", {
502
+ exitCode: EXIT_CODES.usage,
503
+ code: "wsai.emptyUrl"
504
+ });
505
+ }
506
+ try {
507
+ parseDataParams(opts.param);
508
+ } catch (err) {
509
+ cmd.error(`error: ${err.message}`, {
510
+ exitCode: EXIT_CODES.usage,
511
+ code: "wsai.invalidParam"
512
+ });
513
+ }
514
+ const flags = {
515
+ country: opts.country,
516
+ transcript: opts.transcript,
517
+ transcriptLanguage: opts.transcriptLanguage,
518
+ param: opts.param
519
+ };
520
+ const apiKey = await resolveApiKey({ flag: opts.apiKey });
521
+ const client = createClient({ apiKey });
522
+ const write = await openEmitter(opts, url);
523
+ for await (const target of urlIterator(url)) {
524
+ const result = await client.data(buildDataOptions(target, flags));
525
+ await write(result);
526
+ }
527
+ });
528
+ return cmd;
529
+ }
530
+
531
+ // src/commands/extract.ts
532
+ import { Command as Command5 } from "commander";
312
533
  function extractCommand() {
313
- const cmd = new Command4("extract").description("Extract structured fields from a page using AI (wraps /ai/fields)").argument("<url>", "page URL (or `-` to read URLs from stdin)").requiredOption(
534
+ const cmd = new Command5("extract").description("Extract structured fields from a page using AI (wraps /ai/fields)").argument("<url>", "page URL (or `-` to read URLs from stdin)").requiredOption(
314
535
  "-f, --fields <json>",
315
536
  "fields to extract \u2014 JSON object {name: description} or @file.json"
316
537
  );
@@ -321,39 +542,48 @@ function extractCommand() {
321
542
  const headers = await parseHeaders(opts.headers);
322
543
  const fields = await parseFields(opts.fields);
323
544
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
545
+ const write = await openEmitter(opts, url);
324
546
  for await (const target of urlIterator(url)) {
325
547
  const sdkOptions = buildFieldsOptions(target, fields, opts, { headers });
326
548
  const result = await client.fields(sdkOptions);
327
- await emit(result, opts);
549
+ await write(result);
328
550
  }
329
551
  });
330
552
  return cmd;
331
553
  }
332
554
 
333
555
  // src/commands/html.ts
334
- import { Command as Command5, Option as Option3 } from "commander";
556
+ import { Command as Command6, Option as Option3 } from "commander";
335
557
  function htmlCommand() {
336
- const cmd = new Command5("html").description("Fetch the full HTML of a page").argument("<url>", "page URL (or `-` to read URLs from stdin)").addOption(new Option3("--return-script-result", "return the result of --js-script instead of HTML")).addOption(new Option3("--format <fmt>", "response wrapping").choices(["text", "json"]));
558
+ const cmd = new Command6("html").description("Fetch the full HTML of a page").argument("<url>", "page URL (or `-` to read URLs from stdin)").addOption(
559
+ new Option3("--return-script-result", "return the result of --js-script instead of HTML")
560
+ ).addOption(new Option3("--format <fmt>", "response wrapping").choices(["text", "json"]));
337
561
  addCommonScrapeOptions(cmd);
338
562
  cmd.action(async (url, opts) => {
339
563
  const apiKey = await resolveApiKey({ flag: opts.apiKey });
340
564
  const headers = await parseHeaders(opts.headers);
341
565
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
566
+ const write = await openEmitter(opts, url);
342
567
  for await (const target of urlIterator(url)) {
343
- const sdkOptions = buildHtmlOptions(target, opts, { headers }, {
344
- returnScriptResult: opts.returnScriptResult
345
- });
568
+ const sdkOptions = buildHtmlOptions(
569
+ target,
570
+ opts,
571
+ { headers },
572
+ {
573
+ returnScriptResult: opts.returnScriptResult
574
+ }
575
+ );
346
576
  const result = await client.html(sdkOptions);
347
- await emit(result, opts);
577
+ await write(result);
348
578
  }
349
579
  });
350
580
  return cmd;
351
581
  }
352
582
 
353
583
  // src/commands/selected.ts
354
- import { Command as Command6, Option as Option4 } from "commander";
584
+ import { Command as Command7, Option as Option4 } from "commander";
355
585
  function selectedCommand() {
356
- const cmd = new Command6("selected").description("Fetch HTML of one CSS-selected page area").argument("<url>", "page URL (or `-` to read URLs from stdin)").option(
586
+ const cmd = new Command7("selected").description("Fetch HTML of one CSS-selected page area").argument("<url>", "page URL (or `-` to read URLs from stdin)").option(
357
587
  "-s, --selector <css>",
358
588
  "CSS selector for the target element(s); omit for whole-page HTML"
359
589
  ).addOption(new Option4("--format <fmt>", "response wrapping").choices(["text", "json"]));
@@ -362,19 +592,20 @@ function selectedCommand() {
362
592
  const apiKey = await resolveApiKey({ flag: opts.apiKey });
363
593
  const headers = await parseHeaders(opts.headers);
364
594
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
595
+ const write = await openEmitter(opts, url);
365
596
  for await (const target of urlIterator(url)) {
366
597
  const sdkOptions = buildSelectedOptions(target, opts.selector, opts, { headers });
367
598
  const result = await client.selected(sdkOptions);
368
- await emit(result, opts);
599
+ await write(result);
369
600
  }
370
601
  });
371
602
  return cmd;
372
603
  }
373
604
 
374
605
  // src/commands/selectedMultiple.ts
375
- import { Command as Command7 } from "commander";
606
+ import { Command as Command8 } from "commander";
376
607
  function selectedMultipleCommand() {
377
- const cmd = new Command7("selected-multiple").description("Fetch HTML of multiple CSS-selected page areas").argument("<url>", "page URL (or `-` to read URLs from stdin)").option(
608
+ const cmd = new Command8("selected-multiple").description("Fetch HTML of multiple CSS-selected page areas").argument("<url>", "page URL (or `-` to read URLs from stdin)").option(
378
609
  "-s, --selector <css>",
379
610
  "CSS selector \u2014 pass multiple times for multiple selectors",
380
611
  collectMulti,
@@ -386,10 +617,11 @@ function selectedMultipleCommand() {
386
617
  const apiKey = await resolveApiKey({ flag: opts.apiKey });
387
618
  const headers = await parseHeaders(opts.headers);
388
619
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
620
+ const write = await openEmitter(opts, url);
389
621
  for await (const target of urlIterator(url)) {
390
622
  const sdkOptions = buildSelectedMultipleOptions(target, selectors, opts, { headers });
391
623
  const result = await client.selectedMultiple(sdkOptions);
392
- await emit(result, opts);
624
+ await write(result);
393
625
  }
394
626
  });
395
627
  return cmd;
@@ -398,8 +630,45 @@ function collectMulti(value, previous) {
398
630
  return [...previous, value];
399
631
  }
400
632
 
633
+ // src/commands/serp.ts
634
+ import { Command as Command9, Option as Option5 } from "commander";
635
+ function serpCommand() {
636
+ const cmd = new Command9("serp").alias("search").description("Search Google and print parsed results as JSON (wraps /serp, 15 credits/search)").argument("<query...>", "search query (or `-` to read queries from stdin, one per line)").addOption(new Option5("--engine <name>", "search engine (default: google)").choices(["google"])).option("--gl <code>", "two-letter country code for search geolocation (default: us)").option("--hl <code>", "two-letter language code for the results (default: en)").option(
637
+ "--page <n>",
638
+ "results page number, integer >= 1, 10 results per page (default: 1; server rejects > 100 with a 400)"
639
+ ).option("-k, --api-key <key>", "API key (overrides $WEBSCRAPING_AI_API_KEY and config file)").option("-o, --output <file>", "write result to FILE instead of stdout").option("--pretty", "pretty-print JSON output (default on TTY)").option("--no-pretty", "force single-line JSON output");
640
+ cmd.action(async (words, opts) => {
641
+ const query = words.join(" ");
642
+ let page;
643
+ if (opts.page !== void 0) {
644
+ if (!isPositiveIntegerString(opts.page)) {
645
+ cmd.error(`error: --page must be an integer >= 1, got: ${opts.page}`, {
646
+ exitCode: EXIT_CODES.usage,
647
+ code: "wsai.invalidPage"
648
+ });
649
+ }
650
+ page = Number(opts.page);
651
+ }
652
+ if (!urlIsStdin(query) && query.trim() === "") {
653
+ cmd.error("error: serp requires a non-empty query", {
654
+ exitCode: EXIT_CODES.usage,
655
+ code: "wsai.emptyQuery"
656
+ });
657
+ }
658
+ const flags = { engine: opts.engine, gl: opts.gl, hl: opts.hl, page };
659
+ const apiKey = await resolveApiKey({ flag: opts.apiKey });
660
+ const client = createClient({ apiKey });
661
+ const write = await openEmitter(opts, query);
662
+ for await (const q of queryIterator(query)) {
663
+ const result = await client.serp(buildSerpOptions(q, flags));
664
+ await write(result);
665
+ }
666
+ });
667
+ return cmd;
668
+ }
669
+
401
670
  // src/commands/setupSkill.ts
402
- import { Command as Command8 } from "commander";
671
+ import { Command as Command10 } from "commander";
403
672
 
404
673
  // src/lib/skill.ts
405
674
  import { promises as fs4 } from "fs";
@@ -408,24 +677,72 @@ import { dirname as dirname2, join as join2, resolve } from "path";
408
677
  import { fileURLToPath } from "url";
409
678
  var EDITORS = [
410
679
  // Claude Code: user-scope skills live under ~/.claude/skills/<name>/
411
- { id: "claude-code", label: "Claude Code", mode: "dir", kind: "user", path: ".claude/skills/webscraping-ai" },
680
+ {
681
+ id: "claude-code",
682
+ label: "Claude Code",
683
+ mode: "dir",
684
+ kind: "user",
685
+ path: ".claude/skills/webscraping-ai"
686
+ },
412
687
  // Cursor: project-scope rule file. Cursor doesn't have a user-wide skill
413
688
  // dir, so this is the editor's "install" surface.
414
- { id: "cursor", label: "Cursor", mode: "file", kind: "project", path: ".cursor/rules/webscraping-ai.mdc" },
689
+ {
690
+ id: "cursor",
691
+ label: "Cursor",
692
+ mode: "file",
693
+ kind: "project",
694
+ path: ".cursor/rules/webscraping-ai.mdc"
695
+ },
415
696
  // Windsurf: project-scope rules. Same shape as Cursor.
416
- { id: "windsurf", label: "Windsurf", mode: "file", kind: "project", path: ".windsurf/rules/webscraping-ai.md" },
697
+ {
698
+ id: "windsurf",
699
+ label: "Windsurf",
700
+ mode: "file",
701
+ kind: "project",
702
+ path: ".windsurf/rules/webscraping-ai.md"
703
+ },
417
704
  // Kiro: user-scope steering directory.
418
705
  { id: "kiro", label: "Kiro", mode: "dir", kind: "user", path: ".kiro/steering/webscraping-ai" },
419
706
  // OpenCode: project-scope skills (mirrors Claude shape).
420
- { id: "opencode", label: "OpenCode", mode: "dir", kind: "project", path: ".opencode/skills/webscraping-ai" },
707
+ {
708
+ id: "opencode",
709
+ label: "OpenCode",
710
+ mode: "dir",
711
+ kind: "project",
712
+ path: ".opencode/skills/webscraping-ai"
713
+ },
421
714
  // Gemini CLI: user-scope extension dir with a GEMINI.md.
422
- { id: "gemini", label: "Gemini CLI", mode: "file", kind: "user", path: ".gemini/extensions/webscraping-ai/GEMINI.md" },
715
+ {
716
+ id: "gemini",
717
+ label: "Gemini CLI",
718
+ mode: "file",
719
+ kind: "user",
720
+ path: ".gemini/extensions/webscraping-ai/GEMINI.md"
721
+ },
423
722
  // GitHub Copilot: project-scope custom instructions file. Single file.
424
- { id: "copilot", label: "GitHub Copilot", mode: "file", kind: "project", path: ".github/copilot-instructions.md" },
723
+ {
724
+ id: "copilot",
725
+ label: "GitHub Copilot",
726
+ mode: "file",
727
+ kind: "project",
728
+ path: ".github/copilot-instructions.md"
729
+ },
425
730
  // Augment: project-scope rules file.
426
- { id: "augment", label: "Augment", mode: "file", kind: "project", path: ".augment/rules/webscraping-ai.md" },
731
+ {
732
+ id: "augment",
733
+ label: "Augment",
734
+ mode: "file",
735
+ kind: "project",
736
+ path: ".augment/rules/webscraping-ai.md"
737
+ },
427
738
  // Factory: project-scope rules file.
428
- { id: "factory", label: "Factory", mode: "file", kind: "project", path: ".factory/rules/webscraping-ai.md" }
739
+ {
740
+ id: "factory",
741
+ label: "Factory",
742
+ mode: "file",
743
+ kind: "project",
744
+ path: ".factory/rules/webscraping-ai.md"
745
+ }
429
746
  ];
430
747
  function editorById(id) {
431
748
  return EDITORS.find((e) => e.id === id);
@@ -468,11 +785,11 @@ async function copyDirectory(source, destination) {
468
785
 
469
786
  // src/commands/setupSkill.ts
470
787
  function setupCommand() {
471
- const cmd = new Command8("setup").description("Install the AI agent skill into your editor(s)");
788
+ const cmd = new Command10("setup").description("Install the AI agent skill into your editor(s)");
472
789
  cmd.command("skill").description("Install the WebScraping.AI skill into one or more AI coding editors").option(
473
790
  "-e, --editor <id>",
474
791
  `editor id (repeatable). Supported: ${EDITORS.map((e) => e.id).join(", ")}`,
475
- collect,
792
+ collect2,
476
793
  []
477
794
  ).option("-a, --all", "install into every supported editor").option("-f, --force", "overwrite existing skill files").action(async (opts) => {
478
795
  const selected = resolveSelection(opts);
@@ -497,7 +814,7 @@ function setupCommand() {
497
814
  });
498
815
  return cmd;
499
816
  }
500
- function collect(value, previous) {
817
+ function collect2(value, previous) {
501
818
  return [...previous, value];
502
819
  }
503
820
  function resolveSelection(opts) {
@@ -511,76 +828,34 @@ function resolveSelection(opts) {
511
828
  }
512
829
 
513
830
  // src/commands/text.ts
514
- import { Command as Command9, Option as Option5 } from "commander";
831
+ import { Command as Command11, Option as Option6 } from "commander";
515
832
  function textCommand() {
516
- const cmd = new Command9("text").description("Fetch the visible text of a page").argument("<url>", "page URL (or `-` to read URLs from stdin)").addOption(
517
- new Option5("--text-format <fmt>", "text response format").choices(["plain", "json", "xml"])
833
+ const cmd = new Command11("text").description("Fetch the visible text of a page").argument("<url>", "page URL (or `-` to read URLs from stdin)").addOption(
834
+ new Option6("--text-format <fmt>", "text response format").choices(["plain", "json", "xml"])
518
835
  ).option("--return-links", "include page links (text-format=json only)");
519
836
  addCommonScrapeOptions(cmd);
520
837
  cmd.action(async (url, opts) => {
521
838
  const apiKey = await resolveApiKey({ flag: opts.apiKey });
522
839
  const headers = await parseHeaders(opts.headers);
523
840
  const client = createClient({ apiKey, requestTimeoutMs: opts.timeout });
841
+ const write = await openEmitter(opts, url);
524
842
  for await (const target of urlIterator(url)) {
525
- const sdkOptions = buildTextOptions(target, opts, { headers }, {
526
- textFormat: opts.textFormat,
527
- returnLinks: opts.returnLinks
528
- });
843
+ const sdkOptions = buildTextOptions(
844
+ target,
845
+ opts,
846
+ { headers },
847
+ {
848
+ textFormat: opts.textFormat,
849
+ returnLinks: opts.returnLinks
850
+ }
851
+ );
529
852
  const result = await client.text(sdkOptions);
530
- await emit(result, opts);
853
+ await write(result);
531
854
  }
532
855
  });
533
856
  return cmd;
534
857
  }
535
858
 
536
- // src/lib/errors.ts
537
- import {
538
- APIConnectionError,
539
- APIError,
540
- APITimeoutError,
541
- AuthenticationError,
542
- BadRequestError,
543
- GatewayTimeoutError,
544
- PaymentRequiredError,
545
- RateLimitError,
546
- ServerError,
547
- WebScrapingAIError
548
- } from "webscraping-ai";
549
- var EXIT_CODES = {
550
- ok: 0,
551
- generic: 1,
552
- usage: 2,
553
- badRequest: 3,
554
- auth: 4,
555
- paymentRequired: 5,
556
- rateLimit: 6,
557
- serverError: 7,
558
- timeout: 8,
559
- connection: 9
560
- };
561
- function exitCodeFor(err) {
562
- if (err instanceof BadRequestError) return EXIT_CODES.badRequest;
563
- if (err instanceof AuthenticationError) return EXIT_CODES.auth;
564
- if (err instanceof PaymentRequiredError) return EXIT_CODES.paymentRequired;
565
- if (err instanceof RateLimitError) return EXIT_CODES.rateLimit;
566
- if (err instanceof ServerError) return EXIT_CODES.serverError;
567
- if (err instanceof GatewayTimeoutError) return EXIT_CODES.timeout;
568
- if (err instanceof APITimeoutError) return EXIT_CODES.timeout;
569
- if (err instanceof APIConnectionError) return EXIT_CODES.connection;
570
- if (err instanceof APIError) return EXIT_CODES.serverError;
571
- if (err instanceof WebScrapingAIError) return EXIT_CODES.generic;
572
- return EXIT_CODES.generic;
573
- }
574
- function formatError(err) {
575
- if (err instanceof APIError) {
576
- const parts = [err.message];
577
- if (err.status) parts.push(`(HTTP ${err.status})`);
578
- return parts.join(" ");
579
- }
580
- if (err instanceof Error) return err.message;
581
- return String(err);
582
- }
583
-
584
859
  // src/cli.ts
585
860
  function packageVersion() {
586
861
  const here = dirname3(fileURLToPath2(import.meta.url));
@@ -589,9 +864,9 @@ function packageVersion() {
589
864
  return raw.version;
590
865
  }
591
866
  function buildProgram() {
592
- const program = new Command10();
867
+ const program = new Command12();
593
868
  program.name("webscraping-ai").description(
594
- "Official CLI for the WebScraping.AI API. Fetch pages, ask AI, extract structured data.\nSet $WEBSCRAPING_AI_API_KEY or run `webscraping-ai auth set <key>` first."
869
+ "Official CLI for the WebScraping.AI API. Fetch pages, search Google, get structured site data, ask AI, extract fields.\nSet $WEBSCRAPING_AI_API_KEY or run `webscraping-ai auth set <key>` first."
595
870
  ).version(packageVersion(), "-v, --version", "output the version number").showHelpAfterError();
596
871
  program.addCommand(htmlCommand());
597
872
  program.addCommand(textCommand());
@@ -599,6 +874,8 @@ function buildProgram() {
599
874
  program.addCommand(selectedMultipleCommand());
600
875
  program.addCommand(askCommand());
601
876
  program.addCommand(extractCommand());
877
+ program.addCommand(serpCommand());
878
+ program.addCommand(dataCommand());
602
879
  program.addCommand(accountCommand());
603
880
  program.addCommand(authCommand());
604
881
  program.addCommand(setupCommand());