webforai 3.0.0 → 4.0.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.
Files changed (92) hide show
  1. package/README.md +25 -5
  2. package/dist/bin.js +1277 -479
  3. package/dist/cjs/detect-client-shell.js +78 -0
  4. package/dist/cjs/extract-meta-refresh.js +71 -0
  5. package/dist/cjs/extractors/lib/sanitize.js +4 -0
  6. package/dist/cjs/html-to-mdast.js +2 -1
  7. package/dist/cjs/index.js +12 -0
  8. package/dist/cjs/link-replacer.js +19 -13
  9. package/dist/cjs/loaders/cf-puppeteer.js +14 -7
  10. package/dist/cjs/loaders/fetch.js +14 -2
  11. package/dist/cjs/loaders/playwright.js +32 -30
  12. package/dist/cjs/loaders/puppeteer.js +14 -7
  13. package/dist/cjs/md-splitter.js +24 -11
  14. package/dist/cjs/mdast-handlers/custom-code-handler.js +18 -7
  15. package/dist/cjs/mdast-handlers/custom-div-handler.js +12 -33
  16. package/dist/cjs/mdast-handlers/definition-list-handler.js +17 -6
  17. package/dist/cjs/mdast-to-markdown.js +2 -5
  18. package/dist/cjs/normalize/index.js +11 -2
  19. package/dist/cjs/platform/client.js +73 -16
  20. package/dist/cjs/platform/index.js +4 -0
  21. package/dist/cjs/platform/types.js +7 -1
  22. package/dist/detect-client-shell.js +53 -0
  23. package/dist/extract-meta-refresh.js +47 -0
  24. package/dist/extractors/lib/sanitize.js +4 -0
  25. package/dist/html-to-mdast.js +2 -1
  26. package/dist/index.js +14 -0
  27. package/dist/link-replacer.js +19 -13
  28. package/dist/loaders/cf-puppeteer.js +15 -8
  29. package/dist/loaders/fetch.js +14 -2
  30. package/dist/loaders/playwright.js +32 -30
  31. package/dist/loaders/puppeteer.js +15 -8
  32. package/dist/md-splitter.js +24 -11
  33. package/dist/mdast-handlers/custom-code-handler.js +17 -7
  34. package/dist/mdast-handlers/custom-div-handler.js +12 -33
  35. package/dist/mdast-handlers/definition-list-handler.js +17 -6
  36. package/dist/mdast-to-markdown.js +2 -5
  37. package/dist/normalize/index.js +12 -2
  38. package/dist/platform/client.js +73 -16
  39. package/dist/platform/index.js +4 -0
  40. package/dist/platform/types.js +5 -1
  41. package/dist/types/cli/bin.js.map +1 -1
  42. package/dist/types/cli/commands/jobs/files.d.ts +28 -0
  43. package/dist/types/cli/commands/jobs/files.js.map +1 -0
  44. package/dist/types/cli/commands/jobs/llms-txt.d.ts +33 -0
  45. package/dist/types/cli/commands/jobs/llms-txt.js.map +1 -0
  46. package/dist/types/cli/commands/jobs/options.d.ts +58 -0
  47. package/dist/types/cli/commands/jobs/options.js.map +1 -0
  48. package/dist/types/cli/commands/jobs/run.d.ts +44 -0
  49. package/dist/types/cli/commands/jobs/run.js.map +1 -0
  50. package/dist/types/cli/commands/skill/content.js.map +1 -1
  51. package/dist/types/cli/commands/webforai/interactive.d.ts +1 -1
  52. package/dist/types/cli/commands/webforai/interactive.js.map +1 -1
  53. package/dist/types/cli/commands/webforai/options.d.ts +9 -2
  54. package/dist/types/cli/commands/webforai/options.js.map +1 -1
  55. package/dist/types/cli/commands/webforai/run.js.map +1 -1
  56. package/dist/types/cli/utils.d.ts +8 -1
  57. package/dist/types/cli/utils.js.map +1 -1
  58. package/dist/types/detect-client-shell.d.ts +24 -0
  59. package/dist/types/detect-client-shell.js.map +1 -0
  60. package/dist/types/extract-meta-refresh.d.ts +29 -0
  61. package/dist/types/extract-meta-refresh.js.map +1 -0
  62. package/dist/types/extractors/lib/sanitize.js.map +1 -1
  63. package/dist/types/html-to-mdast.js.map +1 -1
  64. package/dist/types/index.d.ts +2 -0
  65. package/dist/types/index.js.map +1 -1
  66. package/dist/types/link-replacer.d.ts +3 -1
  67. package/dist/types/link-replacer.js.map +1 -1
  68. package/dist/types/loaders/cf-puppeteer.js.map +1 -1
  69. package/dist/types/loaders/fetch.d.ts +4 -0
  70. package/dist/types/loaders/fetch.js.map +1 -1
  71. package/dist/types/loaders/playwright.js.map +1 -1
  72. package/dist/types/loaders/puppeteer.js.map +1 -1
  73. package/dist/types/md-splitter.d.ts +1 -1
  74. package/dist/types/md-splitter.js.map +1 -1
  75. package/dist/types/mdast-handlers/custom-code-handler.d.ts +2 -0
  76. package/dist/types/mdast-handlers/custom-code-handler.js.map +1 -1
  77. package/dist/types/mdast-handlers/custom-div-handler.js.map +1 -1
  78. package/dist/types/mdast-handlers/definition-list-handler.d.ts +0 -11
  79. package/dist/types/mdast-handlers/definition-list-handler.js.map +1 -1
  80. package/dist/types/mdast-to-markdown.js.map +1 -1
  81. package/dist/types/normalize/index.js.map +1 -1
  82. package/dist/types/platform/client.d.ts +2 -1
  83. package/dist/types/platform/client.js.map +1 -1
  84. package/dist/types/platform/error.d.ts +5 -3
  85. package/dist/types/platform/error.js.map +1 -1
  86. package/dist/types/platform/fetch.d.ts +2 -0
  87. package/dist/types/platform/fetch.js.map +1 -1
  88. package/dist/types/platform/index.d.ts +1 -1
  89. package/dist/types/platform/index.js.map +1 -1
  90. package/dist/types/platform/types.d.ts +35 -6
  91. package/dist/types/platform/types.js.map +1 -1
  92. package/package.json +16 -5
package/dist/bin.js CHANGED
@@ -6,24 +6,35 @@ import { CommanderError, program } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "webforai",
9
- version: "3.0.0",
10
- description: "A library that provides a web interface for AI",
9
+ version: "4.0.0",
10
+ description: "Convert web pages and local HTML to clean, LLM-ready Markdown: library, CLI (npx webforai) and hosted-API client.",
11
11
  author: "inaridiy",
12
12
  license: "Apache-2.0",
13
13
  keywords: [
14
- "web",
14
+ "html-to-markdown",
15
+ "markdown",
16
+ "llm",
15
17
  "ai",
18
+ "scraper",
19
+ "crawler",
20
+ "readability",
21
+ "web-scraping",
22
+ "cli",
23
+ "rag",
16
24
  "html",
17
25
  "html2md",
18
- "markdown",
19
26
  "mdast",
20
27
  "hast"
21
28
  ],
22
29
  repository: {
23
30
  type: "git",
24
- url: "https://github.com/inaridiy/webforai.git"
31
+ url: "https://github.com/inaridiy/webforai.git",
32
+ directory: "packages/webforai"
25
33
  },
26
34
  homepage: "https://webforai.dev/",
35
+ bugs: {
36
+ url: "https://github.com/inaridiy/webforai/issues"
37
+ },
27
38
  scripts: {
28
39
  "copy:package.cjs.json": "pnpm ncp ./package.cjs.json ./dist/cjs/package.json && pnpm ncp ./package.cjs.json ./dist/types/package.json ",
29
40
  clean: "rimraf dist",
@@ -154,163 +165,6 @@ var package_default = {
154
165
  }
155
166
  };
156
167
 
157
- // src/cli/commands/skill/index.ts
158
- import { spawn } from "node:child_process";
159
- import fs from "node:fs/promises";
160
- import os from "node:os";
161
- import path from "node:path";
162
-
163
- // src/cli/commands/skill/content.ts
164
- var renderSkillMarkdown = (version) => `---
165
- name: webforai
166
- description: Convert web pages or local HTML files to clean, LLM-ready Markdown with the webforai CLI. Use when a task needs page content as Markdown - reading documentation or articles, scraping, summarising URLs, building text corpora - including JavaScript-heavy or bot-protected pages via the hosted platform.
167
- ---
168
-
169
- # webforai CLI${version ? ` (v${version})` : ""}
170
-
171
- Convert a URL or local HTML file to Markdown. Non-interactive by default: the Markdown goes
172
- to stdout, every log goes to stderr, so output can be piped or captured directly.
173
-
174
- ## Basic usage
175
-
176
- \`\`\`bash
177
- npx webforai https://example.com/article # Markdown to stdout
178
- npx webforai https://example.com/article -o out.md # write to a file instead
179
- npx webforai ./page.html # convert a local HTML file
180
- npx webforai https://example.com --json # machine-readable envelope (see below)
181
- \`\`\`
182
-
183
- ## Flags
184
-
185
- - \`-o, --output <path>\` \u2014 write Markdown to a file instead of stdout.
186
- - \`-l, --loader <fetch|playwright|platform>\` \u2014 how HTML is acquired. Default: \`fetch\` for
187
- URLs, automatic \`local\` for file paths. \`playwright\` renders JavaScript locally (needs
188
- \`npx playwright install chromium\`). \`platform\` uses the hosted API (below).
189
- - \`-m, --mode <default|ai>\` \u2014 \`ai\` strips links/tables/images down to plain text
190
- (local conversion only).
191
- - \`--extractor <auto|takumi|minimal|none>\` \u2014 main-content extraction preset
192
- (\`none\` converts the whole page).
193
- - \`--frontmatter\` \u2014 prepend YAML front matter (title, author, canonical URL, ...).
194
- - \`--json\` \u2014 print a JSON envelope to stdout instead of raw Markdown.
195
- - \`--engine <fetch|browser|proxy-fetch|proxy-browser>\` \u2014 platform acquisition engine
196
- (implies \`-l platform\`). \`browser\`/\`proxy-browser\` render JavaScript; \`proxy-*\` engines
197
- egress through rotating proxies for bot-protected sites.
198
- - \`--region <auto|us|eu|uk|jp|asia>\` \u2014 platform proxy egress region (implies \`-l platform\`).
199
- - \`--screenshot\` \u2014 platform browser engines only; the envelope carries an expiring
200
- \`screenshotUrl\`.
201
- - \`--api-key <key>\` / \`--platform-url <url>\` \u2014 platform credentials and host; usually set
202
- via env vars instead.
203
- - \`-i, --interactive\` \u2014 guided prompt flow (for humans; do not use from an agent).
204
- - \`-d, --debug\` \u2014 verbose logs on stderr.
205
-
206
- ## JSON envelope (\`--json\`)
207
-
208
- \`\`\`json
209
- {
210
- "source": "https://example.com",
211
- "loader": "fetch",
212
- "url": "https://example.com",
213
- "markdown": "# ...",
214
- "metadata": { "title": "..." },
215
- "engine": "browser",
216
- "region": "auto",
217
- "credits": 5,
218
- "screenshotUrl": "https://...",
219
- "output": "out.md"
220
- }
221
- \`\`\`
222
-
223
- \`engine\`/\`region\`/\`credits\`/\`screenshotUrl\` appear only for the platform loader; \`output\`
224
- only when \`-o\` also wrote a file.
225
-
226
- ## Hosted platform
227
-
228
- For JavaScript-heavy or bot-protected pages, the hosted platform fetches server-side:
229
-
230
- \`\`\`bash
231
- export WEBFORAI_API_KEY=wfa_... # from https://platform.webforai.dev/dashboard
232
- npx webforai https://example.com --engine browser --json
233
- \`\`\`
234
-
235
- \`WEBFORAI_PLATFORM_URL\` overrides the host for self-hosted deployments. Requests are metered
236
- in credits (fetch 1, proxy-fetch 2, browser/proxy-browser 5); failures are never billed.
237
-
238
- ## Exit codes
239
-
240
- - \`0\` \u2014 success.
241
- - \`1\` \u2014 runtime failure (network, conversion, platform error; message on stderr).
242
- - \`2\` \u2014 usage error (unknown flag or invalid value; message on stderr).
243
-
244
- ## Errors worth handling
245
-
246
- - \`payment_required\` on stderr \u2192 platform credits exhausted.
247
- - \`rate_limited\` \u2192 back off (a \`retryAfter\` seconds hint is included when known).
248
- - Playwright loader errors explain the exact \`npx playwright install\` command to run.
249
- `;
250
-
251
- // src/cli/commands/skill/index.ts
252
- var buildSkillsAddArgs = (source, flags) => {
253
- const args = ["-y", "skills", "add", source];
254
- if (flags.global) {
255
- args.push("--global");
256
- }
257
- if (flags.agent) {
258
- args.push("--agent", flags.agent);
259
- }
260
- if (flags.yes) {
261
- args.push("-y");
262
- }
263
- if (flags.copy) {
264
- args.push("--copy");
265
- }
266
- if (flags.all) {
267
- args.push("--all");
268
- }
269
- return args;
270
- };
271
- var runSkillsAdd = (args) => new Promise((resolve, reject) => {
272
- const child = spawn("npx", args, { stdio: ["inherit", "inherit", "inherit"] });
273
- child.on("error", reject);
274
- child.on("close", (code) => resolve(code ?? 1));
275
- });
276
- var skillCommand = async (flags) => {
277
- const markdown = renderSkillMarkdown(package_default.version);
278
- if (!(flags.install || flags.dir)) {
279
- process.stdout.write(markdown);
280
- return;
281
- }
282
- if (flags.dir) {
283
- const target = path.join(flags.dir, "webforai", "SKILL.md");
284
- await fs.mkdir(path.dirname(target), { recursive: true });
285
- await fs.writeFile(target, markdown);
286
- console.error(`[webforai] Agent Skill written to ${target}`);
287
- return;
288
- }
289
- const stage = await fs.mkdtemp(path.join(os.tmpdir(), "webforai-skill-"));
290
- try {
291
- await fs.mkdir(path.join(stage, "webforai"), { recursive: true });
292
- await fs.writeFile(path.join(stage, "webforai", "SKILL.md"), markdown);
293
- const args = buildSkillsAddArgs(stage, flags);
294
- console.error(`[webforai] running: npx ${args.join(" ")}`);
295
- const code = await runSkillsAdd(args).catch((error) => {
296
- console.error(
297
- `[webforai] could not launch the skills CLI (${error instanceof Error ? error.message : String(error)}).`
298
- );
299
- console.error("[webforai] fallback: `webforai skill --dir .claude/skills` writes the file directly.");
300
- return 1;
301
- });
302
- if (code !== 0) {
303
- process.exitCode = 1;
304
- }
305
- } finally {
306
- await fs.rm(stage, { recursive: true, force: true });
307
- }
308
- };
309
-
310
- // src/cli/commands/webforai/interactive.ts
311
- import { intro, log, outro, select as select7, spinner, text as text5 } from "@clack/prompts";
312
- import pc2 from "picocolors";
313
-
314
168
  // src/platform/error.ts
