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 +8 -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/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
|
|
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
|
-
"
|
|
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
|
+
}
|