decant-core 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ai/chatgpt.js CHANGED
@@ -798,7 +798,13 @@ export class ChatGPTParser extends ChatParser {
798
798
  });
799
799
  }
800
800
 
801
- formatApiResult(convoData, apiMessages, fallbackTitle, images = {}) {
801
+ formatApiResult(
802
+ convoData,
803
+ apiMessages,
804
+ fallbackTitle,
805
+ images = {},
806
+ method = "API",
807
+ ) {
802
808
  const messages = [];
803
809
  for (const msg of apiMessages) {
804
810
  let content = "";
@@ -850,6 +856,7 @@ export class ChatGPTParser extends ChatParser {
850
856
  document.querySelector('[data-testid="model-selector-dropdown"]')
851
857
  ?.innerText ||
852
858
  "ChatGPT",
859
+ Method: method,
853
860
  };
854
861
 
855
862
  return { title: convTitle, messages, url: currentUrl, metadata };
@@ -880,7 +887,7 @@ export class ChatGPTParser extends ChatParser {
880
887
  sharedData.current_node,
881
888
  );
882
889
  if (apiMessages.length > 0) {
883
- return this.formatApiResult(sharedData, apiMessages, title);
890
+ return this.formatApiResult(sharedData, apiMessages, title, {}, "SSR");
884
891
  }
885
892
  }
886
893
 
@@ -929,6 +936,7 @@ export class ChatGPTParser extends ChatParser {
929
936
  apiMessages,
930
937
  title,
931
938
  result.images,
939
+ "API",
932
940
  );
933
941
  }
934
942
  } catch (e) {
@@ -947,7 +955,7 @@ export class ChatGPTParser extends ChatParser {
947
955
  sharedData.current_node,
948
956
  );
949
957
  if (apiMessages.length > 0) {
950
- return this.formatApiResult(sharedData, apiMessages, title);
958
+ return this.formatApiResult(sharedData, apiMessages, title, {}, "SSR");
951
959
  }
952
960
  }
953
961
 
@@ -1082,7 +1090,18 @@ export class ChatGPTParser extends ChatParser {
1082
1090
  });
1083
1091
  }
1084
1092
 
1085
- return { title, messages };
1093
+ const currentUrl =
1094
+ typeof window !== "undefined" && window.location
1095
+ ? window.location.href || ""
1096
+ : "";
1097
+ const metadata = {
1098
+ Source: "ChatGPT",
1099
+ Date: new Date().toLocaleString(),
1100
+ Link: currentUrl,
1101
+ Method: "DOM",
1102
+ };
1103
+
1104
+ return { title, messages, url: currentUrl, metadata };
1086
1105
  }
1087
1106
 
1088
1107
  const fullExport = options.full !== false;
@@ -1106,6 +1125,7 @@ export class ChatGPTParser extends ChatParser {
1106
1125
  Model:
1107
1126
  document.querySelector('[data-testid="model-selector-dropdown"]')
1108
1127
  ?.innerText || "ChatGPT",
1128
+ Method: "DOM",
1109
1129
  };
1110
1130
 
1111
1131
  return { title, messages, url: currentUrl, metadata };
package/ai/chub.js CHANGED
@@ -81,6 +81,7 @@ export class ChubParser extends ChatParser {
81
81
  Source: "Chub",
82
82
  Date: new Date().toLocaleString(),
83
83
  Link: currentUrl,
84
+ Method: "DOM",
84
85
  };
85
86
  if (character) metadata.Character = character;
86
87
  if (user) metadata.User = user;
package/ai/claude.js CHANGED
@@ -284,6 +284,7 @@ export class ClaudeParser extends ChatParser {
284
284
  Date: new Date().toLocaleString(),
285
285
  Link: currentUrl,
286
286
  Model: data.model || "Claude",
287
+ Method: "API",
287
288
  };
288
289
 
289
290
  return { title: convTitle, messages, url: currentUrl, metadata };
@@ -430,6 +431,7 @@ export class ClaudeParser extends ChatParser {
430
431
  Date: new Date().toLocaleString(),
431
432
  Link: currentUrl,
432
433
  Model: "Claude",
434
+ Method: "DOM",
433
435
  };
434
436
 
435
437
  return { title, messages, url: currentUrl, metadata };
