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 +24 -4
- package/ai/chub.js +1 -0
- package/ai/claude.js +2 -0
- package/ai/copilot.js +1 -0
- package/ai/deepseek.js +12 -2
- package/ai/gemini.js +2 -0
- package/ai/gemini_cloud_assist.js +1 -0
- package/ai/google_ai_studio.js +1 -0
- package/ai/google_search_ai.js +1 -0
- package/ai/index.js +8 -0
- package/ai/joyland.js +1 -0
- package/ai/lumo.js +1 -0
- package/ai/meta.js +1 -0
- package/ai/mistral.js +1 -0
- package/ai/notebooklm.js +1 -0
- package/ai/perplexity.js +1 -0
- package/ai/qwen.js +1 -0
- package/ai/z_ai.js +1 -0
- package/package.json +12 -2
- package/utils/html-to-markdown.js +20 -5
- package/web/article.js +286 -0
- package/web/index.js +6 -0
- package/web/scoring.js +76 -0
package/ai/chatgpt.js
CHANGED
|
@@ -798,7 +798,13 @@ export class ChatGPTParser extends ChatParser {
|
|
|
798
798
|
});
|
|
799
799
|
}
|
|
800
800
|
|
|
801
|
-
formatApiResult(
|
|
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
|
-
|
|
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
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
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 {
|
|
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 {
|
|
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
|
}
|
package/ai/google_ai_studio.js
CHANGED
package/ai/google_search_ai.js
CHANGED
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
package/ai/lumo.js
CHANGED
package/ai/meta.js
CHANGED
package/ai/mistral.js
CHANGED
package/ai/notebooklm.js
CHANGED
package/ai/perplexity.js
CHANGED
package/ai/qwen.js
CHANGED
package/ai/z_ai.js
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decant-core",
|
|
3
|
-
"version": "1.0
|
|
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
|
-
"
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
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
|
+
}
|