webforai 2.1.0 → 3.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 (154) hide show
  1. package/README.md +63 -1
  2. package/dist/adapters/index.js +58 -0
  3. package/dist/adapters/registry.js +149 -0
  4. package/dist/adapters/sites/declarative.js +178 -0
  5. package/dist/adapters/sites/hackernews.js +94 -0
  6. package/dist/adapters/sites/youtube.js +165 -0
  7. package/dist/adapters/types.js +0 -0
  8. package/dist/bin.js +3199 -399
  9. package/dist/cjs/adapters/index.js +86 -0
  10. package/dist/cjs/adapters/registry.js +178 -0
  11. package/dist/cjs/adapters/sites/declarative.js +202 -0
  12. package/dist/cjs/adapters/sites/hackernews.js +117 -0
  13. package/dist/cjs/adapters/sites/youtube.js +191 -0
  14. package/dist/cjs/adapters/types.js +16 -0
  15. package/dist/cjs/constants.js +2 -2
  16. package/dist/cjs/extractors/index.js +8 -0
  17. package/dist/cjs/extractors/lib/constants.js +186 -0
  18. package/dist/cjs/extractors/lib/sanitize.js +425 -0
  19. package/dist/cjs/extractors/lib/score.js +264 -0
  20. package/dist/cjs/extractors/lib/terminators.js +131 -0
  21. package/dist/cjs/extractors/lib/ui-chrome.js +119 -0
  22. package/dist/cjs/extractors/pipeExtractors.js +12 -9
  23. package/dist/cjs/extractors/presets/auto.js +44 -0
  24. package/dist/cjs/extractors/presets/minimal-filter.js +27 -48
  25. package/dist/cjs/extractors/presets/takumi.js +84 -122
  26. package/dist/cjs/html-to-markdown.js +44 -7
  27. package/dist/cjs/html-to-mdast.js +9 -1
  28. package/dist/cjs/index.js +33 -2
  29. package/dist/cjs/mdast-handlers/custom-a-handler.js +25 -9
  30. package/dist/cjs/mdast-handlers/custom-img-handler.js +23 -1
  31. package/dist/cjs/mdast-handlers/custom-table-handler.js +16 -0
  32. package/dist/cjs/mdast-handlers/definition-list-handler.js +99 -0
  33. package/dist/cjs/mdast-handlers/math-handler.js +12 -3
  34. package/dist/cjs/mdast-handlers/sup-sub-handler.js +39 -0
  35. package/dist/cjs/metadata/index.js +223 -0
  36. package/dist/cjs/normalize/index.js +168 -0
  37. package/dist/cjs/platform/client.js +147 -0
  38. package/dist/cjs/platform/error.js +36 -0
  39. package/dist/cjs/platform/fetch.js +37 -0
  40. package/dist/cjs/platform/index.js +40 -0
  41. package/dist/cjs/platform/types.js +34 -0
  42. package/dist/cjs/utils/hast-fast.js +284 -0
  43. package/dist/constants.js +2 -2
  44. package/dist/extractors/index.js +5 -0
  45. package/dist/extractors/lib/constants.js +150 -0
  46. package/dist/extractors/lib/sanitize.js +415 -0
  47. package/dist/extractors/lib/score.js +242 -0
  48. package/dist/extractors/lib/terminators.js +107 -0
  49. package/dist/extractors/lib/ui-chrome.js +96 -0
  50. package/dist/extractors/pipeExtractors.js +12 -9
  51. package/dist/extractors/presets/auto.js +20 -0
  52. package/dist/extractors/presets/minimal-filter.js +35 -47
  53. package/dist/extractors/presets/takumi.js +91 -122
  54. package/dist/html-to-markdown.js +42 -6
  55. package/dist/html-to-mdast.js +9 -1
  56. package/dist/index.js +34 -3
  57. package/dist/mdast-handlers/custom-a-handler.js +25 -9
  58. package/dist/mdast-handlers/custom-img-handler.js +23 -1
  59. package/dist/mdast-handlers/custom-table-handler.js +16 -0
  60. package/dist/mdast-handlers/definition-list-handler.js +76 -0
  61. package/dist/mdast-handlers/math-handler.js +12 -3
  62. package/dist/mdast-handlers/sup-sub-handler.js +16 -0
  63. package/dist/metadata/index.js +198 -0
  64. package/dist/normalize/index.js +149 -0
  65. package/dist/platform/client.js +125 -0
  66. package/dist/platform/error.js +13 -0
  67. package/dist/platform/fetch.js +14 -0
  68. package/dist/platform/index.js +19 -0
  69. package/dist/platform/types.js +9 -0
  70. package/dist/types/adapters/index.d.ts +24 -0
  71. package/dist/types/adapters/index.js.map +1 -0
  72. package/dist/types/adapters/registry.d.ts +35 -0
  73. package/dist/types/adapters/registry.js.map +1 -0
  74. package/dist/types/adapters/sites/declarative.d.ts +13 -0
  75. package/dist/types/adapters/sites/declarative.js.map +1 -0
  76. package/dist/types/adapters/sites/hackernews.d.ts +13 -0
  77. package/dist/types/adapters/sites/hackernews.js.map +1 -0
  78. package/dist/types/adapters/sites/youtube.d.ts +31 -0
  79. package/dist/types/adapters/sites/youtube.js.map +1 -0
  80. package/dist/types/adapters/types.d.ts +99 -0
  81. package/dist/types/adapters/types.js.map +1 -0
  82. package/dist/types/cli/bin.js.map +1 -1
  83. package/dist/types/cli/commands/skill/content.d.ts +9 -0
  84. package/dist/types/cli/commands/skill/content.js.map +1 -0
  85. package/dist/types/cli/commands/skill/index.d.ts +19 -0
  86. package/dist/types/cli/commands/skill/index.js.map +1 -0
  87. package/dist/types/cli/commands/webforai/interactive.d.ts +7 -0
  88. package/dist/types/cli/commands/webforai/interactive.js.map +1 -0
  89. package/dist/types/cli/commands/webforai/loadHtml.js.map +1 -1
  90. package/dist/types/cli/commands/webforai/options.d.ts +55 -0
  91. package/dist/types/cli/commands/webforai/options.js.map +1 -0
  92. package/dist/types/cli/commands/webforai/run.d.ts +10 -0
  93. package/dist/types/cli/commands/webforai/run.js.map +1 -0
  94. package/dist/types/cli/constants.d.ts +14 -4
  95. package/dist/types/cli/constants.js.map +1 -1
  96. package/dist/types/cli/helpers/selectExtractMode.js.map +1 -1
  97. package/dist/types/cli/helpers/selectLoader.js.map +1 -1
  98. package/dist/types/constants.d.ts +7 -1
  99. package/dist/types/constants.js.map +1 -1
  100. package/dist/types/extractors/index.d.ts +2 -0
  101. package/dist/types/extractors/index.js.map +1 -1
  102. package/dist/types/extractors/lib/constants.d.ts +81 -0
  103. package/dist/types/extractors/lib/constants.js.map +1 -0
  104. package/dist/types/extractors/lib/sanitize.d.ts +66 -0
  105. package/dist/types/extractors/lib/sanitize.js.map +1 -0
  106. package/dist/types/extractors/lib/score.d.ts +85 -0
  107. package/dist/types/extractors/lib/score.js.map +1 -0
  108. package/dist/types/extractors/lib/terminators.d.ts +26 -0
  109. package/dist/types/extractors/lib/terminators.js.map +1 -0
  110. package/dist/types/extractors/lib/ui-chrome.d.ts +18 -0
  111. package/dist/types/extractors/lib/ui-chrome.js.map +1 -0
  112. package/dist/types/extractors/pipeExtractors.d.ts +5 -0
  113. package/dist/types/extractors/pipeExtractors.js.map +1 -1
  114. package/dist/types/extractors/presets/auto.d.ts +30 -0
  115. package/dist/types/extractors/presets/auto.js.map +1 -0
  116. package/dist/types/extractors/presets/minimal-filter.d.ts +10 -2
  117. package/dist/types/extractors/presets/minimal-filter.js.map +1 -1
  118. package/dist/types/extractors/presets/takumi.d.ts +19 -3
  119. package/dist/types/extractors/presets/takumi.js.map +1 -1
  120. package/dist/types/extractors/types.d.ts +8 -0
  121. package/dist/types/html-to-markdown.d.ts +38 -0
  122. package/dist/types/html-to-markdown.js.map +1 -1
  123. package/dist/types/html-to-mdast.d.ts +11 -0
  124. package/dist/types/html-to-mdast.js.map +1 -1
  125. package/dist/types/index.d.ts +6 -3
  126. package/dist/types/index.js.map +1 -1
  127. package/dist/types/mdast-handlers/custom-a-handler.js.map +1 -1
  128. package/dist/types/mdast-handlers/custom-img-handler.js.map +1 -1
  129. package/dist/types/mdast-handlers/custom-table-handler.js.map +1 -1
  130. package/dist/types/mdast-handlers/definition-list-handler.d.ts +13 -0
  131. package/dist/types/mdast-handlers/definition-list-handler.js.map +1 -0
  132. package/dist/types/mdast-handlers/math-handler.js.map +1 -1
  133. package/dist/types/mdast-handlers/sup-sub-handler.d.ts +10 -0
  134. package/dist/types/mdast-handlers/sup-sub-handler.js.map +1 -0
  135. package/dist/types/metadata/index.d.ts +47 -0
  136. package/dist/types/metadata/index.js.map +1 -0
  137. package/dist/types/normalize/index.d.ts +53 -0
  138. package/dist/types/normalize/index.js.map +1 -0
  139. package/dist/types/platform/client.d.ts +54 -0
  140. package/dist/types/platform/client.js.map +1 -0
  141. package/dist/types/platform/error.d.ts +20 -0
  142. package/dist/types/platform/error.js.map +1 -0
  143. package/dist/types/platform/fetch.d.ts +34 -0
  144. package/dist/types/platform/fetch.js.map +1 -0
  145. package/dist/types/platform/index.d.ts +4 -0
  146. package/dist/types/platform/index.js.map +1 -0
  147. package/dist/types/platform/types.d.ts +135 -0
  148. package/dist/types/platform/types.js.map +1 -0
  149. package/dist/types/utils/hast-fast.d.ts +101 -0
  150. package/dist/types/utils/hast-fast.js.map +1 -0
  151. package/dist/utils/hast-fast.js +246 -0
  152. package/package.json +10 -3
  153. package/dist/types/cli/commands/webforai/index.d.ts +0 -6
  154. package/dist/types/cli/commands/webforai/index.js.map +0 -1
package/dist/bin.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/bin.ts
4
- import { program } from "commander";
4
+ import { CommanderError, program } from "commander";
5
5
 
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "webforai",
9
- version: "2.1.0",
9
+ version: "3.0.0",
10
10
  description: "A library that provides a web interface for AI",
11
11
  author: "inaridiy",
12
12
  license: "Apache-2.0",
@@ -52,6 +52,11 @@ var package_default = {
52
52
  import: "./dist/index.js",
53
53
  require: "./dist/cjs/index.js"
54
54
  },
55
+ "./platform": {
56
+ types: "./dist/types/platform/index.d.ts",
57
+ import: "./dist/platform/index.js",
58
+ require: "./dist/cjs/platform/index.js"
59
+ },
55
60
  "./loaders/playwright": {
56
61
  types: "./dist/types/loaders/playwright.d.ts",
57
62
  import: "./dist/loaders/playwright.js",
@@ -89,6 +94,9 @@ var package_default = {
89
94
  ],
90
95
  "loaders/puppeteer": [
91
96
  "./dist/types/loaders/puppeteer.d.ts"
97
+ ],
98
+ platform: [
99
+ "./dist/types/platform/index.d.ts"
92
100
  ]
93
101
  }
94
102
  },
@@ -124,8 +132,7 @@ var package_default = {
124
132
  "mdast-util-to-markdown": "^2.1.0",
125
133
  picocolors: "^1.0.1",
126
134
  "trim-trailing-lines": "^2.1.0",
127
- "unist-util-filter": "^5.0.1",
128
- zx: "^8.1.5"
135
+ "unist-util-filter": "^5.0.1"
129
136
  },
130
137
  devDependencies: {
131
138
  "@cloudflare/puppeteer": "^0.0.6",
@@ -147,12 +154,520 @@ var package_default = {
147
154
  }
148
155
  };
149
156
 
150
- // src/cli/commands/webforai/index.ts
151
- import fs5 from "node:fs/promises";
152
- import path2 from "node:path";
153
- import { intro, log as log2, outro, spinner } from "@clack/prompts";
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";
154
312
  import pc2 from "picocolors";
155
313
 