package/ai/copilot.js CHANGED
@@ -440,6 +440,7 @@ export class CopilotParser extends ChatParser {
440
440
  Source: "Copilot",
441
441
  Date: new Date().toLocaleString(),
442
442
  Link: currentUrl,
443
+ Method: "DOM",
443
444
  };
444
445
 
445
446
  return { title, messages, url: currentUrl, metadata };
package/ai/deepseek.js CHANGED
@@ -108,7 +108,12 @@ export class DeepSeekParser extends ChatParser {
108
108
  if (token && sessionId) {
109
109
  const apiMessages = await fetchDeepSeekConversation(sessionId, token);
110
110
  if (apiMessages && apiMessages.length > 0) {
111
- return { title, messages: apiMessages, url: currentUrl, metadata };
111
+ return {
112
+ title,
113
+ messages: apiMessages,
114
+ url: currentUrl,
115
+ metadata: { ...metadata, Method: "API" },
116
+ };
112
117
  }
113
118
  }
114
119
  } catch (e) {
@@ -159,6 +164,11 @@ export class DeepSeekParser extends ChatParser {
159
164
  });
160
165
  }
161
166
 
162
- return { title, messages, url: currentUrl, metadata };
167
+ return {
168
+ title,
169
+ messages,
170
+ url: currentUrl,
171
+ metadata: { ...metadata, Method: "DOM" },
172
+ };
163
173
  }
164
174
  }
package/ai/gemini.js CHANGED
@@ -721,6 +721,7 @@ export class GeminiParser extends ChatParser {
721
721
  Source: "Gemini",
722
722
  Date: new Date().toLocaleString(),
723
723
  Link: currentUrl,
724
+ Method: "DOM",
724
725
  };
725
726
 
726
727
  return {
@@ -743,6 +744,7 @@ export class GeminiParser extends ChatParser {
743
744
  Source: "Gemini",
744
745
  Date: new Date().toLocaleString(),
745
746
  Link: currentUrl,
747
+ Method: "DOM",
746
748
  },
747
749
  };
748
750
  }
@@ -93,6 +93,7 @@ export class GeminiCloudAssistParser extends ChatParser {
93
93
  Source: "Gemini Cloud Assist",
94
94
  Date: new Date().toLocaleString(),
95
95
  Link: currentUrl,
96
+ Method: "DOM",
96
97
  };
97
98
 
98
99
  return { title, messages, url: currentUrl, metadata };
@@ -127,6 +127,7 @@ export class GoogleAIStudioParser extends ChatParser {
127
127
  Source: "Google AI Studio",
128
128
  Date: new Date().toLocaleString(),
129
129
  Link: currentUrl,
130
+ Method: "DOM",
130
131
  };
131
132
 
132
133
  return { title, messages, url: currentUrl, metadata };
@@ -116,6 +116,7 @@ export class GoogleSearchAIParser extends ChatParser {
116
116
  Source: "Google Search AI",
117
117
  Date: new Date().toLocaleString(),
118
118
  Link: currentUrl,
119
+ Method: "DOM",
119
120
  };
120
121
 
121
122
  return { title, messages, url: currentUrl, metadata };
package/ai/index.js CHANGED
@@ -38,3 +38,11 @@ export {
38
38
  parsers,
39
39
  } from "../detection/detect-platform.js";
40
40
  export { AI_CHAT_DOMAINS } from "../detection/domains.js";
41
+
42
+ // Web & Article Extraction
43
+ export {
44
+ extractArticle,
45
+ extractArticleIntelligent,
46
+ ArticleParser,
47
+ } from "../web/article.js";
48
+ export { scoreContent, cleanTitle } from "../web/scoring.js";
package/ai/joyland.js CHANGED
@@ -76,6 +76,7 @@ export class JoylandParser extends ChatParser {
76
76
  Source: "Joyland",
77
77
  Date: new Date().toLocaleString(),
78
78
  Link: currentUrl,
79
+ Method: "DOM",
79
80
  };
80
81
  if (botName) metadata.Character = botName;
81
82
  if (creator) metadata.Creator = creator;
package/ai/lumo.js CHANGED
@@ -105,6 +105,7 @@ export class LumoParser extends ChatParser {
105
105
  Source: "Lumo",
106
106
  Date: new Date().toLocaleString(),
107
107
  Link: currentUrl,
108
+ Method: "DOM",
108
109
  };
