decant-core 1.0.1 → 1.1.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/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/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "decant-core",
3
- "version": "1.0.1",
3
+ "version": "1.1.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
+ }