webforai 2.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +86 -4
  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 +4142 -549
  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/detect-client-shell.js +78 -0
  17. package/dist/cjs/extract-meta-refresh.js +71 -0
  18. package/dist/cjs/extractors/index.js +5 -0
  19. package/dist/cjs/extractors/lib/constants.js +186 -0
  20. package/dist/cjs/extractors/lib/sanitize.js +429 -0
  21. package/dist/cjs/extractors/lib/score.js +264 -0
  22. package/dist/cjs/extractors/lib/terminators.js +131 -0
  23. package/dist/cjs/extractors/lib/ui-chrome.js +119 -0
  24. package/dist/cjs/extractors/pipeExtractors.js +12 -9
  25. package/dist/cjs/extractors/presets/auto.js +44 -0
  26. package/dist/cjs/extractors/presets/minimal-filter.js +27 -48
  27. package/dist/cjs/extractors/presets/takumi.js +84 -122
  28. package/dist/cjs/html-to-markdown.js +44 -7
  29. package/dist/cjs/html-to-mdast.js +10 -1
  30. package/dist/cjs/index.js +43 -2
  31. package/dist/cjs/link-replacer.js +19 -13
  32. package/dist/cjs/loaders/cf-puppeteer.js +14 -7
  33. package/dist/cjs/loaders/fetch.js +14 -2
  34. package/dist/cjs/loaders/playwright.js +32 -30
  35. package/dist/cjs/loaders/puppeteer.js +14 -7
  36. package/dist/cjs/md-splitter.js +24 -11
  37. package/dist/cjs/mdast-handlers/custom-a-handler.js +25 -9
  38. package/dist/cjs/mdast-handlers/custom-code-handler.js +18 -7
  39. package/dist/cjs/mdast-handlers/custom-div-handler.js +12 -33
  40. package/dist/cjs/mdast-handlers/custom-img-handler.js +23 -1
  41. package/dist/cjs/mdast-handlers/custom-table-handler.js +16 -0
  42. package/dist/cjs/mdast-handlers/definition-list-handler.js +110 -0
  43. package/dist/cjs/mdast-handlers/math-handler.js +12 -3
  44. package/dist/cjs/mdast-handlers/sup-sub-handler.js +39 -0
  45. package/dist/cjs/mdast-to-markdown.js +2 -5
  46. package/dist/cjs/metadata/index.js +223 -0
  47. package/dist/cjs/normalize/index.js +177 -0
  48. package/dist/cjs/platform/client.js +204 -0
  49. package/dist/cjs/platform/error.js +36 -0
  50. package/dist/cjs/platform/fetch.js +37 -0
  51. package/dist/cjs/platform/index.js +44 -0
  52. package/dist/cjs/platform/types.js +40 -0
  53. package/dist/cjs/utils/hast-fast.js +284 -0
  54. package/dist/constants.js +2 -2
  55. package/dist/detect-client-shell.js +53 -0
  56. package/dist/extract-meta-refresh.js +47 -0
  57. package/dist/extractors/index.js +3 -0
  58. package/dist/extractors/lib/constants.js +150 -0
  59. package/dist/extractors/lib/sanitize.js +419 -0
  60. package/dist/extractors/lib/score.js +242 -0
  61. package/dist/extractors/lib/terminators.js +107 -0
  62. package/dist/extractors/lib/ui-chrome.js +96 -0
  63. package/dist/extractors/pipeExtractors.js +12 -9
  64. package/dist/extractors/presets/auto.js +20 -0
  65. package/dist/extractors/presets/minimal-filter.js +35 -47
  66. package/dist/extractors/presets/takumi.js +91 -122
  67. package/dist/html-to-markdown.js +42 -6
  68. package/dist/html-to-mdast.js +10 -1
  69. package/dist/index.js +45 -2
  70. package/dist/link-replacer.js +19 -13
  71. package/dist/loaders/cf-puppeteer.js +15 -8
  72. package/dist/loaders/fetch.js +14 -2
  73. package/dist/loaders/playwright.js +32 -30
  74. package/dist/loaders/puppeteer.js +15 -8
  75. package/dist/md-splitter.js +24 -11
  76. package/dist/mdast-handlers/custom-a-handler.js +25 -9
  77. package/dist/mdast-handlers/custom-code-handler.js +17 -7
  78. package/dist/mdast-handlers/custom-div-handler.js +12 -33
  79. package/dist/mdast-handlers/custom-img-handler.js +23 -1
  80. package/dist/mdast-handlers/custom-table-handler.js +16 -0
  81. package/dist/mdast-handlers/definition-list-handler.js +87 -0
  82. package/dist/mdast-handlers/math-handler.js +12 -3
  83. package/dist/mdast-handlers/sup-sub-handler.js +16 -0
  84. package/dist/mdast-to-markdown.js +2 -5
  85. package/dist/metadata/index.js +198 -0
  86. package/dist/normalize/index.js +159 -0
  87. package/dist/platform/client.js +182 -0
  88. package/dist/platform/error.js +13 -0
  89. package/dist/platform/fetch.js +14 -0
  90. package/dist/platform/index.js +23 -0
  91. package/dist/platform/types.js +13 -0
  92. package/dist/types/adapters/index.d.ts +24 -0
  93. package/dist/types/adapters/index.js.map +1 -0
  94. package/dist/types/adapters/registry.d.ts +35 -0
  95. package/dist/types/adapters/registry.js.map +1 -0
  96. package/dist/types/adapters/sites/declarative.d.ts +13 -0
  97. package/dist/types/adapters/sites/declarative.js.map +1 -0
  98. package/dist/types/adapters/sites/hackernews.d.ts +13 -0
  99. package/dist/types/adapters/sites/hackernews.js.map +1 -0
  100. package/dist/types/adapters/sites/youtube.d.ts +31 -0
  101. package/dist/types/adapters/sites/youtube.js.map +1 -0
  102. package/dist/types/adapters/types.d.ts +99 -0
  103. package/dist/types/adapters/types.js.map +1 -0
  104. package/dist/types/cli/bin.js.map +1 -1
  105. package/dist/types/cli/commands/jobs/files.d.ts +28 -0
  106. package/dist/types/cli/commands/jobs/files.js.map +1 -0
  107. package/dist/types/cli/commands/jobs/llms-txt.d.ts +33 -0
  108. package/dist/types/cli/commands/jobs/llms-txt.js.map +1 -0
  109. package/dist/types/cli/commands/jobs/options.d.ts +58 -0
  110. package/dist/types/cli/commands/jobs/options.js.map +1 -0
  111. package/dist/types/cli/commands/jobs/run.d.ts +44 -0
  112. package/dist/types/cli/commands/jobs/run.js.map +1 -0
  113. package/dist/types/cli/commands/skill/content.d.ts +9 -0
  114. package/dist/types/cli/commands/skill/content.js.map +1 -0
  115. package/dist/types/cli/commands/skill/index.d.ts +19 -0
  116. package/dist/types/cli/commands/skill/index.js.map +1 -0
  117. package/dist/types/cli/commands/webforai/interactive.d.ts +7 -0
  118. package/dist/types/cli/commands/webforai/interactive.js.map +1 -0
  119. package/dist/types/cli/commands/webforai/loadHtml.js.map +1 -1
  120. package/dist/types/cli/commands/webforai/options.d.ts +62 -0
  121. package/dist/types/cli/commands/webforai/options.js.map +1 -0
  122. package/dist/types/cli/commands/webforai/run.d.ts +10 -0
  123. package/dist/types/cli/commands/webforai/run.js.map +1 -0
  124. package/dist/types/cli/constants.d.ts +14 -4
  125. package/dist/types/cli/constants.js.map +1 -1
  126. package/dist/types/cli/helpers/selectExtractMode.js.map +1 -1
  127. package/dist/types/cli/helpers/selectLoader.js.map +1 -1
  128. package/dist/types/cli/utils.d.ts +8 -1
  129. package/dist/types/cli/utils.js.map +1 -1
  130. package/dist/types/constants.d.ts +7 -1
  131. package/dist/types/constants.js.map +1 -1
  132. package/dist/types/detect-client-shell.d.ts +24 -0
  133. package/dist/types/detect-client-shell.js.map +1 -0
  134. package/dist/types/extract-meta-refresh.d.ts +29 -0
  135. package/dist/types/extract-meta-refresh.js.map +1 -0
  136. package/dist/types/extractors/index.d.ts +1 -0
  137. package/dist/types/extractors/index.js.map +1 -1
  138. package/dist/types/extractors/lib/constants.d.ts +81 -0
  139. package/dist/types/extractors/lib/constants.js.map +1 -0
  140. package/dist/types/extractors/lib/sanitize.d.ts +66 -0
  141. package/dist/types/extractors/lib/sanitize.js.map +1 -0
  142. package/dist/types/extractors/lib/score.d.ts +85 -0
  143. package/dist/types/extractors/lib/score.js.map +1 -0
  144. package/dist/types/extractors/lib/terminators.d.ts +26 -0
  145. package/dist/types/extractors/lib/terminators.js.map +1 -0
  146. package/dist/types/extractors/lib/ui-chrome.d.ts +18 -0
  147. package/dist/types/extractors/lib/ui-chrome.js.map +1 -0
  148. package/dist/types/extractors/pipeExtractors.d.ts +5 -0
  149. package/dist/types/extractors/pipeExtractors.js.map +1 -1
  150. package/dist/types/extractors/presets/auto.d.ts +30 -0
  151. package/dist/types/extractors/presets/auto.js.map +1 -0
  152. package/dist/types/extractors/presets/minimal-filter.d.ts +10 -2
  153. package/dist/types/extractors/presets/minimal-filter.js.map +1 -1
  154. package/dist/types/extractors/presets/takumi.d.ts +19 -3
  155. package/dist/types/extractors/presets/takumi.js.map +1 -1
  156. package/dist/types/extractors/types.d.ts +8 -0
  157. package/dist/types/html-to-markdown.d.ts +38 -0
  158. package/dist/types/html-to-markdown.js.map +1 -1
  159. package/dist/types/html-to-mdast.d.ts +11 -0
  160. package/dist/types/html-to-mdast.js.map +1 -1
  161. package/dist/types/index.d.ts +8 -3
  162. package/dist/types/index.js.map +1 -1
  163. package/dist/types/link-replacer.d.ts +3 -1
  164. package/dist/types/link-replacer.js.map +1 -1
  165. package/dist/types/loaders/cf-puppeteer.js.map +1 -1
  166. package/dist/types/loaders/fetch.d.ts +4 -0
  167. package/dist/types/loaders/fetch.js.map +1 -1
  168. package/dist/types/loaders/playwright.js.map +1 -1
  169. package/dist/types/loaders/puppeteer.js.map +1 -1
  170. package/dist/types/md-splitter.d.ts +1 -1
  171. package/dist/types/md-splitter.js.map +1 -1
  172. package/dist/types/mdast-handlers/custom-a-handler.js.map +1 -1
  173. package/dist/types/mdast-handlers/custom-code-handler.d.ts +2 -0
  174. package/dist/types/mdast-handlers/custom-code-handler.js.map +1 -1
  175. package/dist/types/mdast-handlers/custom-div-handler.js.map +1 -1
  176. package/dist/types/mdast-handlers/custom-img-handler.js.map +1 -1
  177. package/dist/types/mdast-handlers/custom-table-handler.js.map +1 -1
  178. package/dist/types/mdast-handlers/definition-list-handler.d.ts +2 -0
  179. package/dist/types/mdast-handlers/definition-list-handler.js.map +1 -0
  180. package/dist/types/mdast-handlers/math-handler.js.map +1 -1
  181. package/dist/types/mdast-handlers/sup-sub-handler.d.ts +10 -0
  182. package/dist/types/mdast-handlers/sup-sub-handler.js.map +1 -0
  183. package/dist/types/mdast-to-markdown.js.map +1 -1
  184. package/dist/types/metadata/index.d.ts +47 -0
  185. package/dist/types/metadata/index.js.map +1 -0
  186. package/dist/types/normalize/index.d.ts +53 -0
  187. package/dist/types/normalize/index.js.map +1 -0
  188. package/dist/types/platform/client.d.ts +55 -0
  189. package/dist/types/platform/client.js.map +1 -0
  190. package/dist/types/platform/error.d.ts +22 -0
  191. package/dist/types/platform/error.js.map +1 -0
  192. package/dist/types/platform/fetch.d.ts +36 -0
  193. package/dist/types/platform/fetch.js.map +1 -0
  194. package/dist/types/platform/index.d.ts +4 -0
  195. package/dist/types/platform/index.js.map +1 -0
  196. package/dist/types/platform/types.d.ts +164 -0
  197. package/dist/types/platform/types.js.map +1 -0
  198. package/dist/types/utils/hast-fast.d.ts +101 -0
  199. package/dist/types/utils/hast-fast.js.map +1 -0
  200. package/dist/utils/hast-fast.js +246 -0
  201. package/package.json +25 -7
  202. package/dist/types/cli/commands/webforai/index.d.ts +0 -6
  203. package/dist/types/cli/commands/webforai/index.js.map +0 -1
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- A esm-native library that converts HTML to Markdown & Useful Utilities with simple, lightweight and epic quality.
14
- <p>
13
+ Convert web pages and local HTML to clean, LLM-ready Markdown — with a TypeScript library, the <code>npx webforai</code> CLI, or a hosted API.
14
+ </p>
15
15
 