109
110
 
110
111
  return { title, messages, url: currentUrl, metadata };
package/ai/meta.js CHANGED
@@ -109,6 +109,7 @@ export class MetaParser extends ChatParser {
109
109
  Source: "Meta AI",
110
110
  Date: new Date().toLocaleString(),
111
111
  Link: currentUrl,
112
+ Method: "DOM",
112
113
  };
113
114
 
114
115
  return { title, messages, url: currentUrl, metadata };
package/ai/mistral.js CHANGED
@@ -55,6 +55,7 @@ export class MistralParser extends ChatParser {
55
55
  Source: "Mistral",
56
56
  Date: new Date().toLocaleString(),
57
57
  Link: currentUrl,
58
+ Method: "DOM",
58
59
  };
59
60
 
60
61
  return { title, messages, url: currentUrl, metadata };
package/ai/notebooklm.js CHANGED
@@ -149,6 +149,7 @@ export class NotebookLMParser extends ChatParser {
149
149
  Source: "NotebookLM",
150
150
  Date: new Date().toLocaleString(),
151
151
  Link: currentUrl,
152
+ Method: "DOM",
152
153
  };
153
154
 
154
155
  return { title, messages, url: currentUrl, metadata };
package/ai/perplexity.js CHANGED
@@ -81,6 +81,7 @@ export class PerplexityParser extends ChatParser {
81
81
  Source: "Perplexity",
82
82
  Date: new Date().toLocaleString(),
83
83
  Link: currentUrl,
84
+ Method: "DOM",
84
85
  };
85
86
 
86
87
  return { title, messages, url: currentUrl, metadata };
package/ai/qwen.js CHANGED
@@ -98,6 +98,7 @@ export class QwenParser extends ChatParser {
98
98
  Source: "Qwen",
99
99
  Date: new Date().toLocaleString(),
100
100
  Link: currentUrl,
101
+ Method: "DOM",
101
102
  };
102
103
 
103
104
  return { title, messages, url: currentUrl, metadata };
package/ai/z_ai.js CHANGED
@@ -100,6 +100,7 @@ export class ZAiParser extends ChatParser {
100
100
  Source: "Z.ai",
101
101
  Date: new Date().toLocaleString(),
102
102
  Link: currentUrl,
103
+ Method: "DOM",
103
104
  };
104
105
 
105
106
  return { title, messages, url: currentUrl, metadata };
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "decant-core",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Shared AI chat platform parsers and detection for Covai browser extensions.",
5
5
  "type": "module",
6
6
  "main": "ai/index.js",
7
7
  "exports": {
8
8
  ".": "./ai/index.js",
9
9
  "./ai/*": "./ai/*.js",
10
+ "./web": "./web/index.js",
11
+ "./web/*": "./web/*.js",
12
+ "./article": "./web/article.js",
10
13
  "./detection/*": "./detection/*.js"
11
14
  },
12
15
  "files": [
13
16
  "ai",
17
+ "web",
14
18
  "detection",
15
19
  "lib",
16
20
  "utils",
@@ -26,6 +30,7 @@
26
30
  },
27
31
  "homepage": "https://github.com/Covai-Labs/decant-core#readme",
28
32
  "scripts": {
33
+ "test": "node --test tests/*.test.js",
29
34
  "lint": "eslint .",
30
35
  "format": "prettier --write .",
31
36
  "format:check": "prettier --check ."
@@ -47,7 +52,11 @@
47
52
  "access": "public"
48
53
  },
49
54
  "dependencies": {
50
- "turndown": "^7.2.4"
55
+ "@extractus/article-extractor": "^9.0.0",
56
+ "@mozilla/readability": "^0.6.0",
57
+ "defuddle": "^0.19.2",
58
+ "turndown": "^7.2.4",
59
+ "turndown-plugin-gfm": "^1.0.2"
51
60
  },
52
61
  "devDependencies": {
53
62
  "@eslint/js": "^10.0.1",
@@ -59,6 +68,7 @@
59
68
  "@semantic-release/release-notes-generator": "^14.1.1",
60
69
  "eslint": "^10.8.0",
61
70
  "globals": "^17.9.0",
71
+ "linkedom": "^0.18.13",
62
72
  "prettier": "^3.9.6",
63
73
  "semantic-release": "^25.0.9"
64
74
  }
