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/README.md CHANGED
@@ -35,7 +35,13 @@
35
35
  ## Overview
36
36
 
37
37
  ```bash
38
- npx webforai@latest
38
+ # URL or local HTML file → Markdown on stdout (add -o out.md to write a file)
39
+ npx webforai@latest https://example.com/article
40
+
41
+ # machine-readable output, hosted-platform fetching, agent installation
42
+ npx webforai@latest https://example.com --json
43
+ npx webforai@latest https://example.com --engine browser # platform, renders JS
44
+ npx webforai@latest skill --install # Agent Skill via `npx skills add`
39
45
  ```
40
46
 
41
47
  or
@@ -52,6 +58,62 @@ const html = await loadHtml(url);
52
58
  const markdown = htmlToMarkdown(html, { baseUrl: url });
53
59
  ```
54
60
 
61
+ ## What v3 changes
62
+
63
+ **Better extraction.** The main-content extractor scores candidate containers the way Readability
64
+ does — paragraph density, link density, class weighting, ancestor propagation — and adds signals
65
+ the 2010 heuristics predate: semantic markup (`<article>`, `<main>`, `role="main"`, schema.org
66
+ `articleBody`), the rendered geometry a browser loader can supply, and CJK-aware length
67
+ thresholds. Across a 60-page corpus of real sites, the share of output lines that were bare links
68
+ — the clearest sign navigation leaked in — dropped from 10.6% to 5.0%.
69
+
70
+ **Site adapters.** Pages that generic scoring cannot read are handled directly, matched by
71
+ hostname or by a platform fingerprint so self-hosted instances are covered too: GitHub, Stack
72
+ Overflow, npm, MDN, Zenn, Qiita, Medium, Substack, note, Hatena, WordPress, Wikipedia, Reddit,
73
+ YouTube, Hacker News, Docusaurus, VitePress, MkDocs, Read the Docs and GitBook. An adapter that
74
+ produces too little is ignored in favour of generic extraction, so a site redesign degrades
75
+ rather than breaks.
76
+
77
+ ```ts
78
+ // Pass adapters: false for generic extraction only.
79
+ import { createAutoExtractor } from "webforai";
80
+
81
+ const markdown = htmlToMarkdown(html, {
82
+ url,
83
+ extractors: [createAutoExtractor({ adapters: false })],
84
+ });
85
+ ```
86
+
87
+ **Page metadata.**
88
+
89
+ ```ts
90
+ import { htmlToMarkdownWithMetadata } from "webforai";
91
+
92
+ const { markdown, metadata } = htmlToMarkdownWithMetadata(html, { url });
93
+ // metadata: { title, author, published, siteName, canonicalUrl, lang, ... }
94
+
95
+ // Or prepend it as YAML front matter:
96
+ const withFrontmatter = htmlToMarkdown(html, { url, frontmatter: true });
97
+ ```
98
+
99
+ **Markup fixes.** Lazily-loaded images (`data-src`, `srcset`) now resolve to a real URL instead
100
+ of `![alt]()`; KaTeX and MathJax expressions are no longer emitted twice; `role="heading"`
101
+ elements become real headings; superscripts and subscripts stay distinguishable; and definition
102
+ lists keep their term/definition pairing.
103
+
104
+ See the [changeset](/.changeset) for the full list, including the breaking changes.
105
+
106
+ ## Platform (hosted API)
107
+
108
+ [`apps/platform`](https://github.com/inaridiy/webforai/tree/main/apps/platform) is an OSS,
109
+ self-hostable SaaS built on this library: a metered crawl→Markdown HTTP API on Cloudflare
110
+ (Workers + Workflows + Containers + Browser Run) with Better Auth accounts/API keys and
111
+ Stripe usage-based billing. Use it through the typed `webforai/platform` client
112
+ (`import { createPlatformClient } from "webforai/platform"`) or the CLI
113
+ (`npx webforai <url> --engine browser`); docs live at
114
+ [webforai.dev/platform](https://webforai.dev/platform). See its README and the design ledger
115
+ in `docs/specs/platform`.
116
+
55
117
  ## Support
56
118
 
57
119
  - [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
+ };