314
+ // src/platform/error.ts
315
+ var PlatformApiError = class extends Error {
316
+ constructor(code, message, status, retryAfter) {
317
+ super(message);
318
+ this.name = "PlatformApiError";
319
+ this.code = code;
320
+ this.status = status;
321
+ this.retryAfter = retryAfter;
322
+ }
323
+ };
324
+
325
+ // src/platform/fetch.ts
326
+ var resolveFetch = (custom) => {
327
+ if (custom) {
328
+ return custom;
329
+ }
330
+ const globalFetch = globalThis.fetch;
331
+ if (typeof globalFetch !== "function") {
332
+ throw new Error("No global fetch in this runtime \u2014 pass a fetch implementation to createPlatformClient({ fetch })");
333
+ }
334
+ return globalFetch.bind(globalThis);
335
+ };
336
+
337
+ // src/platform/types.ts
338
+ var ENGINES = ["fetch", "browser", "proxy-fetch", "proxy-browser"];
339
+ var REGIONS = ["auto", "us", "eu", "uk", "jp", "asia"];
340
+ var isStoredPageStub = (item) => item.status === "ok" && "resultUrl" in item;
341
+
342
+ // src/platform/client.ts
343
+ var DEFAULT_BASE_URL = "https://platform.webforai.dev";
344
+ var TERMINAL_STATES = /* @__PURE__ */ new Set(["completed", "failed"]);
345
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
346
+ if (signal?.aborted) {
347
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
348
+ return;
349
+ }
350
+ const timer = setTimeout(() => {
351
+ signal?.removeEventListener("abort", onAbort);
352
+ resolve();
353
+ }, ms);
354
+ const onAbort = () => {
355
+ clearTimeout(timer);
356
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
357
+ };
358
+ signal?.addEventListener("abort", onAbort, { once: true });
359
+ });
360
+ var parseRetryAfter = (body, response) => {
361
+ const fromBody = body?.error?.retryAfter;
362
+ if (typeof fromBody === "number") {
363
+ return fromBody;
364
+ }
365
+ const header = Number(response.headers.get("retry-after"));
366
+ return Number.isFinite(header) && header > 0 ? header : void 0;
367
+ };
368
+ var toApiError = (response, body) => {
369
+ const envelope = body;
370
+ 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})`;
372
+ return new PlatformApiError(code, message, response.status, parseRetryAfter(body, response));
373
+ };
374
+ var createPlatformClient = (options = {}) => {
375
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
376
+ const fetchImpl = resolveFetch(options.fetch);
377
+ const request = async (path4, init) => {
378
+ const headers = { accept: "application/json" };
379
+ if (init.body !== void 0) {
380
+ headers["content-type"] = "application/json";
381
+ }
382
+ if (init.auth) {
383
+ if (!options.apiKey) {
384
+ throw new PlatformApiError(
385
+ "missing_api_key",
386
+ "This endpoint needs an API key: pass `apiKey` to createPlatformClient (create one on the platform dashboard).",
387
+ 0
388
+ );
389
+ }
390
+ headers.authorization = `Bearer ${options.apiKey}`;
391
+ }
392
+ const response = await fetchImpl(`${baseUrl}${path4}`, {
393
+ method: init.method,
394
+ headers,
395
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
396
+ });
397
+ const body = await response.json().catch(() => void 0);
398
+ if (!response.ok) {
399
+ throw toApiError(response, body);
400
+ }
401
+ if (body === void 0) {
402
+ throw new PlatformApiError("invalid_response", "response body is not JSON", response.status);
403
+ }
404
+ return body;
405
+ };
406
+ const getJob = (jobId) => request(`/v1/jobs/${encodeURIComponent(jobId)}`, { method: "GET", auth: true });
407
+ const getJobResults = (jobId, opts) => {
408
+ const query = opts?.cursor ? `?cursor=${encodeURIComponent(opts.cursor)}` : "";
409
+ return request(`/v1/jobs/${encodeURIComponent(jobId)}/results${query}`, { method: "GET", auth: true });
410
+ };
411
+ const resolveStored = async (resultUrl) => {
412
+ const response = await fetchImpl(resultUrl, { method: "GET" });
413
+ if (!response.ok) {
414
+ throw new PlatformApiError(
415
+ "artifact_fetch_failed",
416
+ `stored result fetch failed (HTTP ${response.status}) \u2014 result URLs expire after ~24h`,
417
+ response.status
418
+ );
419
+ }
420
+ return await response.json();
421
+ };
422
+ return {
423
+ scrape: (opts) => request("/v1/scrape", { method: "POST", body: opts, auth: true }),
424
+ scrapeAsync: (opts) => request("/v1/scrape", { method: "POST", body: { ...opts, async: true }, auth: true }),
425
+ batch: (opts) => request("/v1/batch", { method: "POST", body: opts, auth: true }),
426
+ crawl: (opts) => request("/v1/crawl", { method: "POST", body: opts, auth: true }),
427
+ getJob,
428
+ getJobResults,
429
+ waitForJob: async (jobId, opts = {}) => {
430
+ const interval = opts.pollIntervalMs ?? 2e3;
431
+ 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);
441
+ }
442
+ await sleep(interval, opts.signal);
443
+ }
444
+ },
445
+ jobResults: async function* (jobId) {
446
+ let cursor;
447
+ do {
448
+ const page = await getJobResults(jobId, { cursor });
449
+ for (const item of page.results) {
450
+ yield isStoredPageStub(item) ? await resolveStored(item.resultUrl) : item;
451
+ }
452
+ cursor = page.cursor;
453
+ } while (cursor);
454
+ },
455
+ demoScrape: (opts) => request("/v1/demo/scrape", { method: "POST", body: opts, auth: false })
456
+ };
457
+ };
458
+
459
+ // src/cli/constants.ts
460
+ var DEFAULT_PATH = "https://example.com";
461
+ var LOADERS = ["fetch", "playwright", "platform"];
462
+ var MODES = ["default", "ai"];
463
+ var EXTRACTORS = ["auto", "takumi", "minimal", "none"];
464
+ var API_KEY_ENV = "WEBFORAI_API_KEY";
465
+ var PLATFORM_URL_ENV = "WEBFORAI_PLATFORM_URL";
466
+
467
+ // src/cli/helpers/assertContinue.ts
468
+ import { cancel, isCancel } from "@clack/prompts";
469
+ function assertContinue(message, cancelMessage = "Canceled.") {
470
+ if (isCancel(message)) {
471
+ cancel(cancelMessage);
472
+ process.exit(1);
473
+ }
474
+ }
475
+
476
+ // src/cli/helpers/inputOutputPath.ts
477
+ import fs3 from "node:fs";
478
+ 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
+ var inputOutputPath = async (sourcePath) => {
561
+ const outputPath = await text({
562
+ message: "Enter the output file path:",
563
+ placeholder: "output.md",
564
+ initialValue: sourcePathToOutputPath(sourcePath),
565
+ validate: (value) => {
566
+ if (value.trim() === "") {
567
+ return "Output path is required";
568
+ }
569
+ if (fs3.existsSync(value) && fs3.statSync(value).isDirectory()) {
570
+ return "No directory can be specified.";
571
+ }
572
+ if (!fs3.existsSync(value) && value.endsWith("/")) {
573
+ return "No directory can be specified.";
574
+ }
575
+ }
576
+ });
577
+ assertContinue(outputPath);
578
+ if (!fs3.existsSync(outputPath)) {
579
+ return outputPath;
580
+ }
581
+ const isOutputFileOverwrite = await confirm({
582
+ message: "The file already exists. Overwrite?",
583
+ initialValue: false
584
+ });
585
+ assertContinue(isOutputFileOverwrite);
586
+ if (isOutputFileOverwrite) {
587
+ return outputPath;
588
+ }
589
+ const escapedOutputPath = await text({
590
+ message: "Enter the output file path:",
591
+ placeholder: "output.md",
592
+ initialValue: getNextAvailableFilePath(outputPath),
593
+ validate: (value) => {
594
+ if (value.trim() === "") {
595
+ return "Output path is required";
596
+ }
597
+ if (fs3.existsSync(value)) {
598
+ return "The file already exists";
599
+ }
600
+ if (!fs3.existsSync(value) && value.endsWith("/")) {
601
+ return "No directory can be specified.";
602
+ }
603
+ }
604
+ });
605
+ assertContinue(escapedOutputPath);
606
+ return escapedOutputPath;
607
+ };
608
+
609
+ // src/cli/helpers/inputSourcePath.ts
610
+ import fs4 from "node:fs";
611
+ import { text as text2 } from "@clack/prompts";
612
+ var inputSourcePath = async () => {
613
+ const result = await text2({
614
+ message: "Enter the URL or html path to be converted to markdown:",
615
+ placeholder: DEFAULT_PATH,
616
+ initialValue: "",
617
+ validate: (value) => {
618
+ if (value.trim() === "") {
619
+ return "Source is required";
620
+ }
621
+ if (!isUrl(value)) {
622
+ if (!fs4.existsSync(value)) {
623
+ 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
+ }
625
+ if (fs4.statSync(value).isDirectory()) {
626
+ return "You are specifying a local file, but you cannot specify a directory. hint: when specifying a url, start with http or https.";
627
+ }
628
+ }
629
+ }
630
+ });
631
+ assertContinue(result);
632
+ return result;
633
+ };
634
+
635
+ // src/cli/helpers/selectExtractMode.ts
636
+ import { select } from "@clack/prompts";
637
+ var selectExtractMode = async () => {
638
+ const result = await select({
639
+ message: "Select processing mode:",
640
+ options: MODES.map((mode) => ({ value: mode, label: mode })),
641
+ initialValue: MODES[0]
642
+ });
643
+ assertContinue(result);
644
+ return result;
645
+ };
646
+
647
+ // src/cli/helpers/selectLoader.ts
648
+ import { select as select2 } from "@clack/prompts";
649
+ var loadersHint = {
650
+ fetch: "Fetch HTML content from the given URL",
651
+ playwright: "Render the page in local headless Chromium first; Playwright must be installed",
652
+ platform: "Convert via the hosted webforai platform API (needs an API key)"
653
+ };
654
+ var selectLoader = async () => {
655
+ const result = await select2({
656
+ message: "Select loader:",
657
+ initialValue: "fetch",
658
+ options: LOADERS.map((loader) => ({ value: loader, label: loader, hint: loadersHint[loader] }))
659
+ });
660
+ assertContinue(result);
661
+ return result;
662
+ };
663
+
664
+ // src/cli/commands/webforai/run.ts
665
+ import fs6 from "node:fs/promises";
666
+ import path3 from "node:path";
667
+
668
+ // src/html-to-markdown.ts
669
+ import { fromHtml as fromHtml2 } from "hast-util-from-html";
670
+
156
671
  // src/html-to-mdast.ts
157
672
  import { fromHtml } from "hast-util-from-html";
158
673
  import { toMdast } from "hast-util-to-mdast";
@@ -179,192 +694,1941 @@ var extractMdast = (node) => {
179
694
  return extracted;
180
695
  };
181
696
 
182
- // src/extractors/presets/takumi.ts
183
- import { select, selectAll } from "hast-util-select";
184
- import { toString as hastToString } from "hast-util-to-string";
185
- import { filter as filter2 } from "unist-util-filter";
697
+ // src/utils/hast-fast.ts
698
+ var EMPTY_METRICS = { text: 0, link: 0, commas: 0, paragraphs: 0, elements: 0 };
699
+ var COMMA_REGEX = /[,、,،]/g;
700
+ var PARAGRAPH_LIKE = /* @__PURE__ */ new Set(["p", "pre", "li", "blockquote", "figcaption", "dd", "dt"]);
701
+ var NON_PROSE_TAGS = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "svg", "math", "iframe", "object"]);
702
+ var isElement = (node) => typeof node === "object" && node !== null && node.type === "element";
703
+ var isParent = (node) => typeof node === "object" && node !== null && Array.isArray(node.children);
704
+ var classList = (element3) => {
705
+ const className = element3.properties?.className;
706
+ if (Array.isArray(className)) {
707
+ return className;
708
+ }
709
+ if (typeof className === "string") {
710
+ return className.split(/\s+/).filter(Boolean);
711
+ }
712
+ return [];
713
+ };
714
+ var matchString = (element3) => {
715
+ const id = element3.properties?.id;
716
+ const classes = classList(element3);
717
+ if (!id && classes.length === 0) {
718
+ return element3.tagName;
719
+ }
720
+ return `${element3.tagName} ${typeof id === "string" ? id : ""} ${classes.join(" ")}`;
721
+ };
722
+ var MetricsCollector = class {
723
+ #cache = /* @__PURE__ */ new WeakMap();
724
+ metrics(node) {
725
+ if (node.type === "text") {
726
+ const value = node.value;
727
+ if (value.trim().length === 0) {
728
+ return EMPTY_METRICS;
729
+ }
730
+ return {
731
+ text: value.length,
732
+ link: 0,
733
+ commas: (value.match(COMMA_REGEX) ?? []).length,
734
+ paragraphs: 0,
735
+ elements: 0
736
+ };
737
+ }
738
+ if (!isParent(node)) {
739
+ return EMPTY_METRICS;
740
+ }
741
+ const cached = this.#cache.get(node);
742
+ if (cached) {
743
+ return cached;
744
+ }
745
+ if (isElement(node) && NON_PROSE_TAGS.has(node.tagName)) {
746
+ this.#cache.set(node, EMPTY_METRICS);
747
+ return EMPTY_METRICS;
748
+ }
749
+ let text6 = 0;
750
+ let link = 0;
751
+ let commas = 0;
752
+ let paragraphs = 0;
753
+ let elements = 0;
754
+ for (const child of node.children) {
755
+ const childMetrics = this.metrics(child);
756
+ text6 += childMetrics.text;
757
+ link += childMetrics.link;
758
+ commas += childMetrics.commas;
759
+ paragraphs += childMetrics.paragraphs;
760
+ elements += childMetrics.elements;
761
+ if (isElement(child)) {
762
+ elements += 1;
763
+ if (PARAGRAPH_LIKE.has(child.tagName)) {
764
+ paragraphs += 1;
765
+ }
766
+ }
767
+ }
768
+ if (isElement(node) && node.tagName === "a") {
769
+ link = text6;
770
+ }
771
+ const metrics = { text: text6, link, commas, paragraphs, elements };
772
+ this.#cache.set(node, metrics);
773
+ return metrics;
774
+ }
775
+ textLength(node) {
776
+ return this.metrics(node).text;
777
+ }
778
+ /** Fraction of a subtree's text that is link anchor text. `0` for empty subtrees. */
779
+ linkDensity(node) {
780
+ const { text: text6, link } = this.metrics(node);
781
+ if (text6 === 0) {
782
+ return 0;
783
+ }
784
+ return Math.min(1, link / text6);
785
+ }
786
+ };
787
+ var pruneInPlace = (tree, keep) => {
788
+ if (!isParent(tree)) {
789
+ return tree;
790
+ }
791
+ const stack = [tree];
792
+ while (stack.length > 0) {
793
+ const parent = stack.pop();
794
+ const children = parent.children;
795
+ let write = 0;
796
+ for (let read = 0; read < children.length; read++) {
797
+ const child = children[read];
798
+ if (!keep(child)) {
799
+ continue;
800
+ }
801
+ children[write++] = child;
802
+ if (isParent(child)) {
803
+ stack.push(child);
804
+ }
805
+ }
806
+ children.length = write;
807
+ }
808
+ return tree;
809
+ };
810
+ var walk = (tree, visit) => {
811
+ const stack = [{ node: tree, parent: void 0 }];
812
+ while (stack.length > 0) {
813
+ const { node, parent } = stack.pop();
814
+ if (visit(node, parent) === false) {
815
+ continue;
816
+ }
817
+ if (isParent(node)) {
818
+ for (let i = node.children.length - 1; i >= 0; i--) {
819
+ stack.push({ node: node.children[i], parent: node });
820
+ }
821
+ }
822
+ }
823
+ };
824
+ var collectElements = (tree, predicate) => {
825
+ const found = [];
826
+ walk(tree, (node) => {
827
+ if (isElement(node) && predicate(node)) {
828
+ found.push(node);
829
+ }
830
+ });
831
+ return found;
832
+ };
833
+ var findElement = (tree, predicate) => {
834
+ let found;
835
+ walk(tree, (node) => {
836
+ if (found) {
837
+ return false;
838
+ }
839
+ if (isElement(node) && predicate(node)) {
840
+ found = node;
841
+ return false;
842
+ }
843
+ });
844
+ return found;
845
+ };
846
+ var cloneHast = (tree) => {
847
+ try {
848
+ return structuredClone(tree);
849
+ } catch {
850
+ return manualClone(tree);
851
+ }
852
+ };
853
+ var manualClone = (node) => {
854
+ if (Array.isArray(node)) {
855
+ return node.map(manualClone);
856
+ }
857
+ if (typeof node !== "object" || node === null) {
858
+ return node;
859
+ }
860
+ const copy = {};
861
+ for (const [key, value] of Object.entries(node)) {
862
+ copy[key] = manualClone(value);
863
+ }
864
+ return copy;
865
+ };
866
+ var property = (element3, key) => {
867
+ const properties = element3.properties;
868
+ if (!properties) {
869
+ return void 0;
870
+ }
871
+ const direct = properties[key];
872
+ if (direct !== void 0) {
873
+ return direct;
874
+ }
875
+ if (!(key.includes("-") || key.includes(":"))) {
876
+ return void 0;
877
+ }
878
+ return properties[camelCaseAttribute(key)];
879
+ };
880
+ var camelCaseAttribute = (key) => key.replace(/[-:]([a-z])/g, (_, character) => character.toUpperCase());
881
+ var numericProperty = (element3, key) => {
882
+ const raw = property(element3, key);
883
+ if (typeof raw === "number") {
884
+ return Number.isFinite(raw) ? raw : void 0;
885
+ }
886
+ if (typeof raw === "string" && raw.length > 0) {
887
+ const parsed = Number.parseFloat(raw);
888
+ return Number.isFinite(parsed) ? parsed : void 0;
889
+ }
890
+ return void 0;
891
+ };
892
+ var stringProperty = (element3, key) => {
893
+ const raw = property(element3, key);
894
+ if (typeof raw === "string") {
895
+ return raw;
896
+ }
897
+ if (Array.isArray(raw)) {
898
+ return raw.join(" ");
899
+ }
900
+ if (raw === true) {
901
+ return "";
902
+ }
903
+ return void 0;
904
+ };
905
+ var isTruthyAttribute = (element3, key) => {
906
+ const raw = property(element3, key);
907
+ if (raw === void 0 || raw === null || raw === false) {
908
+ return false;
909
+ }
910
+ if (raw === true) {
911
+ return true;
912
+ }
913
+ if (typeof raw === "string") {
914
+ return raw.toLowerCase() !== "false";
915
+ }
916
+ return true;
917
+ };
918
+
919
+ // src/extractors/lib/constants.ts
920
+ var REGEXPS = {
921
+ /**
922
+ * Containers that almost never hold the article body.
923
+ *
924
+ * Note the words that are deliberately *absent*. `header` and `footer` match the heading
925
+ * anchors and article furniture that documentation generators emit (`<a class="header">`,
926
+ * `class="post-header"`), so matching them here deletes the page's headings. The landmark
927
+ * versions of those elements are handled structurally instead.
928
+ */
929
+ unlikelyCandidates: /-ad-|ai2html|banner|breadcrumb|combx|comment(?!ary)|community|cover-wrap|disqus|gdpr|legends|menu|related|remark|replies|rss|shoutbox|sidebar|site-index|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote|speechify-ignore|avatar|subscribe|newsletter|paywall|consent|cookie|toolbar|masthead|promo|share|sharing|widget|tooltip|dropdown|modal|overlay|backdrop|carousel|announcement|notification|toast|skip-link|screen-reader|visually-hidden|sr-only/i,
930
+ /** Rescues containers whose class merely happens to contain an unlikely substring. */
931
+ okMaybeItsaCandidate: /and|article|body|column|content|main|shadow|code|post|entry|story|markdown|prose|doc/i,
932
+ /** Very high-confidence boilerplate, not rescued by the clause above. */
933
+ stronglyUnlikely: /^(nav|navbar|breadcrumbs?|cookie-?(banner|consent|notice)|consent-?(banner|manager)|skip-?link|site-?header|site-?footer|global-?(header|footer)|social-?(share|links?)|share-?(bar|buttons?)|related-?(posts?|articles?)|newsletter-?(signup|form)|comment-?(form|list)|toc-?sidebar)$/i,
934
+ /** Class/id fragments that mark real article content. */
935
+ positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story|markdown|prose|documentation|doc-?content|readme/i,
936
+ /** Class/id fragments that mark furniture. */
937
+ negative: /-ad-|hidden|^hid$| hid$|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget|toolbar|nav|menu|cookie|consent|subscribe|newsletter|popup|modal|overlay|breadcrumb|pagination|social/i,
938
+ /** Placeholder imagery that should never reach the output. */
939
+ hidden: /fallback-image/i,
940
+ byline: /byline|author|dateline|writtenby|p-author|c-author|post-meta|entry-meta/i,
941
+ /** Wiki/CMS chrome that generic scoring alone tends to keep. */
942
+ specialUnlikelyCandidates: /frb-|uls-menu|language-link|mw-editsection|noprint|navbox|metadata|catlinks|printfooter/i
943
+ };
944
+ var NON_CONTENT_TAGS = /* @__PURE__ */ new Set([
945
+ "script",
946
+ "style",
947
+ "link",
948
+ "meta",
949
+ "noscript",
950
+ "template",
951
+ "base",
952
+ "param",
953
+ "source",
954
+ "track",
955
+ "title"
956
+ ]);
957
+ var CHROME_TAGS = /* @__PURE__ */ new Set(["nav", "aside", "dialog", "menu"]);
958
+ var CONTEXTUAL_LANDMARK_TAGS = /* @__PURE__ */ new Set(["header", "footer"]);
959
+ var HIDDEN_CLASS_NAMES = /* @__PURE__ */ new Set([
960
+ "hidden",
961
+ "invisible",
962
+ "is-hidden",
963
+ "d-none",
964
+ "display-none",
965
+ "hide",
966
+ "is-invisible"
967
+ ]);
968
+ var RESPONSIVE_DISPLAY_OVERRIDE = /^(sm|md|lg|xl|2xl|print):(block|flex|grid|inline|inline-block|inline-flex|table|contents|list-item)$/;
969
+ var UNLIKELY_ROLES = /* @__PURE__ */ new Set([
970
+ "menu",
971
+ "menubar",
972
+ "menuitem",
973
+ "complementary",
974
+ "navigation",
975
+ "alert",
976
+ "alertdialog",
977
+ "dialog",
978
+ "banner",
979
+ "contentinfo",
980
+ "search",
981
+ "searchbox",
982
+ "toolbar",
983
+ "tablist",
984
+ "tooltip"
985
+ // `presentation` is deliberately absent. It marks an element as decorative *structure* — a
986
+ // layout table, a wrapper — while the content inside it is usually real. Treating it as
987
+ // chrome deletes article text.
988
+ ]);
989
+ var CONTENT_ROLES = /* @__PURE__ */ new Set(["main", "article", "document"]);
990
+ var SCOREABLE_TAGS = /* @__PURE__ */ new Set(["p", "td", "pre", "blockquote", "dd", "li", "figure", "section", "div"]);
991
+ var CANDIDATE_TAGS = /* @__PURE__ */ new Set([
992
+ "div",
993
+ "section",
994
+ "article",
995
+ "main",
996
+ "td",
997
+ "pre",
998
+ "blockquote",
999
+ "form",
1000
+ "ul",
1001
+ "ol",
1002
+ "dl"
1003
+ ]);
1004
+ var EMBEDDED_CONTENT_TAGS = /* @__PURE__ */ new Set([
1005
+ "img",
1006
+ "picture",
1007
+ "video",
1008
+ "audio",
1009
+ "iframe",
1010
+ "embed",
1011
+ "object",
1012
+ "svg",
1013
+ // Formulae hold no text of their own, so an empty-wrapper rule would otherwise delete them.
1014
+ "math",
1015
+ "mjx-container",
1016
+ "canvas"
1017
+ ]);
1018
+ var DROP_WHEN_EMPTY_TAGS = /* @__PURE__ */ new Set([
1019
+ "p",
1020
+ "h1",
1021
+ "h2",
1022
+ "h3",
1023
+ "h4",
1024
+ "h5",
1025
+ "h6",
1026
+ "li",
1027
+ "td",
1028
+ "th",
1029
+ "blockquote",
1030
+ "figcaption",
1031
+ "dd",
1032
+ "dt",
1033
+ "a",
1034
+ "span",
1035
+ "div",
1036
+ "section",
1037
+ "article"
1038
+ ]);
1039
+ var MIN_CONTENT_LENGTH = {
1040
+ ja: 140,
1041
+ zh: 140,
1042
+ ko: 160,
1043
+ th: 160,
1044
+ default: 400
1045
+ };
1046
+ var minContentLength = (lang) => {
1047
+ if (!lang) {
1048
+ return MIN_CONTENT_LENGTH.default;
1049
+ }
1050
+ const primary = lang.toLowerCase().split("-")[0];
1051
+ return MIN_CONTENT_LENGTH[primary] ?? MIN_CONTENT_LENGTH.default;
1052
+ };
1053
+
1054
+ // src/extractors/lib/ui-chrome.ts
1055
+ var UI_PHRASES = /* @__PURE__ */ new Set([
1056
+ // Documentation and article controls
1057
+ "copy",
1058
+ "copy page",
1059
+ "copy link",
1060
+ "copy code",
1061
+ "copied",
1062
+ "edit this page",
1063
+ "edit on github",
1064
+ "on this page",
1065
+ "in this article",
1066
+ "table of contents",
1067
+ "contents",
1068
+ "print",
1069
+ "print this page",
1070
+ "back to top",
1071
+ "scroll to top",
1072
+ "skip to content",
1073
+ "skip to main content",
1074
+ "permalink",
1075
+ "anchor",
1076
+ // Feedback widgets
1077
+ "was this page helpful",
1078
+ "was this helpful",
1079
+ "was this article helpful",
1080
+ "yes no",
1081
+ "feedback",
1082
+ "send feedback",
1083
+ "report an issue",
1084
+ "rate this page",
1085
+ // Sharing and subscription
1086
+ "share",
1087
+ "share this",
1088
+ "share this article",
1089
+ "share on twitter",
1090
+ "share on facebook",
1091
+ "subscribe",
1092
+ "sign up",
1093
+ "sign in",
1094
+ "log in",
1095
+ "login",
1096
+ "newsletter",
1097
+ // Advertising and promotion
1098
+ "advertisement",
1099
+ "advertisements",
1100
+ "sponsored",
1101
+ "sponsored content",
1102
+ "promoted",
1103
+ // Navigation affordances
1104
+ "read more",
1105
+ "show more",
1106
+ "show less",
1107
+ "see more",
1108
+ "load more",
1109
+ "previous",
1110
+ "next",
1111
+ "previous page",
1112
+ "next page",
1113
+ "menu",
1114
+ "close",
1115
+ "search",
1116
+ // Japanese equivalents
1117
+ "\u76EE\u6B21",
1118
+ "\u5E83\u544A",
1119
+ "\u30B7\u30A7\u30A2",
1120
+ "\u5171\u6709",
1121
+ "\u3053\u306E\u8A18\u4E8B\u3092\u30B7\u30A7\u30A2",
1122
+ "\u7D9A\u304D\u3092\u8AAD\u3080",
1123
+ "\u3082\u3063\u3068\u898B\u308B",
1124
+ "\u95A2\u9023\u8A18\u4E8B",
1125
+ "\u30B9\u30DD\u30F3\u30B5\u30FC\u30EA\u30F3\u30AF",
1126
+ "\u30DA\u30FC\u30B8\u306E\u5148\u982D\u3078",
1127
+ "\u30C8\u30C3\u30D7\u3078\u623B\u308B",
1128
+ "\u524D\u3078",
1129
+ "\u6B21\u3078",
1130
+ "\u9589\u3058\u308B",
1131
+ "\u691C\u7D22",
1132
+ "\u30ED\u30B0\u30A4\u30F3",
1133
+ "\u4F1A\u54E1\u767B\u9332"
1134
+ ]);
1135
+ var MAX_PHRASE_LENGTH = 40;
1136
+ var normalize = (text6) => text6.toLowerCase().replace(/[?!.,:;·・…"'“”‘’()[\]{}<>|/\\—–-]/g, " ").replace(/\s+/g, " ").trim();
1137
+ var isUiChromeText = (text6) => {
1138
+ if (text6.length === 0 || text6.length > MAX_PHRASE_LENGTH) {
1139
+ return false;
1140
+ }
1141
+ const normalized = normalize(text6);
1142
+ if (normalized.length === 0) {
1143
+ return false;
1144
+ }
1145
+ return UI_PHRASES.has(normalized);
1146
+ };
1147
+
1148
+ // src/extractors/lib/sanitize.ts
1149
+ var INVISIBLE_STYLE = /(^|;)\s*(display\s*:\s*none|visibility\s*:\s*hidden)\s*(;|$)/i;
1150
+ var hasHiddenClass = (element3) => {
1151
+ const classes = classList(element3);
1152
+ if (!classes.some((name) => HIDDEN_CLASS_NAMES.has(name))) {
1153
+ return classes.some((name) => REGEXPS.hidden.test(name));
1154
+ }
1155
+ return !classes.some((name) => RESPONSIVE_DISPLAY_OVERRIDE.test(name));
1156
+ };
1157
+ var isNonContent = (node) => {
1158
+ if (node.type === "comment" || node.type === "doctype") {
1159
+ return true;
1160
+ }
1161
+ return isElement(node) && NON_CONTENT_TAGS.has(node.tagName);
1162
+ };
1163
+ var isHidden = (element3) => {
1164
+ if (containsMath(element3)) {
1165
+ return false;
1166
+ }
1167
+ if (isTruthyAttribute(element3, "hidden")) {
1168
+ return true;
1169
+ }
1170
+ const style = stringProperty(element3, "style");
1171
+ if (style && INVISIBLE_STYLE.test(style)) {
1172
+ return true;
1173
+ }
1174
+ if (hasHiddenClass(element3)) {
1175
+ return true;
1176
+ }
1177
+ const width = numericProperty(element3, "data-rwidth");
1178
+ const height = numericProperty(element3, "data-rheight");
1179
+ if (width === 0 && height === 0) {
1180
+ return true;
1181
+ }
1182
+ return false;
1183
+ };
1184
+ var PLACEHOLDER_IMAGE_SRC = /^data:image\/|^about:blank$|spacer\.gif$|blank\.(gif|png)$/i;
1185
+ var isPlaceholderImage = (element3) => {
1186
+ if (element3.tagName === "img") {
1187
+ const src = stringProperty(element3, "src");
1188
+ return src !== void 0 && PLACEHOLDER_IMAGE_SRC.test(src);
1189
+ }
1190
+ if (element3.tagName !== "picture") {
1191
+ return false;
1192
+ }
1193
+ for (const child of element3.children) {
1194
+ if (isElement(child) && child.tagName === "img") {
1195
+ return isPlaceholderImage(child);
1196
+ }
1197
+ }
1198
+ return false;
1199
+ };
1200
+ var hoistNoscriptImages = (tree) => {
1201
+ const visit = (parent) => {
1202
+ for (let index = 0; index < parent.children.length; index++) {
1203
+ const child = parent.children[index];
1204
+ if (!isElement(child)) {
1205
+ continue;
1206
+ }
1207
+ if (child.tagName !== "noscript") {
1208
+ visit(child);
1209
+ continue;
1210
+ }
1211
+ const hasImage = child.children.some(
1212
+ (node) => isElement(node) && (node.tagName === "img" || node.tagName === "picture")
1213
+ );
1214
+ if (!hasImage) {
1215
+ continue;
1216
+ }
1217
+ const rescued = child.children;
1218
+ parent.children.splice(index, 1, ...rescued);
1219
+ const previous = parent.children[index - 1];
1220
+ if (index > 0 && isElement(previous) && isPlaceholderImage(previous)) {
1221
+ parent.children.splice(index - 1, 1);
1222
+ index -= 1;
1223
+ }
1224
+ index += rescued.length - 1;
1225
+ }
1226
+ };
1227
+ if ("children" in tree) {
1228
+ visit(tree);
1229
+ }
1230
+ };
1231
+ var containsMath = (element3) => {
1232
+ if (element3.tagName === "math") {
1233
+ return true;
1234
+ }
1235
+ for (const child of element3.children) {
1236
+ if (isElement(child) && containsMath(child)) {
1237
+ return true;
1238
+ }
1239
+ }
1240
+ return false;
1241
+ };
1242
+ var stripNonContent = (tree) => {
1243
+ hoistNoscriptImages(tree);
1244
+ return pruneInPlace(tree, (node) => {
1245
+ if (isNonContent(node)) {
1246
+ return false;
1247
+ }
1248
+ if (isElement(node) && isHidden(node)) {
1249
+ return false;
1250
+ }
1251
+ return true;
1252
+ });
1253
+ };
1254
+ var findLandmarkChrome = (tree) => {
1255
+ const chrome = /* @__PURE__ */ new Set();
1256
+ const visit = (node, depth, insideContent) => {
1257
+ if (!("children" in node)) {
1258
+ return;
1259
+ }
1260
+ for (const child of node.children) {
1261
+ if (!isElement(child)) {
1262
+ continue;
1263
+ }
1264
+ const nowInsideContent = insideContent || child.tagName === "article" || child.tagName === "main" || stringProperty(child, "role") === "main";
1265
+ if (CONTEXTUAL_LANDMARK_TAGS.has(child.tagName) && !insideContent && depth <= 3) {
1266
+ chrome.add(child);
1267
+ continue;
1268
+ }
1269
+ visit(child, depth + 1, nowInsideContent);
1270
+ }
1271
+ };
1272
+ visit(tree, 0, false);
1273
+ return chrome;
1274
+ };
1275
+ var isUnlikelyCandidate = (element3) => {
1276
+ if (element3.tagName === "article" || element3.tagName === "main" || element3.tagName === "body") {
1277
+ return false;
1278
+ }
1279
+ const role = stringProperty(element3, "role");
1280
+ if (role && UNLIKELY_ROLES.has(role)) {
1281
+ return true;
1282
+ }
1283
+ if (CHROME_TAGS.has(element3.tagName)) {
1284
+ return true;
1285
+ }
1286
+ const match = matchString(element3);
1287
+ if (REGEXPS.specialUnlikelyCandidates.test(match)) {
1288
+ return true;
1289
+ }
1290
+ for (const name of [...classList(element3), stringProperty(element3, "id") ?? ""]) {
1291
+ if (REGEXPS.stronglyUnlikely.test(name)) {
1292
+ return true;
1293
+ }
1294
+ }
1295
+ if (REGEXPS.unlikelyCandidates.test(match) && !REGEXPS.okMaybeItsaCandidate.test(match)) {
1296
+ return true;
1297
+ }
1298
+ return false;
1299
+ };
1300
+ var findUnlikelyElements = (tree) => {
1301
+ const landmarks = findLandmarkChrome(tree);
1302
+ const doomed = [];
1303
+ const visit = (node) => {
1304
+ if (!("children" in node)) {
1305
+ return;
1306
+ }
1307
+ for (const child of node.children) {
1308
+ if (!isElement(child)) {
1309
+ continue;
1310
+ }
1311
+ if (landmarks.has(child) || isUnlikelyCandidate(child)) {
1312
+ doomed.push(child);
1313
+ continue;
1314
+ }
1315
+ visit(child);
1316
+ }
1317
+ };
1318
+ visit(tree);
1319
+ return doomed;
1320
+ };
1321
+ var isEmbeddedContent = (element3) => {
1322
+ if (EMBEDDED_CONTENT_TAGS.has(element3.tagName)) {
1323
+ return true;
1324
+ }
1325
+ return element3.tagName === "br" || element3.tagName === "hr" || element3.tagName === "input";
1326
+ };
1327
+ var collectPreformattedElements = (tree) => {
1328
+ const protectedElements = /* @__PURE__ */ new Set();
1329
+ const markSubtree = (node) => {
1330
+ if (!("children" in node)) {
1331
+ return;
1332
+ }
1333
+ for (const child of node.children) {
1334
+ if (isElement(child)) {
1335
+ protectedElements.add(child);
1336
+ markSubtree(child);
1337
+ }
1338
+ }
1339
+ };
1340
+ walk(tree, (node) => {
1341
+ if (isElement(node) && (node.tagName === "pre" || node.tagName === "code")) {
1342
+ protectedElements.add(node);
1343
+ markSubtree(node);
1344
+ return false;
1345
+ }
1346
+ });
1347
+ return protectedElements;
1348
+ };
1349
+ var dropEmptyElements = (tree, collector) => {
1350
+ const preformatted = collectPreformattedElements(tree);
1351
+ const visit = (parent) => {
1352
+ for (let index = 0; index < parent.children.length; index++) {
1353
+ const child = parent.children[index];
1354
+ if (!isElement(child)) {
1355
+ continue;
1356
+ }
1357
+ if (preformatted.has(child) || !DROP_WHEN_EMPTY_TAGS.has(child.tagName)) {
1358
+ visit(child);
1359
+ continue;
1360
+ }
1361
+ if (collector.metrics(child).text > 0 || hasEmbeddedDescendant(child)) {
1362
+ visit(child);
1363
+ continue;
1364
+ }
1365
+ if (holdsWhitespace(child)) {
1366
+ parent.children[index] = { type: "text", value: " " };
1367
+ continue;
1368
+ }
1369
+ parent.children.splice(index, 1);
1370
+ index -= 1;
1371
+ }
1372
+ };
1373
+ if ("children" in tree) {
1374
+ visit(tree);
1375
+ }
1376
+ return tree;
1377
+ };
1378
+ var holdsWhitespace = (element3) => {
1379
+ for (const child of element3.children) {
1380
+ if (child.type === "text" && /\s/.test(child.value)) {
1381
+ return true;
1382
+ }
1383
+ if (isElement(child) && holdsWhitespace(child)) {
1384
+ return true;
1385
+ }
1386
+ }
1387
+ return false;
1388
+ };
1389
+ var hasEmbeddedDescendant = (element3) => {
1390
+ for (const child of element3.children) {
1391
+ if (!isElement(child)) {
1392
+ continue;
1393
+ }
1394
+ if (isEmbeddedContent(child) || hasEmbeddedDescendant(child)) {
1395
+ return true;
1396
+ }
1397
+ }
1398
+ return false;
1399
+ };
1400
+ var REFERENCE_HEADING = /^(see also|references?|further reading|external links?|bibliography|sources|footnotes|citations|関連項目|参考文献|外部リンク|脚注|出典|参考リンク|参考資料)$/i;
1401
+ var MAX_DANGLING_HEADING_LENGTH = 60;
1402
+ var isHeadingTag = (element3) => /^h[1-6]$/.test(element3.tagName);
1403
+ var previousElement = (siblings, index) => {
1404
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
1405
+ const sibling = siblings[cursor];
1406
+ if (isElement(sibling)) {
1407
+ return sibling;
1408
+ }
1409
+ if (sibling.type === "text" && sibling.value.trim().length > 0) {
1410
+ return void 0;
1411
+ }
1412
+ }
1413
+ return void 0;
1414
+ };
1415
+ var condemnLinkBlock = (block, siblings, index, collector, doomed) => {
1416
+ const heading2 = previousElement(siblings, index);
1417
+ const isHeading = heading2 !== void 0 && isHeadingTag(heading2);
1418
+ if (isHeading && REFERENCE_HEADING.test(elementText(heading2).trim())) {
1419
+ return;
1420
+ }
1421
+ if (introducedByColon(siblings, index, collector)) {
1422
+ return;
1423
+ }
1424
+ doomed.push(block);
1425
+ if (isHeading && elementText(heading2).trim().length <= MAX_DANGLING_HEADING_LENGTH) {
1426
+ doomed.push(heading2);
1427
+ }
1428
+ };
1429
+ var introducedByColon = (siblings, index, collector) => {
1430
+ const before = previousElement(siblings, index);
1431
+ if (!before || collector.metrics(before).text === 0) {
1432
+ return false;
1433
+ }
1434
+ return /[::]\s*$/.test(elementText(before).trim());
1435
+ };
1436
+ var findCleanupTargets = (tree, collector) => {
1437
+ const doomed = [];
1438
+ const visit = (node) => {
1439
+ if (!("children" in node)) {
1440
+ return;
1441
+ }
1442
+ const siblings = node.children;
1443
+ for (let index = 0; index < siblings.length; index++) {
1444
+ const child = siblings[index];
1445
+ if (!isElement(child)) {
1446
+ continue;
1447
+ }
1448
+ if (child.tagName === "article" || child.tagName === "main") {
1449
+ visit(child);
1450
+ continue;
1451
+ }
1452
+ if (isLinkOnlyBlock(child, collector)) {
1453
+ condemnLinkBlock(child, siblings, index, collector, doomed);
1454
+ continue;
1455
+ }
1456
+ if (isMidArticleWidget(child)) {
1457
+ doomed.push(child);
1458
+ continue;
1459
+ }
1460
+ visit(child);
1461
+ }
1462
+ };
1463
+ visit(tree);
1464
+ return doomed;
1465
+ };
1466
+ var LINK_BLOCK_TAGS = /* @__PURE__ */ new Set(["ul", "ol", "nav", "div", "p"]);
1467
+ var MIN_LINK_BLOCK_ANCHORS = 3;
1468
+ var isLinkOnlyBlock = (element3, collector) => {
1469
+ if (!LINK_BLOCK_TAGS.has(element3.tagName)) {
1470
+ return false;
1471
+ }
1472
+ const metrics = collector.metrics(element3);
1473
+ if (metrics.text === 0 || metrics.commas > 0) {
1474
+ return false;
1475
+ }
1476
+ if (metrics.link / metrics.text <= 0.9) {
1477
+ return false;
1478
+ }
1479
+ return countAnchors(element3) >= MIN_LINK_BLOCK_ANCHORS;
1480
+ };
1481
+ var countAnchors = (element3) => {
1482
+ let count = 0;
1483
+ const stack = [element3];
1484
+ while (stack.length > 0 && count < MIN_LINK_BLOCK_ANCHORS) {
1485
+ const node = stack.pop();
1486
+ if (isElement(node) && node.tagName === "a") {
1487
+ count += 1;
1488
+ }
1489
+ if ("children" in node) {
1490
+ for (const child of node.children) {
1491
+ stack.push(child);
1492
+ }
1493
+ }
1494
+ }
1495
+ return count;
1496
+ };
1497
+ var isMidArticleWidget = (element3) => {
1498
+ if (element3.tagName === "form" || element3.tagName === "fieldset") {
1499
+ return true;
1500
+ }
1501
+ if (element3.tagName === "button") {
1502
+ return true;
1503
+ }
1504
+ return isUiChromeText(elementText(element3));
1505
+ };
1506
+ var cleanContent = (tree, collector) => {
1507
+ const doomed = new Set(findCleanupTargets(tree, collector));
1508
+ if (doomed.size > 0) {
1509
+ pruneInPlace(tree, (node) => !(isElement(node) && doomed.has(node)));
1510
+ }
1511
+ return dropEmptyElements(tree, new MetricsCollector());
1512
+ };
1513
+ var elementText = (node) => {
1514
+ if (node.type === "text") {
1515
+ return node.value;
1516
+ }
1517
+ if (!("children" in node)) {
1518
+ return "";
1519
+ }
1520
+ let text6 = "";
1521
+ for (const child of node.children) {
1522
+ text6 += elementText(child);
1523
+ }
1524
+ return text6;
1525
+ };
1526
+
1527
+ // src/adapters/registry.ts
1528
+ import { select as select3, selectAll } from "hast-util-select";
1529
+ var EMPTY_SIGNATURE = {
1530
+ classes: /* @__PURE__ */ new Set(),
1531
+ ids: /* @__PURE__ */ new Set(),
1532
+ tags: /* @__PURE__ */ new Set(),
1533
+ generator: "",
1534
+ meta: ""
1535
+ };
1536
+ var buildPageSignature = (root) => {
1537
+ const classes = /* @__PURE__ */ new Set();
1538
+ const ids = /* @__PURE__ */ new Set();
1539
+ const tags = /* @__PURE__ */ new Set();
1540
+ const generatorParts = [];
1541
+ const metaParts = [];
1542
+ walk(root, (node) => {
1543
+ if (!isElement(node)) {
1544
+ return;
1545
+ }
1546
+ tags.add(node.tagName);
1547
+ for (const name of classList(node)) {
1548
+ classes.add(name);
1549
+ }
1550
+ const id = stringProperty(node, "id");
1551
+ if (id) {
1552
+ ids.add(id);
1553
+ }
1554
+ if (node.tagName === "meta") {
1555
+ const key = stringProperty(node, "name") ?? stringProperty(node, "property") ?? "";
1556
+ const content = stringProperty(node, "content") ?? "";
1557
+ metaParts.push(`${key}=${content}`);
1558
+ if (key.toLowerCase() === "generator") {
1559
+ generatorParts.push(content);
1560
+ }
1561
+ }
1562
+ });
1563
+ return {
1564
+ classes,
1565
+ ids,
1566
+ tags,
1567
+ generator: generatorParts.join(" ").toLowerCase(),
1568
+ meta: metaParts.join("\n").toLowerCase()
1569
+ };
1570
+ };
1571
+ var DEFAULT_MIN_LENGTH = 40;
1572
+ var hostOf = (url) => {
1573
+ if (!url) {
1574
+ return void 0;
1575
+ }
1576
+ try {
1577
+ return new URL(url).hostname;
1578
+ } catch {
1579
+ return void 0;
1580
+ }
1581
+ };
1582
+ var defineSelectorAdapter = (spec) => ({
1583
+ id: spec.id,
1584
+ matches: (context) => {
1585
+ const host = hostOf(context.url);
1586
+ return Boolean(host && spec.hosts?.test(host));
1587
+ },
1588
+ matchesFingerprint: (context) => {
1589
+ const signature = context.signature ?? EMPTY_SIGNATURE;
1590
+ return spec.fingerprint?.(signature) ?? false;
1591
+ },
1592
+ extract: (context) => {
1593
+ for (const selector of spec.content) {
1594
+ const content = tryContentSelector(selector, spec, context);
1595
+ if (content) {
1596
+ return content;
1597
+ }
1598
+ }
1599
+ return void 0;
1600
+ }
1601
+ });
1602
+ var tryContentSelector = (selector, spec, context) => {
1603
+ const found = selectAll(selector, context.hast);
1604
+ if (found.length === 0) {
1605
+ return void 0;
1606
+ }
1607
+ for (const element3 of found) {
1608
+ removeAll(element3, spec.remove);
1609
+ }
1610
+ const total = found.reduce((sum, element3) => sum + new MetricsCollector().textLength(element3), 0);
1611
+ if (total < (spec.minLength ?? DEFAULT_MIN_LENGTH)) {
1612
+ return void 0;
1613
+ }
1614
+ const decorated = spec.decorate?.(found, context);
1615
+ if (decorated) {
1616
+ return decorated;
1617
+ }
1618
+ return found.length === 1 ? found[0] : { type: "root", children: found };
1619
+ };
1620
+ var removeAll = (root, selectors) => {
1621
+ if (!selectors || selectors.length === 0) {
1622
+ return;
1623
+ }
1624
+ const doomed = /* @__PURE__ */ new Set();
1625
+ for (const selector of selectors) {
1626
+ for (const element3 of selectAll(selector, root)) {
1627
+ doomed.add(element3);
1628
+ }
1629
+ }
1630
+ if (doomed.size > 0) {
1631
+ pruneInPlace(root, (node) => !(isElement(node) && doomed.has(node)));
1632
+ }
1633
+ };
1634
+ var resolveAdapterByHost = (context, adapters) => {
1635
+ for (const adapter of adapters) {
1636
+ try {
1637
+ if (adapter.matches(context)) {
1638
+ return adapter;
1639
+ }
1640
+ } catch {
1641
+ }
1642
+ }
1643
+ return void 0;
1644
+ };
1645
+ var resolveAdapterByFingerprint = (context, adapters) => {
1646
+ for (const adapter of adapters) {
1647
+ try {
1648
+ if (adapter.matchesFingerprint?.(context)) {
1649
+ return adapter;
1650
+ }
1651
+ } catch {
1652
+ }
1653
+ }
1654
+ return void 0;
1655
+ };
1656
+
1657
+ // src/adapters/sites/declarative.ts
1658
+ var COMMON_NOISE = [
1659
+ ".sr-only",
1660
+ "[aria-hidden='true'][class*='icon']",
1661
+ "button",
1662
+ ".social-share",
1663
+ ".share-buttons",
1664
+ ".newsletter-signup"
1665
+ ];
1666
+ var SPECS = [
1667
+ {
1668
+ id: "github",
1669
+ hosts: /(^|\.)github\.com$/,
1670
+ // Issues and PRs expose a testid; repository landing pages render the README instead.
1671
+ content: ["[data-testid='issue-body']", "article.markdown-body", ".markdown-body"],
1672
+ remove: [".js-comment-edit-history", ".Details-content--hidden", "clipboard-copy", ...COMMON_NOISE],
1673
+ minLength: 30
1674
+ },
1675
+ {
1676
+ id: "stackoverflow",
1677
+ hosts: /(^|\.)(stackoverflow\.com|stackexchange\.com|superuser\.com|serverfault\.com|askubuntu\.com)$/,
1678
+ content: ["#mainbar", ".question, .answer"],
1679
+ remove: [
1680
+ ".js-post-menu",
1681
+ ".post-menu",
1682
+ ".user-info",
1683
+ ".votecell",
1684
+ ".comments-link",
1685
+ ".js-add-link",
1686
+ ...COMMON_NOISE
1687
+ ],
1688
+ minLength: 100
1689
+ },
1690
+ {
1691
+ id: "npm",
1692
+ hosts: /(^|\.)npmjs\.com$/,
1693
+ content: ["#readme", "#package-tab-readme", "[class*='markdown']"],
1694
+ remove: COMMON_NOISE,
1695
+ minLength: 50
1696
+ },
1697
+ {
1698
+ id: "zenn",
1699
+ hosts: /(^|\.)zenn\.dev$/,
1700
+ content: [".znc"],
1701
+ remove: COMMON_NOISE,
1702
+ minLength: 50
1703
+ },
1704
+ {
1705
+ id: "qiita",
1706
+ hosts: /(^|\.)qiita\.com$/,
1707
+ content: [".it-MdContent", "[class*='mdContent']", "article"],
1708
+ remove: [".code-copy", ...COMMON_NOISE],
1709
+ minLength: 50
1710
+ },
1711
+ {
1712
+ id: "medium",
1713
+ hosts: /(^|\.)medium\.com$/,
1714
+ // Medium also powers custom domains; the fingerprint catches those.
1715
+ fingerprint: (signature) => signature.meta.includes("al:android:app_name=medium"),
1716
+ content: ["article section", "article", "[data-testid='storyContent']"],
1717
+ remove: ["[data-testid='audioPlayButton']", "[role='separator']", ...COMMON_NOISE],
1718
+ minLength: 200
1719
+ },
1720
+ {
1721
+ id: "substack",
1722
+ hosts: /(^|\.)substack\.com$/,
1723
+ // Substack's custom domains are far more common than *.substack.com.
1724
+ fingerprint: (signature) => signature.classes.has("available-content"),
1725
+ content: [".available-content", ".body.markup", ".post-content"],
1726
+ remove: [".subscription-widget-wrap", ".button-wrapper", ".paywall", ...COMMON_NOISE],
1727
+ minLength: 200
1728
+ },
1729
+ {
1730
+ id: "note",
1731
+ hosts: /(^|\.)note\.com$/,
1732
+ content: [".note-common-styles__textnote-body", "[class*='textnote-body']"],
1733
+ remove: COMMON_NOISE,
1734
+ minLength: 50
1735
+ },
1736
+ {
1737
+ id: "hatena",
1738
+ hosts: /(^|\.)(hatenablog\.com|hatenadiary\.(com|jp)|hateblo\.jp|hatenastaff\.com)$/,
1739
+ fingerprint: (signature) => signature.classes.has("hatenablog-entry") || signature.classes.has("hatena-body"),
1740
+ content: [".entry-content"],
1741
+ remove: [".entry-footer", ".social-buttons", ".hatena-asin-detail", ...COMMON_NOISE],
1742
+ minLength: 100
1743
+ },
1744
+ {
1745
+ id: "wordpress",
1746
+ // Deliberately fingerprint-only: WordPress powers a large share of the web on its own
1747
+ // domains, so keying off a hostname would cover almost none of it.
1748
+ fingerprint: (signature) => signature.generator.includes("wordpress") || signature.classes.has("wp-block-post-content") || signature.classes.has("wp-singular"),
1749
+ content: [".wp-block-post-content", ".entry-content", ".post-content", "article .content"],
1750
+ remove: [".sharedaddy", ".jp-relatedposts", ".wp-block-comments", "#comments", ...COMMON_NOISE],
1751
+ minLength: 150
1752
+ },
1753
+ {
1754
+ id: "wikipedia",
1755
+ hosts: /(^|\.)(wikipedia\.org|wikimedia\.org|wikibooks\.org|wiktionary\.org|wikisource\.org)$/,
1756
+ fingerprint: (signature) => signature.classes.has("mw-parser-output"),
1757
+ content: [".mw-parser-output", "#mw-content-text"],
1758
+ remove: [
1759
+ ".mw-editsection",
1760
+ ".navbox",
1761
+ ".navbox-inner",
1762
+ ".vertical-navbox",
1763
+ ".sidebar",
1764
+ ".side-box",
1765
+ ".metadata",
1766
+ ".reflist",
1767
+ ".refbegin",
1768
+ ".sistersitebox",
1769
+ ".ambox",
1770
+ ".hatnote",
1771
+ ".shortdescription",
1772
+ ".mw-jump-link",
1773
+ ".printfooter",
1774
+ ".catlinks",
1775
+ "#toc",
1776
+ ".toc",
1777
+ "table.mbox-small",
1778
+ ...COMMON_NOISE
1779
+ ],
1780
+ minLength: 200
1781
+ },
1782
+ {
1783
+ id: "reddit",
1784
+ hosts: /(^|\.)reddit\.com$/,
1785
+ content: ["shreddit-post [slot='text-body']", "[data-test-id='post-content']", ".Post"],
1786
+ remove: ["faceplate-tracker[noun='comment_action_bar']", "shreddit-comment-action-row", ...COMMON_NOISE],
1787
+ // Reddit renders most of a thread into shadow roots that never reach the serialised HTML,
1788
+ // so a weak match here is worse than generic extraction. Decline unless a substantial
1789
+ // post body is actually present in the document.
1790
+ minLength: 600
1791
+ },
1792
+ {
1793
+ id: "docusaurus",
1794
+ fingerprint: (signature) => signature.classes.has("theme-doc-markdown") || signature.classes.has("docusaurus-mt-lg"),
1795
+ content: [".theme-doc-markdown", "article .markdown"],
1796
+ remove: [".theme-doc-toc-mobile", ".pagination-nav", ".theme-doc-version-banner", ...COMMON_NOISE],
1797
+ minLength: 150
1798
+ },
1799
+ {
1800
+ id: "vitepress",
1801
+ fingerprint: (signature) => signature.classes.has("vp-doc") || signature.classes.has("VPDoc"),
1802
+ content: [".vp-doc > div", ".vp-doc", ".VPDoc .content-container"],
1803
+ remove: [".VPDocFooter", ".vp-doc-footer", ".header-anchor", ...COMMON_NOISE],
1804
+ minLength: 150
1805
+ },
1806
+ {
1807
+ id: "mkdocs",
1808
+ fingerprint: (signature) => signature.classes.has("md-content__inner"),
1809
+ content: [".md-content__inner", "[data-md-component='content']"],
1810
+ remove: [".md-source-file", ".md-feedback", ".headerlink", ...COMMON_NOISE],
1811
+ minLength: 150
1812
+ },
1813
+ {
1814
+ id: "readthedocs",
1815
+ fingerprint: (signature) => signature.classes.has("rst-content"),
1816
+ content: [".rst-content", "[role='main']"],
1817
+ remove: [".headerlink", ".rst-footer-buttons", ".wy-breadcrumbs", ...COMMON_NOISE],
1818
+ minLength: 150
1819
+ },
1820
+ {
1821
+ id: "gitbook",
1822
+ fingerprint: (signature) => signature.classes.has("gitbook-root"),
1823
+ content: ["[data-testid='page.contentEditor']", "main article"],
1824
+ remove: COMMON_NOISE,
1825
+ minLength: 150
1826
+ }
1827
+ ];
1828
+ var DECLARATIVE_ADAPTERS = SPECS.map(defineSelectorAdapter);
1829
+
1830
+ // src/adapters/sites/hackernews.ts
1831
+ import { select as select4, selectAll as selectAll2 } from "hast-util-select";
1832
+ var MAX_QUOTE_DEPTH = 6;
1833
+ var text3 = (value) => ({ type: "text", value });
1834
+ var element = (tagName, children) => ({
1835
+ type: "element",
1836
+ tagName,
1837
+ properties: {},
1838
+ children
1839
+ });
1840
+ var commentDepth = (row) => {
1841
+ const cell = select4("td.ind", row);
1842
+ if (!cell) {
1843
+ return 0;
1844
+ }
1845
+ const indent = numericProperty(cell, "indent");
1846
+ if (indent !== void 0) {
1847
+ return indent;
1848
+ }
1849
+ const spacer = select4("img", cell);
1850
+ const width = spacer ? numericProperty(spacer, "width") : void 0;
1851
+ return width === void 0 ? 0 : Math.round(width / 40);
1852
+ };
1853
+ var nest = (content, depth) => {
1854
+ let current = content;
1855
+ for (let level = 0; level < Math.min(depth, MAX_QUOTE_DEPTH); level++) {
1856
+ current = [element("blockquote", current)];
1857
+ }
1858
+ return current[0];
1859
+ };
1860
+ var storyNodes = (root) => {
1861
+ const nodes = [];
1862
+ const titleLink = select4(".titleline > a", root);
1863
+ if (titleLink) {
1864
+ nodes.push(element("h1", [...titleLink.children]));
1865
+ const href = stringProperty(titleLink, "href");
1866
+ if (href && /^https?:/.test(href)) {
1867
+ nodes.push(element("p", [{ ...titleLink, children: [text3(href)] }]));
1868
+ }
1869
+ }
1870
+ const topText = select4(".toptext", root);
1871
+ if (topText) {
1872
+ nodes.push(element("div", [...topText.children]));
1873
+ }
1874
+ return nodes;
1875
+ };
1876
+ var commentNodes = (root) => {
1877
+ const nodes = [];
1878
+ for (const row of selectAll2("tr.comtr, tr.athing.comtr", root)) {
1879
+ const body = select4(".commtext", row);
1880
+ if (!body) {
1881
+ continue;
1882
+ }
1883
+ const author = select4(".hnuser", row);
1884
+ const header = author ? [element("strong", [...author.children])] : [];
1885
+ const content = [
1886
+ ...header.length > 0 ? [element("p", header)] : [],
1887
+ element("div", [...body.children])
1888
+ ];
1889
+ nodes.push(nest(content, commentDepth(row)));
1890
+ }
1891
+ return nodes;
1892
+ };
1893
+ var hackernewsAdapter = {
1894
+ id: "hackernews",
1895
+ matches: ({ url }) => {
1896
+ if (!url) {
1897
+ return false;
1898
+ }
1899
+ try {
1900
+ return /(^|\.)ycombinator\.com$/.test(new URL(url).hostname);
1901
+ } catch {
1902
+ return false;
1903
+ }
1904
+ },
1905
+ matchesFingerprint: ({ signature }) => Boolean(signature?.ids.has("hnmain") || signature?.classes.has("fatitem")),
1906
+ extract: ({ hast }) => {
1907
+ const story = storyNodes(hast);
1908
+ const comments = commentNodes(hast);
1909
+ if (story.length === 0 && comments.length === 0) {
1910
+ return void 0;
1911
+ }
1912
+ const children = [...story];
1913
+ if (comments.length > 0) {
1914
+ children.push(element("h2", [text3("Comments")]));
1915
+ children.push(...comments);
1916
+ }
1917
+ return { type: "root", children };
1918
+ }
1919
+ };
1920
+
1921
+ // src/adapters/sites/youtube.ts
1922
+ var findObjectEnd = (source, start) => {
1923
+ let depth = 0;
1924
+ let inString = false;
1925
+ let escaped = false;
1926
+ for (let index = start; index < source.length; index++) {
1927
+ const character = source[index];
1928
+ if (escaped) {
1929
+ escaped = false;
1930
+ } else if (character === "\\") {
1931
+ escaped = true;
1932
+ } else if (character === '"') {
1933
+ inString = !inString;
1934
+ } else if (!inString) {
1935
+ if (character === "{") {
1936
+ depth += 1;
1937
+ } else if (character === "}" && --depth === 0) {
1938
+ return index + 1;
1939
+ }
1940
+ }
1941
+ }
1942
+ return -1;
1943
+ };
1944
+ var extractAssignedJson = (source, name) => {
1945
+ const markerIndex = source.indexOf(`${name} =`);
1946
+ if (markerIndex === -1) {
1947
+ return void 0;
1948
+ }
1949
+ const start = source.indexOf("{", markerIndex);
1950
+ if (start === -1) {
1951
+ return void 0;
1952
+ }
1953
+ const end = findObjectEnd(source, start);
1954
+ if (end === -1) {
1955
+ return void 0;
1956
+ }
1957
+ try {
1958
+ return JSON.parse(source.slice(start, end));
1959
+ } catch {
1960
+ return void 0;
1961
+ }
1962
+ };
1963
+ var scriptText = (root) => collectElements(root, (element3) => element3.tagName === "script").map(
1964
+ (script) => script.children.map((child) => child.type === "text" ? child.value : "").join("")
1965
+ );
1966
+ var findPlayerResponse = (root) => {
1967
+ for (const source of scriptText(root)) {
1968
+ if (!source.includes("ytInitialPlayerResponse")) {
1969
+ continue;
1970
+ }
1971
+ const parsed = extractAssignedJson(source, "ytInitialPlayerResponse");
1972
+ if (parsed && typeof parsed === "object") {
1973
+ return parsed;
1974
+ }
1975
+ }
1976
+ return void 0;
1977
+ };
1978
+ var youtubeCaptionTracks = (root) => {
1979
+ const player = findPlayerResponse(root);
1980
+ const renderer = player?.captions?.playerCaptionsTracklistRenderer;
1981
+ const tracks = renderer?.captionTracks;
1982
+ if (!Array.isArray(tracks)) {
1983
+ return [];
1984
+ }
1985
+ return tracks.flatMap((track) => {
1986
+ const url = typeof track.baseUrl === "string" ? track.baseUrl : void 0;
1987
+ const languageCode = typeof track.languageCode === "string" ? track.languageCode : void 0;
1988
+ if (!(url && languageCode)) {
1989
+ return [];
1990
+ }
1991
+ const name = track.name?.simpleText;
1992
+ return [
1993
+ {
1994
+ url,
1995
+ languageCode,
1996
+ name: typeof name === "string" ? name : void 0,
1997
+ isAutomatic: track.kind === "asr"
1998
+ }
1999
+ ];
2000
+ });
2001
+ };
2002
+ var text4 = (value) => ({ type: "text", value });
2003
+ var element2 = (tagName, children) => ({
2004
+ type: "element",
2005
+ tagName,
2006
+ properties: {},
2007
+ children
2008
+ });
2009
+ var heading = (depth, value) => element2(`h${depth}`, [text4(value)]);
2010
+ var paragraph = (value) => element2("p", [text4(value)]);
2011
+ var descriptionNodes = (description) => description.split(/\n{2,}/).map((block) => block.trim()).filter((block) => block.length > 0).map(
2012
+ (block) => element2(
2013
+ "p",
2014
+ block.split("\n").flatMap((line, index) => index === 0 ? [text4(line)] : [element2("br", []), text4(line)])
2015
+ )
2016
+ );
2017
+ var formatDuration = (seconds) => {
2018
+ const hours = Math.floor(seconds / 3600);
2019
+ const minutes = Math.floor(seconds % 3600 / 60);
2020
+ const remainder = seconds % 60;
2021
+ const pad = (value) => String(value).padStart(2, "0");
2022
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(remainder)}` : `${minutes}:${pad(remainder)}`;
2023
+ };
2024
+ var metadataList = (details) => {
2025
+ const items = [];
2026
+ if (details.author) {
2027
+ items.push(element2("li", [text4(`Channel: ${details.author}`)]));
2028
+ }
2029
+ const seconds = Number(details.lengthSeconds);
2030
+ if (Number.isFinite(seconds) && seconds > 0) {
2031
+ items.push(element2("li", [text4(`Duration: ${formatDuration(seconds)}`)]));
2032
+ }
2033
+ const views = Number(details.viewCount);
2034
+ if (Number.isFinite(views) && views > 0) {
2035
+ items.push(element2("li", [text4(`Views: ${views.toLocaleString("en-US")}`)]));
2036
+ }
2037
+ return items.length > 0 ? element2("ul", items) : void 0;
2038
+ };
2039
+ var youtubeAdapter = {
2040
+ id: "youtube",
2041
+ matches: ({ url }) => {
2042
+ if (!url) {
2043
+ return false;
2044
+ }
2045
+ try {
2046
+ return /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/.test(new URL(url).hostname);
2047
+ } catch {
2048
+ return false;
2049
+ }
2050
+ },
2051
+ // Scanning every script body for the JSON blob would cost a full traversal on every page, so
2052
+ // the fallback keys off markers the signature already collected.
2053
+ matchesFingerprint: ({ signature }) => Boolean(signature?.ids.has("movie_player") || signature?.classes.has("ytd-app")),
2054
+ extract: ({ hast }) => {
2055
+ const player = findPlayerResponse(hast);
2056
+ const details = player?.videoDetails;
2057
+ if (!details?.title) {
2058
+ return void 0;
2059
+ }
2060
+ const children = [heading(1, details.title)];
2061
+ const metadata = metadataList(details);
2062
+ if (metadata) {
2063
+ children.push(metadata);
2064
+ }
2065
+ const description = details.shortDescription?.trim();
2066
+ if (description) {
2067
+ children.push(heading(2, "Description"));
2068
+ children.push(...descriptionNodes(description));
2069
+ }
2070
+ const tracks = youtubeCaptionTracks(hast);
2071
+ if (tracks.length > 0) {
2072
+ const languages2 = [...new Set(tracks.map((track) => track.languageCode))].sort();
2073
+ children.push(paragraph(`Captions available: ${languages2.join(", ")}`));
2074
+ }
2075
+ return { type: "root", children };
2076
+ }
2077
+ };
2078
+
2079
+ // src/adapters/index.ts
2080
+ var BUILTIN_ADAPTERS = [youtubeAdapter, hackernewsAdapter, ...DECLARATIVE_ADAPTERS];
2081
+ var MIN_ADAPTER_TEXT = 40;
2082
+ var runAdapters = (context, adapters = BUILTIN_ADAPTERS) => {
2083
+ let matched = resolveAdapterByHost(context, adapters);
2084
+ let withSignature = context;
2085
+ if (!matched) {
2086
+ withSignature = { ...context, signature: context.signature ?? buildPageSignature(context.hast) };
2087
+ matched = resolveAdapterByFingerprint(withSignature, adapters);
2088
+ }
2089
+ const adapter = matched;
2090
+ if (!adapter) {
2091
+ return void 0;
2092
+ }
2093
+ const target = withSignature.owned ? withSignature : { ...withSignature, hast: cloneHast(withSignature.hast), owned: true };
2094
+ let extracted;
2095
+ try {
2096
+ extracted = adapter.extract(target);
2097
+ } catch {
2098
+ return void 0;
2099
+ }
2100
+ if (!extracted) {
2101
+ return void 0;
2102
+ }
2103
+ stripNonContent(extracted);
2104
+ return new MetricsCollector().textLength(extracted) >= MIN_ADAPTER_TEXT ? extracted : void 0;
2105
+ };
186
2106
 