16
16
  <p align="center">
17
17
  <a href="https://www.npmjs.com/package/webforai">
@@ -30,12 +30,30 @@
30
30
 
31
31
  ## Documentation
32
32
 
33
- [Head to the documentation](https://webforai.dev/) to read and learn more about Webforai.
33
+ Everything is documented at **[webforai.dev](https://webforai.dev)**. Start from what you want to do:
34
+
35
+ | I want to… | Use | Start here |
36
+ | --- | --- | --- |
37
+ | convert HTML in my own code (Node.js, browsers, Workers) | the `webforai` library | [Getting started](https://webforai.dev/getting-started) |
38
+ | get Markdown for a URL from the shell or an AI agent | the CLI, `npx webforai <url>` | [CLI](https://webforai.dev/cli) |
39
+ | call a hosted API that runs the browsers and proxies for me | webforai platform | [platform.webforai.dev](https://platform.webforai.dev) (sign-up, API keys, usage) · [platform docs](https://webforai.dev/platform) |
40
+ | run my own copy of that platform | [`apps/platform`](https://github.com/inaridiy/webforai/tree/main/apps/platform) | [Deploying your own instance](https://github.com/inaridiy/webforai/tree/main/apps/platform#deploying-your-own-instance) |
41
+
42
+ The library, the CLI and the typed platform client (`webforai/platform`) all ship in the one
43
+ `webforai` npm package.
34
44
 
35
45
  ## Overview
36
46
 
37
47
  ```bash
38
- npx webforai@latest
48
+ # URL or local HTML file → Markdown on stdout (add -o out.md to write a file)
49
+ npx webforai@latest https://example.com/article
50
+
51
+ # machine-readable output, hosted-platform fetching, agent installation
52
+ npx webforai@latest https://example.com --json
53
+ npx webforai@latest https://example.com --engine auto # platform, renders JS when needed
54
+ npx webforai@latest crawl https://docs.example.com -o docs --llms-txt # site → .md files + llms.txt
55
+ npx webforai@latest batch --file urls.txt -o pages # many URLs → one .md per page
56
+ npx webforai@latest skill --install # Agent Skill via `npx skills add`
39
57
  ```
40
58
 
41
59
  or
@@ -52,6 +70,70 @@ const html = await loadHtml(url);
52
70
  const markdown = htmlToMarkdown(html, { baseUrl: url });
53
71
  ```
54
72
 
73
+ The CLI keeps Markdown/JSON on stdout and API failures on stderr, including the error code
74
+ and `retryAfter` when supplied; `invalid_api_key`, `payment_required` and `spend_cap_reached`
75
+ name the platform dashboard. `crawl` and `batch` run hosted jobs (API key required) and write
76
+ one Markdown file per page. `--engine` and `--region` select the platform loader; combining
77
+ them with an explicit local loader exits with usage error `2`. The interactive wizard applies
78
+ the same validation and honors `WEBFORAI_PLATFORM_URL`.
79
+
80
+ ## What v3 changes
81
+
82
+ **Better extraction.** The main-content extractor scores candidate containers the way Readability
83
+ does — paragraph density, link density, class weighting, ancestor propagation — and adds signals
84
+ the 2010 heuristics predate: semantic markup (`<article>`, `<main>`, `role="main"`, schema.org
85
+ `articleBody`), the rendered geometry a browser loader can supply, and CJK-aware length
86
+ thresholds. Across a 60-page corpus of real sites, the share of output lines that were bare links
87
+ — the clearest sign navigation leaked in — dropped from 10.6% to 5.0%.
88
+
89
+ **Site adapters.** Pages that generic scoring cannot read are handled directly, matched by
90
+ hostname or by a platform fingerprint so self-hosted instances are covered too: GitHub, Stack
91
+ Overflow, npm, MDN, Zenn, Qiita, Medium, Substack, note, Hatena, WordPress, Wikipedia, Reddit,
92
+ YouTube, Hacker News, Docusaurus, VitePress, MkDocs, Read the Docs and GitBook. An adapter that
93
+ produces too little is ignored in favour of generic extraction, so a site redesign degrades
94
+ rather than breaks.
95
+
96
+ ```ts
97
+ // Pass adapters: false for generic extraction only.
98
+ import { createAutoExtractor } from "webforai";
99
+
100
+ const markdown = htmlToMarkdown(html, {
101
+ url,
102
+ extractors: [createAutoExtractor({ adapters: false })],
103
+ });
104
+ ```
105
+
106
+ **Page metadata.**
107
+
108
+ ```ts
109
+ import { htmlToMarkdownWithMetadata } from "webforai";
110
+
111
+ const { markdown, metadata } = htmlToMarkdownWithMetadata(html, { url });
112
+ // metadata: { title, author, published, siteName, canonicalUrl, lang, ... }
113
+
114
+ // Or prepend it as YAML front matter:
115
+ const withFrontmatter = htmlToMarkdown(html, { url, frontmatter: true });
116
+ ```
117
+
118
+ **Markup fixes.** Lazily-loaded images (`data-src`, `srcset`) now resolve to a real URL instead
119
+ of `![alt]()`; KaTeX and MathJax expressions are no longer emitted twice; `role="heading"`
120
+ elements become real headings; superscripts and subscripts stay distinguishable; and definition
121
+ lists keep their term/definition pairing.
122
+
123
+ See the [changeset](/.changeset) for the full list, including the breaking changes.
124
+
125
+ ## Platform (hosted API)
126
+
127
+ [`apps/platform`](https://github.com/inaridiy/webforai/tree/main/apps/platform) is an OSS,
128
+ self-hostable SaaS built on this library: a metered crawl→Markdown HTTP API on Cloudflare
129
+ (Workers + Workflows + Containers + Browser Run) with Better Auth accounts/API keys and
130
+ Stripe usage-based billing. Use it through the typed `webforai/platform` client
131
+ (`import { createPlatformClient } from "webforai/platform"`) or the CLI
132
+ (`npx webforai <url> --engine auto`). Sign up and manage API keys at
133
+ [platform.webforai.dev](https://platform.webforai.dev); the docs live at
134
+ [webforai.dev/platform](https://webforai.dev/platform). See its README and the design ledger
135
+ in `docs/specs/platform`.
136
+
55
137
  ## Support
56
138
 
57
139
  - [GitHub Sponsors](https://github.com/sponsors/inaridiy)
@@ -0,0 +1,58 @@
1
+ // src/adapters/index.ts
2
+ import { stripNonContent } from "../extractors/lib/sanitize.js";
3
+ import { MetricsCollector, cloneHast } from "../utils/hast-fast.js";
4
+ import { buildPageSignature, resolveAdapterByFingerprint, resolveAdapterByHost } from "./registry.js";
5
+ import { DECLARATIVE_ADAPTERS } from "./sites/declarative.js";
6
+ import { hackernewsAdapter } from "./sites/hackernews.js";
7
+ import { youtubeAdapter } from "./sites/youtube.js";
8
+ import {
9
+ buildPageSignature as buildPageSignature2,
10
+ defineSelectorAdapter,
11
+ removeAll,
12
+ resolveAdapter,
13
+ selectFirst
14
+ } from "./registry.js";
15
+ import { DECLARATIVE_ADAPTERS as DECLARATIVE_ADAPTERS2 } from "./sites/declarative.js";
16
+ import { hackernewsAdapter as hackernewsAdapter2 } from "./sites/hackernews.js";
17
+ import { youtubeAdapter as youtubeAdapter2, youtubeCaptionTracks } from "./sites/youtube.js";
18
+ var BUILTIN_ADAPTERS = [youtubeAdapter, hackernewsAdapter, ...DECLARATIVE_ADAPTERS];
19
+ var adapterById = (id) => BUILTIN_ADAPTERS.find((adapter) => adapter.id === id);
20
+ var MIN_ADAPTER_TEXT = 40;
21
+ var runAdapters = (context, adapters = BUILTIN_ADAPTERS) => {
22
+ let matched = resolveAdapterByHost(context, adapters);
23
+ let withSignature = context;
24
+ if (!matched) {
25
+ withSignature = { ...context, signature: context.signature ?? buildPageSignature(context.hast) };
26
+ matched = resolveAdapterByFingerprint(withSignature, adapters);
27
+ }
28
+ const adapter = matched;
29
+ if (!adapter) {
30
+ return void 0;
31
+ }
32
+ const target = withSignature.owned ? withSignature : { ...withSignature, hast: cloneHast(withSignature.hast), owned: true };
33
+ let extracted;
34
+ try {
35
+ extracted = adapter.extract(target);
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ if (!extracted) {
40
+ return void 0;
41
+ }
42
+ stripNonContent(extracted);
43
+ return new MetricsCollector().textLength(extracted) >= MIN_ADAPTER_TEXT ? extracted : void 0;
44
+ };
45
+ export {
46
+ BUILTIN_ADAPTERS,
47
+ DECLARATIVE_ADAPTERS2 as DECLARATIVE_ADAPTERS,
48
+ adapterById,
49
+ buildPageSignature2 as buildPageSignature,
50
+ defineSelectorAdapter,
51
+ hackernewsAdapter2 as hackernewsAdapter,
52
+ removeAll,
53
+ resolveAdapter,
54
+ runAdapters,
55
+ selectFirst,
56
+ youtubeAdapter2 as youtubeAdapter,
57
+ youtubeCaptionTracks
58
+ };
@@ -0,0 +1,149 @@
1
+ // src/adapters/registry.ts
2
+ import { select, selectAll } from "hast-util-select";
3
+ import { MetricsCollector, classList, isElement, pruneInPlace, stringProperty, walk } from "../utils/hast-fast.js";
4
+ var EMPTY_SIGNATURE = {
5
+ classes: /* @__PURE__ */ new Set(),
6
+ ids: /* @__PURE__ */ new Set(),
7
+ tags: /* @__PURE__ */ new Set(),
8
+ generator: "",
9
+ meta: ""
10
+ };
11
+ var buildPageSignature = (root) => {
12
+ const classes = /* @__PURE__ */ new Set();
13
+ const ids = /* @__PURE__ */ new Set();
14
+ const tags = /* @__PURE__ */ new Set();
15
+ const generatorParts = [];
16
+ const metaParts = [];
17
+ walk(root, (node) => {
18
+ if (!isElement(node)) {
19
+ return;
20
+ }
21
+ tags.add(node.tagName);
22
+ for (const name of classList(node)) {
23
+ classes.add(name);
24
+ }
25
+ const id = stringProperty(node, "id");
26
+ if (id) {
27
+ ids.add(id);
28
+ }
29
+ if (node.tagName === "meta") {
30
+ const key = stringProperty(node, "name") ?? stringProperty(node, "property") ?? "";
31
+ const content = stringProperty(node, "content") ?? "";
32
+ metaParts.push(`${key}=${content}`);
33
+ if (key.toLowerCase() === "generator") {
34
+ generatorParts.push(content);
35
+ }
36
+ }
37
+ });
38
+ return {
39
+ classes,
40
+ ids,
41
+ tags,
42
+ generator: generatorParts.join(" ").toLowerCase(),
43
+ meta: metaParts.join("\n").toLowerCase()
44
+ };
45
+ };
46
+ var DEFAULT_MIN_LENGTH = 40;
47
+ var hostOf = (url) => {
48
+ if (!url) {
49
+ return void 0;
50
+ }
51
+ try {
52
+ return new URL(url).hostname;
53
+ } catch {
54
+ return void 0;
55
+ }
56
+ };
57
+ var defineSelectorAdapter = (spec) => ({
58
+ id: spec.id,
59
+ matches: (context) => {
60
+ const host = hostOf(context.url);
61
+ return Boolean(host && spec.hosts?.test(host));
62
+ },
63
+ matchesFingerprint: (context) => {
64
+ const signature = context.signature ?? EMPTY_SIGNATURE;
65
+ return spec.fingerprint?.(signature) ?? false;
66
+ },
67
+ extract: (context) => {
68
+ for (const selector of spec.content) {
69
+ const content = tryContentSelector(selector, spec, context);
70
+ if (content) {
71
+ return content;
72
+ }
73
+ }
74
+ return void 0;
75
+ }
76
+ });
77
+ var tryContentSelector = (selector, spec, context) => {
78
+ const found = selectAll(selector, context.hast);
79
+ if (found.length === 0) {
80
+ return void 0;
81
+ }
82
+ for (const element of found) {
83
+ removeAll(element, spec.remove);
84
+ }
85
+ const total = found.reduce((sum, element) => sum + new MetricsCollector().textLength(element), 0);
86
+ if (total < (spec.minLength ?? DEFAULT_MIN_LENGTH)) {
87
+ return void 0;
88
+ }
89
+ const decorated = spec.decorate?.(found, context);
90
+ if (decorated) {
91
+ return decorated;
92
+ }
93
+ return found.length === 1 ? found[0] : { type: "root", children: found };
94
+ };
95
+ var removeAll = (root, selectors) => {
96
+ if (!selectors || selectors.length === 0) {
97
+ return;
98
+ }
99
+ const doomed = /* @__PURE__ */ new Set();
100
+ for (const selector of selectors) {
101
+ for (const element of selectAll(selector, root)) {
102
+ doomed.add(element);
103
+ }
104
+ }
105
+ if (doomed.size > 0) {
106
+ pruneInPlace(root, (node) => !(isElement(node) && doomed.has(node)));
107
+ }
108
+ };
109
+ var selectFirst = (root, selectors) => {
110
+ for (const selector of selectors) {
111
+ const found = select(selector, root);
112
+ if (found) {
113
+ return found;
114
+ }
115
+ }
116
+ return void 0;
117
+ };
118
+ var resolveAdapterByHost = (context, adapters) => {
119
+ for (const adapter of adapters) {
120
+ try {
121
+ if (adapter.matches(context)) {
122
+ return adapter;
123
+ }
124
+ } catch {
125
+ }
126
+ }
127
+ return void 0;
128
+ };
129
+ var resolveAdapterByFingerprint = (context, adapters) => {
130
+ for (const adapter of adapters) {
131
+ try {
132
+ if (adapter.matchesFingerprint?.(context)) {
133
+ return adapter;
134
+ }
135
+ } catch {
136
+ }
137
+ }
138
+ return void 0;
139
+ };
140
+ var resolveAdapter = (context, adapters) => resolveAdapterByHost(context, adapters) ?? resolveAdapterByFingerprint(context, adapters);
141
+ export {
142
+ buildPageSignature,
143
+ defineSelectorAdapter,
144
+ removeAll,
145
+ resolveAdapter,
146
+ resolveAdapterByFingerprint,
147
+ resolveAdapterByHost,
148
+ selectFirst
149
+ };
@@ -0,0 +1,178 @@
1
+ // src/adapters/sites/declarative.ts
2
+ import { defineSelectorAdapter } from "../registry.js";
3
+ var COMMON_NOISE = [
4
+ ".sr-only",
5
+ "[aria-hidden='true'][class*='icon']",
6
+ "button",
7
+ ".social-share",
8
+ ".share-buttons",
9
+ ".newsletter-signup"
10
+ ];
11
+ var SPECS = [
12
+ {
13
+ id: "github",
14
+ hosts: /(^|\.)github\.com$/,
15
+ // Issues and PRs expose a testid; repository landing pages render the README instead.
16
+ content: ["[data-testid='issue-body']", "article.markdown-body", ".markdown-body"],
17
+ remove: [".js-comment-edit-history", ".Details-content--hidden", "clipboard-copy", ...COMMON_NOISE],
18
+ minLength: 30
19
+ },
20
+ {
21
+ id: "stackoverflow",
22
+ hosts: /(^|\.)(stackoverflow\.com|stackexchange\.com|superuser\.com|serverfault\.com|askubuntu\.com)$/,
23
+ content: ["#mainbar", ".question, .answer"],
24
+ remove: [
25
+ ".js-post-menu",
26
+ ".post-menu",
27
+ ".user-info",
28
+ ".votecell",
29
+ ".comments-link",
30
+ ".js-add-link",
31
+ ...COMMON_NOISE
32
+ ],
33
+ minLength: 100
34
+ },
35
+ {
36
+ id: "npm",
37
+ hosts: /(^|\.)npmjs\.com$/,
38
+ content: ["#readme", "#package-tab-readme", "[class*='markdown']"],
39
+ remove: COMMON_NOISE,
40
+ minLength: 50
41
+ },
42
+ {
43
+ id: "zenn",
44
+ hosts: /(^|\.)zenn\.dev$/,
45
+ content: [".znc"],
46
+ remove: COMMON_NOISE,
47
+ minLength: 50
48
+ },
49
+ {
50
+ id: "qiita",
51
+ hosts: /(^|\.)qiita\.com$/,
52
+ content: [".it-MdContent", "[class*='mdContent']", "article"],
53
+ remove: [".code-copy", ...COMMON_NOISE],
54
+ minLength: 50
55
+ },
56
+ {
57
+ id: "medium",
58
+ hosts: /(^|\.)medium\.com$/,
59
+ // Medium also powers custom domains; the fingerprint catches those.
60
+ fingerprint: (signature) => signature.meta.includes("al:android:app_name=medium"),
61
+ content: ["article section", "article", "[data-testid='storyContent']"],
62
+ remove: ["[data-testid='audioPlayButton']", "[role='separator']", ...COMMON_NOISE],
63
+ minLength: 200
64
+ },
65
+ {
66
+ id: "substack",
67
+ hosts: /(^|\.)substack\.com$/,
68
+ // Substack's custom domains are far more common than *.substack.com.
69
+ fingerprint: (signature) => signature.classes.has("available-content"),
70
+ content: [".available-content", ".body.markup", ".post-content"],
71
+ remove: [".subscription-widget-wrap", ".button-wrapper", ".paywall", ...COMMON_NOISE],
72
+ minLength: 200
73
+ },
74
+ {
75
+ id: "note",
76
+ hosts: /(^|\.)note\.com$/,
77
+ content: [".note-common-styles__textnote-body", "[class*='textnote-body']"],
78
+ remove: COMMON_NOISE,
79
+ minLength: 50
80
+ },
81
+ {
82
+ id: "hatena",
83
+ hosts: /(^|\.)(hatenablog\.com|hatenadiary\.(com|jp)|hateblo\.jp|hatenastaff\.com)$/,
84
+ fingerprint: (signature) => signature.classes.has("hatenablog-entry") || signature.classes.has("hatena-body"),
85
+ content: [".entry-content"],
86
+ remove: [".entry-footer", ".social-buttons", ".hatena-asin-detail", ...COMMON_NOISE],
87
+ minLength: 100
88
+ },
89
+ {
90
+ id: "wordpress",
91
+ // Deliberately fingerprint-only: WordPress powers a large share of the web on its own
92
+ // domains, so keying off a hostname would cover almost none of it.
93
+ fingerprint: (signature) => signature.generator.includes("wordpress") || signature.classes.has("wp-block-post-content") || signature.classes.has("wp-singular"),
94
+ content: [".wp-block-post-content", ".entry-content", ".post-content", "article .content"],
95
+ remove: [".sharedaddy", ".jp-relatedposts", ".wp-block-comments", "#comments", ...COMMON_NOISE],
96
+ minLength: 150
97
+ },
98
+ {
99
+ id: "wikipedia",
100
+ hosts: /(^|\.)(wikipedia\.org|wikimedia\.org|wikibooks\.org|wiktionary\.org|wikisource\.org)$/,
101
+ fingerprint: (signature) => signature.classes.has("mw-parser-output"),
102
+ content: [".mw-parser-output", "#mw-content-text"],
103
+ remove: [
104
+ ".mw-editsection",
105
+ ".navbox",
106
+ ".navbox-inner",
107
+ ".vertical-navbox",
108
+ ".sidebar",
109
+ ".side-box",
110
+ ".metadata",
111
+ ".reflist",
112
+ ".refbegin",
113
+ ".sistersitebox",
114
+ ".ambox",
115
+ ".hatnote",
116
+ ".shortdescription",
117
+ ".mw-jump-link",
118
+ ".printfooter",
119
+ ".catlinks",
120
+ "#toc",
121
+ ".toc",
122
+ "table.mbox-small",
123
+ ...COMMON_NOISE
124
+ ],
125
+ minLength: 200
126
+ },
127
+ {
128
+ id: "reddit",
129
+ hosts: /(^|\.)reddit\.com$/,
130
+ content: ["shreddit-post [slot='text-body']", "[data-test-id='post-content']", ".Post"],
131
+ remove: ["faceplate-tracker[noun='comment_action_bar']", "shreddit-comment-action-row", ...COMMON_NOISE],
132
+ // Reddit renders most of a thread into shadow roots that never reach the serialised HTML,
133
+ // so a weak match here is worse than generic extraction. Decline unless a substantial
134
+ // post body is actually present in the document.
135
+ minLength: 600
136
+ },
137
+ {
138
+ id: "docusaurus",
139
+ fingerprint: (signature) => signature.classes.has("theme-doc-markdown") || signature.classes.has("docusaurus-mt-lg"),
140
+ content: [".theme-doc-markdown", "article .markdown"],
141
+ remove: [".theme-doc-toc-mobile", ".pagination-nav", ".theme-doc-version-banner", ...COMMON_NOISE],
142
+ minLength: 150
143
+ },
144
+ {
145
+ id: "vitepress",
146
+ fingerprint: (signature) => signature.classes.has("vp-doc") || signature.classes.has("VPDoc"),
147
+ content: [".vp-doc > div", ".vp-doc", ".VPDoc .content-container"],
148
+ remove: [".VPDocFooter", ".vp-doc-footer", ".header-anchor", ...COMMON_NOISE],
149
+ minLength: 150
150
+ },
151
+ {
152
+ id: "mkdocs",
153
+ fingerprint: (signature) => signature.classes.has("md-content__inner"),
154
+ content: [".md-content__inner", "[data-md-component='content']"],
155
+ remove: [".md-source-file", ".md-feedback", ".headerlink", ...COMMON_NOISE],
156
+ minLength: 150
157
+ },
158
+ {
159
+ id: "readthedocs",
160
+ fingerprint: (signature) => signature.classes.has("rst-content"),
161
+ content: [".rst-content", "[role='main']"],
162
+ remove: [".headerlink", ".rst-footer-buttons", ".wy-breadcrumbs", ...COMMON_NOISE],
163
+ minLength: 150
164
+ },
165
+ {
166
+ id: "gitbook",
167
+ fingerprint: (signature) => signature.classes.has("gitbook-root"),
168
+ content: ["[data-testid='page.contentEditor']", "main article"],
169
+ remove: COMMON_NOISE,
170
+ minLength: 150
171
+ }
172
+ ];
173
+ var DECLARATIVE_ADAPTERS = SPECS.map(defineSelectorAdapter);
174
+ var declarativeAdapterById = (id) => DECLARATIVE_ADAPTERS.find((adapter) => adapter.id === id);
175
+ export {
176
+ DECLARATIVE_ADAPTERS,
177
+ declarativeAdapterById
178
+ };
@@ -0,0 +1,94 @@
1
+ // src/adapters/sites/hackernews.ts
2
+ import { select, selectAll } from "hast-util-select";
3
+ import { numericProperty, stringProperty } from "../../utils/hast-fast.js";
4
+ var MAX_QUOTE_DEPTH = 6;
5
+ var text = (value) => ({ type: "text", value });
6
+ var element = (tagName, children) => ({
7
+ type: "element",
8
+ tagName,
9
+ properties: {},
10
+ children
11
+ });
12
+ var commentDepth = (row) => {
13
+ const cell = select("td.ind", row);
14
+ if (!cell) {
15
+ return 0;
16
+ }
17
+ const indent = numericProperty(cell, "indent");
18
+ if (indent !== void 0) {
19
+ return indent;
20
+ }
21
+ const spacer = select("img", cell);
22
+ const width = spacer ? numericProperty(spacer, "width") : void 0;
23
+ return width === void 0 ? 0 : Math.round(width / 40);
24
+ };
25
+ var nest = (content, depth) => {
26
+ let current = content;
27
+ for (let level = 0; level < Math.min(depth, MAX_QUOTE_DEPTH); level++) {
28
+ current = [element("blockquote", current)];
29
+ }
30
+ return current[0];
31
+ };
32
+ var storyNodes = (root) => {
33
+ const nodes = [];
34
+ const titleLink = select(".titleline > a", root);
35
+ if (titleLink) {
36
+ nodes.push(element("h1", [...titleLink.children]));
37
+ const href = stringProperty(titleLink, "href");
38
+ if (href && /^https?:/.test(href)) {
39
+ nodes.push(element("p", [{ ...titleLink, children: [text(href)] }]));
40
+ }
41
+ }
42
+ const topText = select(".toptext", root);
43
+ if (topText) {
44
+ nodes.push(element("div", [...topText.children]));
45
+ }
46
+ return nodes;
47
+ };
48
+ var commentNodes = (root) => {
49
+ const nodes = [];
50
+ for (const row of selectAll("tr.comtr, tr.athing.comtr", root)) {
51
+ const body = select(".commtext", row);
52
+ if (!body) {
53
+ continue;
54
+ }
55
+ const author = select(".hnuser", row);
56
+ const header = author ? [element("strong", [...author.children])] : [];
57
+ const content = [
58
+ ...header.length > 0 ? [element("p", header)] : [],
59
+ element("div", [...body.children])
60
+ ];
61
+ nodes.push(nest(content, commentDepth(row)));
62
+ }
63
+ return nodes;
64
+ };
65
+ var hackernewsAdapter = {
66
+ id: "hackernews",
67
+ matches: ({ url }) => {
68
+ if (!url) {
69
+ return false;
70
+ }
71
+ try {
72
+ return /(^|\.)ycombinator\.com$/.test(new URL(url).hostname);
73
+ } catch {
74
+ return false;
75
+ }
76
+ },
77
+ matchesFingerprint: ({ signature }) => Boolean(signature?.ids.has("hnmain") || signature?.classes.has("fatitem")),
78
+ extract: ({ hast }) => {
79
+ const story = storyNodes(hast);
80
+ const comments = commentNodes(hast);
81
+ if (story.length === 0 && comments.length === 0) {
82
+ return void 0;
83
+ }
84
+ const children = [...story];
85
+ if (comments.length > 0) {
86
+ children.push(element("h2", [text("Comments")]));
87
+ children.push(...comments);
88
+ }
89
+ return { type: "root", children };
90
+ }
91
+ };
92
+ export {
93
+ hackernewsAdapter
94
+ };