decant-core 1.0.0 → 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/README.md +20 -20
- package/ai/base.js +4 -4
- package/ai/chatgpt.js +356 -216
- package/ai/chatgpt_helper.js +46 -28
- package/ai/chatgpt_scroll_collector.js +36 -33
- package/ai/chub.js +90 -0
- package/ai/claude.js +119 -92
- package/ai/claude_react_reader.js +8 -6
- package/ai/copilot.js +158 -124
- package/ai/deepseek.js +36 -27
- package/ai/gemini.js +384 -230
- package/ai/gemini_cloud_assist.js +31 -22
- package/ai/google_ai_studio.js +45 -27
- package/ai/google_search_ai.js +32 -23
- package/ai/index.js +33 -19
- package/ai/joyland.js +85 -0
- package/ai/lumo.js +39 -28
- package/ai/meta.js +30 -24
- package/ai/mistral.js +21 -16
- package/ai/notebooklm.js +50 -34
- package/ai/perplexity.js +22 -19
- package/ai/qwen.js +29 -25
- package/ai/z_ai.js +34 -28
- package/detection/detect-platform.js +27 -19
- package/detection/domains.js +32 -24
- package/lib/turndown.js +352 -183
- package/package.json +24 -5
- package/utils/html-to-markdown.js +148 -112
- package/web/article.js +286 -0
- package/web/index.js +6 -0
- package/web/scoring.js +76 -0
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
|
+
}
|