@@ -13,6 +13,7 @@
13
13
  * @returns {string} Markdown formatted text
14
14
  */
15
15
  import TurndownService from "../lib/turndown.js";
16
+ import { strikethrough, taskListItems } from "turndown-plugin-gfm";
16
17
 
17
18
  /**
18
19
  * Convert an HTML element to markdown
@@ -35,6 +36,9 @@ export function convertToMarkdown(htmlContent, options = {}) {
35
36
  ...options,
36
37
  });
37
38
 
39
+ // Enable GFM plugins (strikethrough and task list checkboxes)
40
+ turndownService.use([strikethrough, taskListItems]);
41
+
38
42
  // Add custom rules for better conversion
39
43
 
40
44
  // Preserve line breaks
@@ -110,8 +114,9 @@ export function convertToMarkdown(htmlContent, options = {}) {
110
114
 
111
115
  // Convert each cell's HTML to markdown using the isolated service
112
116
  const cellContents = cells.map((cell) => {
113
- let cellMarkdown = cellTurndown.turndown(cell.innerHTML);
117
+ let cellMarkdown = cellTurndown.turndown(cell);
114
118
  // Replace any actual newlines that Turndown generated (e.g. from P tags) with <br>
119
+
115
120
  // as tables cannot have literal newlines in GFM.
116
121
  return cellMarkdown.trim().replace(/\n/g, "<br>");
117
122
  });
@@ -135,7 +140,8 @@ export function convertToMarkdown(htmlContent, options = {}) {
135
140
  html = htmlContent;
136
141
  } else if (
137
142
  htmlContent &&
138
- (htmlContent instanceof HTMLElement ||
143
+ ((typeof HTMLElement !== "undefined" &&
144
+ htmlContent instanceof HTMLElement) ||
139
145
  htmlContent.nodeType === 1 ||
140
146
  htmlContent.nodeType === 9 ||
141
147
  typeof htmlContent.querySelectorAll === "function")
@@ -370,9 +376,18 @@ export function convertToMarkdown(htmlContent, options = {}) {
370
376
  } catch (error) {
371
377
  console.error("Error converting HTML to markdown:", error);
372
378
  // Fallback to plain text
373
- const parser = new DOMParser();
374
- const doc = parser.parseFromString(html, "text/html");
375
- return doc.body.innerText || doc.body.textContent || "";
379
+ if (typeof DOMParser !== "undefined") {
380
+ const parser = new DOMParser();
381
+ const doc = parser.parseFromString(html, "text/html");
382
+ return doc.body?.innerText || doc.body?.textContent || "";
383
+ }
384
+ if (typeof html === "string") {
385
+ return html
386
+ .replace(/<[^>]+>/g, " ")
387
+ .replace(/\s+/g, " ")
388
+ .trim();
389
+ }
390
+ return html?.textContent || "";
376
391
  }
377
392
  }
378
393
 
package/web/article.js ADDED
@@ -0,0 +1,286 @@
1
+ import { Readability } from "@mozilla/readability";
2
+ import Defuddle from "defuddle";
3
+ import { extractFromHtml } from "@extractus/article-extractor";
4
+ import { convertToMarkdown } from "../utils/html-to-markdown.js";
5
+ import { scoreContent, cleanTitle } from "./scoring.js";
6
+ import { ChatParser } from "../ai/base.js";
7
+
8
+ /**
9
+ * Resolves a Document object from a document or an HTML string.
10
+ * Works seamlessly in browser DOM and Node/testing environments (via linkedom).
11
+ *
12
+ * @param {Document|string} docOrHtml
13
+ * @param {string} [url]
14
+ * @returns {Promise<{ doc: Document, rawHtml: string, url: string }>}
15
+ */
16
+ async function resolveDocument(docOrHtml, url = "") {
17
+ let doc = null;
18
+ let rawHtml = "";
19
+ let finalUrl = url;
20
+
21
+ if (typeof docOrHtml === "string") {
22
+ rawHtml = docOrHtml;
23
+ if (typeof DOMParser !== "undefined") {
24
+ doc = new DOMParser().parseFromString(docOrHtml, "text/html");
25
+ } else {
26
+ const { parseHTML } = await import("linkedom");
27
+ const parsed = parseHTML(docOrHtml);
28
+ doc = parsed.document;
29
+ }
30
+ } else if (
31
+ docOrHtml &&
32
+ typeof docOrHtml === "object" &&
33
+ docOrHtml.documentElement
34
+ ) {
35
+ doc = docOrHtml;
36
+ rawHtml = docOrHtml.documentElement.innerHTML || "";
37
+ if (
38
+ !finalUrl &&
39
+ docOrHtml.location &&
40
+ docOrHtml.location.href &&
41
+ docOrHtml.location.href !== "about:blank"
42
+ ) {
43
+ finalUrl = docOrHtml.location.href;
44
+ }
45
+ } else if (typeof document !== "undefined") {
46
+ doc = document;
47
+ rawHtml = document.documentElement.innerHTML || "";
48
+ if (!finalUrl && typeof window !== "undefined" && window.location) {
49
+ finalUrl = window.location.href;
50
+ }
51
+ }
52
+
53
+ if (!finalUrl) {
54
+ finalUrl = "https://localhost/article";
55
+ }
56
+
57
+ return { doc, rawHtml, url: finalUrl };
58
+ }
59
+
60
+ /**
61
+ * Extracts content from any web page running Readability, Defuddle,
62
+ * and Article-Extractor in parallel, with intelligent scoring and arbitration.
63
+ *
64
+ * @param {Document|string} [docOrHtml] - Document or HTML string
65
+ * @param {Object} [options] - Options { url?: string, turndownOptions?: object }
66
+ * @returns {Promise<{
67
+ * title: string,
68
+ * author: string,
69
+ * published: string,
70
+ * siteName: string,
71
+ * description: string,
72
+ * image: string,
73
+ * url: string,
74
+ * content: string,
75
+ * htmlContent: string,
76
+ * markdown: string,
77
+ * engine: 'readability' | 'defuddle' | 'raw',
78
+ * wordCount: number
79
+ * }>}
80
+ */
81
+ export async function extractArticleIntelligent(docOrHtml, options = {}) {
82
+ const { doc, rawHtml, url } = await resolveDocument(docOrHtml, options.url);
83
+
84
+ if (!doc) {
85
+ throw new Error(
86
+ "Unable to resolve a valid Document object for extraction.",
87
+ );
88
+ }
89
+
90
+ // 1. Run all 3 extractors concurrently
91
+ const [readabilitySettled, defuddleSettled, extractusSettled] =
92
+ await Promise.allSettled([
93
+ // Mozilla Readability
94
+ Promise.resolve().then(() => {
95
+ try {
96
+ const clone = doc.cloneNode(true);
97
+ const reader = new Readability(clone);
98
+ return reader.parse();
99
+ } catch {
100
+ return null;
101
+ }
102
+ }),
103
+ // Defuddle
104
+ Promise.resolve().then(() => {
105
+ try {
106
+ const defuddle = new Defuddle(doc, { url });
107
+ return defuddle.parse();
108
+ } catch {
109
+ return null;
110
+ }
111
+ }),
112
+ // Article-Extractor (Metadata & JSON-LD)
113
+ Promise.resolve().then(async () => {
114
+ try {
115
+ if (!rawHtml) return null;
116
+ return await extractFromHtml(rawHtml, url);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }),
121
+ ]);
122
+
123
+ const rData =
124
+ readabilitySettled.status === "fulfilled" ? readabilitySettled.value : null;
125
+ const dData =
126
+ defuddleSettled.status === "fulfilled" ? defuddleSettled.value : null;
127
+ const mData =
128
+ extractusSettled.status === "fulfilled" ? extractusSettled.value : null;
129
+
130
+ // 2. Best-of-breed Metadata Resolution
131
+ const ogSiteName = doc
132
+ .querySelector?.('meta[property="og:site_name"], meta[name="og:site_name"]')
133
+ ?.getAttribute?.("content");
134
+ const metaAuthor = doc
135
+ .querySelector?.('meta[name="author"], meta[property="article:author"]')
136
+ ?.getAttribute?.("content");
137
+ const siteName =
138
+ ogSiteName || mData?.source || rData?.siteName || dData?.site || "";
139
+
140
+ const articleH1 = doc
141
+ .querySelector?.("article h1, main h1")
142
+ ?.textContent?.trim();
143
+ const rawTitle =
144
+ articleH1 && articleH1.length > 3
145
+ ? articleH1
146
+ : mData?.title || rData?.title || dData?.title || doc.title || "Untitled";
147
+ const title = cleanTitle(rawTitle, siteName);
148
+
149
+ const author =
150
+ metaAuthor || mData?.author || rData?.byline || dData?.author || "";
151
+ const published =
152
+ mData?.published || rData?.publishedTime || dData?.published || "";
153
+ const description =
154
+ mData?.description || rData?.excerpt || dData?.description || "";
155
+ const image = mData?.image || dData?.image || "";
156
+
157
+ // 3. Content Quality Scoring & Arbitration
158
+ const rHtml = rData?.content || "";
159
+ const dHtml = dData?.content || "";
160
+
161
+ const rScore = scoreContent(rHtml);
162
+ const dScore = scoreContent(dHtml);
163
+
164
+ let bestHtml;
165
+ let winningEngine;
166
+
167
+ if (rScore === 0 && dScore === 0) {
168
+ // Fallback: take main/article or body
169
+ const mainEl = doc.querySelector(
170
+ "main, article, #content, .content, .post",
171
+ );
172
+ bestHtml = mainEl
173
+ ? mainEl.innerHTML
174
+ : doc.body
175
+ ? doc.body.innerHTML
176
+ : rawHtml;
177
+ winningEngine = "raw";
178
+ } else if (rScore >= dScore) {
179
+ bestHtml = rHtml || dHtml;
180
+ winningEngine = "readability";
181
+ } else {
182
+ bestHtml = dHtml || rHtml;
183
+ winningEngine = "defuddle";
184
+ }
185
+
186
+ // 4. Convert to Markdown using decant-core convertToMarkdown
187
+ let markdownBody;
188
+ try {
189
+ const container = doc.createElement("div");
190
+ container.innerHTML = bestHtml;
191
+ markdownBody = convertToMarkdown(container, options.turndownOptions || {});
192
+ } catch {
193
+ markdownBody = convertToMarkdown(bestHtml, options.turndownOptions || {});
194
+ }
195
+ const cleanMarkdown = markdownBody ? markdownBody.trim() : "";
196
+ const fullMarkdown = title ? `# ${title}\n\n${cleanMarkdown}` : cleanMarkdown;
197
+
198
+ // Approximate word count
199
+ const rawText = bestHtml
200
+ .replace(/<[^>]+>/g, " ")
201
+ .replace(/\s+/g, " ")
202
+ .trim();
203
+ const wordCount = rawText ? rawText.split(" ").length : 0;
204
+
205
+ return {
206
+ title,
207
+ author,
208
+ published,
209
+ siteName,
210
+ description,
211
+ image,
212
+ url,
213
+ content: cleanMarkdown,
214
+ htmlContent: bestHtml,
215
+ markdown: fullMarkdown,
216
+ engine: winningEngine,
217
+ wordCount,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Standard alias for extractArticleIntelligent.
223
+ */
224
+ export const extractArticle = extractArticleIntelligent;
225
+
226
+ /**
227
+ * ArticleParser conforms to the decant-core Parser interface,
228
+ * allowing extensions to treat generic web pages uniformly alongside AI chat parsers.
229
+ */
230
+ export class ArticleParser extends ChatParser {
231
+ name = "WebArticle";
232
+
233
+ getPlatformName() {
234
+ return "Web Article";
235
+ }
236
+
237
+ /**
238
+ * Always available for standard HTTP/HTTPS URLs.
239
+ * @param {string} url
240
+ * @returns {boolean}
241
+ */
242
+ isAvailable(url) {
243
+ if (!url || typeof url !== "string") return false;
244
+ return (
245
+ url.startsWith("http://") ||
246
+ url.startsWith("https://") ||
247
+ url.startsWith("file://")
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Parses the current document into a standardized format.
253
+ * @param {Object} [options]
254
+ * @returns {Promise<{
255
+ * title: string,
256
+ * messages: Array<{ role: string, content: string }>,
257
+ * metadata: Record<string, string>,
258
+ * url: string
259
+ * }>}
260
+ */
261
+ async parse(options = {}) {
262
+ const article = await extractArticleIntelligent(
263
+ typeof document !== "undefined" ? document : null,
264
+ options,
265
+ );
266
+
267
+ return {
268
+ title: article.title,
269
+ messages: [
270
+ {
271
+ role: "Assistant",
272
+ content: article.content,
273
+ },
274
+ ],
275
+ metadata: {
276
+ Source: article.siteName || "Web Article",
277
+ Author: article.author || "",
278
+ Date: article.published || new Date().toISOString().split("T")[0],
279
+ Description: article.description || "",
280
+ Engine: article.engine,
281
+ },
282
+ url: article.url,
283
+ rawArticle: article,
284
+ };
285
+ }
286
+ }
package/web/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ extractArticle,
3
+ extractArticleIntelligent,
4
+ ArticleParser,
5
+ } from "./article.js";
6
+ export { scoreContent, cleanTitle } from "./scoring.js";
package/web/scoring.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Evaluates the quality and richness of extracted HTML content.
3
+ * Higher scores indicate cleaner article body with good formatting (headings, code, tables)
4
+ * and low link/navigation noise.
5
+ *
6
+ * @param {string} html
7
+ * @returns {number} Quality score >= 0
8
+ */
9
+ export function scoreContent(html) {
10
+ if (!html || typeof html !== "string") return 0;
11
+
12
+ // Strip HTML tags to get raw visible text
13
+ const rawText = html.replace(/<[^>]+>/g, "").trim();
14
+ const textLength = rawText.length;
15
+ if (textLength < 30) return 0;
16
+
17
+ // 1. Base score scaled logarithmically by text volume
18
+ let score = Math.log10(textLength) * 25;
19
+
20
+ // 2. Bonus for structural elements (paragraphs, headings)
21
+ const paragraphs = (html.match(/<p[\s>]/gi) || []).length;
22
+ const headings = (html.match(/<h[1-6][\s>]/gi) || []).length;
23
+ score += paragraphs * 2 + headings * 4;
24
+
25
+ // 3. High bonus for code blocks & tables (technical articles/documentation)
26
+ const codeBlocks = (html.match(/<pre[\s>]/gi) || []).length;
27
+ const tables = (html.match(/<table[\s>]/gi) || []).length;
28
+ const listItems = (html.match(/<li[\s>]/gi) || []).length;
29
+ score += codeBlocks * 20 + tables * 15 + Math.min(listItems, 20) * 1.5;
30
+
31
+ // 4. Heavy penalty for anchor density (detects sidebar link farms / navigation headers)
32
+ const linkMatches = html.match(/<a[^>]*>(.*?)<\/a>/gi) || [];
33
+ const linkTextLength = linkMatches.reduce((acc, tag) => {
34
+ return acc + tag.replace(/<[^>]+>/g, "").length;
35
+ }, 0);
36
+
37
+ const anchorDensity = textLength > 0 ? linkTextLength / textLength : 0;
38
+ if (anchorDensity > 0.3) {
39
+ score -= (anchorDensity - 0.3) * 150;
40
+ }
41
+
42
+ return Math.max(0, Math.round(score * 100) / 100);
43
+ }
44
+
45
+ /**
46
+ * Normalizes an article title by trimming brand/site suffixes (e.g. "Article Title | Medium" -> "Article Title").
47
+ *
48
+ * @param {string} title
49
+ * @param {string} [siteName]
50
+ * @returns {string} Cleaned title
51
+ */
52
+ export function cleanTitle(title, siteName = "") {
53
+ if (!title || typeof title !== "string") return "Untitled";
54
+ let cleaned = title.trim();
55
+
56
+ // Strip known siteName if provided at end
57
+ if (siteName && siteName.trim()) {
58
+ const escapedSite = siteName.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ const siteRegex = new RegExp(
60
+ `\\s*[\\|\\-–—:•·]\\s*${escapedSite}\\s*$`,
61
+ "i",
62
+ );
63
+ cleaned = cleaned.replace(siteRegex, "");
64
+ }
65
+
66
+ // Strip general brand suffix if short (e.g., " | BrandName", " - The Verge")
67
+ cleaned = cleaned
68
+ .replace(/\s*[|\-–—•·]\s*[^|\-–—•·]+$/, (match) => {
69
+ // If the trailing segment is relatively short (brand name), remove it
70
+ const segment = match.replace(/^[\s|\-–—•·]+/, "").trim();
71
+ return segment.length > 0 && segment.length <= 35 ? "" : match;
72
+ })
73
+ .trim();
74
+
75
+ return cleaned || title.trim() || "Untitled";
76
+ }