315
169
  var PlatformApiError = class extends Error {
316
170
  constructor(code, message, status, retryAfter) {
@@ -336,7 +190,9 @@ var resolveFetch = (custom) => {
336
190
 
337
191
  // src/platform/types.ts
338
192
  var ENGINES = ["fetch", "browser", "proxy-fetch", "proxy-browser"];
339
- var REGIONS = ["auto", "us", "eu", "uk", "jp", "asia"];
193
+ var REQUESTED_ENGINES = [...ENGINES, "auto"];
194
+ var REGIONS = ["auto", "jp"];
195
+ var CRAWL_SITEMAP_MODES = ["skip", "include", "only"];
340
196
  var isStoredPageStub = (item) => item.status === "ok" && "resultUrl" in item;
341
197
 
342
198
  // src/platform/client.ts
@@ -365,16 +221,30 @@ var parseRetryAfter = (body, response) => {
365
221
  const header = Number(response.headers.get("retry-after"));
366
222
  return Number.isFinite(header) && header > 0 ? header : void 0;
367
223
  };
368
- var toApiError = (response, body) => {
224
+ var dashboardHint = (status, dashboardUrl) => {
225
+ if (status === 401) {
226
+ return `create or check API keys at ${dashboardUrl}`;
227
+ }
228
+ if (status === 402) {
229
+ return `manage billing, credits and the spend cap at ${dashboardUrl}`;
230
+ }
231
+ return void 0;
232
+ };
233
+ var toApiError = (response, body, dashboardUrl) => {
369
234
  const envelope = body;
370
235
  const code = typeof envelope?.error?.code === "string" ? envelope.error.code : "invalid_response";
371
- const message = typeof envelope?.error?.message === "string" ? envelope.error.message : `unexpected response (HTTP ${response.status})`;
236
+ let message = typeof envelope?.error?.message === "string" ? envelope.error.message : `unexpected response (HTTP ${response.status})`;
237
+ const hint = code === "invalid_response" ? void 0 : dashboardHint(response.status, dashboardUrl);
238
+ if (hint && !message.includes("/dashboard")) {
239
+ message = `${message} (${hint})`;
240
+ }
372
241
  return new PlatformApiError(code, message, response.status, parseRetryAfter(body, response));
373
242
  };
374
243
  var createPlatformClient = (options = {}) => {
375
244
  const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
245
+ const dashboardUrl = `${baseUrl}/dashboard`;
376
246
  const fetchImpl = resolveFetch(options.fetch);
377
- const request = async (path4, init) => {
247
+ const request = async (path6, init) => {
378
248
  const headers = { accept: "application/json" };
379
249
  if (init.body !== void 0) {
380
250
  headers["content-type"] = "application/json";
@@ -383,27 +253,28 @@ var createPlatformClient = (options = {}) => {
383
253
  if (!options.apiKey) {
384
254
  throw new PlatformApiError(
385
255
  "missing_api_key",
386
- "This endpoint needs an API key: pass `apiKey` to createPlatformClient (create one on the platform dashboard).",
256
+ `This endpoint needs an API key: pass \`apiKey\` to createPlatformClient (create one at ${dashboardUrl}; 1,000 free credits/month).`,
387
257
  0
388
258
  );
389
259
  }
390
260
  headers.authorization = `Bearer ${options.apiKey}`;
391
261
  }
392
- const response = await fetchImpl(`${baseUrl}${path4}`, {
262
+ const response = await fetchImpl(`${baseUrl}${path6}`, {
393
263
  method: init.method,
394
264
  headers,
395
- body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
265
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
266
+ ...init.signal ? { signal: init.signal } : {}
396
267
  });
397
268
  const body = await response.json().catch(() => void 0);
398
269
  if (!response.ok) {
399
- throw toApiError(response, body);
270
+ throw toApiError(response, body, dashboardUrl);
400
271
  }
401
272
  if (body === void 0) {
402
273
  throw new PlatformApiError("invalid_response", "response body is not JSON", response.status);
403
274
  }
404
275
  return body;
405
276
  };
406
- const getJob = (jobId) => request(`/v1/jobs/${encodeURIComponent(jobId)}`, { method: "GET", auth: true });
277
+ const getJob = (jobId, signal) => request(`/v1/jobs/${encodeURIComponent(jobId)}`, { method: "GET", auth: true, signal });
407
278
  const getJobResults = (jobId, opts) => {
408
279
  const query = opts?.cursor ? `?cursor=${encodeURIComponent(opts.cursor)}` : "";
409
280
  return request(`/v1/jobs/${encodeURIComponent(jobId)}/results${query}`, { method: "GET", auth: true });
@@ -419,6 +290,17 @@ var createPlatformClient = (options = {}) => {
419
290
  }
420
291
  return await response.json();
421
292
  };
293
+ const getJobOrBackOff = async (jobId, signal, interval) => {
294
+ try {
295
+ return await getJob(jobId, signal);
296
+ } catch (error) {
297
+ if (!(error instanceof PlatformApiError && error.status === 429)) {
298
+ throw error;
299
+ }
300
+ await sleep(error.retryAfter !== void 0 ? error.retryAfter * 1e3 : interval, signal);
301
+ return void 0;
302
+ }
303
+ };
422
304
  return {
423
305
  scrape: (opts) => request("/v1/scrape", { method: "POST", body: opts, auth: true }),
424
306
  scrapeAsync: (opts) => request("/v1/scrape", { method: "POST", body: { ...opts, async: true }, auth: true }),
@@ -429,17 +311,48 @@ var createPlatformClient = (options = {}) => {
429
311
  waitForJob: async (jobId, opts = {}) => {
430
312
  const interval = opts.pollIntervalMs ?? 2e3;
431
313
  const timeout = opts.timeoutMs ?? 10 * 60 * 1e3;
432
- const startedAt = Date.now();
433
- for (; ; ) {
434
- const status = await getJob(jobId);
435
- opts.onStatus?.(status);
436
- if (TERMINAL_STATES.has(status.status)) {
437
- return status;
438
- }
439
- if (Date.now() - startedAt + interval > timeout) {
440
- throw new PlatformApiError("poll_timeout", `job ${jobId} still ${status.status} after ${timeout}ms`, 0);
314
+ if (!Number.isFinite(interval) || interval < 0 || interval > 2147483647) {
315
+ throw new RangeError("pollIntervalMs must be between 0 and 2147483647");
316
+ }
317
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2147483647) {
318
+ throw new RangeError("timeoutMs must be greater than 0 and at most 2147483647");
319
+ }
320
+ opts.signal?.throwIfAborted();
321
+ const controller = new AbortController();
322
+ const { signal } = controller;
323
+ const onAbort = () => controller.abort(opts.signal?.reason);
324
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
325
+ const timer = setTimeout(
326
+ () => controller.abort(new PlatformApiError("poll_timeout", `job ${jobId} did not finish within ${timeout}ms`, 0)),
327
+ timeout
328
+ );
329
+ let rejectOnAbort = () => {
330
+ };
331
+ const aborted = new Promise((_resolve, reject) => {
332
+ rejectOnAbort = () => reject(signal.reason);
333
+ signal.addEventListener("abort", rejectOnAbort, { once: true });
334
+ });
335
+ const poll = async () => {
336
+ for (; ; ) {
337
+ signal.throwIfAborted();
338
+ const status = await getJobOrBackOff(jobId, signal, interval);
339
+ signal.throwIfAborted();
340
+ if (status === void 0) {
341
+ continue;
342
+ }
343
+ opts.onStatus?.(status);
344
+ if (TERMINAL_STATES.has(status.status)) {
345
+ return status;
346
+ }
347
+ await sleep(interval, signal);
441
348
  }
442
- await sleep(interval, opts.signal);
349
+ };
350
+ try {
351
+ return await Promise.race([poll(), aborted]);
352
+ } finally {
353
+ clearTimeout(timer);
354
+ opts.signal?.removeEventListener("abort", onAbort);
355
+ signal.removeEventListener("abort", rejectOnAbort);
443
356
  }
444
357
  },
445
358
  jobResults: async function* (jobId) {
@@ -456,6 +369,9 @@ var createPlatformClient = (options = {}) => {
456
369
  };
457
370
  };
458
371
 
372
+ // src/cli/commands/jobs/options.ts
373
+ import fs3 from "node:fs";
374
+
459
375
  // src/cli/constants.ts
460
376
  var DEFAULT_PATH = "https://example.com";
461
377
  var LOADERS = ["fetch", "playwright", "platform"];
@@ -464,6 +380,850 @@ var EXTRACTORS = ["auto", "takumi", "minimal", "none"];
464
380
  var API_KEY_ENV = "WEBFORAI_API_KEY";
465
381
  var PLATFORM_URL_ENV = "WEBFORAI_PLATFORM_URL";
466
382
 
383
+ // src/cli/commands/webforai/options.ts
384
+ import fs2 from "node:fs";
385
+
386
+ // src/cli/utils.ts
387
+ import fs from "node:fs";
388
+ import path from "node:path";
389
+ var isUrl = (maybeUrl) => {
390
+ try {
391
+ new URL(maybeUrl);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ };
397
+ function changeFileExtension(filePath, newExtension) {
398
+ const parsedPath = filePath.split("/");
399
+ const fileName = parsedPath[parsedPath.length - 1];
400
+ const formattedNewExtension = newExtension.startsWith(".") ? newExtension : `.${newExtension}`;
401
+ if (fileName.startsWith(".")) {
402
+ const parts = fileName.split(".");
403
+ if (parts.length === 2) {
404
+ return parsedPath.slice(0, -1).concat(`${fileName}${formattedNewExtension}`).join("/");
405
+ }
406
+ parts[parts.length - 1] = newExtension.replace(/^\./, "");
407
+ return parsedPath.slice(0, -1).concat(parts.join(".")).join("/");
408
+ }
409
+ const lastDotIndex = fileName.lastIndexOf(".");
410
+ const baseName = lastDotIndex !== -1 ? fileName.slice(0, lastDotIndex) : fileName;
411
+ const newFileName = `${baseName}${formattedNewExtension}`;
412
+ parsedPath[parsedPath.length - 1] = newFileName;
413
+ return parsedPath.join("/");
414
+ }
415
+ function urlToFilename(url) {
416
+ try {
417
+ const urlObj = new URL(url);
418
+ const domainParts = urlObj.hostname.split(".").reverse().reduce((acc, part, index) => {
419
+ if (index === 0) {
420
+ return acc;
421
+ }
422
+ if (acc.length >= 2) {
423
+ return acc;
424
+ }
425
+ if (part === "www") {
426
+ return acc;
427
+ }
428
+ return [part, ...acc];
429
+ }, []);
430
+ const domainString = domainParts.reverse().join("-");
431
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
432
+ const relevantPathParts = pathParts.slice(-2);
433
+ const pathString = relevantPathParts.map((part) => decodeURIComponent(part)).join("-");
434
+ let filename = [domainString, pathString].filter(Boolean).join("-");
435
+ filename = filename.toLowerCase().replace(/[<>:"/\\|?*\x00-\x1F]/g, "").replace(/[\s.]+/g, "-").replace(/^-+|-+$/g, "");
436
+ return filename || "output";
437
+ } catch {
438
+ return "output";
439
+ }
440
+ }
441
+ var sourcePathToOutputPath = (sourcePath) => {
442
+ return isUrl(sourcePath) ? `${urlToFilename(sourcePath)}.md` : changeFileExtension(sourcePath, "md");
443
+ };
444
+ function getNextAvailableFilePath(filePath, exists = fs.existsSync) {
445
+ const parsedPath = path.parse(filePath);
446
+ const directory = parsedPath.dir;
447
+ const fullName = parsedPath.base;
448
+ const [firstPart, ...restParts] = fullName.split(".");
449
+ const restName = restParts.length > 0 ? `.${restParts.join(".")}` : "";
450
+ const baseName = firstPart.replace(/_\d+$/, "");
451
+ const match = firstPart.match(/_(\d+)$/);
452
+ let counter = match ? Number.parseInt(match[1], 10) + 1 : 1;
453
+ let nextFilePath = filePath;
454
+ while (exists(nextFilePath)) {
455
+ const newName = `${baseName}_${counter}${restName}`;
456
+ nextFilePath = path.join(directory, newName);
457
+ counter++;
458
+ }
459
+ return nextFilePath;
460
+ }
461
+ var platformDashboardUrl = (platformUrl) => `${(platformUrl || DEFAULT_BASE_URL).replace(/\/+$/u, "")}/dashboard`;
462
+
463
+ // src/cli/commands/webforai/options.ts
464
+ var UsageError = class extends Error {
465
+ };
466
+ var oneOf = (name, value, allowed) => {
467
+ if (!allowed.includes(value)) {
468
+ throw new UsageError(`invalid --${name} "${value}" (expected: ${allowed.join(" | ")})`);
469
+ }
470
+ return value;
471
+ };
472
+ var impliesPlatform = (flags) => flags.engine !== void 0 || flags.region !== void 0;
473
+ var resolveLoader = (sourceIsUrl, flags) => {
474
+ if (flags.loader) {
475
+ const loader = oneOf("loader", flags.loader, LOADERS);
476
+ if (impliesPlatform(flags) && loader !== "platform") {
477
+ throw new UsageError(`--engine/--region require --loader platform (received --loader ${loader})`);
478
+ }
479
+ return loader;
480
+ }
481
+ if (impliesPlatform(flags)) {
482
+ return "platform";
483
+ }
484
+ return sourceIsUrl ? "fetch" : "local";
485
+ };
486
+ var assertUsableLocalSource = (source, loader, flags) => {
487
+ if (loader !== "local" && flags.loader) {
488
+ throw new UsageError(`--loader ${loader} needs a URL source, but "${source}" is a local path`);
489
+ }
490
+ if (impliesPlatform(flags)) {
491
+ throw new UsageError("--engine/--region need a URL source (the platform cannot read local files)");
492
+ }
493
+ if (!fs2.existsSync(source)) {
494
+ throw new UsageError(`source not found: "${source}" (URLs must start with http:// or https://)`);
495
+ }
496
+ };
497
+ var applyPlatformFields = (resolved, flags, env) => {
498
+ resolved.engine = flags.engine ? oneOf("engine", flags.engine, REQUESTED_ENGINES) : void 0;
499
+ resolved.region = flags.region ? oneOf("region", flags.region, REGIONS) : void 0;
500
+ resolved.apiKey = flags.apiKey ?? env[API_KEY_ENV];
501
+ resolved.platformUrl = flags.platformUrl ?? env[PLATFORM_URL_ENV];
502
+ if (!resolved.apiKey) {
503
+ throw missingApiKeyError(resolved.platformUrl);
504
+ }
505
+ };
506
+ var missingApiKeyError = (platformUrl) => new UsageError(
507
+ `the platform loader needs an API key: pass --api-key or set ${API_KEY_ENV} (create one at ${platformDashboardUrl(
508
+ platformUrl
509
+ )}; 1,000 free credits/month, no card)`
510
+ );
511
+ var resolveRunOptions = (source, flags, env = process.env) => {
512
+ const sourceIsUrl = isUrl(source);
513
+ const loader = resolveLoader(sourceIsUrl, flags);
514
+ if (!sourceIsUrl) {
515
+ assertUsableLocalSource(source, loader, flags);
516
+ }
517
+ const resolved = {
518
+ source,
519
+ loader: sourceIsUrl ? loader : "local",
520
+ mode: flags.mode ? oneOf("mode", flags.mode, MODES) : "default",
521
+ extractor: flags.extractor ? oneOf("extractor", flags.extractor, EXTRACTORS) : "auto",
522
+ frontmatter: flags.frontmatter ?? false,
523
+ output: flags.output,
524
+ json: flags.json ?? false,
525
+ debug: flags.debug ?? false,
526
+ screenshot: flags.screenshot ?? false,
527
+ respectRobotsTxt: flags.respectRobots ?? false
528
+ };
529
+ if (resolved.loader === "platform") {
530
+ applyPlatformFields(resolved, flags, env);
531
+ } else if (flags.screenshot) {
532
+ throw new UsageError("--screenshot is only available with the platform loader (browser engines)");
533
+ } else if (flags.respectRobots) {
534
+ throw new UsageError("--respect-robots is only available with the platform loader");
535
+ }
536
+ return resolved;
537
+ };
538
+
539
+ // src/cli/commands/jobs/options.ts
540
+ var MAX_BATCH_URLS = 100;
541
+ var MAX_CRAWL_DEPTH = 5;
542
+ var MAX_CRAWL_LIMIT = 500;
543
+ var DEFAULT_TIMEOUT_SECONDS = 30 * 60;
544
+ var isHttpUrl = (value) => {
545
+ try {
546
+ const { protocol } = new URL(value);
547
+ return protocol === "http:" || protocol === "https:";
548
+ } catch {
549
+ return false;
550
+ }
551
+ };
552
+ var oneOf2 = (name, value, allowed) => {
553
+ if (!allowed.includes(value)) {
554
+ throw new UsageError(`invalid --${name} "${value}" (expected: ${allowed.join(" | ")})`);
555
+ }
556
+ return value;
557
+ };
558
+ var integerIn = (name, value, min, max) => {
559
+ if (value === void 0) {
560
+ return void 0;
561
+ }
562
+ const parsed = Number(value);
563
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
564
+ throw new UsageError(`invalid --${name} "${value}" (expected an integer from ${min} to ${max})`);
565
+ }
566
+ return parsed;
567
+ };
568
+ var patterns = (name, values) => {
569
+ if (!values || values.length === 0) {
570
+ return void 0;
571
+ }
572
+ for (const value of values) {
573
+ try {
574
+ new RegExp(value);
575
+ } catch {
576
+ throw new UsageError(`invalid --${name} pattern "${value}" (expected a regular expression)`);
577
+ }
578
+ }
579
+ return values;
580
+ };
581
+ var parseUrlList = (text6) => text6.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
582
+ var readUrlFile = (file, readFile) => {
583
+ try {
584
+ return parseUrlList(readFile(file));
585
+ } catch (error) {
586
+ throw new UsageError(`cannot read --file "${file}": ${error instanceof Error ? error.message : String(error)}`);
587
+ }
588
+ };
589
+ var defaultReadFile = (file) => fs3.readFileSync(file === "-" ? 0 : file, "utf-8");
590
+ var resolveJobOptions = (kind, sources, flags, env = process.env, readFile = defaultReadFile) => {
591
+ const fromFile = kind === "batch" && flags.file ? readUrlFile(flags.file, readFile) : [];
592
+ const urls = [.../* @__PURE__ */ new Set([...sources, ...fromFile])];
593
+ if (kind === "crawl" && urls.length !== 1) {
594
+ throw new UsageError("crawl takes exactly one seed URL");
595
+ }
596
+ if (urls.length === 0) {
597
+ throw new UsageError("batch needs at least one URL (as arguments or via --file <path|->)");
598
+ }
599
+ if (urls.length > MAX_BATCH_URLS) {
600
+ throw new UsageError(
601
+ `batch takes at most ${MAX_BATCH_URLS} URLs per job (received ${urls.length}); split the list`
602
+ );
603
+ }
604
+ const invalid = urls.find((url) => !isHttpUrl(url));
605
+ if (invalid !== void 0) {
606
+ throw new UsageError(`not an http(s) URL: "${invalid}"`);
607
+ }
608
+ if (!flags.output) {
609
+ throw new UsageError(`${kind} writes one Markdown file per page: pass -o, --output <dir>`);
610
+ }
611
+ const apiKey = flags.apiKey ?? env[API_KEY_ENV];
612
+ const platformUrl = flags.platformUrl ?? env[PLATFORM_URL_ENV];
613
+ if (!apiKey) {
614
+ throw missingApiKeyError(platformUrl);
615
+ }
616
+ const timeoutSeconds = integerIn("timeout", flags.timeout, 1, 24 * 60 * 60) ?? DEFAULT_TIMEOUT_SECONDS;
617
+ return {
618
+ kind,
619
+ urls,
620
+ outputDir: flags.output,
621
+ engine: flags.engine ? oneOf2("engine", flags.engine, REQUESTED_ENGINES) : void 0,
622
+ region: flags.region ? oneOf2("region", flags.region, REGIONS) : void 0,
623
+ extractor: flags.extractor ? oneOf2("extractor", flags.extractor, EXTRACTORS) : "auto",
624
+ frontmatter: flags.frontmatter ?? false,
625
+ respectRobotsTxt: flags.respectRobots,
626
+ apiKey,
627
+ platformUrl,
628
+ json: flags.json ?? false,
629
+ debug: flags.debug ?? false,
630
+ timeoutMs: timeoutSeconds * 1e3,
631
+ maxDepth: integerIn("max-depth", flags.maxDepth, 0, MAX_CRAWL_DEPTH),
632
+ limit: integerIn("limit", flags.limit, 1, MAX_CRAWL_LIMIT),
633
+ includePaths: patterns("include", flags.include),
634
+ excludePaths: patterns("exclude", flags.exclude),
635
+ sitemap: flags.sitemap ? oneOf2("sitemap", flags.sitemap, CRAWL_SITEMAP_MODES) : void 0,
636
+ llmsTxt: flags.llmsTxt ?? false
637
+ };
638
+ };
639
+
640
+ // src/cli/commands/jobs/run.ts
641
+ import fs4 from "node:fs/promises";
642
+ import path3 from "node:path";
643
+
644
+ // src/cli/commands/jobs/files.ts
645
+ import { createHash } from "node:crypto";
646
+ import path2 from "node:path";
647
+ var MAX_SEGMENT_LENGTH = 80;
648
+ var MAX_DEPTH = 12;
649
+ var PAGE_EXTENSION = /\.(?:html?|php|aspx?|jsp|md)$/iu;
650
+ var shortHash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 8);
651
+ var decodeSegment = (segment) => {
652
+ try {
653
+ return decodeURIComponent(segment);
654
+ } catch {
655
+ return segment;
656
+ }
657
+ };
658
+ var sanitizeSegment = (segment) => {
659
+ const cleaned = decodeSegment(segment).normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f\u007f]+/gu, "-").replace(/\s+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+|[-.]+$/gu, "");
660
+ if (cleaned.length <= MAX_SEGMENT_LENGTH) {
661
+ return cleaned;
662
+ }
663
+ return `${cleaned.slice(0, MAX_SEGMENT_LENGTH - 9)}-${shortHash(cleaned)}`;
664
+ };
665
+ var pageRelativePath = (pageUrl, options = {}) => {
666
+ const url = new URL(pageUrl);
667
+ const rawSegments = url.pathname.split("/");
668
+ const trailingSlash = url.pathname.endsWith("/");
669
+ const segments = rawSegments.map(sanitizeSegment).filter(Boolean).slice(0, MAX_DEPTH);
670
+ let leaf = trailingSlash || segments.length === 0 ? "index" : segments.pop() ?? "index";
671
+ leaf = leaf.replace(PAGE_EXTENSION, "") || "index";
672
+ if (url.search.length > 1) {
673
+ leaf = `${leaf}_q-${shortHash(url.search)}`;
674
+ }
675
+ const directories = options.includeHost ? [sanitizeSegment(url.host) || "host", ...segments] : segments;
676
+ return path2.posix.join(...directories, `${leaf}.md`);
677
+ };
678
+ var planOutputFiles = (urls, options = {}) => {
679
+ const taken = /* @__PURE__ */ new Set();
680
+ const sorted = [...new Set(urls)].sort();
681
+ return sorted.map((url) => {
682
+ const candidate = pageRelativePath(url, options);
683
+ const relativePath = getNextAvailableFilePath(candidate, (name) => taken.has(name.toLowerCase()));
684
+ taken.add(relativePath.toLowerCase());
685
+ return { url, relativePath };
686
+ });
687
+ };
688
+ var resolveInside = (outputDir, relativePath) => {
689
+ const root = path2.resolve(outputDir);
690
+ const target = path2.resolve(root, ...relativePath.split("/"));
691
+ if (target !== root && !target.startsWith(`${root}${path2.sep}`)) {
692
+ throw new Error(`refusing to write outside the output directory: ${relativePath}`);
693
+ }
694
+ return target;
695
+ };
696
+
697
+ // src/cli/commands/jobs/llms-txt.ts
698
+ var str = (value) => typeof value === "string" && value.trim() !== "" ? value.trim().replace(/\s+/gu, " ") : void 0;
699
+ var firstHeading = (markdown) => {
700
+ const match = /^#{1,2}[ \t]+(.+?)[ \t#]*$/mu.exec(markdown);
701
+ return match ? str(match[1]) : void 0;
702
+ };
703
+ var escapeLinkText = (text6) => text6.replace(/([\\[\]])/gu, "\\$1");
704
+ var pageTitle = (page) => str(page.metadata.title) ?? firstHeading(page.markdown) ?? (new URL(page.url).pathname || page.url);
705
+ var normalizeUrl = (value) => {
706
+ const url = new URL(value);
707
+ url.hash = "";
708
+ return url.href;
709
+ };
710
+ var findSeed = (site) => {
711
+ const seed = normalizeUrl(site.seedUrl);
712
+ return site.pages.find((page) => normalizeUrl(page.url) === seed);
713
+ };
714
+ var siteTitle = (site, seed) => str(seed?.metadata.siteName) ?? str(site.pages.find((page) => str(page.metadata.siteName))?.metadata.siteName) ?? str(seed?.metadata.title) ?? new URL(site.seedUrl).host;
715
+ var sectionOf = (pageUrl) => {
716
+ const { pathname } = new URL(pageUrl);
717
+ const segments = pathname.split("/").filter(Boolean);
718
+ const first = segments[0];
719
+ if (!first || segments.length === 1 && !pathname.endsWith("/")) {
720
+ return "";
721
+ }
722
+ let decoded = first;
723
+ try {
724
+ decoded = decodeURIComponent(first);
725
+ } catch {
726
+ }
727
+ const words = decoded.replace(/[-_]+/gu, " ").trim();
728
+ return words.charAt(0).toUpperCase() + words.slice(1);
729
+ };
730
+ var orderPages = (pages) => {
731
+ const groups = /* @__PURE__ */ new Map();
732
+ for (const page of [...pages].sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0)) {
733
+ const section = sectionOf(page.url);
734
+ groups.set(section, [...groups.get(section) ?? [], page]);
735
+ }
736
+ return [...groups.entries()].sort(([a], [b]) => a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)).map(([section, grouped]) => ({ section, pages: grouped }));
737
+ };
738
+ var renderLlmsTxt = (site) => {
739
+ const seed = findSeed(site);
740
+ const lines = [`# ${siteTitle(site, seed)}`, ""];
741
+ const summary = str(seed?.metadata.description);
742
+ if (summary) {
743
+ lines.push(`> ${summary}`, "");
744
+ }
745
+ for (const { section, pages } of orderPages(site.pages)) {
746
+ lines.push(`## ${section || "Pages"}`, "");
747
+ for (const page of pages) {
748
+ const description = str(page.metadata.description);
749
+ const link = `- [${escapeLinkText(pageTitle(page))}](${page.url})`;
750
+ lines.push(description ? `${link}: ${description}` : link);
751
+ }
752
+ lines.push("");
753
+ }
754
+ return `${lines.join("\n").trimEnd()}
755
+ `;
756
+ };
757
+ var renderLlmsFullTxt = (site) => {
758
+ const seed = findSeed(site);
759
+ const parts = [`# ${siteTitle(site, seed)}`];
760
+ for (const { pages } of orderPages(site.pages)) {
761
+ for (const page of pages) {
762
+ const body = page.markdown.trim();
763
+ const heading2 = /^#[ \t]/u.test(body) ? "" : `# ${pageTitle(page)}
764
+
765
+ `;
766
+ parts.push(`${heading2}Source: ${page.url}
767
+
768
+ ${body}`);
769
+ }
770
+ }
771
+ return `${parts.join("\n\n---\n\n")}
772
+ `;
773
+ };
774
+
775
+ // src/cli/commands/jobs/run.ts
776
+ var POLL_INTERVAL_MS = 2e3;
777
+ var MAX_RATE_LIMIT_RETRIES = 5;
778
+ var MAX_LISTED_FAILURES = 20;
779
+ var defaultIo = () => ({
780
+ stdout: (text6) => process.stdout.write(text6),
781
+ stderr: (text6) => process.stderr.write(text6),
782
+ isTTY: Boolean(process.stderr.isTTY),
783
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
784
+ fetch: globalThis.fetch.bind(globalThis)
785
+ });
786
+ var JobIncompleteError = class extends Error {
787
+ };
788
+ var progressLine = (kind, status) => {
789
+ const total = status.total > 0 ? `/${status.total}` : "";
790
+ const failed = status.failed > 0 ? ` \xB7 ${status.failed} failed` : "";
791
+ return `${kind} ${status.jobId} \xB7 ${status.status} \xB7 ${status.completed}${total} pages${failed} \xB7 ${status.credits} credits`;
792
+ };
793
+ var withRateLimitRetry = async (io, log2, call) => {
794
+ for (let attempt = 1; ; attempt++) {
795
+ try {
796
+ return await call();
797
+ } catch (error) {
798
+ if (!(error instanceof PlatformApiError) || error.code !== "rate_limited" || attempt > MAX_RATE_LIMIT_RETRIES) {
799
+ throw error;
800
+ }
801
+ const seconds = Math.min(error.retryAfter ?? 2 ** attempt, 120);
802
+ log2(`rate limited; retrying in ${seconds}s`);
803
+ await io.sleep(seconds * 1e3);
804
+ }
805
+ }
806
+ };
807
+ var submit = (client, job) => {
808
+ const common = {
809
+ engine: job.engine,
810
+ region: job.region,
811
+ respectRobotsTxt: job.respectRobotsTxt,
812
+ convert: { extractor: job.extractor, frontmatter: job.frontmatter || void 0 }
813
+ };
814
+ if (job.kind === "batch") {
815
+ return client.batch({ urls: job.urls, ...common });
816
+ }
817
+ return client.crawl({
818
+ url: job.urls[0],
819
+ maxDepth: job.maxDepth,
820
+ limit: job.limit,
821
+ includePaths: job.includePaths,
822
+ excludePaths: job.excludePaths,
823
+ sitemap: job.sitemap,
824
+ ...common
825
+ });
826
+ };
827
+ var resolveItem = async (io, item) => {
828
+ if (!isStoredPageStub(item)) {
829
+ return item;
830
+ }
831
+ const response = await io.fetch(item.resultUrl);
832
+ if (!response.ok) {
833
+ throw new PlatformApiError(
834
+ "artifact_fetch_failed",
835
+ `stored result for ${item.url} could not be downloaded (HTTP ${response.status})`,
836
+ response.status
837
+ );
838
+ }
839
+ return await response.json();
840
+ };
841
+ var createReporter = (io, job) => {
842
+ let liveLine = false;
843
+ const clearLive = () => {
844
+ if (liveLine) {
845
+ io.stderr("\r\x1B[2K");
846
+ liveLine = false;
847
+ }
848
+ };
849
+ const log2 = (line) => {
850
+ clearLive();
851
+ io.stderr(`[webforai] ${line}
852
+ `);
853
+ };
854
+ const status = (current) => {
855
+ if (io.isTTY) {
856
+ io.stderr(`\r\x1B[2K[webforai] ${progressLine(job.kind, current)}`);
857
+ liveLine = true;
858
+ } else if (job.debug) {
859
+ log2(progressLine(job.kind, current));
860
+ }
861
+ };
862
+ return { log: log2, status, clearLive };
863
+ };
864
+ var waitWithProgress = async (client, job, jobId, io, reporter) => {
865
+ const deadline = Date.now() + job.timeoutMs;
866
+ try {
867
+ return await withRateLimitRetry(
868
+ io,
869
+ reporter.log,
870
+ () => client.waitForJob(jobId, {
871
+ pollIntervalMs: POLL_INTERVAL_MS,
872
+ timeoutMs: Math.max(1, deadline - Date.now()),
873
+ onStatus: reporter.status
874
+ })
875
+ );
876
+ } catch (error) {
877
+ if (error instanceof PlatformApiError && error.code === "poll_timeout") {
878
+ throw new PlatformApiError(
879
+ "poll_timeout",
880
+ `${error.message}; the job keeps running on the platform and its results stay available for 7 days (GET /v1/jobs/${jobId}/results)`,
881
+ 0
882
+ );
883
+ }
884
+ throw error;
885
+ } finally {
886
+ reporter.clearLive();
887
+ }
888
+ };
889
+ var collectResults = async (client, jobId, io, reporter) => {
890
+ const successes = [];
891
+ const failures = [];
892
+ let cursor;
893
+ do {
894
+ const page = await withRateLimitRetry(io, reporter.log, () => client.getJobResults(jobId, { cursor }));
895
+ for (const item of page.results) {
896
+ const result = await resolveItem(io, item);
897
+ if (result.status === "ok") {
898
+ successes.push(result);
899
+ } else {
900
+ failures.push(result);
901
+ }
902
+ }
903
+ cursor = page.cursor;
904
+ } while (cursor);
905
+ return { successes, failures };
906
+ };
907
+ var writePages = async (job, successes) => {
908
+ const byUrl = new Map(successes.map((page) => [page.url, page]));
909
+ const written = [];
910
+ await fs4.mkdir(job.outputDir, { recursive: true });
911
+ for (const { url, relativePath } of planOutputFiles([...byUrl.keys()], { includeHost: job.kind === "batch" })) {
912
+ const page = byUrl.get(url);
913
+ const target = resolveInside(job.outputDir, relativePath);
914
+ await fs4.mkdir(path3.dirname(target), { recursive: true });
915
+ await fs4.writeFile(target, page.markdown.endsWith("\n") ? page.markdown : `${page.markdown}
916
+ `);
917
+ written.push({
918
+ url,
919
+ file: path3.join(job.outputDir, relativePath),
920
+ title: typeof page.metadata.title === "string" ? page.metadata.title : void 0,
921
+ engine: page.engine,
922
+ credits: page.credits,
923
+ warning: page.warning
924
+ });
925
+ }
926
+ return written;
927
+ };
928
+ var writeLlmsFiles = async (job, successes, envelope) => {
929
+ const pages = [...new Map(successes.map((page) => [page.url, page])).values()];
930
+ const site = { seedUrl: job.urls[0], pages };
931
+ envelope.llmsTxt = path3.join(job.outputDir, "llms.txt");
932
+ envelope.llmsFullTxt = path3.join(job.outputDir, "llms-full.txt");
933
+ await fs4.writeFile(envelope.llmsTxt, renderLlmsTxt(site));
934
+ await fs4.writeFile(envelope.llmsFullTxt, renderLlmsFullTxt(site));
935
+ };
936
+ var report = (job, envelope, io, log2) => {
937
+ if (job.json) {
938
+ io.stdout(`${JSON.stringify(envelope, null, 2)}
939
+ `);
940
+ } else {
941
+ for (const page of envelope.pages) {
942
+ io.stdout(`${page.file}
943
+ `);
944
+ }
945
+ }
946
+ const failed = envelope.failures.length;
947
+ log2(
948
+ `${job.kind} ${envelope.jobId} ${envelope.status}: ${envelope.pages.length} page(s) written to ${job.outputDir}${failed > 0 ? `, ${failed} failed` : ""} \xB7 ${envelope.credits} credits`
949
+ );
950
+ if (envelope.llmsTxt) {
951
+ log2(`llms.txt and llms-full.txt written to ${job.outputDir}`);
952
+ }
953
+ for (const failure of envelope.failures.slice(0, MAX_LISTED_FAILURES)) {
954
+ log2(` failed ${failure.url} \u2014 ${failure.code}: ${failure.message}`);
955
+ }
956
+ if (failed > MAX_LISTED_FAILURES) {
957
+ log2(` \u2026 and ${failed - MAX_LISTED_FAILURES} more (see --json)`);
958
+ }
959
+ };
960
+ var runJobCommand = async (job, io = defaultIo()) => {
961
+ const client = createPlatformClient({ apiKey: job.apiKey, baseUrl: job.platformUrl, fetch: io.fetch });
962
+ const reporter = createReporter(io, job);
963
+ const { jobId } = await withRateLimitRetry(io, reporter.log, () => submit(client, job));
964
+ reporter.log(
965
+ `${job.kind} job ${jobId} submitted (${job.kind === "crawl" ? job.urls[0] : `${job.urls.length} URLs`})`
966
+ );
967
+ const final = await waitWithProgress(client, job, jobId, io, reporter);
968
+ const { successes, failures } = await collectResults(client, jobId, io, reporter);
969
+ const envelope = {
970
+ jobId,
971
+ type: job.kind,
972
+ status: final.status,
973
+ outputDir: job.outputDir,
974
+ credits: final.credits,
975
+ pages: await writePages(job, successes),
976
+ failures: failures.map(({ url, error }) => ({ url, code: error.code, message: error.message })),
977
+ error: final.error
978
+ };
979
+ if (job.llmsTxt && successes.length > 0) {
980
+ await writeLlmsFiles(job, successes, envelope);
981
+ }
982
+ report(job, envelope, io, reporter.log);
983
+ if (final.status === "failed") {
984
+ throw new JobIncompleteError(`${job.kind} job ${jobId} failed${final.error ? `: ${final.error}` : ""}`);
985
+ }
986
+ if (envelope.pages.length === 0) {
987
+ throw new JobIncompleteError(`${job.kind} job ${jobId} converted no pages`);
988
+ }
989
+ return envelope;
990
+ };
991
+
992
+ // src/cli/commands/skill/index.ts
993
+ import { spawn } from "node:child_process";
994
+ import fs5 from "node:fs/promises";
995
+ import os from "node:os";
996
+ import path4 from "node:path";
997
+
998
+ // src/cli/commands/skill/content.ts
999
+ var renderSkillMarkdown = (version) => `---
1000
+ name: webforai
1001
+ description: Convert web pages or local HTML files to clean, LLM-ready Markdown with the webforai CLI. Use when a task needs page content as Markdown - reading documentation or articles, scraping, summarising URLs, crawling a docs site into files or llms.txt, building text corpora - including JavaScript-heavy or bot-protected pages via the hosted platform.
1002
+ ---
1003
+
1004
+ # webforai CLI${version ? ` (v${version})` : ""}
1005
+
1006
+ Convert a URL or local HTML file to Markdown. Non-interactive by default: the Markdown goes
1007
+ to stdout, every log goes to stderr, so output can be piped or captured directly.
1008
+
1009
+ ## Basic usage
1010
+
1011
+ \`\`\`bash
1012
+ npx webforai https://example.com/article # Markdown to stdout
1013
+ npx webforai https://example.com/article -o out.md # write to a file instead
1014
+ npx webforai ./page.html # convert a local HTML file
1015
+ npx webforai https://example.com --json # machine-readable envelope (see below)
1016
+ \`\`\`
1017
+
1018
+ ## Which command
1019
+
1020
+ - One page, or a handful you will read right away \u2192 \`npx webforai <url>\` (free, local;
1021
+ add \`--engine auto\` when the page needs JavaScript or blocks plain fetches).
1022
+ - A known list of URLs (2\u2013100) to keep as files \u2192 \`npx webforai batch\` (hosted platform).
1023
+ - A whole site or docs section \u2192 \`npx webforai crawl\` (hosted platform); add \`--llms-txt\`
1024
+ for a single \`llms-full.txt\` you can read in one go.
1025
+
1026
+ ## Flags
1027
+
1028
+ - \`-o, --output <path>\` \u2014 write Markdown to a file instead of stdout.
1029
+ - \`-l, --loader <fetch|playwright|platform>\` \u2014 how HTML is acquired. Default: \`fetch\` for
1030
+ URLs, automatic \`local\` for file paths. \`playwright\` renders JavaScript locally (needs
1031
+ \`npx playwright install chromium\`). \`platform\` uses the hosted API (below).
1032
+ - \`-m, --mode <default|ai>\` \u2014 \`ai\` strips links/tables/images down to plain text
1033
+ (local conversion only).
1034
+ - \`--extractor <auto|takumi|minimal|none>\` \u2014 main-content extraction preset
1035
+ (\`none\` converts the whole page).
1036
+ - \`--frontmatter\` \u2014 prepend YAML front matter (title, author, canonical URL, ...).
1037
+ - \`--json\` \u2014 print a JSON envelope to stdout instead of raw Markdown.
1038
+ - \`--engine <auto|fetch|browser|proxy-fetch|proxy-browser>\` \u2014 platform acquisition engine
1039
+ (implies \`-l platform\`). \`auto\` (the platform default) starts with a plain fetch and
1040
+ escalates to browser rendering when the page is a client-rendered shell;
1041
+ \`browser\`/\`proxy-browser\` always render JavaScript; \`proxy-*\` engines egress through
1042
+ rotating proxies for bot-protected sites (paid plan only).
1043
+ - \`--region <auto|jp>\` \u2014 platform proxy egress region (implies \`-l platform\`); \`jp\` runs
1044
+ on the proxy tier, so it needs a paid plan too.
1045
+ - \`--screenshot\` \u2014 platform browser engines only; the envelope carries an expiring
1046
+ \`screenshotUrl\`.
1047
+ - \`--respect-robots\` \u2014 platform loader only; honor the target site's robots.txt rules
1048
+ (off by default). A disallowed URL fails with \`robots_disallowed\` and is not billed.
1049
+ - \`--api-key <key>\` / \`--platform-url <url>\` \u2014 platform credentials and host; usually set
1050
+ via env vars instead.
1051
+ - \`-i, --interactive\` \u2014 guided prompt flow (for humans; do not use from an agent).
1052
+ - \`-d, --debug\` \u2014 verbose logs on stderr.
1053
+
1054
+ ## JSON envelope (\`--json\`)
1055
+
1056
+ \`\`\`json
1057
+ {
1058
+ "source": "https://example.com",
1059
+ "loader": "fetch",
1060
+ "url": "https://example.com",
1061
+ "markdown": "# ...",
1062
+ "metadata": { "title": "..." },
1063
+ "engine": "browser",
1064
+ "region": "auto",
1065
+ "credits": 5,
1066
+ "screenshotUrl": "https://...",
1067
+ "output": "out.md"
1068
+ }
1069
+ \`\`\`
1070
+
1071
+ \`engine\`/\`region\`/\`credits\`/\`screenshotUrl\` appear only for the platform loader; \`output\`
1072
+ only when \`-o\` also wrote a file. A \`warning\` field appears when the result is probably
1073
+ degraded \u2014 most commonly a client-rendered shell fetched without JavaScript; rerun with
1074
+ \`--engine auto\` (or \`-l playwright\`) when you see one.
1075
+
1076
+ ## Hosted platform
1077
+
1078
+ For JavaScript-heavy or bot-protected pages, the hosted platform fetches server-side:
1079
+
1080
+ \`\`\`bash
1081
+ export WEBFORAI_API_KEY=wfa_... # from https://platform.webforai.dev/dashboard
1082
+ npx webforai https://example.com --engine auto --json
1083
+ \`\`\`
1084
+
1085
+ \`WEBFORAI_PLATFORM_URL\` overrides the host for self-hosted deployments. Accounts get 1,000
1086
+ free credits per month; failures are never billed.
1087
+
1088
+ | operation | credits |
1089
+ | --- | --- |
1090
+ | \`fetch\` | 1 |
1091
+ | \`browser\` | 2 |
1092
+ | \`proxy-fetch\` (paid plan only) | 2 |
1093
+ | \`proxy-browser\` (paid plan only) | 3 |
1094
+ | \`--screenshot\` | +1 |
1095
+ | image rehosting (API \`rehostImages\`) | +1 per started 5 images |
1096
+
1097
+ \`auto\` bills the engine that actually ran (1, or 2 when it needed a browser). The proxy
1098
+ engines \u2014 and \`--region jp\`, which runs on them \u2014 need an active subscription; on the free
1099
+ allowance they fail with \`payment_required\`. Prefer \`--engine auto\` unless a site blocks
1100
+ it. Batch and crawl jobs bill per converted page.
1101
+
1102
+ ## Crawl and batch (hosted platform)
1103
+
1104
+ \`\`\`bash
1105
+ # a docs site \u2192 one .md per page, mirroring URL paths, plus llms.txt / llms-full.txt
1106
+ npx webforai crawl https://docs.example.com -o docs --limit 100 --include '^/docs' --llms-txt
1107
+
1108
+ # a list of URLs (arguments, or one per line via --file; '-' reads stdin)
1109
+ npx webforai batch https://a.example/post https://b.example/news -o pages
1110
+ npx webforai batch --file urls.txt -o pages --json
1111
+ \`\`\`
1112
+
1113
+ Both submit an async job, wait for it, then write every converted page under \`-o <dir>\`
1114
+ (\`/docs/intro\` \u2192 \`docs/intro.md\`; batch prefixes the host: \`a.example/post.md\`). stdout
1115
+ lists the written files, one per line; \`--json\` prints \`{ jobId, status, credits, pages:
1116
+ [{ url, file, title }], failures: [{ url, code, message }], llmsTxt?, llmsFullTxt? }\`
1117
+ instead. Progress and the summary go to stderr. Exit code 1 when the job failed or no page
1118
+ converted; individual failed pages are listed but do not fail the command.
1119
+
1120
+ - \`crawl <url>\` \u2014 same-origin only. \`--max-depth <0-5>\` (default 2), \`--limit <1-500>\`
1121
+ (default 50), \`--include <regex>\` / \`--exclude <regex>\` on the pathname (repeatable).
1122
+ \`--sitemap <skip|include|only>\`: \`include\` adds the site's sitemap URLs to the links it
1123
+ follows, \`only\` crawls just the seed plus sitemap URLs (best for blogs and large docs with
1124
+ a good sitemap). Honors robots.txt by default; \`--no-respect-robots\` turns that off.
1125
+ - \`--llms-txt\` (crawl) \u2014 also write \`llms.txt\` (site title, summary, \`- [title](url):
1126
+ description\` links grouped by top-level path) and \`llms-full.txt\` (every page's Markdown
1127
+ concatenated) per llmstxt.org. Read \`llms-full.txt\` when you need the whole site in context.
1128
+ - \`batch [urls...]\` \u2014 up to 100 URLs per job; \`-f, --file <path|->\` adds URLs from a file.
1129
+ \`--respect-robots\` opts in to robots.txt checks.
1130
+ - Shared: \`--engine\`, \`--region\`, \`--extractor\`, \`--frontmatter\`, \`--json\`,
1131
+ \`--timeout <seconds>\` (default 1800; the job keeps running after a timeout and results stay
1132
+ available for 7 days), \`--api-key\`, \`--platform-url\`, \`-d\`.
1133
+
1134
+ ## Exit codes
1135
+
1136
+ - \`0\` \u2014 success.
1137
+ - \`1\` \u2014 runtime failure (network, conversion, platform error; message on stderr).
1138
+ - \`2\` \u2014 usage error (unknown flag or invalid value; message on stderr).
1139
+
1140
+ ## Errors worth handling
1141
+
1142
+ Platform errors print as \`error: <code>: <message>\` on stderr (exit 1). What to do:
1143
+
1144
+ - \`invalid_api_key\` (401) \u2192 the key is missing, revoked or mistyped. Do not retry; ask the
1145
+ user for a key from the dashboard named in the message.
1146
+ - \`payment_required\` (402) \u2192 the free monthly allowance is used up, or a proxy engine /
1147
+ \`--region jp\` was requested without a subscription. Do not retry; fall back to
1148
+ \`--engine auto\` (or the local loaders) for proxy requests, otherwise tell the user.
1149
+ - \`spend_cap_reached\` (402) \u2192 the account's monthly spend cap (default $50) is reached; the
1150
+ user can raise it on the dashboard. Do not retry.
1151
+ - \`rate_limited\` (429) \u2192 per-account limit: 60 requests/minute (free), 600 (paid). Wait the
1152
+ \`retryAfter\` seconds shown, then retry; for many URLs use one \`batch\` job instead of a
1153
+ loop of single requests. \`crawl\`/\`batch\` retry this automatically.
1154
+ - \`too_many_jobs\` (429) \u2192 3 (free) / 20 (paid) batch/crawl jobs already queued or running.
1155
+ Wait for one to finish, then resubmit.
1156
+ - \`robots_disallowed\` (403) \u2192 robots.txt disallows the URL (\`--respect-robots\`, or a crawl
1157
+ by default). Not billed; respect it unless the user explicitly decides otherwise.
1158
+ - \`invalid_request\` (400) \u2192 a bad flag value or URL; fix the input.
1159
+ - \`engine_unavailable\` / \`scheduling_unknown\` / \`result_unavailable\` (503) \u2192 transient;
1160
+ retry later.
1161
+ - Playwright loader errors explain the exact \`npx playwright install\` command to run.
1162
+ `;
1163
+
1164
+ // src/cli/commands/skill/index.ts
1165
+ var buildSkillsAddArgs = (source, flags) => {
1166
+ const args = ["-y", "skills", "add", source];
1167
+ if (flags.global) {
1168
+ args.push("--global");
1169
+ }
1170
+ if (flags.agent) {
1171
+ args.push("--agent", flags.agent);
1172
+ }
1173
+ if (flags.yes) {
1174
+ args.push("-y");
1175
+ }
1176
+ if (flags.copy) {
1177
+ args.push("--copy");
1178
+ }
1179
+ if (flags.all) {
1180
+ args.push("--all");
1181
+ }
1182
+ return args;
1183
+ };
1184
+ var runSkillsAdd = (args) => new Promise((resolve, reject) => {
1185
+ const child = spawn("npx", args, { stdio: ["inherit", "inherit", "inherit"] });
1186
+ child.on("error", reject);
1187
+ child.on("close", (code) => resolve(code ?? 1));
1188
+ });
1189
+ var skillCommand = async (flags) => {
1190
+ const markdown = renderSkillMarkdown(package_default.version);
1191
+ if (!(flags.install || flags.dir)) {
1192
+ process.stdout.write(markdown);
1193
+ return;
1194
+ }
1195
+ if (flags.dir) {
1196
+ const target = path4.join(flags.dir, "webforai", "SKILL.md");
1197
+ await fs5.mkdir(path4.dirname(target), { recursive: true });
1198
+ await fs5.writeFile(target, markdown);
1199
+ console.error(`[webforai] Agent Skill written to ${target}`);
1200
+ return;
1201
+ }
1202
+ const stage = await fs5.mkdtemp(path4.join(os.tmpdir(), "webforai-skill-"));
1203
+ try {
1204
+ await fs5.mkdir(path4.join(stage, "webforai"), { recursive: true });
1205
+ await fs5.writeFile(path4.join(stage, "webforai", "SKILL.md"), markdown);
1206
+ const args = buildSkillsAddArgs(stage, flags);
1207
+ console.error(`[webforai] running: npx ${args.join(" ")}`);
1208
+ const code = await runSkillsAdd(args).catch((error) => {
1209
+ console.error(
1210
+ `[webforai] could not launch the skills CLI (${error instanceof Error ? error.message : String(error)}).`
1211
+ );
1212
+ console.error("[webforai] fallback: `webforai skill --dir .claude/skills` writes the file directly.");
1213
+ return 1;
1214
+ });
1215
+ if (code !== 0) {
1216
+ process.exitCode = 1;
1217
+ }
1218
+ } finally {
1219
+ await fs5.rm(stage, { recursive: true, force: true });
1220
+ }
1221
+ };
1222
+
1223
+ // src/cli/commands/webforai/interactive.ts
1224
+ import { intro, log, outro, select as select7, spinner, text as text5 } from "@clack/prompts";
1225
+ import pc2 from "picocolors";
1226
+
467
1227
  // src/cli/helpers/assertContinue.ts
468
1228
  import { cancel, isCancel } from "@clack/prompts";
469
1229
  function assertContinue(message, cancelMessage = "Canceled.") {
@@ -474,89 +1234,8 @@ function assertContinue(message, cancelMessage = "Canceled.") {
474
1234
  }
475
1235
 
476
1236
  // src/cli/helpers/inputOutputPath.ts
477
- import fs3 from "node:fs";
1237
+ import fs6 from "node:fs";
478
1238
  import { confirm, text } from "@clack/prompts";
479
-
480
- // src/cli/utils.ts
481
- import fs2 from "node:fs";
482
- import path2 from "node:path";
483
- var isUrl = (maybeUrl) => {
484
- try {
485
- new URL(maybeUrl);
486
- return true;
487
- } catch {
488
- return false;
489
- }
490
- };
491
- function changeFileExtension(filePath, newExtension) {
492
- const parsedPath = filePath.split("/");
493
- const fileName = parsedPath[parsedPath.length - 1];
494
- const formattedNewExtension = newExtension.startsWith(".") ? newExtension : `.${newExtension}`;
495
- if (fileName.startsWith(".")) {
496
- const parts = fileName.split(".");
497
- if (parts.length === 2) {
498
- return parsedPath.slice(0, -1).concat(`${fileName}${formattedNewExtension}`).join("/");
499
- }
500
- parts[parts.length - 1] = newExtension.replace(/^\./, "");
501
- return parsedPath.slice(0, -1).concat(parts.join(".")).join("/");
502
- }
503
- const lastDotIndex = fileName.lastIndexOf(".");
504
- const baseName = lastDotIndex !== -1 ? fileName.slice(0, lastDotIndex) : fileName;
505
- const newFileName = `${baseName}${formattedNewExtension}`;
506
- parsedPath[parsedPath.length - 1] = newFileName;
507
- return parsedPath.join("/");
508
- }
509
- function urlToFilename(url) {
510
- try {
511
- const urlObj = new URL(url);
512
- const domainParts = urlObj.hostname.split(".").reverse().reduce((acc, part, index) => {
513
- if (index === 0) {
514
- return acc;
515
- }
516
- if (acc.length >= 2) {
517
- return acc;
518
- }
519
- if (part === "www") {
520
- return acc;
521
- }
522
- return [part, ...acc];
523
- }, []);
524
- const domainString = domainParts.reverse().join("-");
525
- const pathParts = urlObj.pathname.split("/").filter(Boolean);
526
- const relevantPathParts = pathParts.slice(-2);
527
- const pathString = relevantPathParts.map((part) => decodeURIComponent(part)).join("-");
528
- let filename = [domainString, pathString].filter(Boolean).join("-");
529
- filename = filename.toLowerCase().replace(/[<>:"/\\|?*\x00-\x1F]/g, "").replace(/[\s.]+/g, "-").replace(/^-+|-+$/g, "");
530
- return filename || "output";
531
- } catch {
532
- return "output";
533
- }
534
- }
535
- var sourcePathToOutputPath = (sourcePath) => {
536
- return isUrl(sourcePath) ? `${urlToFilename(sourcePath)}.md` : changeFileExtension(sourcePath, "md");
537
- };
538
- function getNextAvailableFilePath(filePath) {
539
- const parsedPath = path2.parse(filePath);
540
- const directory = parsedPath.dir;
541
- const fullName = parsedPath.base;
542
- const [firstPart, ...restParts] = fullName.split(".");
543
- const restName = restParts.length > 0 ? `.${restParts.join(".")}` : "";
544
- const baseName = firstPart.replace(/_\d+$/, "");
545
- let counter = 1;
546
- let nextFilePath = filePath;
547
- while (fs2.existsSync(nextFilePath)) {
548
- const match = firstPart.match(/_(\d+)$/);
549
- if (match) {
550
- counter = Number.parseInt(match[1], 10) + 1;
551
- }
552
- const newName = `${baseName}_${counter}${restName}`;
553
- nextFilePath = path2.join(directory, newName);
554
- counter++;
555
- }
556
- return nextFilePath;
557
- }
558
-
559
- // src/cli/helpers/inputOutputPath.ts
560
1239
  var inputOutputPath = async (sourcePath) => {
561
1240
  const outputPath = await text({
562
1241
  message: "Enter the output file path:",
@@ -566,16 +1245,16 @@ var inputOutputPath = async (sourcePath) => {
566
1245
  if (value.trim() === "") {
567
1246
  return "Output path is required";
568
1247
  }
569
- if (fs3.existsSync(value) && fs3.statSync(value).isDirectory()) {
1248
+ if (fs6.existsSync(value) && fs6.statSync(value).isDirectory()) {
570
1249
  return "No directory can be specified.";
571
1250
  }
572
- if (!fs3.existsSync(value) && value.endsWith("/")) {
1251
+ if (!fs6.existsSync(value) && value.endsWith("/")) {
573
1252
  return "No directory can be specified.";
574
1253
  }
575
1254
  }
576
1255
  });
577
1256
  assertContinue(outputPath);
578
- if (!fs3.existsSync(outputPath)) {
1257
+ if (!fs6.existsSync(outputPath)) {
579
1258
  return outputPath;
580
1259
  }
581
1260
  const isOutputFileOverwrite = await confirm({
@@ -594,10 +1273,10 @@ var inputOutputPath = async (sourcePath) => {
594
1273
  if (value.trim() === "") {
595
1274
  return "Output path is required";
596
1275
  }
597
- if (fs3.existsSync(value)) {
1276
+ if (fs6.existsSync(value)) {
598
1277
  return "The file already exists";
599
1278
  }
600
- if (!fs3.existsSync(value) && value.endsWith("/")) {
1279
+ if (!fs6.existsSync(value) && value.endsWith("/")) {
601
1280
  return "No directory can be specified.";
602
1281
  }
603
1282
  }
@@ -607,7 +1286,7 @@ var inputOutputPath = async (sourcePath) => {
607
1286
  };
608
1287
 
609
1288
  // src/cli/helpers/inputSourcePath.ts
610
- import fs4 from "node:fs";
1289
+ import fs7 from "node:fs";
611
1290
  import { text as text2 } from "@clack/prompts";
612
1291
  var inputSourcePath = async () => {
613
1292
  const result = await text2({
@@ -619,10 +1298,10 @@ var inputSourcePath = async () => {
619
1298
  return "Source is required";
620
1299
  }
621
1300
  if (!isUrl(value)) {
622
- if (!fs4.existsSync(value)) {
1301
+ if (!fs7.existsSync(value)) {
623
1302
  return "It appears that you are specifying a local file, but the file cannot be found. hint: when specifying a url, start with http or https.";
624
1303
  }
625
- if (fs4.statSync(value).isDirectory()) {
1304
+ if (fs7.statSync(value).isDirectory()) {
626
1305
  return "You are specifying a local file, but you cannot specify a directory. hint: when specifying a url, start with http or https.";
627
1306
  }
628
1307
  }
@@ -662,8 +1341,8 @@ var selectLoader = async () => {
662
1341
  };
663
1342
 
664
1343
  // src/cli/commands/webforai/run.ts
665
- import fs6 from "node:fs/promises";
666
- import path3 from "node:path";
1344
+ import fs9 from "node:fs/promises";
1345
+ import path5 from "node:path";
667
1346
 
668
1347
  // src/html-to-markdown.ts
669
1348
  import { fromHtml as fromHtml2 } from "hast-util-from-html";
@@ -1494,7 +2173,11 @@ var countAnchors = (element3) => {
1494
2173
  }
1495
2174
  return count;
1496
2175
  };
2176
+ var LITERAL_TEXT_TAGS = /* @__PURE__ */ new Set(["code", "kbd", "samp", "var", "pre"]);
1497
2177
  var isMidArticleWidget = (element3) => {
2178
+ if (LITERAL_TEXT_TAGS.has(element3.tagName)) {
2179
+ return false;
2180
+ }
1498
2181
  if (element3.tagName === "form" || element3.tagName === "fieldset") {
1499
2182
  return true;
1500
2183
  }
@@ -2684,14 +3367,22 @@ var detectLanguage = (code) => {
2684
3367
  };
2685
3368
 
2686
3369
  // src/mdast-handlers/custom-code-handler.ts
2687
- var LANGUAGE_MATCH_REGEX = [/language-(\w+)/, /highlight-source-(\w+)/, /CodeBlock--language-(\w+)/];
3370
+ var LANGUAGE_MATCH_REGEX = [/^language-(\S+)$/, /^highlight-source-(\S+)$/, /^CodeBlock--language-(\S+)$/];
3371
+ var LANGUAGE_ATTRIBUTES = ["dataLanguage", "dataLang"];
3372
+ var attributeLanguage = (element3) => {
3373
+ for (const name of LANGUAGE_ATTRIBUTES) {
3374
+ const value = element3.properties?.[name];
3375
+ if (typeof value === "string" && /^[\w+#.-]+$/.test(value.trim()) && value.trim() !== "plaintext") {
3376
+ return value.trim();
3377
+ }
3378
+ }
3379
+ return void 0;
3380
+ };
3381
+ var codeLanguage = (...elements) => elements.flatMap(classList).map((className) => LANGUAGE_MATCH_REGEX.map((regex) => className.match(regex)).find(Boolean)?.[1]).find(Boolean) ?? elements.map(attributeLanguage).find(Boolean);
2688
3382
  var customCodeHandler = (state, node) => {
2689
- const classNames = node.properties?.className || [];
2690
- const codeValue = trimTrailingLines(toText(node)).trim();
2691
- const classLang = classNames.map((className) => {
2692
- const match = LANGUAGE_MATCH_REGEX.map((regex) => className.match(regex)).find((match2) => match2);
2693
- return match?.[1];
2694
- }).find((className) => className);
3383
+ const code = node.children.find((child) => isElement(child) && child.tagName === "code");
3384
+ const codeValue = trimTrailingLines(toText(node));
3385
+ const classLang = codeLanguage(...isElement(code) ? [code, node] : [node]);
2695
3386
  const lang = classLang || detectLanguage(codeValue) || null;
2696
3387
  const result = { type: "code", lang, meta: null, value: codeValue };
2697
3388
  state.patch(node, result);
@@ -2706,42 +3397,19 @@ import { toText as toText2 } from "hast-util-to-text";
2706
3397
  import { trimTrailingLines as trimTrailingLines2 } from "trim-trailing-lines";
2707
3398
  var CODE_BLOCK_REGEX = /highlight-source|language-|codegroup|codeblock|code-block/i;
2708
3399
  var CODE_FILENAME_SELECTORS = "[class*='fileName'],[class*='fileName'],[class*='title'],[class*='Title']";
2709
- var LANGUAGE_MATCH_REGEX2 = [/language-(\w+)/, /highlight-source-(\w+)/, /CodeBlock--language-(\w+)/];
2710
- var findRecursive = (array, condition, maxDepth = 3) => {
2711
- if (maxDepth <= 0) {
2712
- return null;
2713
- }
2714
- for (const value of array) {
2715
- const result = condition(value);
2716
- if (Array.isArray(result)) {
2717
- return findRecursive(result, condition, maxDepth - 1);
2718
- }
2719
- if (result) {
2720
- return value;
2721
- }
2722
- }
2723
- return null;
2724
- };
2725
3400
  var customDivHandler = (state, node) => {
2726
- const classNames = Array.isArray(node.properties.className) ? node.properties.className : [];
2727
- const codeBlock = findRecursive(node.children, (child) => {
2728
- if (child.type !== "element") {
2729
- return false;
2730
- }
2731
- if (child.tagName === "pre") {
2732
- return true;
2733
- }
2734
- return child.children.filter((child2) => child2.type === "element");
2735
- });
2736
- if (codeBlock && classNames.some((className) => CODE_BLOCK_REGEX.test(className))) {
2737
- const codeBlockClassNames = codeBlock.type === "element" ? codeBlock.properties.className ?? [] : [];
2738
- const codeValue = trimTrailingLines2(toText2(codeBlock)).trim();
3401
+ const classNames = classList(node);
3402
+ if (!classNames.some((className) => CODE_BLOCK_REGEX.test(className))) {
3403
+ return defaultHandlers2.div(state, node);
3404
+ }
3405
+ const codeBlocks = collectElements(node, (element3) => element3.tagName === "pre");
3406
+ const codeBlock = codeBlocks.length === 1 ? codeBlocks[0] : void 0;
3407
+ if (codeBlock) {
3408
+ const codeValue = trimTrailingLines2(toText2(codeBlock));
2739
3409
  const filenameElement = select5(CODE_FILENAME_SELECTORS, node);
2740
3410
  const fileLang = filenameElement ? hastToString2(filenameElement).match(/\.(\w+)$/)?.[1] : null;
2741
- const classLang = [...classNames, ...codeBlockClassNames].map((className) => {
2742
- const match = LANGUAGE_MATCH_REGEX2.map((regex) => className.match(regex)).find((match2) => match2);
2743
- return match?.[1];
2744
- }).find((className) => className);
3411
+ const code = codeBlock.children.find((child) => isElement(child) && child.tagName === "code");
3412
+ const classLang = codeLanguage(...isElement(code) ? [code, codeBlock, node] : [codeBlock, node]);
2745
3413
  const lang = fileLang || classLang || detectLanguage(codeValue) || null;
2746
3414
  const result = { type: "code", lang, meta: null, value: codeValue };
2747
3415
  state.patch(node, result);
@@ -2837,6 +3505,20 @@ var hoistBoundaryWhitespace = (nodes) => {
2837
3505
  }
2838
3506
  return result;
2839
3507
  };
3508
+ var termsAndDefinitions = (parent) => {
3509
+ const found = [];
3510
+ for (const child of parent.children) {
3511
+ if (child.type !== "element") {
3512
+ continue;
3513
+ }
3514
+ if (child.tagName === "dt" || child.tagName === "dd") {
3515
+ found.push(child);
3516
+ } else if (child.tagName === "div") {
3517
+ found.push(...termsAndDefinitions(child));
3518
+ }
3519
+ }
3520
+ return found;
3521
+ };
2840
3522
  var definitionListHandler = (state, node) => {
2841
3523
  const items = [];
2842
3524
  let current;
@@ -2851,11 +3533,7 @@ var definitionListHandler = (state, node) => {
2851
3533
  items.push({ type: "listItem", spread: false, children: [heading2, ...current.definitions] });
2852
3534
  current = void 0;
2853
3535
  };
2854
- for (const child of node.children) {
2855
- if (child.type !== "element") {
2856
- continue;
2857
- }
2858
- const element3 = child;
3536
+ for (const element3 of termsAndDefinitions(node)) {
2859
3537
  if (element3.tagName === "dt") {
2860
3538
  if (current && current.definitions.length > 0) {
2861
3539
  flush();
@@ -2874,7 +3552,8 @@ var definitionListHandler = (state, node) => {
2874
3552
  }
2875
3553
  flush();
2876
3554
  if (items.length === 0) {
2877
- return void 0;
3555
+ const fallback = state.toFlow(state.all(node));
3556
+ return fallback.length > 0 ? fallback : void 0;
2878
3557
  }
2879
3558
  const result = { type: "list", ordered: false, spread: false, children: items };
2880
3559
  state.patch(node, result);
@@ -2987,14 +3666,16 @@ var katexDuplicates = (node) => {
2987
3666
  if (!(classes.includes("katex") || classes.includes("katex-display"))) {
2988
3667
  return [];
2989
3668
  }
2990
- const hasMathml = node.children.some((child) => isElement(child) && classList(child).includes("katex-mathml"));
3669
+ const hasMathml = node.children.some(
3670
+ (child) => isElement(child) && classList(child).includes("katex-mathml") && hasMathSource(child)
3671
+ );
2991
3672
  if (!hasMathml) {
2992
3673
  return [];
2993
3674
  }
2994
3675
  return node.children.filter((child) => isElement(child) && classList(child).includes("katex-html"));
2995
3676
  };
2996
3677
  var mediawikiDuplicates = (node) => {
2997
- if (!classList(node).includes("mwe-math-element")) {
3678
+ if (!classList(node).includes("mwe-math-element") || !hasMathSource(node)) {
2998
3679
  return [];
2999
3680
  }
3000
3681
  return node.children.filter(
@@ -3005,8 +3686,15 @@ var mathjaxDuplicates = (node) => {
3005
3686
  if (node.tagName !== "mjx-container") {
3006
3687
  return [];
3007
3688
  }
3689
+ const hasMathml = node.children.some(
3690
+ (child) => isElement(child) && child.tagName === "mjx-assistive-mml" && hasMathSource(child)
3691
+ );
3692
+ if (!hasMathml) {
3693
+ return [];
3694
+ }
3008
3695
  return node.children.filter((child) => isElement(child) && child.tagName !== "mjx-assistive-mml");
3009
3696
  };
3697
+ var hasMathSource = (node) => Boolean(findElement(node, (element3) => element3.tagName === "math" && element3.children.length > 0));
3010
3698
  var dedupeRenderedMath = (tree) => {
3011
3699
  const doomed = /* @__PURE__ */ new Set();
3012
3700
  walk(tree, (node) => {
@@ -3068,8 +3756,8 @@ var getLangFromHast = (node) => {
3068
3756
  }
3069
3757
  return void 0;
3070
3758
  };
3071
- var getLangFromStr = (str) => {
3072
- const match = str.match(/lang=["']([^"']+)["']/);
3759
+ var getLangFromStr = (str2) => {
3760
+ const match = str2.match(/lang=["']([^"']+)["']/);
3073
3761
  if (match) {
3074
3762
  return match[1];
3075
3763
  }
@@ -3096,7 +3784,7 @@ var htmlToMdast = (htmlOrHast, options) => {
3096
3784
  const [lang, hast] = typeof htmlOrHast === "string" ? [defaultLang || getLangFromStr(htmlOrHast), fromHtml(htmlOrHast, { fragment: true })] : [defaultLang || getLangFromHast(htmlOrHast), htmlOrHast];
3097
3785
  const isOwned = typeof htmlOrHast === "string" || (options?.owned ?? false);
3098
3786
  const url = defaultUrl || getUrlFromHast(hast);
3099
- const extractedHast = pipeExtractors({ hast, lang, url, owned: isOwned }, extractors);
3787
+ const extractedHast = pipeExtractors({ hast: isOwned ? hast : cloneHast(hast), lang, url, owned: true }, extractors);
3100
3788
  normalizeHast(extractedHast, options?.normalize);
3101
3789
  const mdast = toMdast(extractedHast, {
3102
3790
  handlers: {
@@ -3121,19 +3809,25 @@ import { mathToMarkdown } from "mdast-util-math";
3121
3809
  import { toMarkdown } from "mdast-util-to-markdown";
3122
3810
 
3123
3811
  // src/link-replacer.ts
3124
- var linkReplacer = (markdown, base) => {
3125
- const regex = /(!?\[.*?\]\()([^)\s]+)(\))/g;
3126
- return markdown.replace(regex, (match, pre, url, post) => {
3127
- if (/^(https?:|#)/.test(url)) {
3128
- return match;
3129
- }
3130
- try {
3131
- const absoluteUrl = new URL(url, base).href;
3132
- return `${pre}${absoluteUrl}${post}`;
3133
- } catch {
3134
- return match;
3135
- }
3136
- });
3812
+ var absoluteUrl = (url, base) => {
3813
+ if (/^(?:[a-z][a-z\d+.-]*:|#)/i.test(url)) {
3814
+ return url;
3815
+ }
3816
+ try {
3817
+ return new URL(url, base).href;
3818
+ } catch {
3819
+ return url;
3820
+ }
3821
+ };
3822
+ var linkReplacer = (node, base) => {
3823
+ let result = node;
3824
+ if (node.type === "link" || node.type === "image" || node.type === "definition") {
3825
+ result = { ...node, url: absoluteUrl(node.url, base) };
3826
+ }
3827
+ if ("children" in result) {
3828
+ return { ...result, children: result.children.map((child) => linkReplacer(child, base)) };
3829
+ }
3830
+ return result;
3137
3831
  };
3138
3832
 
3139
3833
  // src/utils/mdast-utils.ts
@@ -3151,11 +3845,8 @@ var DEFAULT_MDAST_TO_MARKDOWN_OPTIONS = {
3151
3845
  };
3152
3846
  var mdastToMarkdown = (mdast, options) => {
3153
3847
  const { baseUrl, ...toMarkdownOptions } = { ...DEFAULT_MDAST_TO_MARKDOWN_OPTIONS, ...options };
3154
- let markdown = toMarkdown(warpRoot(mdast), toMarkdownOptions).replace(/\*\*\*\*/g, "");
3155
- if (baseUrl) {
3156
- markdown = linkReplacer(markdown, baseUrl);
3157
- }
3158
- return markdown;
3848
+ const tree = warpRoot(mdast);
3849
+ return toMarkdown(baseUrl ? linkReplacer(tree, baseUrl) : tree, toMarkdownOptions);
3159
3850
  };
3160
3851
 
3161
3852
  // src/metadata/index.ts
@@ -3269,7 +3960,7 @@ var documentTitle = (tree) => {
3269
3960
  }
3270
3961
  return title.children.map((child) => child.type === "text" ? child.value : "").join("");
3271
3962
  };
3272
- var firstHeading = (tree) => {
3963
+ var firstHeading2 = (tree) => {
3273
3964
  const heading2 = findElement(tree, (element3) => element3.tagName === "h1");
3274
3965
  if (!heading2) {
3275
3966
  return void 0;
@@ -3319,7 +4010,7 @@ var extractMetadata = (tree) => {
3319
4010
  }
3320
4011
  return void 0;
3321
4012
  };
3322
- const title = asTitle(get("og:title", "twitter:title")) ?? asTitle(asString(article?.headline)) ?? asTitle(asString(article?.name)) ?? asTitle(documentTitle(tree)) ?? asTitle(firstHeading(tree));
4013
+ const title = asTitle(get("og:title", "twitter:title")) ?? asTitle(asString(article?.headline)) ?? asTitle(asString(article?.name)) ?? asTitle(documentTitle(tree)) ?? asTitle(firstHeading2(tree));
3323
4014
  return {
3324
4015
  title,
3325
4016
  description: clean(get("og:description", "twitter:description", "description")) ?? clean(asString(article?.description)),
@@ -3390,8 +4081,101 @@ ${body}`;
3390
4081
  };
3391
4082
  var normalizeForCompare = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
3392
4083
 
4084
+ // src/detect-client-shell.ts
4085
+ var SHELL_SUSPICION_THRESHOLD = 300;
4086
+ var SHELL_EMPTY_THRESHOLD = 50;
4087
+ var BODY_PATTERN = /<body[^>]*>([\s\S]*?)<\/body>/i;
4088
+ var HEAD_PATTERN = /<head[^>]*>[\s\S]*?<\/head>/gi;
4089
+ var NOSCRIPT_PATTERN = /<noscript[^>]*>([\s\S]*?)<\/noscript>/gi;
4090
+ var INVISIBLE_ELEMENT_PATTERN = /<(script|style|template|svg|noscript)[^>]*>[\s\S]*?<\/\1>/gi;
4091
+ var COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
4092
+ var TAG_PATTERN = /<[^>]+>/g;
4093
+ var MOUNT_POINT_PATTERN = /<[a-z][^>]*\sid=["'](?:root|app|__next|___gatsby|__nuxt|q-app|svelte|react-root)["']|<app-root[\s>]|\sdata-reactroot[\s>=]/i;
4094
+ var CHALLENGE_PATTERN = /cf-chl|challenge-platform|cf_chl_opt|just a moment|attention required|checking your browser|_incapsula_|px-captcha|datadome/i;
4095
+ var ENTITIES = {
4096
+ "&nbsp;": " ",
4097
+ "&amp;": "&",
4098
+ "&lt;": "<",
4099
+ "&gt;": ">",
4100
+ "&quot;": '"',
4101
+ "&#39;": "'"
4102
+ };
4103
+ var visibleTextOf = (markup) => markup.replace(COMMENT_PATTERN, " ").replace(INVISIBLE_ELEMENT_PATTERN, " ").replace(TAG_PATTERN, " ").replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (entity) => ENTITIES[entity] ?? " ").replace(/\s+/g, " ").trim();
4104
+ var noscriptMentionsJavascript = (html) => {
4105
+ for (const match of html.matchAll(NOSCRIPT_PATTERN)) {
4106
+ if (/javascript/i.test(match[1] ?? "")) {
4107
+ return true;
4108
+ }
4109
+ }
4110
+ return false;
4111
+ };
4112
+ var detectClientShell = (html) => {
4113
+ const body = BODY_PATTERN.exec(html)?.[1] ?? html.replace(HEAD_PATTERN, " ");
4114
+ const visibleTextLength = visibleTextOf(body).length;
4115
+ if (visibleTextLength >= SHELL_SUSPICION_THRESHOLD) {
4116
+ return { isShell: false, visibleTextLength };
4117
+ }
4118
+ if (CHALLENGE_PATTERN.test(html)) {
4119
+ return { isShell: true, reason: "anti-bot-challenge", visibleTextLength };
4120
+ }
4121
+ if (MOUNT_POINT_PATTERN.test(body)) {
4122
+ return { isShell: true, reason: "spa-shell", visibleTextLength };
4123
+ }
4124
+ if (noscriptMentionsJavascript(html)) {
4125
+ return { isShell: true, reason: "noscript-only", visibleTextLength };
4126
+ }
4127
+ if (visibleTextLength < SHELL_EMPTY_THRESHOLD) {
4128
+ return { isShell: true, reason: "empty-body", visibleTextLength };
4129
+ }
4130
+ return { isShell: false, visibleTextLength };
4131
+ };
4132
+
4133
+ // src/extract-meta-refresh.ts
4134
+ var MAX_META_REFRESH_DELAY_SECONDS = 10;
4135
+ var META_TAG_PATTERN = /<meta\b[^>]*>/gi;
4136
+ var HTTP_EQUIV_REFRESH_PATTERN = /\bhttp-equiv\s*=\s*("\s*refresh\s*"|'\s*refresh\s*'|refresh\b)/i;
4137
+ var CONTENT_ATTR_PATTERN = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
4138
+ var CONTENT_VALUE_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*[;,]\s*url\s*=\s*(?:"([^"]*)"|'([^']*)'|(.+?))\s*$/i;
4139
+ var resolveTarget = (raw, baseUrl) => {
4140
+ try {
4141
+ return baseUrl === void 0 ? new URL(raw) : new URL(raw, baseUrl);
4142
+ } catch {
4143
+ return void 0;
4144
+ }
4145
+ };
4146
+ var extractMetaRefresh = (html, baseUrl) => {
4147
+ for (const [tag] of html.matchAll(META_TAG_PATTERN)) {
4148
+ if (!HTTP_EQUIV_REFRESH_PATTERN.test(tag)) {
4149
+ continue;
4150
+ }
4151
+ const contentMatch = CONTENT_ATTR_PATTERN.exec(tag);
4152
+ const content = contentMatch?.[1] ?? contentMatch?.[2] ?? contentMatch?.[3];
4153
+ if (!content) {
4154
+ continue;
4155
+ }
4156
+ const value = CONTENT_VALUE_PATTERN.exec(content);
4157
+ if (!value) {
4158
+ continue;
4159
+ }
4160
+ const delaySeconds = Number.parseFloat(value[1] ?? "");
4161
+ const raw = (value[2] ?? value[3] ?? value[4] ?? "").trim();
4162
+ if (!raw || delaySeconds > MAX_META_REFRESH_DELAY_SECONDS) {
4163
+ continue;
4164
+ }
4165
+ const target = resolveTarget(raw, baseUrl);
4166
+ if (!target || target.protocol !== "http:" && target.protocol !== "https:") {
4167
+ continue;
4168
+ }
4169
+ if (baseUrl !== void 0 && resolveTarget(baseUrl, void 0)?.href === target.href) {
4170
+ continue;
4171
+ }
4172
+ return { url: target.href, delaySeconds };
4173
+ }
4174
+ return void 0;
4175
+ };
4176
+
3393
4177
  // src/cli/commands/webforai/loadHtml.ts
3394
- import fs5 from "node:fs/promises";
4178
+ import fs8 from "node:fs/promises";
3395
4179
  import { fileURLToPath } from "node:url";
3396
4180
  import boxen from "boxen";
3397
4181
  import pc from "picocolors";
@@ -3399,9 +4183,20 @@ import { chromium as chromium2 } from "playwright-core";
3399
4183
 
3400
4184
  // src/loaders/fetch.ts
3401
4185
  var USER_AGENT = "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/125.0.0.0 safari/537.36";
4186
+ var MAX_META_REFRESH_HOPS = 3;
3402
4187
  var loadHtml = async (url, userAgent = USER_AGENT) => {
3403
- const response = await fetch(url, { headers: { "User-Agent": userAgent } });
3404
- return response.text();
4188
+ let current = url;
4189
+ let html = "";
4190
+ for (let hop = 0; hop <= MAX_META_REFRESH_HOPS; hop++) {
4191
+ const response = await fetch(current, { headers: { "User-Agent": userAgent } });
4192
+ html = await response.text();
4193
+ const refresh = extractMetaRefresh(html, response.url || current);
4194
+ if (!refresh) {
4195
+ break;
4196
+ }
4197
+ current = refresh.url;
4198
+ }
4199
+ return html;
3405
4200
  };
3406
4201
 
3407
4202
  // src/loaders/playwright.ts
@@ -3419,40 +4214,42 @@ var SUPER_BYPASS_DEVICE = {
3419
4214
  var loadHtml2 = async (url, options) => {
3420
4215
  const { browser, waitUntil, timeout, superBypassMode } = options ?? {};
3421
4216
  const _browser = browser ?? await chromium.launch({ headless: true });
3422
- const context = await _browser.newContext(superBypassMode ? SUPER_BYPASS_DEVICE : devices["Desktop Chrome"]);
3423
- if (superBypassMode) {
3424
- await context.addInitScript(() => {
3425
- Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
3426
- Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
3427
- Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
3428
- });
3429
- }
3430
- const page = await context.newPage();
3431
- if (superBypassMode) {
3432
- await page.route("**/*.js", (route) => {
3433
- if (route.request().url().includes("captcha-delivery")) {
3434
- return route.abort();
4217
+ let context;
4218
+ try {
4219
+ context = await _browser.newContext(superBypassMode ? SUPER_BYPASS_DEVICE : devices["Desktop Chrome"]);
4220
+ if (superBypassMode) {
4221
+ await context.addInitScript(() => {
4222
+ Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
4223
+ Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
4224
+ Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
4225
+ });
4226
+ }
4227
+ const page = await context.newPage();
4228
+ if (superBypassMode) {
4229
+ await page.route("**/*.js", (route) => {
4230
+ if (route.request().url().includes("captcha-delivery")) {
4231
+ return route.abort();
4232
+ }
4233
+ return route.continue();
4234
+ });
4235
+ }
4236
+ await page.goto(url, { waitUntil: waitUntil ?? "load", timeout });
4237
+ await page.evaluate(() => {
4238
+ const elements = document.querySelectorAll("*");
4239
+ for (const element3 of elements) {
4240
+ const rect = element3.getBoundingClientRect();
4241
+ element3.setAttribute("data-rwidth", rect.width.toString());
4242
+ element3.setAttribute("data-rheight", rect.height.toString());
3435
4243
  }
3436
- return route.continue();
3437
4244
  });
3438
- }
3439
- await page.goto(url, { waitUntil: waitUntil ?? "load", timeout });
3440
- await page.evaluate(() => {
3441
- const elements = document.querySelectorAll("*");
3442
- for (const element3 of elements) {
3443
- const rect = element3.getBoundingClientRect();
3444
- element3.setAttribute("data-rwidth", rect.width.toString());
3445
- element3.setAttribute("data-rheight", rect.height.toString());
4245
+ return await page.content();
4246
+ } finally {
4247
+ if (browser) {
4248
+ await context?.close();
4249
+ } else {
4250
+ await _browser.close();
3446
4251
  }
3447
- });
3448
- const html = await page.content();
3449
- await page.close();
3450
- if (browser) {
3451
- await context.close();
3452
- } else {
3453
- await _browser.close();
3454
4252
  }
3455
- return html;
3456
4253
  };
3457
4254
 
3458
4255
  // src/cli/commands/webforai/loadHtml.ts
@@ -3462,23 +4259,23 @@ var debugLog = (enabled, message) => {
3462
4259
  }
3463
4260
  };
3464
4261
  var checkPlaywrightAvailable = async () => {
3465
- const path4 = chromium2.executablePath();
4262
+ const path6 = chromium2.executablePath();
3466
4263
  try {
3467
- await fs5.access(path4);
4264
+ await fs8.access(path6);
3468
4265
  return true;
3469
4266
  } catch {
3470
4267
  return false;
3471
4268
  }
3472
4269
  };
3473
4270
  var getPlaywrightVersion = async () => {
3474
- const path4 = await import.meta.resolve("playwright-core/package.json");
3475
- const pwPackageJson = await fs5.readFile(fileURLToPath(path4), "utf-8").then((res) => JSON.parse(res.toString())).catch(() => null);
4271
+ const path6 = await import.meta.resolve("playwright-core/package.json");
4272
+ const pwPackageJson = await fs8.readFile(fileURLToPath(path6), "utf-8").then((res) => JSON.parse(res.toString())).catch(() => null);
3476
4273
  return pwPackageJson?.version;
3477
4274
  };
3478
4275
  var loadHtml3 = async (sourcePath, loader, options) => {
3479
4276
  if (loader === "local") {
3480
4277
  debugLog(options.debug, `Loading HTML from local file: ${sourcePath}`);
3481
- const content = await fs5.readFile(sourcePath, "utf-8");
4278
+ const content = await fs8.readFile(sourcePath, "utf-8");
3482
4279
  debugLog(options.debug, `HTML loaded: ${content.slice(0, 100)}`);
3483
4280
  return content;
3484
4281
  }
@@ -3545,12 +4342,13 @@ var convertViaPlatform = async (run) => {
3545
4342
  if (run.mode !== "default") {
3546
4343
  console.error("[webforai] --mode is applied by local conversion only and is ignored by the platform loader");
3547
4344
  }
3548
- debugLog2(run.debug, `platform scrape: ${run.source} (engine=${run.engine ?? "fetch"})`);
4345
+ debugLog2(run.debug, `platform scrape: ${run.source} (engine=${run.engine ?? "auto"})`);
3549
4346
  const result = await platform.scrape({
3550
4347
  url: run.source,
3551
4348
  engine: run.engine,
3552
4349
  region: run.region,
3553
4350
  screenshot: run.screenshot ? true : void 0,
4351
+ respectRobotsTxt: run.respectRobotsTxt ? true : void 0,
3554
4352
  convert: { extractor: run.extractor, frontmatter: run.frontmatter }
3555
4353
  });
3556
4354
  return {
@@ -3562,7 +4360,8 @@ var convertViaPlatform = async (run) => {
3562
4360
  markdown: result.markdown,
3563
4361
  metadata: result.metadata,
3564
4362
  credits: result.credits,
3565
- screenshotUrl: result.screenshotUrl
4363
+ screenshotUrl: result.screenshotUrl,
4364
+ warning: result.warning
3566
4365
  };
3567
4366
  };
3568
4367
  var convertLocally = async (run) => {
@@ -3575,18 +4374,21 @@ var convertLocally = async (run) => {
3575
4374
  extractors: extractorOptions(run.extractor),
3576
4375
  ...run.mode === "ai" ? AI_MODE_OPTIONS : {}
3577
4376
  });
4377
+ const shell = run.loader === "fetch" ? detectClientShell(html) : void 0;
4378
+ const warning = shell?.isShell ? "This page looks like it renders client-side; the fetched HTML has no readable content. Try --engine auto (hosted platform, renders JavaScript) or --loader playwright." : void 0;
3578
4379
  return {
3579
4380
  source: run.source,
3580
4381
  loader: run.loader,
3581
4382
  url: sourceUrl,
3582
4383
  markdown,
3583
- metadata
4384
+ metadata,
4385
+ warning
3584
4386
  };
3585
4387
  };
3586
4388
  var executeRun = (run) => run.loader === "platform" ? convertViaPlatform(run) : convertLocally(run);
3587
4389
  var writeOutputFile = async (outputPath, markdown) => {
3588
- await fs6.mkdir(path3.dirname(path3.resolve(outputPath)), { recursive: true });
3589
- await fs6.writeFile(outputPath, markdown);
4390
+ await fs9.mkdir(path5.dirname(path5.resolve(outputPath)), { recursive: true });
4391
+ await fs9.writeFile(outputPath, markdown);
3590
4392
  };
3591
4393
  var runCommand = async (run) => {
3592
4394
  const envelope = await executeRun(run);
@@ -3604,6 +4406,9 @@ var runCommand = async (run) => {
3604
4406
  process.stdout.write(envelope.markdown.endsWith("\n") ? envelope.markdown : `${envelope.markdown}
3605
4407
  `);
3606
4408
  }
4409
+ if (envelope.warning) {
4410
+ console.error(`[webforai] warning: ${envelope.warning}`);
4411
+ }
3607
4412
  if (envelope.screenshotUrl) {
3608
4413
  console.error(`[webforai] screenshot (expires ~24h): ${envelope.screenshotUrl}`);
3609
4414
  }
@@ -3614,20 +4419,21 @@ var runCommand = async (run) => {
3614
4419
 
3615
4420
  // src/cli/commands/webforai/interactive.ts
3616
4421
  var ENGINE_HINTS = {
4422
+ auto: "Cheapest first, renders when the page needs JavaScript \xB7 1\u20132 credits",
3617
4423
  fetch: "Plain fetch \xB7 1 credit",
3618
- browser: "Browser rendering, screenshots \xB7 5 credits",
3619
- "proxy-fetch": "Rotating-proxy egress \xB7 2 credits",
3620
- "proxy-browser": "Browser rendering behind the proxy \xB7 5 credits"
4424
+ browser: "Browser rendering, screenshots \xB7 2 credits",
4425
+ "proxy-fetch": "Rotating-proxy egress \xB7 2 credits \xB7 paid plan",
4426
+ "proxy-browser": "Browser rendering behind the proxy \xB7 3 credits \xB7 paid plan"
3621
4427
  };
3622
4428
  var promptPlatformOptions = async (flags, env) => {
3623
4429
  const engine = flags.engine ?? await select7({
3624
4430
  message: "Select platform engine:",
3625
- initialValue: "fetch",
3626
- options: ENGINES.map((value) => ({ value, label: value, hint: ENGINE_HINTS[value] }))
4431
+ initialValue: "auto",
4432
+ options: REQUESTED_ENGINES.map((value) => ({ value, label: value, hint: ENGINE_HINTS[value] }))
3627
4433
  });
3628
4434
  assertContinue(engine);
3629
4435
  const region = flags.region ?? await select7({
3630
- message: "Select egress region (proxy engines only):",
4436
+ message: "Select egress region (proxy engines only; jp needs a paid plan):",
3631
4437
  initialValue: "auto",
3632
4438
  options: REGIONS.map((value) => ({ value, label: value }))
3633
4439
  });
@@ -3647,26 +4453,18 @@ var promptPlatformOptions = async (flags, env) => {
3647
4453
  var interactiveCommand = async (initialSource, flags, env = process.env) => {
3648
4454
  intro(pc2.bold(pc2.green(`webforai CLI version ${package_default.version}`)));
3649
4455
  const source = initialSource ?? await inputSourcePath();
3650
- const loader = isUrl(source) ? flags.loader ?? await selectLoader() : "local";
3651
- const run = {
3652
- source,
3653
- loader,
3654
- mode: "default",
3655
- extractor: flags.extractor ?? "auto",
3656
- frontmatter: flags.frontmatter ?? false,
3657
- json: false,
3658
- debug: flags.debug ?? false,
3659
- screenshot: flags.screenshot ?? false
4456
+ const loader = isUrl(source) ? flags.loader ?? (flags.engine !== void 0 || flags.region !== void 0 ? "platform" : await selectLoader()) : "local";
4457
+ const selectedFlags = {
4458
+ ...flags,
4459
+ loader: loader === "local" ? flags.loader : loader
3660
4460
  };
3661
4461
  if (loader === "platform") {
3662
4462
  const platform = await promptPlatformOptions(flags, env);
3663
- run.engine = platform.engine;
3664
- run.region = platform.region;
3665
- run.apiKey = platform.apiKey;
3666
- run.platformUrl = flags.platformUrl;
4463
+ Object.assign(selectedFlags, platform);
3667
4464
  } else {
3668
- run.mode = flags.mode ?? await selectExtractMode();
4465
+ selectedFlags.mode = flags.mode ?? await selectExtractMode();
3669
4466
  }
4467
+ const run = resolveRunOptions(source, selectedFlags, env);
3670
4468
  const outputPath = flags.output ?? await inputOutputPath(source);
3671
4469
  const s = spinner();
3672
4470
  s.start(loader === "platform" ? "Converting via the platform..." : "Loading and converting...");
@@ -3689,73 +4487,6 @@ var interactiveCommand = async (initialSource, flags, env = process.env) => {
3689
4487
  outro(pc2.green(`${pc2.bold("Done!")} Markdown saved to ${outputPath}`));
3690
4488
  };
3691
4489
 
3692
- // src/cli/commands/webforai/options.ts
3693
- import fs7 from "node:fs";
3694
- var UsageError = class extends Error {
3695
- };
3696
- var oneOf = (name, value, allowed) => {
3697
- if (!allowed.includes(value)) {
3698
- throw new UsageError(`invalid --${name} "${value}" (expected: ${allowed.join(" | ")})`);
3699
- }
3700
- return value;
3701
- };
3702
- var impliesPlatform = (flags) => flags.engine !== void 0 || flags.region !== void 0;
3703
- var resolveLoader = (sourceIsUrl, flags) => {
3704
- if (flags.loader) {
3705
- return oneOf("loader", flags.loader, LOADERS);
3706
- }
3707
- if (impliesPlatform(flags)) {
3708
- return "platform";
3709
- }
3710
- return sourceIsUrl ? "fetch" : "local";
3711
- };
3712
- var assertUsableLocalSource = (source, loader, flags) => {
3713
- if (loader !== "local" && flags.loader) {
3714
- throw new UsageError(`--loader ${loader} needs a URL source, but "${source}" is a local path`);
3715
- }
3716
- if (impliesPlatform(flags)) {
3717
- throw new UsageError("--engine/--region need a URL source (the platform cannot read local files)");
3718
- }
3719
- if (!fs7.existsSync(source)) {
3720
- throw new UsageError(`source not found: "${source}" (URLs must start with http:// or https://)`);
3721
- }
3722
- };
3723
- var applyPlatformFields = (resolved, flags, env) => {
3724
- resolved.engine = flags.engine ? oneOf("engine", flags.engine, ENGINES) : void 0;
3725
- resolved.region = flags.region ? oneOf("region", flags.region, REGIONS) : void 0;
3726
- resolved.apiKey = flags.apiKey ?? env[API_KEY_ENV];
3727
- resolved.platformUrl = flags.platformUrl ?? env[PLATFORM_URL_ENV];
3728
- if (!resolved.apiKey) {
3729
- throw new UsageError(
3730
- `the platform loader needs an API key: pass --api-key or set ${API_KEY_ENV} (create one at https://platform.webforai.dev/dashboard)`
3731
- );
3732
- }
3733
- };
3734
- var resolveRunOptions = (source, flags, env = process.env) => {
3735
- const sourceIsUrl = isUrl(source);
3736
- const loader = resolveLoader(sourceIsUrl, flags);
3737
- if (!sourceIsUrl) {
3738
- assertUsableLocalSource(source, loader, flags);
3739
- }
3740
- const resolved = {
3741
- source,
3742
- loader: sourceIsUrl ? loader : "local",
3743
- mode: flags.mode ? oneOf("mode", flags.mode, MODES) : "default",
3744
- extractor: flags.extractor ? oneOf("extractor", flags.extractor, EXTRACTORS) : "auto",
3745
- frontmatter: flags.frontmatter ?? false,
3746
- output: flags.output,
3747
- json: flags.json ?? false,
3748
- debug: flags.debug ?? false,
3749
- screenshot: flags.screenshot ?? false
3750
- };
3751
- if (resolved.loader === "platform") {
3752
- applyPlatformFields(resolved, flags, env);
3753
- } else if (flags.screenshot) {
3754
- throw new UsageError("--screenshot is only available with the platform loader (browser engines)");
3755
- }
3756
- return resolved;
3757
- };
3758
-
3759
4490
  // src/cli/bin.ts
3760
4491
  var HELP_EPILOGUE = `
3761
4492
  Examples:
@@ -3764,7 +4495,9 @@ Examples:
3764
4495
  $ webforai ./page.html --extractor none convert a local file, no extraction
3765
4496
  $ webforai https://example.com --json | jq .markdown
3766
4497
  $ webforai https://spa.example.com -l playwright render JS in local Chromium
3767
- $ webforai https://example.com --engine browser render via the hosted platform
4498
+ $ webforai https://example.com --engine auto hosted platform, renders JS when needed
4499
+ $ webforai crawl https://docs.example.com -o docs --llms-txt crawl a site (platform) + llms.txt
4500
+ $ webforai batch --file urls.txt -o pages many URLs, one .md per page (platform)
3768
4501
  $ webforai -i guided interactive mode
3769
4502
  $ webforai skill --install install the Agent Skill for this CLI
3770
4503
 
@@ -3777,9 +4510,23 @@ Exit codes:
3777
4510
 
3778
4511
  Output contract:
3779
4512
  stdout carries only the Markdown (or the --json envelope); all logs go to stderr.
4513
+
4514
+ Docs:
4515
+ https://webforai.dev/cli this CLI
4516
+ https://webforai.dev/platform the hosted platform (--engine / --region)
4517
+ ${DEFAULT_BASE_URL}/dashboard API keys, usage and billing
3780
4518
  `;
3781
- program.name("webforai").description("Convert web pages and local HTML to clean, LLM-ready Markdown.").version(package_default.version, "-v, --version", "output the current version").addHelpText("after", HELP_EPILOGUE).showSuggestionAfterError();
3782
- program.argument("[source]", "URL or local HTML file to convert").option("-o, --output <path>", "write Markdown to a file instead of stdout").option("-l, --loader <loader>", `how HTML is acquired (${LOADERS.join(" | ")}; local files are automatic)`).option("-m, --mode <mode>", `conversion mode (${MODES.join(" | ")}); ai strips links/tables/images`).option("--extractor <extractor>", `main-content extraction preset (${EXTRACTORS.join(" | ")})`, void 0).option("--frontmatter", "prepend YAML front matter built from the page metadata").option("--json", "print a JSON envelope {source, loader, url, markdown, metadata, ...} to stdout").option("--engine <engine>", "platform engine (fetch | browser | proxy-fetch | proxy-browser); implies -l platform").option("--region <region>", "platform proxy egress region (auto | us | eu | uk | jp | asia); implies -l platform").option("--screenshot", "platform browser engines: also capture a screenshot (expiring URL)").option("--api-key <key>", `platform API key (defaults to $${API_KEY_ENV})`).option("--platform-url <url>", `platform base URL (defaults to $${PLATFORM_URL_ENV} or the hosted instance)`).option("-i, --interactive", "guided prompt flow (the pre-v3 default)").option("-d, --debug", "verbose logs on stderr").action(async (source, flags) => {
4519
+ var DASHBOARD_ERRORS = /* @__PURE__ */ new Set(["invalid_api_key", "payment_required", "spend_cap_reached"]);
4520
+ var activePlatformUrl;
4521
+ var rememberPlatformUrl = (flags) => {
4522
+ activePlatformUrl = flags.platformUrl ?? process.env[PLATFORM_URL_ENV];
4523
+ };
4524
+ program.name("webforai").enablePositionalOptions().exitOverride().description("Convert web pages and local HTML to clean, LLM-ready Markdown.").version(package_default.version, "-v, --version", "output the current version").addHelpText("after", HELP_EPILOGUE).showSuggestionAfterError();
4525
+ program.argument("[source]", "URL or local HTML file to convert").option("-o, --output <path>", "write Markdown to a file instead of stdout").option("-l, --loader <loader>", `how HTML is acquired (${LOADERS.join(" | ")}; local files are automatic)`).option("-m, --mode <mode>", `conversion mode (${MODES.join(" | ")}); ai strips links/tables/images`).option("--extractor <extractor>", `main-content extraction preset (${EXTRACTORS.join(" | ")})`, void 0).option("--frontmatter", "prepend YAML front matter built from the page metadata").option("--json", "print a JSON envelope {source, loader, url, markdown, metadata, ...} to stdout").option(
4526
+ "--engine <engine>",
4527
+ "platform engine (auto | fetch | browser | proxy-fetch | proxy-browser; proxy-* need a paid plan); implies -l platform"
4528
+ ).option("--region <region>", "platform proxy egress region (auto | jp; jp needs a paid plan); implies -l platform").option("--screenshot", "platform browser engines: also capture a screenshot (expiring URL)").option("--respect-robots", "platform loader: honor the target site's robots.txt rules (off by default)").option("--api-key <key>", `platform API key (defaults to $${API_KEY_ENV})`).option("--platform-url <url>", `platform base URL (defaults to $${PLATFORM_URL_ENV} or the hosted instance)`).option("-i, --interactive", "guided prompt flow (the pre-v3 default)").option("-d, --debug", "verbose logs on stderr").action(async (source, flags) => {
4529
+ rememberPlatformUrl(flags);
3783
4530
  if (flags.interactive) {
3784
4531
  await interactiveCommand(source, flags);
3785
4532
  return;
@@ -3789,6 +4536,46 @@ program.argument("[source]", "URL or local HTML file to convert").option("-o, --
3789
4536
  }
3790
4537
  await runCommand(resolveRunOptions(source, flags));
3791
4538
  });
4539
+ var collect = (value, previous = []) => [...previous, value];
4540
+ var addPlatformJobOptions = (command) => command.requiredOption("-o, --output <dir>", "directory for the Markdown files (one per page; created if missing)").option(
4541
+ "--engine <engine>",
4542
+ "platform engine (auto | fetch | browser | proxy-fetch | proxy-browser; proxy-* need a paid plan)"
4543
+ ).option("--region <region>", "proxy egress region (auto | jp); jp runs on the paid proxy tier").option("--extractor <extractor>", `main-content extraction preset (${EXTRACTORS.join(" | ")})`).option("--frontmatter", "prepend YAML front matter built from each page's metadata").option("--json", "print a JSON envelope {jobId, pages[], failures[], ...} to stdout instead of file paths").option("--timeout <seconds>", "stop waiting after this long (the job keeps running); default 1800").option("--api-key <key>", `platform API key (defaults to $${API_KEY_ENV})`).option("--platform-url <url>", `platform base URL (defaults to $${PLATFORM_URL_ENV} or the hosted instance)`).option("-d, --debug", "verbose logs on stderr");
4544
+ var JOB_OUTPUT_HELP = `
4545
+ Output:
4546
+ One .md per converted page under --output, mirroring URL paths (/docs/intro \u2192 docs/intro.md).
4547
+ stdout lists the written files (or the --json envelope); progress and the summary go to stderr.
4548
+ Exit 1 when the job failed or converted no pages; failed pages are listed and never billed.
4549
+ `;
4550
+ addPlatformJobOptions(
4551
+ program.command("crawl").description("crawl a site on the hosted platform and write one Markdown file per page").argument("<url>", "seed URL; only same-origin links are followed")
4552
+ ).option("--max-depth <n>", "link depth from the seed (0-5, default 2)").option("--limit <n>", "maximum pages (1-500, default 50)").option("--include <regex>", "only crawl pathnames matching this regex (repeatable)", collect).option("--exclude <regex>", "skip pathnames matching this regex (repeatable)", collect).option(
4553
+ "--sitemap <mode>",
4554
+ "use the site's sitemaps: skip (default) | include (add their URLs) | only (no link following)"
4555
+ ).option("--respect-robots", "honor robots.txt (the default for crawls)").option("--no-respect-robots", "do not read robots.txt").option("--llms-txt", "also write llms.txt (page index) and llms-full.txt (all pages) per llmstxt.org").addHelpText(
4556
+ "after",
4557
+ `${JOB_OUTPUT_HELP}
4558
+ Examples:
4559
+ $ webforai crawl https://docs.example.com -o docs --limit 100 --include '^/docs' --llms-txt
4560
+ $ webforai crawl https://blog.example.com -o blog --sitemap only --limit 500
4561
+ `
4562
+ ).action(async (url, flags) => {
4563
+ rememberPlatformUrl(flags);
4564
+ await runJobCommand(resolveJobOptions("crawl", [url], flags));
4565
+ });
4566
+ addPlatformJobOptions(
4567
+ program.command("batch").description("convert many URLs on the hosted platform, one Markdown file per page").argument("[urls...]", "URLs to convert (up to 100 per job)")
4568
+ ).option("-f, --file <path>", "read URLs from a file, one per line ('-' for stdin; # comments allowed)").option("--respect-robots", "honor each site's robots.txt (off by default for batches)").addHelpText(
4569
+ "after",
4570
+ `${JOB_OUTPUT_HELP}
4571
+ Examples:
4572
+ $ webforai batch https://a.example/post https://b.example/news -o pages
4573
+ $ cat urls.txt | webforai batch --file - -o pages --json
4574
+ `
4575
+ ).action(async (urls, flags) => {
4576
+ rememberPlatformUrl(flags);
4577
+ await runJobCommand(resolveJobOptions("batch", urls, flags));
4578
+ });
3792
4579
  program.command("skill").description("print the Agent Skill (SKILL.md) for this CLI, or install it via the skills CLI").option("--install", "install through `npx skills add` (interactive agent picker; flags below forward to it)").option("-g, --global", "forward to skills add: install user-level instead of project-level").option("-a, --agent <agents>", "forward to skills add: agents to install to ('*' for all)").option("-y, --yes", "forward to skills add: skip confirmation prompts").option("--copy", "forward to skills add: copy files instead of symlinking").option("--all", "forward to skills add: all skills, all agents, no prompts").option("--dir <path>", "offline fallback: write <path>/webforai/SKILL.md directly, without the skills CLI").addHelpText("after", "\nAlso installable straight from the repository: npx skills add inaridiy/webforai\n").action(async (flags) => {
3793
4580
  await skillCommand(flags);
3794
4581
  });
@@ -3800,8 +4587,19 @@ var fail = (error) => {
3800
4587
  console.error(`error: ${error.message}`);
3801
4588
  process.exit(2);
3802
4589
  }
4590
+ if (error instanceof PlatformApiError) {
4591
+ const retry = error.retryAfter === void 0 ? "" : ` (retryAfter: ${error.retryAfter}s)`;
4592
+ console.error(`error: ${error.code}: ${error.message}${retry}`);
4593
+ if (DASHBOARD_ERRORS.has(error.code) && !error.message.includes("/dashboard")) {
4594
+ console.error(`hint: manage API keys and credits at ${platformDashboardUrl(activePlatformUrl)}`);
4595
+ }
4596
+ process.exit(1);
4597
+ }
4598
+ if (error instanceof JobIncompleteError) {
4599
+ console.error(`error: ${error.message}`);
4600
+ process.exit(1);
4601
+ }
3803
4602
  console.error(error instanceof Error ? `error: ${error.message}` : `error: ${String(error)}`);
3804
4603
  process.exit(1);
3805
4604
  };
3806
- program.exitOverride();
3807
4605
  program.parseAsync().catch(fail);