187
- // src/extractors/presets/utils.ts
188
- var matchString = (element) => `${element.tagName} ${element.properties.id} ${classnames(element).join(" ")}`;
189
- var classnames = (element) => {
190
- if (Array.isArray(element.properties.className)) {
191
- return element.properties.className;
2107
+ // src/extractors/lib/score.ts
2108
+ var TAG_BASE_SCORE = {
2109
+ div: 5,
2110
+ pre: 3,
2111
+ td: 3,
2112
+ blockquote: 3,
2113
+ article: 10,
2114
+ main: 10,
2115
+ section: 5,
2116
+ address: -3,
2117
+ ol: -3,
2118
+ ul: -3,
2119
+ dl: -3,
2120
+ dd: -3,
2121
+ dt: -3,
2122
+ li: -3,
2123
+ form: -3,
2124
+ h1: -5,
2125
+ h2: -5,
2126
+ h3: -5,
2127
+ h4: -5,
2128
+ h5: -5,
2129
+ h6: -5,
2130
+ th: -5
2131
+ };
2132
+ var MAX_ANCESTOR_DEPTH = 5;
2133
+ var MIN_SCOREABLE_TEXT = 25;
2134
+ var buildScoringContext = (root, collector, lang) => {
2135
+ const parents = /* @__PURE__ */ new WeakMap();
2136
+ const scoreable = [];
2137
+ let hasGeometry = false;
2138
+ walk(root, (node, parent) => {
2139
+ if (parent) {
2140
+ parents.set(node, parent);
2141
+ }
2142
+ if (!isElement(node)) {
2143
+ return;
2144
+ }
2145
+ if (!hasGeometry && numericProperty(node, "data-rheight") !== void 0) {
2146
+ hasGeometry = true;
2147
+ }
2148
+ if (isParagraphLike(node)) {
2149
+ scoreable.push(node);
2150
+ }
2151
+ });
2152
+ return { collector, parents, lang, hasGeometry, scoreable };
2153
+ };
2154
+ var classWeight = (element3) => {
2155
+ let weight = 0;
2156
+ const id = stringProperty(element3, "id") ?? "";
2157
+ const classes = classList(element3).join(" ");
2158
+ if (REGEXPS.negative.test(classes)) {
2159
+ weight -= 25;
192
2160
  }
193
- return [];
2161
+ if (REGEXPS.positive.test(classes)) {
2162
+ weight += 25;
2163
+ }
2164
+ if (REGEXPS.negative.test(id)) {
2165
+ weight -= 25;
2166
+ }
2167
+ if (REGEXPS.positive.test(id)) {
2168
+ weight += 25;
2169
+ }
2170
+ return weight;
194
2171
  };
195
- var isStrInclude = (value, match) => {
196
- if (typeof value === "string") {
197
- return value.includes(match);
2172
+ var semanticBonus = (element3) => {
2173
+ let bonus = 0;
2174
+ if (element3.tagName === "article") {
2175
+ bonus += 60;
198
2176
  }
199
- return false;
2177
+ if (element3.tagName === "main") {
2178
+ bonus += 50;
2179
+ }
2180
+ const role = stringProperty(element3, "role");
2181
+ if (role && CONTENT_ROLES.has(role)) {
2182
+ bonus += 40;
2183
+ }
2184
+ const itemprop = stringProperty(element3, "itemprop");
2185
+ if (itemprop?.includes("articleBody")) {
2186
+ bonus += 80;
2187
+ }
2188
+ const match = matchString(element3);
2189
+ if (/(^|[\s_-])(markdown|prose|article-?content|post-?content|entry-?content|doc-?content|md-?content)([\s_-]|$)/i.test(
2190
+ match
2191
+ )) {
2192
+ bonus += 35;
2193
+ }
2194
+ return bonus;
200
2195
  };
201
-
202
- // src/extractors/presets/takumi.ts
203
- var UNLIKELY_ROLES = ["menu", "menubar", "complementary", "navigation", "alert", "alertdialog", "dialog"];
204
- var REGEXPS = {
205
- hidden: /hidden|invisible|fallback-image/i,
206
- byline: /byline|author|dateline|writtenby|p-author/i,
207
- specialUnlikelyCandidates: /frb-|uls-menu|language-link/i,
208
- unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|tooltip|disqus|extra|footer|gdpr|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote|speechify-ignore|avatar/i,
209
- okMaybeItsaCandidate: /and|article|body|column|content|main|shadow|code/i
210
- };
211
- var BODY_SELECTORS = ["article", "#article", ".article_body", ".article-body", "#content", ".entry"];
212
- var PARAGRAPH_TAGS = ["a", "p", "div", "section", "article", "main", "ul", "ol", "li"];
213
- var CONTENTABLE_TAGS = ["article", "main", "section", "h1", "h2", "h3", "h4", "h5", "h6", "p"];
214
- var TEXTABLE_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ul"];
215
- var BASE_MINIMAL_LENGTH = { ja: 200, en: 500 };
216
- var metadataFilter = (node) => {
217
- return !(["comment", "doctype"].includes(node.type) || node.type === "element" && ["script", "style", "link", "meta", "noscript", "svg", "title"].includes(node.tagName));
218
- };
219
- var universalElementFilter = (node) => {
220
- if (node.type !== "element") {
221
- return true;
2196
+ var geometryMultiplier = (element3, context) => {
2197
+ if (!context.hasGeometry) {
2198
+ return 1;
222
2199
  }
223
- const element = node;
224
- if (["aside", "nav"].includes(element.tagName)) {
225
- return false;
2200
+ const height = numericProperty(element3, "data-rheight");
2201
+ const width = numericProperty(element3, "data-rwidth");
2202
+ if (height === void 0 || width === void 0) {
2203
+ return 1;
226
2204
  }
227
- if (["hidden", "aria-hidden"].some((key) => element.properties[key])) {
228
- return false;
2205
+ if (height === 0 || width === 0) {
2206
+ return 0;
229
2207
  }
230
- if (classnames(element).some((classname) => REGEXPS.hidden.test(classname))) {
231
- return false;
2208
+ if (width < 220) {
2209
+ return 0.35;
232
2210
  }
233
- if (element.tagName === "dialog") {
234
- return false;
2211
+ return 1;
2212
+ };
2213
+ var paragraphScore = (textLength, commas, densityDivisor) => 1 + commas + Math.min(Math.floor(textLength / densityDivisor), 3);
2214
+ var ancestorWeight = (level) => {
2215
+ if (level === 0) {
2216
+ return 1;
235
2217
  }
236
- if (element.properties.role === "dialog" && element.properties["aria-modal"]) {
237
- return false;
2218
+ if (level === 1) {
2219
+ return 2;
238
2220
  }
239
- if (element.properties.rel === "author" && isStrInclude(element.properties.itemprop, "author")) {
2221
+ return level * 3;
2222
+ };
2223
+ var isParagraphLike = (element3) => {
2224
+ if (!SCOREABLE_TAGS.has(element3.tagName)) {
240
2225
  return false;
241
2226
  }
242
- if (REGEXPS.byline.test(matchString(element))) {
243
- return false;
2227
+ if (element3.tagName !== "div" && element3.tagName !== "section") {
2228
+ return true;
244
2229
  }
245
- if (element.properties.role && UNLIKELY_ROLES.includes(element.properties.role)) {
246
- return false;
2230
+ return !element3.children.some((child) => isElement(child) && CANDIDATE_TAGS.has(child.tagName));
2231
+ };
2232
+ var scoreCandidates = (_root, context) => {
2233
+ const { collector, parents } = context;
2234
+ const scores = /* @__PURE__ */ new Map();
2235
+ const densityDivisor = minContentLength(context.lang) / 4;
2236
+ for (const node of context.scoreable) {
2237
+ const metrics = collector.metrics(node);
2238
+ if (metrics.text < MIN_SCOREABLE_TEXT) {
2239
+ continue;
2240
+ }
2241
+ const contribution = paragraphScore(metrics.text, metrics.commas, densityDivisor);
2242
+ let current = node;
2243
+ for (let level = 0; level < MAX_ANCESTOR_DEPTH && current; level++) {
2244
+ const parent = parents.get(current);
2245
+ if (!(parent && isElement(parent))) {
2246
+ break;
2247
+ }
2248
+ if (CANDIDATE_TAGS.has(parent.tagName) || parent.tagName === "article" || parent.tagName === "main") {
2249
+ const base = scores.get(parent) ?? (TAG_BASE_SCORE[parent.tagName] ?? 0) + classWeight(parent) + semanticBonus(parent);
2250
+ scores.set(parent, base + contribution / ancestorWeight(level));
2251
+ }
2252
+ current = parent;
2253
+ }
247
2254
  }
248
- return true;
2255
+ const candidates = [];
2256
+ for (const [element3, rawScore] of scores) {
2257
+ const density = collector.linkDensity(element3);
2258
+ const score = rawScore * (1 - density) * geometryMultiplier(element3, context);
2259
+ if (score > 0) {
2260
+ candidates.push({ element: element3, score });
2261
+ }
2262
+ }
2263
+ candidates.sort((a, b) => b.score - a.score);
2264
+ return candidates;
249
2265
  };
250
- var unlikelyElementFilter = (node) => {
251
- if (node.type !== "element") {
252
- return true;
2266
+ var collectArticleNodes = (top, candidates, context) => {
2267
+ const { collector, parents } = context;
2268
+ const parent = parents.get(top.element);
2269
+ if (!(parent && isElement(parent))) {
2270
+ return [top.element];
253
2271
  }
254
- const element = node;
255
- if (CONTENTABLE_TAGS.includes(element.tagName)) {
256
- return true;
2272
+ const scoreOf = new Map(candidates.map((candidate) => [candidate.element, candidate.score]));
2273
+ const threshold = Math.max(10, top.score * 0.2);
2274
+ const selected = [];
2275
+ for (const child of parent.children) {
2276
+ if (!isElement(child)) {
2277
+ continue;
2278
+ }
2279
+ if (child === top.element) {
2280
+ selected.push(child);
2281
+ continue;
2282
+ }
2283
+ const siblingScore = scoreOf.get(child) ?? 0;
2284
+ if (siblingScore >= threshold) {
2285
+ selected.push(child);
2286
+ continue;
2287
+ }
2288
+ if (child.tagName === "p" || child.tagName === "section") {
2289
+ const metrics = collector.metrics(child);
2290
+ const density = collector.linkDensity(child);
2291
+ if (metrics.text > 80 && density < 0.25) {
2292
+ selected.push(child);
2293
+ }
2294
+ }
257
2295
  }
258
- const match = matchString(element);
259
- if (REGEXPS.specialUnlikelyCandidates.test(match)) {
2296
+ return selected.length > 0 ? selected : [top.element];
2297
+ };
2298
+ var MAX_CLIMB_HOPS = 5;
2299
+ var MIN_CLIMB_RELATIVE_GAIN = 0.08;
2300
+ var MIN_CLIMB_ABSOLUTE_GAIN = 500;
2301
+ var MAX_CLIMB_GAIN_LINK_DENSITY = 1 / 3;
2302
+ var CLIMB_SCORE_FLOOR = 1 / 3;
2303
+ var climbToArticleBoundary = (top, candidates, context) => {
2304
+ const { collector, parents } = context;
2305
+ const scoreOf = new Map(candidates.map((candidate) => [candidate.element, candidate.score]));
2306
+ const scoreFloor = top.score * CLIMB_SCORE_FLOOR;
2307
+ let current = top.element;
2308
+ for (let hop = 0; hop < MAX_CLIMB_HOPS; hop++) {
2309
+ const parent = parents.get(current);
2310
+ if (!(parent && isElement(parent))) {
2311
+ break;
2312
+ }
2313
+ const parentScore = scoreOf.get(parent);
2314
+ if (parentScore === void 0 || parentScore < scoreFloor) {
2315
+ break;
2316
+ }
2317
+ const currentMetrics = collector.metrics(current);
2318
+ const parentMetrics = collector.metrics(parent);
2319
+ const gainedText = parentMetrics.text - currentMetrics.text;
2320
+ if (gainedText < Math.max(MIN_CLIMB_ABSOLUTE_GAIN, currentMetrics.text * MIN_CLIMB_RELATIVE_GAIN)) {
2321
+ break;
2322
+ }
2323
+ const gainedLink = parentMetrics.link - currentMetrics.link;
2324
+ if (gainedLink / gainedText > MAX_CLIMB_GAIN_LINK_DENSITY) {
2325
+ break;
2326
+ }
2327
+ current = parent;
2328
+ }
2329
+ return current;
2330
+ };
2331
+
2332
+ // src/extractors/lib/terminators.ts
2333
+ var TERMINATOR_PATTERNS = [
2334
+ /^was this (page|article|document) helpful/i,
2335
+ /^thanks for the feedback/i,
2336
+ /^(this page was )?last (modified|updated) (on|at|:)?\s/i,
2337
+ /^learn how to contribute/i,
2338
+ /^help (us )?improve\b/i,
2339
+ // The stranded Yes/No pair of a feedback widget whose question was removed separately.
2340
+ /^yes ?no$/i,
2341
+ /^(related (articles?|posts?|stories|content)|you might also like|recommended for you|more from|read next)$/i,
2342
+ /^(comments?|leave a (comment|reply)|join the (conversation|discussion))$/i,
2343
+ /^(この(ページ|記事)は(参考|役に立ち)ました?か)/,
2344
+ /^(関連記事|あわせて読みたい|こちらもおすすめ|おすすめ記事)$/,
2345
+ /^(コメントを(残す|書く)|コメント一覧)$/,
2346
+ /^最終更新日?[::\s]/
2347
+ ];
2348
+ var TAIL_START_FRACTION = 0.8;
2349
+ var MAX_TERMINATOR_BLOCK_LENGTH = 500;
2350
+ var BLOCK_TAGS = /* @__PURE__ */ new Set(["p", "div", "section", "footer", "aside", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol"]);
2351
+ var isTerminatorBlock = (element3, start, tailStart) => {
2352
+ if (!BLOCK_TAGS.has(element3.tagName) || start < tailStart) {
260
2353
  return false;
261
2354
  }
262
- if (REGEXPS.unlikelyCandidates.test(match) && !REGEXPS.okMaybeItsaCandidate.test(match)) {
2355
+ const text6 = textOf(element3).replace(/\s+/g, " ").trim();
2356
+ if (text6.length === 0 || text6.length > MAX_TERMINATOR_BLOCK_LENGTH) {
263
2357
  return false;
264
2358
  }
265
- return true;
2359
+ return TERMINATOR_PATTERNS.some((pattern) => pattern.test(text6));
266
2360
  };
267
- var removeEmptyFilter = (node, _lang) => {
268
- if (node.type !== "element") {
269
- return true;
2361
+ var textOf = (node) => {
2362
+ if (node.type === "text") {
2363
+ return node.value;
270
2364
  }
271
- const element = node;
272
- if (PARAGRAPH_TAGS.includes(element.tagName)) {
273
- return true;
2365
+ if (!isParent(node)) {
2366
+ return "";
2367
+ }
2368
+ let text6 = "";
2369
+ for (const child of node.children) {
2370
+ text6 += textOf(child);
274
2371
  }
275
- if (element.tagName === "img" && !element.properties.src) {
2372
+ return text6;
2373
+ };
2374
+ var truncateTrailingBoilerplate = (tree) => {
2375
+ const starts = /* @__PURE__ */ new Map();
2376
+ let total = 0;
2377
+ const measure = (node) => {
2378
+ starts.set(node, total);
2379
+ if (node.type === "text") {
2380
+ total += node.value.trim().length;
2381
+ return;
2382
+ }
2383
+ if (isParent(node)) {
2384
+ for (const child of node.children) {
2385
+ measure(child);
2386
+ }
2387
+ }
2388
+ };
2389
+ measure(tree);
2390
+ if (total === 0) {
276
2391
  return false;
277
2392
  }
278
- if (TEXTABLE_TAGS.includes(element.tagName) && hastToString(element).length === 0) {
2393
+ const tailStart = total * TAIL_START_FRACTION;
2394
+ let cutoff = -1;
2395
+ const find = (node) => {
2396
+ if (cutoff !== -1 || !isParent(node)) {
2397
+ return;
2398
+ }
2399
+ for (const child of node.children) {
2400
+ if (cutoff !== -1) {
2401
+ return;
2402
+ }
2403
+ if (!isElement(child)) {
2404
+ continue;
2405
+ }
2406
+ const start = starts.get(child) ?? 0;
2407
+ if (isTerminatorBlock(child, start, tailStart)) {
2408
+ cutoff = start;
2409
+ return;
2410
+ }
2411
+ find(child);
2412
+ }
2413
+ };
2414
+ find(tree);
2415
+ if (cutoff === -1) {
279
2416
  return false;
280
2417
  }
2418
+ pruneInPlace(tree, (node) => {
2419
+ if (node === tree) {
2420
+ return true;
2421
+ }
2422
+ const start = starts.get(node);
2423
+ if (start === void 0) {
2424
+ return true;
2425
+ }
2426
+ if (start >= cutoff) {
2427
+ return false;
2428
+ }
2429
+ return true;
2430
+ });
281
2431
  return true;
282
2432
  };
283
- var takumiExtractor = (params) => {
284
- const { hast, lang = "en" } = params;
285
- const body = select("body", hast) ?? hast;
286
- const metadataFilteredHast = filter2(body, (node) => metadataFilter(node));
287
- const metadataFilteredHastText = metadataFilteredHast && hastToString(metadataFilteredHast);
288
- if (!(metadataFilteredHast && metadataFilteredHastText)) {
289
- return body;
290
- }
291
- const baseFilterd = filter2(metadataFilteredHast, (node) => universalElementFilter(node));
292
- const baseFilterdText = baseFilterd ? hastToString(baseFilterd) : "";
293
- const [baseTree, baseText] = baseFilterdText.length > metadataFilteredHastText.length / 3 || baseFilterdText.length > 5e3 ? [baseFilterd, baseFilterdText] : [metadataFilteredHast, metadataFilteredHastText];
294
- let minimalLength = lang in BASE_MINIMAL_LENGTH ? BASE_MINIMAL_LENGTH[lang] : 500;
295
- if (baseText.length < minimalLength) {
296
- minimalLength = Math.max(0, baseText.length - 200);
297
- }
298
- let extractedTree = baseTree;
299
- let extractedText = baseText;
300
- for (const selector of BODY_SELECTORS) {
301
- const content = { type: "root", children: selectAll(selector, baseFilterd) };
302
- const contentText = hastToString(content);
303
- if (contentText.length < 25) {
2433
+
2434
+ // src/extractors/presets/takumi.ts
2435
+ var MIN_RETAINED_FRACTION = 0.25;
2436
+ var SEMANTIC_SELECTORS = [
2437
+ (element3) => stringProperty(element3, "itemprop")?.includes("articleBody") ?? false,
2438
+ (element3) => element3.tagName === "article",
2439
+ (element3) => element3.tagName === "main",
2440
+ (element3) => stringProperty(element3, "role") === "main"
2441
+ ];
2442
+ var findBody = (hast) => findElement(hast, (element3) => element3.tagName === "body") ?? hast;
2443
+ var semanticShortcut = (tree, collector, minLength) => {
2444
+ for (const matches of SEMANTIC_SELECTORS) {
2445
+ const found = [];
2446
+ collectMatching(tree, matches, found);
2447
+ if (found.length !== 1) {
304
2448
  continue;
305
2449
  }
306
- const links = selectAll("a", content);
307
- const linkText = links.map((link) => hastToString(link)).join("");
308
- const linkDensity = linkText.length / contentText.length;
309
- if (linkDensity > 0.4) {
2450
+ const candidate = found[0];
2451
+ const metrics = collector.metrics(candidate);
2452
+ if (metrics.text >= minLength && collector.linkDensity(candidate) < 0.5) {
2453
+ return candidate;
2454
+ }
2455
+ }
2456
+ return void 0;
2457
+ };
2458
+ var collectMatching = (tree, matches, into) => {
2459
+ const stack = [tree];
2460
+ while (stack.length > 0 && into.length < 2) {
2461
+ const node = stack.pop();
2462
+ if (isElement(node) && matches(node)) {
2463
+ into.push(node);
310
2464
  continue;
311
2465
  }
312
- if (contentText.length > minimalLength) {
313
- extractedTree = content;
314
- extractedText = contentText;
315
- break;
2466
+ if ("children" in node) {
2467
+ for (const child of node.children) {
2468
+ stack.push(child);
2469
+ }
316
2470
  }
317
2471
  }
318
- const finalFilteredTree = filter2(extractedTree, (node) => {
319
- if (!removeEmptyFilter(node, lang)) {
320
- return false;
321
- }
322
- if (!unlikelyElementFilter(node)) {
323
- return false;
2472
+ };
2473
+ var asRootOf = (nodes) => ({ type: "root", children: nodes });
2474
+ var scoredSelection = (tree, collector, lang) => {
2475
+ const context = buildScoringContext(tree, collector, lang);
2476
+ const candidates = scoreCandidates(tree, context);
2477
+ const top = candidates[0];
2478
+ if (!top) {
2479
+ return void 0;
2480
+ }
2481
+ const promoted = climbToArticleBoundary(top, candidates, context);
2482
+ const anchor = promoted === top.element ? top : { element: promoted, score: top.score };
2483
+ const selected = collectArticleNodes(anchor, candidates, context);
2484
+ return selected.length === 1 ? selected[0] : asRootOf(selected);
2485
+ };
2486
+ var takumiExtractor = (params) => {
2487
+ const { hast, lang, owned } = params;
2488
+ const body = owned ? findBody(hast) : cloneHast(findBody(hast));
2489
+ stripNonContent(body);
2490
+ let collector = new MetricsCollector();
2491
+ const baseText = collector.textLength(body);
2492
+ if (baseText === 0) {
2493
+ return body;
2494
+ }
2495
+ const minLength = Math.min(minContentLength(lang), Math.max(0, baseText - 200));
2496
+ const shortcut = semanticShortcut(body, collector, minLength);
2497
+ const searchRoot = shortcut ?? body;
2498
+ const searchText = collector.textLength(searchRoot);
2499
+ const doomed = findUnlikelyElements(searchRoot);
2500
+ const doomedText = doomed.reduce((sum, element3) => sum + collector.metrics(element3).text, 0);
2501
+ if (searchText - doomedText > Math.max(minLength, searchText * MIN_RETAINED_FRACTION)) {
2502
+ const doomedSet = new Set(doomed);
2503
+ pruneInPlace(searchRoot, (node) => !(isElement(node) && doomedSet.has(node)));
2504
+ collector = new MetricsCollector();
2505
+ }
2506
+ const scoringRoot = searchRoot;
2507
+ const scoringRootText = collector.textLength(scoringRoot);
2508
+ const selection = shortcut ? scoringRoot : scoredSelection(scoringRoot, collector, lang) ?? scoringRoot;
2509
+ const selectionText = collector.textLength(selection);
2510
+ const selectionIsSound = selectionText >= minLength || selectionText > scoringRootText * MIN_RETAINED_FRACTION;
2511
+ const chosen = selectionIsSound ? selection : scoringRoot;
2512
+ truncateTrailingBoilerplate(chosen);
2513
+ const cleanupCollector = new MetricsCollector();
2514
+ const chosenText = cleanupCollector.textLength(chosen);
2515
+ const widgets = findCleanupTargets(chosen, cleanupCollector);
2516
+ const widgetText = widgets.reduce((sum, element3) => sum + cleanupCollector.metrics(element3).text, 0);
2517
+ const remainingText = chosenText - widgetText;
2518
+ if (remainingText < chosenText * MIN_RETAINED_FRACTION && remainingText < minLength) {
2519
+ return chosen;
2520
+ }
2521
+ return cleanContent(chosen, cleanupCollector);
2522
+ };
2523
+
2524
+ // src/extractors/presets/auto.ts
2525
+ var createAutoExtractor = (options = {}) => {
2526
+ const { adapters } = options;
2527
+ return (params) => {
2528
+ if (adapters !== false) {
2529
+ const extracted = runAdapters({ hast: params.hast, url: params.url, lang: params.lang }, adapters ?? void 0);
2530
+ if (extracted) {
2531
+ return extracted;
2532
+ }
324
2533
  }
325
- return true;
326
- });
327
- const finalTree = hastToString(finalFilteredTree).length > extractedText.length / 3 ? finalFilteredTree : extractedTree;
328
- return finalTree;
2534
+ return takumiExtractor(params);
2535
+ };
329
2536
  };
2537
+ var autoExtractor = createAutoExtractor();
330
2538
 
331
2539
  // src/constants.ts
332
- var DEFAULT_EXTRACTORS = [takumiExtractor];
2540
+ var DEFAULT_EXTRACTORS = [autoExtractor];
333
2541
 
334
2542
  // src/extractors/pipeExtractors.ts
335
2543
  var pipeExtractors = (params, extractors = DEFAULT_EXTRACTORS) => {
336
- const { hast, lang } = params;
337
- const _extractors = Array.isArray(extractors) ? extractors : [extractors];
338
- const extracted = _extractors.reduce((acc, extractor) => {
2544
+ const { hast, lang, url, owned } = params;
2545
+ const list = Array.isArray(extractors) ? extractors : [extractors];
2546
+ let current = hast;
2547
+ let currentOwned = owned ?? false;
2548
+ for (const extractor of list) {
339
2549
  if (extractor === false) {
340
- return acc;
2550
+ continue;
341
2551
  }
342
- if (typeof extractor === "function") {
343
- return extractor({ hast: acc, lang });
2552
+ if (typeof extractor !== "function") {
2553
+ throw new Error(`Invalid extractor: ${String(extractor)}`);
344
2554
  }
345
- throw new Error(`Invalid extractor: ${extractor}`);
346
- }, hast) || hast;
347
- return extracted;
2555
+ current = extractor({ hast: current, lang, url, owned: currentOwned });
2556
+ currentOwned = true;
2557
+ }
2558
+ return current;
2559
+ };
2560
+
2561
+ // src/extractors/presets/minimal-filter.ts
2562
+ var MIN_RETAINED_FRACTION2 = 1 / 3;
2563
+ var ALWAYS_ACCEPT_LENGTH = 5e3;
2564
+ var isChrome = (element3) => {
2565
+ if (element3.tagName === "aside" || element3.tagName === "nav" || element3.tagName === "dialog") {
2566
+ return true;
2567
+ }
2568
+ const role = stringProperty(element3, "role");
2569
+ if (role && UNLIKELY_ROLES.has(role)) {
2570
+ return true;
2571
+ }
2572
+ if (role === "dialog" && isTruthyAttribute(element3, "aria-modal")) {
2573
+ return true;
2574
+ }
2575
+ if (stringProperty(element3, "rel") === "author") {
2576
+ return true;
2577
+ }
2578
+ if (REGEXPS.byline.test(matchString(element3))) {
2579
+ return true;
2580
+ }
2581
+ return false;
2582
+ };
2583
+ var minimalFilter = (params) => {
2584
+ const { hast } = params;
2585
+ const body = cloneHast(findElement(hast, (element3) => element3.tagName === "body") ?? hast);
2586
+ stripNonContent(body);
2587
+ const collector = new MetricsCollector();
2588
+ const baseText = collector.textLength(body);
2589
+ if (baseText === 0) {
2590
+ return body;
2591
+ }
2592
+ const filtered = cloneHast(body);
2593
+ pruneInPlace(filtered, (node) => !(isElement(node) && isChrome(node)));
2594
+ const filteredText = new MetricsCollector().textLength(filtered);
2595
+ const isOverExtracted = filteredText < baseText * MIN_RETAINED_FRACTION2 && filteredText < ALWAYS_ACCEPT_LENGTH;
2596
+ return isOverExtracted ? body : filtered;
348
2597
  };
349
2598
 
350
2599
  // src/mdast-handlers/custom-a-handler.ts
351
2600
  import { defaultHandlers } from "hast-util-to-mdast";
352
- import { toString as hastToString2 } from "hast-util-to-string";
2601
+ import { toString as hastToString } from "hast-util-to-string";
2602
+ var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:", "ftp:", "ftps:"]);
2603
+ var isFollowableHref = (href) => {
2604
+ const trimmed = href?.trim();
2605
+ if (!trimmed) {
2606
+ return false;
2607
+ }
2608
+ const scheme = trimmed.match(/^[a-z][a-z0-9+.-]*:/i)?.[0];
2609
+ if (!scheme) {
2610
+ return true;
2611
+ }
2612
+ return ALLOWED_SCHEMES.has(scheme.toLowerCase());
2613
+ };
353
2614
  var customAHandler = (options) => (state, node) => {
354
- if (options?.asText) {
355
- const text3 = hastToString2(node);
356
- if (3 >= text3.length) {
2615
+ const asPlainText = () => {
2616
+ const text6 = hastToString(node);
2617
+ if (text6.length === 0) {
357
2618
  return void 0;
358
2619
  }
359
- const link2 = { type: "text", value: text3 };
360
- state.patch(node, link2);
361
- return link2;
2620
+ const result = { type: "text", value: text6 };
2621
+ state.patch(node, result);
2622
+ return result;
2623
+ };
2624
+ if (options?.asText) {
2625
+ return hastToString(node).length <= 3 ? void 0 : asPlainText();
362
2626
  }
363
- const link = defaultHandlers.a(state, node);
364
- if (link.children.length > 0) {
365
- return link;
2627
+ if (!isFollowableHref(stringProperty(node, "href"))) {
2628
+ return asPlainText();
366
2629
  }
367
- return void 0;
2630
+ const link = defaultHandlers.a(state, node);
2631
+ return link.children.length > 0 ? link : void 0;
368
2632
  };
369
2633
 
370
2634
  // src/mdast-handlers/custom-code-handler.ts
@@ -435,9 +2699,9 @@ var customCodeHandler = (state, node) => {
435
2699
  };
436
2700
 
437
2701
  // src/mdast-handlers/custom-div-handler.ts
438
- import { select as select2 } from "hast-util-select";
2702
+ import { select as select5 } from "hast-util-select";
439
2703
  import { defaultHandlers as defaultHandlers2 } from "hast-util-to-mdast";
440
- import { toString as hastToString3 } from "hast-util-to-string";
2704
+ import { toString as hastToString2 } from "hast-util-to-string";
441
2705
  import { toText as toText2 } from "hast-util-to-text";
442
2706
  import { trimTrailingLines as trimTrailingLines2 } from "trim-trailing-lines";
443
2707
  var CODE_BLOCK_REGEX = /highlight-source|language-|codegroup|codeblock|code-block/i;
@@ -472,8 +2736,8 @@ var customDivHandler = (state, node) => {
472
2736
  if (codeBlock && classNames.some((className) => CODE_BLOCK_REGEX.test(className))) {
473
2737
  const codeBlockClassNames = codeBlock.type === "element" ? codeBlock.properties.className ?? [] : [];
474
2738
  const codeValue = trimTrailingLines2(toText2(codeBlock)).trim();
475
- const filenameElement = select2(CODE_FILENAME_SELECTORS, node);
476
- const fileLang = filenameElement ? hastToString3(filenameElement).match(/\.(\w+)$/)?.[1] : null;
2739
+ const filenameElement = select5(CODE_FILENAME_SELECTORS, node);
2740
+ const fileLang = filenameElement ? hastToString2(filenameElement).match(/\.(\w+)$/)?.[1] : null;
477
2741
  const classLang = [...classNames, ...codeBlockClassNames].map((className) => {
478
2742
  const match = LANGUAGE_MATCH_REGEX2.map((regex) => className.match(regex)).find((match2) => match2);
479
2743
  return match?.[1];
@@ -488,51 +2752,317 @@ var customDivHandler = (state, node) => {
488
2752
 
489
2753
  // src/mdast-handlers/custom-img-handler.ts
490
2754
  import { defaultHandlers as defaultHandlers3 } from "hast-util-to-mdast";
2755
+ var MAX_DATA_URL_LENGTH = 256;
2756
+ var hasNoUsableSource = (node) => {
2757
+ const src = stringProperty(node, "src")?.trim();
2758
+ if (!src) {
2759
+ return true;
2760
+ }
2761
+ if (src.startsWith("data:")) {
2762
+ return src.length > MAX_DATA_URL_LENGTH;
2763
+ }
2764
+ return false;
2765
+ };
491
2766
  var customImgHandler = (options) => (state, node) => {
492
2767
  if (options?.hideImage) {
493
2768
  return void 0;
494
2769
  }
495
- return defaultHandlers3.image(state, node);
2770
+ if (hasNoUsableSource(node)) {
2771
+ return void 0;
2772
+ }
2773
+ const image = defaultHandlers3.image(state, node);
2774
+ if (!image.alt) {
2775
+ const title = stringProperty(node, "title");
2776
+ if (title) {
2777
+ image.alt = title;
2778
+ }
2779
+ }
2780
+ return image;
496
2781
  };
497
2782
 
498
2783
  // src/mdast-handlers/custom-table-handler.ts
499
2784
  import { defaultHandlers as defaultHandlers4 } from "hast-util-to-mdast";
500
2785
  import { toText as toText3 } from "hast-util-to-text";
2786
+ var MAX_SPAN = 1e3;
2787
+ var clampSpans = (node) => {
2788
+ walk(node, (candidate) => {
2789
+ if (!isElement(candidate) || candidate.tagName !== "td" && candidate.tagName !== "th") {
2790
+ return;
2791
+ }
2792
+ for (const attribute of ["colSpan", "rowSpan"]) {
2793
+ const span = numericProperty(candidate, attribute);
2794
+ if (span !== void 0 && span > MAX_SPAN) {
2795
+ candidate.properties[attribute] = MAX_SPAN;
2796
+ }
2797
+ }
2798
+ });
2799
+ };
501
2800
  var customTableHandler = (options) => (state, node) => {
502
2801
  if (options?.asText) {
503
- const paragraph = { type: "paragraph", children: [{ type: "text", value: toText3(node) }] };
504
- state.patch(node, paragraph);
505
- return paragraph;
2802
+ const paragraph2 = { type: "paragraph", children: [{ type: "text", value: toText3(node) }] };
2803
+ state.patch(node, paragraph2);
2804
+ return paragraph2;
506
2805
  }
2806
+ clampSpans(node);
507
2807
  return defaultHandlers4.table(state, node);
508
2808
  };
509
2809
 
2810
+ // src/mdast-handlers/definition-list-handler.ts
2811
+ var hoistBoundaryWhitespace = (nodes) => {
2812
+ const result = [];
2813
+ for (const node of nodes) {
2814
+ if (node.type !== "emphasis" && node.type !== "strong") {
2815
+ result.push(node);
2816
+ continue;
2817
+ }
2818
+ node.children = hoistBoundaryWhitespace(node.children);
2819
+ const first = node.children[0];
2820
+ if (first?.type === "text" && /^\s/.test(first.value)) {
2821
+ result.push({ type: "text", value: " " });
2822
+ first.value = first.value.replace(/^\s+/, "");
2823
+ }
2824
+ let trailing = false;
2825
+ const last = node.children[node.children.length - 1];
2826
+ if (last?.type === "text" && /\s$/.test(last.value)) {
2827
+ last.value = last.value.replace(/\s+$/, "");
2828
+ trailing = true;
2829
+ }
2830
+ node.children = node.children.filter((child) => !(child.type === "text" && child.value.length === 0));
2831
+ if (node.children.length > 0) {
2832
+ result.push(node);
2833
+ }
2834
+ if (trailing) {
2835
+ result.push({ type: "text", value: " " });
2836
+ }
2837
+ }
2838
+ return result;
2839
+ };
2840
+ var definitionListHandler = (state, node) => {
2841
+ const items = [];
2842
+ let current;
2843
+ const flush = () => {
2844
+ if (!current) {
2845
+ return;
2846
+ }
2847
+ const heading2 = {
2848
+ type: "paragraph",
2849
+ children: current.term.length > 0 ? [{ type: "strong", children: current.term }] : []
2850
+ };
2851
+ items.push({ type: "listItem", spread: false, children: [heading2, ...current.definitions] });
2852
+ current = void 0;
2853
+ };
2854
+ for (const child of node.children) {
2855
+ if (child.type !== "element") {
2856
+ continue;
2857
+ }
2858
+ const element3 = child;
2859
+ if (element3.tagName === "dt") {
2860
+ if (current && current.definitions.length > 0) {
2861
+ flush();
2862
+ }
2863
+ const phrasing = hoistBoundaryWhitespace(state.all(element3));
2864
+ current = current ? { term: [...current.term, { type: "text", value: ", " }, ...phrasing], definitions: [] } : { term: phrasing, definitions: [] };
2865
+ continue;
2866
+ }
2867
+ if (element3.tagName === "dd") {
2868
+ if (!current) {
2869
+ current = { term: [], definitions: [] };
2870
+ }
2871
+ const content = state.toFlow(state.all(element3));
2872
+ current.definitions.push(...content);
2873
+ }
2874
+ }
2875
+ flush();
2876
+ if (items.length === 0) {
2877
+ return void 0;
2878
+ }
2879
+ const result = { type: "list", ordered: false, spread: false, children: items };
2880
+ state.patch(node, result);
2881
+ return result;
2882
+ };
2883
+
510
2884
  // src/mdast-handlers/math-handler.ts
511
2885
  import { toHtml } from "hast-util-to-html";
512
2886
  import { MathMLToLaTeX } from "mathml-to-latex";
2887
+ var readAnnotation = (node) => {
2888
+ const alttext = stringProperty(node, "alttext");
2889
+ if (!alttext) {
2890
+ return void 0;
2891
+ }
2892
+ const unwrapped = alttext.trim().replace(/^\{\\displaystyle\s*([\s\S]*)\}$/, "$1").trim();
2893
+ return unwrapped.length > 0 ? unwrapped : void 0;
2894
+ };
2895
+ var isDisplayMath = (node) => stringProperty(node, "display") === "block";
513
2896
  var mathHandler = (state, node) => {
514
- const mathMl = toHtml(node);
515
- const latex = MathMLToLaTeX.convert(mathMl);
516
- const result = { type: "inlineMath", value: latex };
2897
+ const latex = readAnnotation(node) ?? MathMLToLaTeX.convert(toHtml(node));
2898
+ const result = isDisplayMath(node) ? { type: "math", value: latex, meta: null } : { type: "inlineMath", value: latex };
2899
+ state.patch(node, result);
2900
+ return result;
2901
+ };
2902
+
2903
+ // src/mdast-handlers/sup-sub-handler.ts
2904
+ import { toString as hastToString3 } from "hast-util-to-string";
2905
+ var MAX_ANNOTATION_LENGTH = 32;
2906
+ var supSubHandler = (tagName) => (state, node) => {
2907
+ const inner = hastToString3(node).trim();
2908
+ if (inner.length === 0 || inner.length > MAX_ANNOTATION_LENGTH) {
2909
+ return state.all(node);
2910
+ }
2911
+ const escaped = inner.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2912
+ const result = { type: "html", value: `<${tagName}>${escaped}</${tagName}>` };
517
2913
  state.patch(node, result);
518
2914
  return result;
519
2915
  };
520
2916
 
2917
+ // src/normalize/index.ts
2918
+ var LAZY_SRC_ATTRIBUTES = [
2919
+ "data-src",
2920
+ "data-original",
2921
+ "data-lazy-src",
2922
+ "data-original-src",
2923
+ "data-actualsrc",
2924
+ "data-hi-res-src",
2925
+ "data-echo"
2926
+ ];
2927
+ var PLACEHOLDER_SRC = /^$|^data:image\/|^about:blank$|spacer\.gif$|blank\.(gif|png)$/i;
2928
+ var readSrcset = (element3, attribute) => {
2929
+ const raw = property(element3, attribute);
2930
+ if (typeof raw === "string") {
2931
+ return raw;
2932
+ }
2933
+ if (Array.isArray(raw)) {
2934
+ return raw.join(", ");
2935
+ }
2936
+ return void 0;
2937
+ };
2938
+ var bestFromSrcset = (srcset) => {
2939
+ let best;
2940
+ for (const entry of srcset.split(",")) {
2941
+ const parts = entry.trim().split(/\s+/);
2942
+ const url = parts[0];
2943
+ if (!url) {
2944
+ continue;
2945
+ }
2946
+ const descriptor = parts[1];
2947
+ let weight = 1;
2948
+ if (descriptor?.endsWith("w")) {
2949
+ weight = Number.parseFloat(descriptor) || 1;
2950
+ } else if (descriptor?.endsWith("x")) {
2951
+ weight = (Number.parseFloat(descriptor) || 1) * 1e3;
2952
+ }
2953
+ if (!best || weight > best.weight) {
2954
+ best = { url, weight };
2955
+ }
2956
+ }
2957
+ return best?.url;
2958
+ };
2959
+ var resolveImageSources = (tree) => {
2960
+ walk(tree, (node) => {
2961
+ if (!isElement(node) || node.tagName !== "img") {
2962
+ return;
2963
+ }
2964
+ const current = stringProperty(node, "src");
2965
+ if (current && !PLACEHOLDER_SRC.test(current)) {
2966
+ return;
2967
+ }
2968
+ for (const attribute of LAZY_SRC_ATTRIBUTES) {
2969
+ const candidate = stringProperty(node, attribute);
2970
+ if (candidate && !PLACEHOLDER_SRC.test(candidate)) {
2971
+ node.properties.src = candidate;
2972
+ return;
2973
+ }
2974
+ }
2975
+ for (const attribute of ["srcSet", "srcset", "data-srcset"]) {
2976
+ const srcset = readSrcset(node, attribute);
2977
+ const best = srcset ? bestFromSrcset(srcset) : void 0;
2978
+ if (best) {
2979
+ node.properties.src = best;
2980
+ return;
2981
+ }
2982
+ }
2983
+ });
2984
+ };
2985
+ var katexDuplicates = (node) => {
2986
+ const classes = classList(node);
2987
+ if (!(classes.includes("katex") || classes.includes("katex-display"))) {
2988
+ return [];
2989
+ }
2990
+ const hasMathml = node.children.some((child) => isElement(child) && classList(child).includes("katex-mathml"));
2991
+ if (!hasMathml) {
2992
+ return [];
2993
+ }
2994
+ return node.children.filter((child) => isElement(child) && classList(child).includes("katex-html"));
2995
+ };
2996
+ var mediawikiDuplicates = (node) => {
2997
+ if (!classList(node).includes("mwe-math-element")) {
2998
+ return [];
2999
+ }
3000
+ return node.children.filter(
3001
+ (child) => isElement(child) && classList(child).some((name) => name.startsWith("mwe-math-fallback-image"))
3002
+ );
3003
+ };
3004
+ var mathjaxDuplicates = (node) => {
3005
+ if (node.tagName !== "mjx-container") {
3006
+ return [];
3007
+ }
3008
+ return node.children.filter((child) => isElement(child) && child.tagName !== "mjx-assistive-mml");
3009
+ };
3010
+ var dedupeRenderedMath = (tree) => {
3011
+ const doomed = /* @__PURE__ */ new Set();
3012
+ walk(tree, (node) => {
3013
+ if (!isElement(node)) {
3014
+ return;
3015
+ }
3016
+ for (const duplicate of [...katexDuplicates(node), ...mathjaxDuplicates(node), ...mediawikiDuplicates(node)]) {
3017
+ doomed.add(duplicate);
3018
+ }
3019
+ });
3020
+ if (doomed.size > 0) {
3021
+ pruneInPlace(tree, (node) => !(isElement(node) && doomed.has(node)));
3022
+ }
3023
+ };
3024
+ var promoteAriaHeadings = (tree) => {
3025
+ walk(tree, (node) => {
3026
+ if (!isElement(node) || stringProperty(node, "role") !== "heading") {
3027
+ return;
3028
+ }
3029
+ if (/^h[1-6]$/.test(node.tagName)) {
3030
+ return;
3031
+ }
3032
+ const level = numericProperty(node, "aria-level") ?? 2;
3033
+ const clamped = Math.min(6, Math.max(1, Math.round(level)));
3034
+ node.tagName = `h${clamped}`;
3035
+ });
3036
+ };
3037
+ var normalizeHast = (tree, options = {}) => {
3038
+ const { images = true, math = true, ariaHeadings = true } = options;
3039
+ if (images) {
3040
+ resolveImageSources(tree);
3041
+ }
3042
+ if (math) {
3043
+ dedupeRenderedMath(tree);
3044
+ }
3045
+ if (ariaHeadings) {
3046
+ promoteAriaHeadings(tree);
3047
+ }
3048
+ return tree;
3049
+ };
3050
+
521
3051
  // src/utils/hast-utils.ts
522
- import { select as select3, selectAll as selectAll2 } from "hast-util-select";
3052
+ import { select as select6, selectAll as selectAll3 } from "hast-util-select";
523
3053
  var getLangFromHast = (node) => {
524
- const html = select3("html", node);
3054
+ const html = select6("html", node);
525
3055
  if (html && typeof html.properties.lang === "string") {
526
3056
  return html.properties.lang;
527
3057
  }
528
3058
  if (node.type !== "element") {
529
3059
  return;
530
3060
  }
531
- const element = node;
532
- if (element.tagName !== "html") {
3061
+ const element3 = node;
3062
+ if (element3.tagName !== "html") {
533
3063
  return;
534
3064
  }
535
- const langAttr = element.properties.lang || element.properties["xml:lang"];
3065
+ const langAttr = element3.properties.lang || element3.properties["xml:lang"];
536
3066
  if (langAttr) {
537
3067
  return langAttr;
538
3068
  }
@@ -550,10 +3080,10 @@ var getUrlFromHast = (node) => {
550
3080
  return void 0;
551
3081
  }
552
3082
  const metaTagAttributes = ["og:url", "twitter:url"];
553
- const metaTags = selectAll2("meta", node);
3083
+ const metaTags = selectAll3("meta", node);
554
3084
  for (const meta of metaTags) {
555
- const property = meta.properties.property || meta.properties.name;
556
- if (typeof property === "string" && metaTagAttributes.includes(property)) {
3085
+ const property2 = meta.properties.property || meta.properties.name;
3086
+ if (typeof property2 === "string" && metaTagAttributes.includes(property2)) {
557
3087
  return typeof meta.properties.content === "string" ? meta.properties.content : void 0;
558
3088
  }
559
3089
  }
@@ -564,13 +3094,18 @@ var getUrlFromHast = (node) => {
564
3094
  var htmlToMdast = (htmlOrHast, options) => {
565
3095
  const { extractors, url: defaultUrl, lang: defaultLang } = options || {};
566
3096
  const [lang, hast] = typeof htmlOrHast === "string" ? [defaultLang || getLangFromStr(htmlOrHast), fromHtml(htmlOrHast, { fragment: true })] : [defaultLang || getLangFromHast(htmlOrHast), htmlOrHast];
3097
+ const isOwned = typeof htmlOrHast === "string" || (options?.owned ?? false);
567
3098
  const url = defaultUrl || getUrlFromHast(hast);
568
- const extractedHast = pipeExtractors({ hast, lang, url }, extractors);
3099
+ const extractedHast = pipeExtractors({ hast, lang, url, owned: isOwned }, extractors);
3100
+ normalizeHast(extractedHast, options?.normalize);
569
3101
  const mdast = toMdast(extractedHast, {
570
3102
  handlers: {
571
3103
  math: mathHandler,
572
3104
  div: customDivHandler,
573
3105
  pre: customCodeHandler,
3106
+ dl: definitionListHandler,
3107
+ sup: supSubHandler("sup"),
3108
+ sub: supSubHandler("sub"),
574
3109
  a: customAHandler({ asText: options?.linkAsText }),
575
3110
  img: customImgHandler({ hideImage: options?.hideImage }),
576
3111
  table: customTableHandler({ asText: options?.tableAsText })
@@ -623,222 +3158,241 @@ var mdastToMarkdown = (mdast, options) => {
623
3158
  return markdown;
624
3159
  };
625
3160
 
626
- // src/html-to-markdown.ts
627
- var htmlToMarkdown = (htmlOrHast, options) => {
628
- const { baseUrl, formatting: toMarkdownOptions, ...toMdastOptions } = options || {};
629
- const mdast = htmlToMdast(htmlOrHast, toMdastOptions);
630
- const markdown = mdastToMarkdown(mdast, { baseUrl, ...toMarkdownOptions });
631
- return markdown;
3161
+ // src/metadata/index.ts
3162
+ var readMetaTags = (tree) => {
3163
+ const tags = /* @__PURE__ */ new Map();
3164
+ for (const meta of collectElements(tree, (element3) => element3.tagName === "meta")) {
3165
+ const key = stringProperty(meta, "property") ?? stringProperty(meta, "name") ?? stringProperty(meta, "itemprop");
3166
+ const content = stringProperty(meta, "content");
3167
+ if (!(key && content)) {
3168
+ continue;
3169
+ }
3170
+ const normalized = key.toLowerCase();
3171
+ if (!tags.has(normalized)) {
3172
+ tags.set(normalized, content);
3173
+ }
3174
+ }
3175
+ return tags;
3176
+ };
3177
+ var readJsonLd = (tree) => {
3178
+ const entries = [];
3179
+ for (const script of collectElements(tree, (element3) => element3.tagName === "script")) {
3180
+ const type = stringProperty(script, "type");
3181
+ if (type?.toLowerCase() !== "application/ld+json") {
3182
+ continue;
3183
+ }
3184
+ const source = script.children.map((child) => child.type === "text" ? child.value : "").join("").trim();
3185
+ if (source.length === 0) {
3186
+ continue;
3187
+ }
3188
+ let parsed;
3189
+ try {
3190
+ parsed = JSON.parse(source);
3191
+ } catch {
3192
+ continue;
3193
+ }
3194
+ for (const entry of flattenJsonLd(parsed)) {
3195
+ entries.push(entry);
3196
+ }
3197
+ }
3198
+ return entries;
3199
+ };
3200
+ var flattenJsonLd = (value) => {
3201
+ if (Array.isArray(value)) {
3202
+ return value.flatMap(flattenJsonLd);
3203
+ }
3204
+ if (typeof value !== "object" || value === null) {
3205
+ return [];
3206
+ }
3207
+ const record = value;
3208
+ const graph = record["@graph"];
3209
+ if (graph) {
3210
+ return flattenJsonLd(graph);
3211
+ }
3212
+ return [record];
632
3213
  };
633
-
634
- // src/cli/helpers/inputOutputPath.ts
635
- import fs2 from "node:fs";
636
- import { confirm, text } from "@clack/prompts";
637
-
638
- // src/cli/utils.ts
639
- import fs from "node:fs";
640
- import path from "node:path";
641
- var isUrl = (maybeUrl) => {
642
- try {
643
- new URL(maybeUrl);
644
- return true;
645
- } catch {
646
- return false;
3214
+ var ARTICLE_TYPES = /* @__PURE__ */ new Set([
3215
+ "article",
3216
+ "newsarticle",
3217
+ "blogposting",
3218
+ "techarticle",
3219
+ "scholarlyarticle",
3220
+ "report",
3221
+ "webpage"
3222
+ ]);
3223
+ var jsonLdType = (entry) => {
3224
+ const type = entry["@type"];
3225
+ if (typeof type === "string") {
3226
+ return [type.toLowerCase()];
3227
+ }
3228
+ if (Array.isArray(type)) {
3229
+ return type.filter((value) => typeof value === "string").map((value) => value.toLowerCase());
647
3230
  }
3231
+ return [];
648
3232
  };
649
- function changeFileExtension(filePath, newExtension) {
650
- const parsedPath = filePath.split("/");
651
- const fileName = parsedPath[parsedPath.length - 1];
652
- const formattedNewExtension = newExtension.startsWith(".") ? newExtension : `.${newExtension}`;
653
- if (fileName.startsWith(".")) {
654
- const parts = fileName.split(".");
655
- if (parts.length === 2) {
656
- return parsedPath.slice(0, -1).concat(`${fileName}${formattedNewExtension}`).join("/");
657
- }
658
- parts[parts.length - 1] = newExtension.replace(/^\./, "");
659
- return parsedPath.slice(0, -1).concat(parts.join(".")).join("/");
3233
+ var readName = (value) => {
3234
+ if (typeof value === "string") {
3235
+ return value;
660
3236
  }
661
- const lastDotIndex = fileName.lastIndexOf(".");
662
- const baseName = lastDotIndex !== -1 ? fileName.slice(0, lastDotIndex) : fileName;
663
- const newFileName = `${baseName}${formattedNewExtension}`;
664
- parsedPath[parsedPath.length - 1] = newFileName;
665
- return parsedPath.join("/");
666
- }
667
- function urlToFilename(url) {
668
- try {
669
- const urlObj = new URL(url);
670
- const domainParts = urlObj.hostname.split(".").reverse().reduce((acc, part, index) => {
671
- if (index === 0) {
672
- return acc;
673
- }
674
- if (acc.length >= 2) {
675
- return acc;
676
- }
677
- if (part === "www") {
678
- return acc;
3237
+ if (Array.isArray(value)) {
3238
+ for (const entry of value) {
3239
+ const name = readName(entry);
3240
+ if (name) {
3241
+ return name;
679
3242
  }
680
- return [part, ...acc];
681
- }, []);
682
- const domainString = domainParts.reverse().join("-");
683
- const pathParts = urlObj.pathname.split("/").filter(Boolean);
684
- const relevantPathParts = pathParts.slice(-2);
685
- const pathString = relevantPathParts.map((part) => decodeURIComponent(part)).join("-");
686
- let filename = [domainString, pathString].filter(Boolean).join("-");
687
- filename = filename.toLowerCase().replace(/[<>:"/\\|?*\x00-\x1F]/g, "").replace(/[\s.]+/g, "-").replace(/^-+|-+$/g, "");
688
- return filename || "output";
689
- } catch {
690
- return "output";
3243
+ }
3244
+ return void 0;
691
3245
  }
692
- }
693
- var sourcePathToOutputPath = (sourcePath) => {
694
- return isUrl(sourcePath) ? `${urlToFilename(sourcePath)}.md` : changeFileExtension(sourcePath, "md");
3246
+ if (typeof value === "object" && value !== null) {
3247
+ const name = value.name;
3248
+ return typeof name === "string" ? name : void 0;
3249
+ }
3250
+ return void 0;
695
3251
  };
696
- function getNextAvailableFilePath(filePath) {
697
- const parsedPath = path.parse(filePath);
698
- const directory = parsedPath.dir;
699
- const fullName = parsedPath.base;
700
- const [firstPart, ...restParts] = fullName.split(".");
701
- const restName = restParts.length > 0 ? `.${restParts.join(".")}` : "";
702
- const baseName = firstPart.replace(/_\d+$/, "");
703
- let counter = 1;
704
- let nextFilePath = filePath;
705
- while (fs.existsSync(nextFilePath)) {
706
- const match = firstPart.match(/_(\d+)$/);
707
- if (match) {
708
- counter = Number.parseInt(match[1], 10) + 1;
709
- }
710
- const newName = `${baseName}_${counter}${restName}`;
711
- nextFilePath = path.join(directory, newName);
712
- counter++;
3252
+ var asString = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
3253
+ var MAX_TITLE_LENGTH = 160;
3254
+ var asTitle = (value) => {
3255
+ const cleaned = clean(value);
3256
+ return cleaned && cleaned.length <= MAX_TITLE_LENGTH ? cleaned : void 0;
3257
+ };
3258
+ var clean = (value) => {
3259
+ if (value === void 0) {
3260
+ return void 0;
713
3261
  }
714
- return nextFilePath;
715
- }
716
-
717
- // src/cli/helpers/assertContinue.ts
718
- import { cancel, isCancel } from "@clack/prompts";
719
- function assertContinue(message, cancelMessage = "Canceled.") {
720
- if (isCancel(message)) {
721
- cancel(cancelMessage);
722
- process.exit(1);
3262
+ const trimmed = value.replace(/\s+/g, " ").trim();
3263
+ return trimmed.length > 0 ? trimmed : void 0;
3264
+ };
3265
+ var documentTitle = (tree) => {
3266
+ const title = findElement(tree, (element3) => element3.tagName === "title");
3267
+ if (!title) {
3268
+ return void 0;
723
3269
  }
724
- }
725
-
726
- // src/cli/helpers/inputOutputPath.ts
727
- var inputOutputPath = async (sourcePath) => {
728
- const outputPath = await text({
729
- message: "Enter the output file path:",
730
- placeholder: "output.md",
731
- initialValue: sourcePathToOutputPath(sourcePath),
732
- validate: (value) => {
733
- if (value.trim() === "") {
734
- return "Output path is required";
735
- }
736
- if (fs2.existsSync(value) && fs2.statSync(value).isDirectory()) {
737
- return "No directory can be specified.";
738
- }
739
- if (!fs2.existsSync(value) && value.endsWith("/")) {
740
- return "No directory can be specified.";
741
- }
3270
+ return title.children.map((child) => child.type === "text" ? child.value : "").join("");
3271
+ };
3272
+ var firstHeading = (tree) => {
3273
+ const heading2 = findElement(tree, (element3) => element3.tagName === "h1");
3274
+ if (!heading2) {
3275
+ return void 0;
3276
+ }
3277
+ let text6 = "";
3278
+ walk(heading2, (node) => {
3279
+ if (node.type === "text") {
3280
+ text6 += node.value;
742
3281
  }
743
3282
  });
744
- assertContinue(outputPath);
745
- if (!fs2.existsSync(outputPath)) {
746
- return outputPath;
3283
+ return text6;
3284
+ };
3285
+ var linkHref = (tree, rel) => {
3286
+ const link = findElement(
3287
+ tree,
3288
+ (element3) => element3.tagName === "link" && (stringProperty(element3, "rel") ?? "").toLowerCase().includes(rel)
3289
+ );
3290
+ return link ? stringProperty(link, "href") : void 0;
3291
+ };
3292
+ var documentLang = (tree) => {
3293
+ const html = findElement(tree, (element3) => element3.tagName === "html");
3294
+ if (html) {
3295
+ const declared = stringProperty(html, "lang") ?? stringProperty(html, "xml:lang");
3296
+ if (declared) {
3297
+ return declared;
3298
+ }
747
3299
  }
748
- const isOutputFileOverwrite = await confirm({
749
- message: "The file already exists. Overwrite?",
750
- initialValue: false
751
- });
752
- assertContinue(isOutputFileOverwrite);
753
- if (isOutputFileOverwrite) {
754
- return outputPath;
3300
+ if (isElement(tree)) {
3301
+ const own = stringProperty(tree, "lang");
3302
+ if (own) {
3303
+ return own;
3304
+ }
755
3305
  }
756
- const escapedOutputPath = await text({
757
- message: "Enter the output file path:",
758
- placeholder: "output.md",
759
- initialValue: getNextAvailableFilePath(outputPath),
760
- validate: (value) => {
761
- if (value.trim() === "") {
762
- return "Output path is required";
763
- }
764
- if (fs2.existsSync(value)) {
765
- return "The file already exists";
766
- }
767
- if (!fs2.existsSync(value) && value.endsWith("/")) {
768
- return "No directory can be specified.";
3306
+ const tagged = findElement(tree, (element3) => Boolean(stringProperty(element3, "lang")));
3307
+ return tagged ? stringProperty(tagged, "lang") : void 0;
3308
+ };
3309
+ var extractMetadata = (tree) => {
3310
+ const meta = readMetaTags(tree);
3311
+ const jsonLd = readJsonLd(tree);
3312
+ const article = jsonLd.find((entry) => jsonLdType(entry).some((type) => ARTICLE_TYPES.has(type))) ?? jsonLd[0];
3313
+ const get = (...keys) => {
3314
+ for (const key of keys) {
3315
+ const value = meta.get(key);
3316
+ if (value) {
3317
+ return value;
769
3318
  }
770
3319
  }
771
- });
772
- assertContinue(escapedOutputPath);
773
- return escapedOutputPath;
3320
+ return void 0;
3321
+ };
3322
+ const title = asTitle(get("og:title", "twitter:title")) ?? asTitle(asString(article?.headline)) ?? asTitle(asString(article?.name)) ?? asTitle(documentTitle(tree)) ?? asTitle(firstHeading(tree));
3323
+ return {
3324
+ title,
3325
+ description: clean(get("og:description", "twitter:description", "description")) ?? clean(asString(article?.description)),
3326
+ author: clean(readName(article?.author)) ?? clean(get("author", "article:author", "twitter:creator")),
3327
+ published: clean(asString(article?.datePublished)) ?? clean(get("article:published_time", "date", "pubdate")),
3328
+ modified: clean(asString(article?.dateModified)) ?? clean(get("article:modified_time", "og:updated_time")),
3329
+ siteName: clean(get("og:site_name", "application-name")) ?? clean(readName(article?.publisher)),
3330
+ canonicalUrl: clean(linkHref(tree, "canonical")) ?? clean(get("og:url", "twitter:url")),
3331
+ lang: clean(documentLang(tree)),
3332
+ image: clean(get("og:image", "twitter:image", "twitter:image:src")),
3333
+ type: clean(get("og:type"))
3334
+ };
774
3335
  };
775
-
776
- // src/cli/helpers/inputSourcePath.ts
777
- import fs3 from "node:fs";
778
- import { text as text2 } from "@clack/prompts";
779
-
780
- // src/cli/constants.ts
781
- var DEFAULT_PATH = "https://example.com";
782
- var LOADERS = ["fetch", "playwright"];
783
- var MODES = ["default", "ai"];
784
-
785
- // src/cli/helpers/inputSourcePath.ts
786
- var inputSourcePath = async () => {
787
- const result = await text2({
788
- message: "Enter the URL or html path to be converted to markdown:",
789
- placeholder: DEFAULT_PATH,
790
- initialValue: "",
791
- validate: (value) => {
792
- if (value.trim() === "") {
793
- return "Source is required";
794
- }
795
- if (!isUrl(value)) {
796
- if (!fs3.existsSync(value)) {
797
- 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.";
798
- }
799
- if (fs3.statSync(value).isDirectory()) {
800
- return "You are specifying a local file, but you cannot specify a directory. hint: when specifying a url, start with http or https.";
801
- }
802
- }
3336
+ var yamlString = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3337
+ var toFrontmatter = (metadata) => {
3338
+ const lines = [];
3339
+ for (const [key, value] of Object.entries(metadata)) {
3340
+ if (typeof value === "string" && value.length > 0) {
3341
+ lines.push(`${key}: ${yamlString(value)}`);
803
3342
  }
804
- });
805
- assertContinue(result);
806
- return result;
3343
+ }
3344
+ if (lines.length === 0) {
3345
+ return "";
3346
+ }
3347
+ return `---
3348
+ ${lines.join("\n")}
3349
+ ---
3350
+
3351
+ `;
807
3352
  };
808
3353
 
809
- // src/cli/helpers/selectExtractMode.ts
810
- import { select as select4 } from "@clack/prompts";
811
- var selectExtractMode = async () => {
812
- const result = await select4({
813
- message: "Select processing mode:",
814
- options: MODES.map((mode) => ({ value: mode, label: mode })),
815
- initialValue: MODES[0]
3354
+ // src/html-to-markdown.ts
3355
+ var htmlToMarkdownWithMetadata = (htmlOrHast, options) => {
3356
+ const { baseUrl, frontmatter, title, formatting: toMarkdownOptions, ...toMdastOptions } = options || {};
3357
+ const hast = typeof htmlOrHast === "string" ? fromHtml2(htmlOrHast, { fragment: true }) : htmlOrHast;
3358
+ const isOwned = typeof htmlOrHast === "string";
3359
+ const declaredLang = typeof htmlOrHast === "string" ? getLangFromStr(htmlOrHast) : void 0;
3360
+ const metadata = extractMetadata(hast);
3361
+ if (declaredLang && !metadata.lang) {
3362
+ metadata.lang = declaredLang;
3363
+ }
3364
+ const mdast = htmlToMdast(hast, {
3365
+ ...toMdastOptions,
3366
+ owned: isOwned,
3367
+ lang: toMdastOptions.lang ?? metadata.lang,
3368
+ url: toMdastOptions.url ?? metadata.canonicalUrl
816
3369
  });
817
- assertContinue(result);
818
- return result;
3370
+ const body = mdastToMarkdown(mdast, { baseUrl, ...toMarkdownOptions });
3371
+ const titled = title === false ? body : withTitle(body, metadata.title);
3372
+ const markdown = frontmatter ? `${toFrontmatter(metadata)}${titled}` : titled;
3373
+ return { markdown, metadata };
819
3374
  };
3375
+ var TITLE_LOOKAHEAD = 400;
3376
+ var withTitle = (body, title) => {
3377
+ if (!title) {
3378
+ return body;
3379
+ }
3380
+ const opening = body.slice(0, TITLE_LOOKAHEAD);
3381
+ if (/^#\s+\S/m.test(opening)) {
3382
+ return body;
3383
+ }
3384
+ if (normalizeForCompare(opening).includes(normalizeForCompare(title))) {
3385
+ return body;
3386
+ }
3387
+ return `# ${title}
820
3388
 
821
- // src/cli/helpers/selectLoader.ts
822
- import { select as select5 } from "@clack/prompts";
823
- var loadersHint = {
824
- fetch: "Fetch HTML content from the given URL",
825
- playwright: "Retrieve HTML content after rendering using Playwright; Playwright must be installed in advance.",
826
- puppeteer: "Retrieve HTML content after rendering using Puppeteer; Puppeteer must be installed in advance."
827
- };
828
- var selectLoader = async () => {
829
- const result = await select5({
830
- message: "Select loader:",
831
- initialValue: "fetch",
832
- options: LOADERS.map((mode) => ({ value: mode, label: mode, hint: loadersHint[mode] || "" }))
833
- });
834
- assertContinue(result);
835
- return result;
3389
+ ${body}`;
836
3390
  };
3391
+ var normalizeForCompare = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
837
3392
 
838
3393
  // src/cli/commands/webforai/loadHtml.ts
839
- import fs4 from "node:fs/promises";
3394
+ import fs5 from "node:fs/promises";
840
3395
  import { fileURLToPath } from "node:url";
841
- import { log } from "@clack/prompts";
842
3396
  import boxen from "boxen";
843
3397
  import pc from "picocolors";
844
3398
  import { chromium as chromium2 } from "playwright-core";
@@ -885,10 +3439,10 @@ var loadHtml2 = async (url, options) => {
885
3439
  await page.goto(url, { waitUntil: waitUntil ?? "load", timeout });
886
3440
  await page.evaluate(() => {
887
3441
  const elements = document.querySelectorAll("*");
888
- for (const element of elements) {
889
- const rect = element.getBoundingClientRect();
890
- element.setAttribute("data-rwidth", rect.width.toString());
891
- element.setAttribute("data-rheight", rect.height.toString());
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());
892
3446
  }
893
3447
  });
894
3448
  const html = await page.content();
@@ -902,37 +3456,42 @@ var loadHtml2 = async (url, options) => {
902
3456
  };
903
3457
 
904
3458
  // src/cli/commands/webforai/loadHtml.ts
3459
+ var debugLog = (enabled, message) => {
3460
+ if (enabled) {
3461
+ console.error(`[webforai] ${message}`);
3462
+ }
3463
+ };
905
3464
  var checkPlaywrightAvailable = async () => {
906
- const path3 = chromium2.executablePath();
3465
+ const path4 = chromium2.executablePath();
907
3466
  try {
908
- await fs4.access(path3);
3467
+ await fs5.access(path4);
909
3468
  return true;
910
3469
  } catch {
911
3470
  return false;
912
3471
  }
913
3472
  };
914
3473
  var getPlaywrightVersion = async () => {
915
- const path3 = await import.meta.resolve("playwright-core/package.json");
916
- const pwPackageJson = await fs4.readFile(fileURLToPath(path3), "utf-8").then((res) => JSON.parse(res.toString())).catch(() => null);
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);
917
3476
  return pwPackageJson?.version;
918
3477
  };
919
3478
  var loadHtml3 = async (sourcePath, loader, options) => {
920
3479
  if (loader === "local") {
921
- options.debug && log.info(`Loading HTML from local file: ${sourcePath}`);
922
- const content = await fs4.readFile(sourcePath, "utf-8");
923
- options.debug && log.info(`HTML loaded: ${content.slice(0, 100)}`);
3480
+ debugLog(options.debug, `Loading HTML from local file: ${sourcePath}`);
3481
+ const content = await fs5.readFile(sourcePath, "utf-8");
3482
+ debugLog(options.debug, `HTML loaded: ${content.slice(0, 100)}`);
924
3483
  return content;
925
3484
  }
926
3485
  if (loader === "fetch") {
927
- options.debug && log.info(`Loading HTML from URL: ${sourcePath}`);
3486
+ debugLog(options.debug, `Loading HTML from URL: ${sourcePath}`);
928
3487
  const content = await loadHtml(sourcePath);
929
- options.debug && log.info(`HTML loaded: ${content.slice(0, 100)}`);
3488
+ debugLog(options.debug, `HTML loaded: ${content.slice(0, 100)}`);
930
3489
  return content;
931
3490
  }
932
3491
  if (loader === "playwright") {
933
- options.debug && log.info(`Loading HTML from playwright: ${sourcePath}`);
3492
+ debugLog(options.debug, `Loading HTML with playwright: ${sourcePath}`);
934
3493
  const isPlaywrightAvailable = await checkPlaywrightAvailable();
935
- options.debug && log.info(`Playwright available: ${isPlaywrightAvailable}`);
3494
+ debugLog(options.debug, `Playwright available: ${isPlaywrightAvailable}`);
936
3495
  const pwVersion = await getPlaywrightVersion();
937
3496
  if (!isPlaywrightAvailable) {
938
3497
  const message = [
@@ -952,56 +3511,297 @@ var loadHtml3 = async (sourcePath, loader, options) => {
952
3511
  "",
953
3512
  ` sudo npx playwright@${pwVersion} install-deps`
954
3513
  ];
955
- log.error(boxen(message.join("\n"), { padding: 1, borderStyle: "round" }));
3514
+ console.error(boxen(message.join("\n"), { padding: 1, borderStyle: "round" }));
956
3515
  throw new Error("Playwright is not available");
957
3516
  }
958
3517
  const content = await loadHtml2(sourcePath);
959
- options.debug && log.info(`HTML loaded: ${content.slice(0, 100)}`);
3518
+ debugLog(options.debug, `HTML loaded: ${content.slice(0, 100)}`);
960
3519
  return content;
961
3520
  }
962
3521
  throw new Error(`Unsupported loader: ${loader}`);
963
3522
  };
964
3523
 
965
- // src/cli/commands/webforai/index.ts
966
- var aiModeOptions = { linkAsText: true, tableAsText: true, hideImage: true };
967
- var readabilityModeOptions = { linkAsText: false, tableAsText: false, hideImage: false };
968
- var webforaiCommand = async (initialPath, options) => {
3524
+ // src/cli/commands/webforai/run.ts
3525
+ var debugLog2 = (enabled, message) => {
3526
+ if (enabled) {
3527
+ console.error(`[webforai] ${message}`);
3528
+ }
3529
+ };
3530
+ var AI_MODE_OPTIONS = { linkAsText: true, tableAsText: true, hideImage: true };
3531
+ var extractorOptions = (name) => {
3532
+ switch (name) {
3533
+ case "takumi":
3534
+ return takumiExtractor;
3535
+ case "minimal":
3536
+ return minimalFilter;
3537
+ case "none":
3538
+ return false;
3539
+ default:
3540
+ return void 0;
3541
+ }
3542
+ };
3543
+ var convertViaPlatform = async (run) => {
3544
+ const platform = createPlatformClient({ apiKey: run.apiKey, baseUrl: run.platformUrl });
3545
+ if (run.mode !== "default") {
3546
+ console.error("[webforai] --mode is applied by local conversion only and is ignored by the platform loader");
3547
+ }
3548
+ debugLog2(run.debug, `platform scrape: ${run.source} (engine=${run.engine ?? "fetch"})`);
3549
+ const result = await platform.scrape({
3550
+ url: run.source,
3551
+ engine: run.engine,
3552
+ region: run.region,
3553
+ screenshot: run.screenshot ? true : void 0,
3554
+ convert: { extractor: run.extractor, frontmatter: run.frontmatter }
3555
+ });
3556
+ return {
3557
+ source: run.source,
3558
+ loader: "platform",
3559
+ url: result.url,
3560
+ engine: result.engine,
3561
+ region: run.region,
3562
+ markdown: result.markdown,
3563
+ metadata: result.metadata,
3564
+ credits: result.credits,
3565
+ screenshotUrl: result.screenshotUrl
3566
+ };
3567
+ };
3568
+ var convertLocally = async (run) => {
3569
+ const html = await loadHtml3(run.source, run.loader, { debug: run.debug });
3570
+ const sourceUrl = isUrl(run.source) ? run.source : void 0;
3571
+ const { markdown, metadata } = htmlToMarkdownWithMetadata(html, {
3572
+ baseUrl: sourceUrl,
3573
+ url: sourceUrl,
3574
+ frontmatter: run.frontmatter,
3575
+ extractors: extractorOptions(run.extractor),
3576
+ ...run.mode === "ai" ? AI_MODE_OPTIONS : {}
3577
+ });
3578
+ return {
3579
+ source: run.source,
3580
+ loader: run.loader,
3581
+ url: sourceUrl,
3582
+ markdown,
3583
+ metadata
3584
+ };
3585
+ };
3586
+ var executeRun = (run) => run.loader === "platform" ? convertViaPlatform(run) : convertLocally(run);
3587
+ var writeOutputFile = async (outputPath, markdown) => {
3588
+ await fs6.mkdir(path3.dirname(path3.resolve(outputPath)), { recursive: true });
3589
+ await fs6.writeFile(outputPath, markdown);
3590
+ };
3591
+ var runCommand = async (run) => {
3592
+ const envelope = await executeRun(run);
3593
+ if (run.output) {
3594
+ await writeOutputFile(run.output, envelope.markdown);
3595
+ envelope.output = run.output;
3596
+ console.error(`[webforai] markdown written to ${run.output}`);
3597
+ }
3598
+ if (run.json) {
3599
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}
3600
+ `);
3601
+ return;
3602
+ }
3603
+ if (!run.output) {
3604
+ process.stdout.write(envelope.markdown.endsWith("\n") ? envelope.markdown : `${envelope.markdown}
3605
+ `);
3606
+ }
3607
+ if (envelope.screenshotUrl) {
3608
+ console.error(`[webforai] screenshot (expires ~24h): ${envelope.screenshotUrl}`);
3609
+ }
3610
+ if (envelope.credits !== void 0) {
3611
+ console.error(`[webforai] credits used: ${envelope.credits}`);
3612
+ }
3613
+ };
3614
+
3615
+ // src/cli/commands/webforai/interactive.ts
3616
+ var ENGINE_HINTS = {
3617
+ 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"
3621
+ };
3622
+ var promptPlatformOptions = async (flags, env) => {
3623
+ const engine = flags.engine ?? await select7({
3624
+ message: "Select platform engine:",
3625
+ initialValue: "fetch",
3626
+ options: ENGINES.map((value) => ({ value, label: value, hint: ENGINE_HINTS[value] }))
3627
+ });
3628
+ assertContinue(engine);
3629
+ const region = flags.region ?? await select7({
3630
+ message: "Select egress region (proxy engines only):",
3631
+ initialValue: "auto",
3632
+ options: REGIONS.map((value) => ({ value, label: value }))
3633
+ });
3634
+ assertContinue(region);
3635
+ let apiKey = flags.apiKey ?? env[API_KEY_ENV];
3636
+ if (!apiKey) {
3637
+ const entered = await text5({
3638
+ message: `Enter your platform API key (or set ${API_KEY_ENV}):`,
3639
+ placeholder: "wfa_...",
3640
+ validate: (value) => value.trim() === "" ? "An API key is required for the platform loader" : void 0
3641
+ });
3642
+ assertContinue(entered);
3643
+ apiKey = entered;
3644
+ }
3645
+ return { engine, region, apiKey };
3646
+ };
3647
+ var interactiveCommand = async (initialSource, flags, env = process.env) => {
969
3648
  intro(pc2.bold(pc2.green(`webforai CLI version ${package_default.version}`)));
970
- const sourcePath = initialPath ?? await inputSourcePath();
971
- options.debug && log2.info(`sourcePath: ${sourcePath}`);
972
- const loader = isUrl(sourcePath) ? options.loader ?? await selectLoader() : "local";
973
- options.debug && log2.info(`loader: ${loader}`);
974
- const outputPath = options.output ?? await inputOutputPath(sourcePath);
975
- options.debug && log2.info(`outputPath: ${outputPath}`);
976
- const mode = options.mode ?? await selectExtractMode();
977
- options.debug && log2.info(`mode: ${mode}`);
978
- let html;
3649
+ 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
3660
+ };
3661
+ if (loader === "platform") {
3662
+ 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;
3667
+ } else {
3668
+ run.mode = flags.mode ?? await selectExtractMode();
3669
+ }
3670
+ const outputPath = flags.output ?? await inputOutputPath(source);
979
3671
  const s = spinner();
3672
+ s.start(loader === "platform" ? "Converting via the platform..." : "Loading and converting...");
3673
+ let markdown;
980
3674
  try {
981
- s.start("Loading content...");
982
- html = await loadHtml3(sourcePath, loader, { debug: options.debug });
983
- s.stop(pc2.green("Content loaded!"));
3675
+ const envelope = await executeRun(run);
3676
+ markdown = envelope.markdown;
3677
+ s.stop(pc2.green("Converted!"));
3678
+ if (envelope.credits !== void 0) {
3679
+ log.info(`Credits used: ${envelope.credits}`);
3680
+ }
3681
+ if (envelope.screenshotUrl) {
3682
+ log.info(`Screenshot (expires ~24h): ${envelope.screenshotUrl}`);
3683
+ }
984
3684
  } catch (error) {
985
- s.stop(pc2.red("Content loading failed!"));
986
- console.error(error);
987
- process.exit(1);
988
- }
989
- options.debug && log2.info(`html: ${html}`);
990
- const markdown = htmlToMarkdown(html, {
991
- baseUrl: isUrl(sourcePath) ? sourcePath : void 0,
992
- ...mode === "ai" ? aiModeOptions : readabilityModeOptions
993
- });
994
- options.debug && log2.info(`markdown: ${markdown}`);
995
- const directory = path2.dirname(outputPath);
996
- const isDirectoryExists = await fs5.stat(directory).then((stat) => stat.isDirectory());
997
- if (!isDirectoryExists) {
998
- await fs5.mkdir(directory, { recursive: true });
3685
+ s.stop(pc2.red("Conversion failed!"));
3686
+ throw error;
999
3687
  }
1000
- await fs5.writeFile(outputPath, markdown);
3688
+ await writeOutputFile(outputPath, markdown);
1001
3689
  outro(pc2.green(`${pc2.bold("Done!")} Markdown saved to ${outputPath}`));
1002
3690
  };
1003
3691
 
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
+
1004
3759
  // src/cli/bin.ts
1005
- program.name("webforai").description("CLI tool for ultra-precise HTML to Markdown conversion").version(package_default.version, "-v, --version", "output the current version");
1006
- program.argument("[source]", "URL or path to process").option("-o, --output <output>", "Path to output file or directory").option("-m, --mode <mode>", `Processing mode (${MODES.join(", ")})`).option("-l, --loader <loader>", `Loader to use (${LOADERS.join(", ")})`).option("-d, --debug", "output extra debugging information").action(webforaiCommand);
1007
- program.parse();
3760
+ var HELP_EPILOGUE = `
3761
+ Examples:
3762
+ $ webforai https://example.com/article Markdown to stdout
3763
+ $ webforai https://example.com -o article.md write to a file
3764
+ $ webforai ./page.html --extractor none convert a local file, no extraction
3765
+ $ webforai https://example.com --json | jq .markdown
3766
+ $ webforai https://spa.example.com -l playwright render JS in local Chromium
3767
+ $ webforai https://example.com --engine browser render via the hosted platform
3768
+ $ webforai -i guided interactive mode
3769
+ $ webforai skill --install install the Agent Skill for this CLI
3770
+
3771
+ Environment:
3772
+ ${API_KEY_ENV} platform API key (used by the platform loader)
3773
+ ${PLATFORM_URL_ENV} platform base URL override (self-hosted deployments)
3774
+
3775
+ Exit codes:
3776
+ 0 success \xB7 1 runtime failure \xB7 2 usage error
3777
+
3778
+ Output contract:
3779
+ stdout carries only the Markdown (or the --json envelope); all logs go to stderr.
3780
+ `;
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) => {
3783
+ if (flags.interactive) {
3784
+ await interactiveCommand(source, flags);
3785
+ return;
3786
+ }
3787
+ if (!source) {
3788
+ program.help({ error: true });
3789
+ }
3790
+ await runCommand(resolveRunOptions(source, flags));
3791
+ });
3792
+ 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
+ await skillCommand(flags);
3794
+ });
3795
+ var fail = (error) => {
3796
+ if (error instanceof CommanderError) {
3797
+ process.exit(error.exitCode === 0 ? 0 : 2);
3798
+ }
3799
+ if (error instanceof UsageError) {
3800
+ console.error(`error: ${error.message}`);
3801
+ process.exit(2);
3802
+ }
3803
+ console.error(error instanceof Error ? `error: ${error.message}` : `error: ${String(error)}`);
3804
+ process.exit(1);
3805
+ };
3806
+ program.exitOverride();
3807
+ program.parseAsync().catch(fail);