decant-core 1.0.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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "decant-core",
3
+ "version": "1.0.0",
4
+ "description": "Shared AI chat platform parsers and detection for Covai browser extensions.",
5
+ "type": "module",
6
+ "main": "ai/index.js",
7
+ "exports": {
8
+ ".": "./ai/index.js",
9
+ "./ai/*": "./ai/*.js",
10
+ "./detection/*": "./detection/*.js"
11
+ },
12
+ "files": [
13
+ "ai",
14
+ "detection",
15
+ "lib",
16
+ "utils",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Covai-Labs/decant-core.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Covai-Labs/decant-core/issues"
26
+ },
27
+ "homepage": "https://github.com/Covai-Labs/decant-core#readme",
28
+ "scripts": {
29
+ "lint": "eslint .",
30
+ "format": "prettier --write .",
31
+ "format:check": "prettier --check ."
32
+ },
33
+ "keywords": [
34
+ "ai",
35
+ "chat",
36
+ "parser",
37
+ "chatgpt",
38
+ "claude",
39
+ "gemini",
40
+ "deepseek",
41
+ "copilot",
42
+ "browser-extension"
43
+ ],
44
+ "author": "Rat-S",
45
+ "license": "AGPL-3.0-only",
46
+ "dependencies": {
47
+ "turndown": "^7.2.4"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^10.0.1",
51
+ "eslint": "^10.8.0",
52
+ "globals": "^17.9.0",
53
+ "prettier": "^3.9.6"
54
+ }
55
+ }
56
+
@@ -0,0 +1,374 @@
1
+ /**
2
+ * HTML to Markdown Converter Utility
3
+ * Uses Turndown.js to convert HTML elements to markdown format
4
+ */
5
+
6
+ // Note: Turndown.js will be loaded separately via manifest.json
7
+ // This utility provides a wrapper around it for consistent usage across parsers
8
+
9
+ /**
10
+ * Convert an HTML element to markdown
11
+ * @param {HTMLElement|string} htmlContent - HTML element or HTML string to convert
12
+ * @param {Object} options - Optional configuration for the conversion
13
+ * @returns {string} Markdown formatted text
14
+ */
15
+ import TurndownService from '../lib/turndown.js';
16
+
17
+ /**
18
+ * Convert an HTML element to markdown
19
+ * @param {HTMLElement|string} htmlContent - HTML element or HTML string to convert
20
+ * @param {Object} options - Optional configuration for the conversion
21
+ * @returns {string} Markdown formatted text
22
+ */
23
+ export function convertToMarkdown(htmlContent, options = {}) {
24
+ // Configure Turndown service
25
+ const turndownService = new TurndownService({
26
+ headingStyle: 'atx', // Use # for headings (not underline style)
27
+ hr: '* * *', // Horizontal rules
28
+ bulletListMarker: '*', // Use * for bullet lists
29
+ codeBlockStyle: 'fenced', // Use ``` for code blocks
30
+ fence: '```', // Code block fence characters
31
+ emDelimiter: '*', // Use * for emphasis
32
+ strongDelimiter: '**', // Use ** for strong
33
+ linkStyle: 'inlined', // [text](url) format
34
+ linkReferenceStyle: 'full', // Full reference style for links
35
+ ...options,
36
+ });
37
+
38
+ // Add custom rules for better conversion
39
+
40
+ // Preserve line breaks
41
+ turndownService.addRule('lineBreak', {
42
+ filter: ['br'],
43
+ replacement: () => ' \n',
44
+ });
45
+
46
+ // Handle Qwen's Monaco editor code blocks
47
+ turndownService.addRule('qwenCodeBlock', {
48
+ filter: function (node) {
49
+ return node.nodeName === 'PRE' && node.classList.contains('qwen-markdown-code');
50
+ },
51
+ replacement: function (content, node) {
52
+ // Extract language from header
53
+ let language = '';
54
+ const headerDiv = node.querySelector('.qwen-markdown-code-header div');
55
+ if (headerDiv) {
56
+ language = headerDiv.textContent.trim();
57
+ }
58
+
59
+ // Extract code from Monaco editor view-lines
60
+ const viewLines = node.querySelectorAll('.view-line');
61
+ const codeLines = [];
62
+ viewLines.forEach((line) => {
63
+ // Get text content, preserving structure
64
+ let lineText = '';
65
+ const spans = line.querySelectorAll('span');
66
+ spans.forEach((span) => {
67
+ // Replace   with spaces and get text
68
+ const text = span.textContent.replace(/\u00A0/g, ' ');
69
+ lineText += text;
70
+ });
71
+ codeLines.push(lineText);
72
+ });
73
+
74
+ const code = codeLines.join('\n');
75
+ if (!code.trim()) return '';
76
+
77
+ return '\n```' + language + '\n' + code + '\n```\n';
78
+ },
79
+ });
80
+
81
+ // Add table conversion rule
82
+ turndownService.addRule('tables', {
83
+ filter: 'table',
84
+ replacement: function (content, node) {
85
+ // Convert HTML table to markdown table
86
+ const rows = Array.from(node.querySelectorAll('tr'));
87
+ if (rows.length === 0) return content;
88
+
89
+ // Create a separate Turndown service for cell content to avoid recursion/state issues
90
+ // and incorrectly stripping or double-processing content
91
+ const cellTurndown = new TurndownService({
92
+ emDelimiter: '*',
93
+ strongDelimiter: '**',
94
+ });
95
+
96
+ // Handle breaks in cells
97
+ // Convert BR to newlines first so Turndown sees them as breaks (or handle directly)
98
+ // Actually Turndown by default drops BRs or converts to newline.
99
+ // We want to force them to <br> string for table cells.
100
+ cellTurndown.addRule('lineBreak', {
101
+ filter: ['br'],
102
+ replacement: () => '<br>',
103
+ });
104
+
105
+ let markdown = '\n';
106
+ rows.forEach((row, rowIndex) => {
107
+ const cells = Array.from(row.querySelectorAll('th, td'));
108
+
109
+ // Convert each cell's HTML to markdown using the isolated service
110
+ const cellContents = cells.map((cell) => {
111
+ let cellMarkdown = cellTurndown.turndown(cell.innerHTML);
112
+ // Replace any actual newlines that Turndown generated (e.g. from P tags) with <br>
113
+ // as tables cannot have literal newlines in GFM.
114
+ return cellMarkdown.trim().replace(/\n/g, '<br>');
115
+ });
116
+
117
+ // Add row
118
+ markdown += '| ' + cellContents.join(' | ') + ' |\n';
119
+
120
+ // Add separator after header row
121
+ if (rowIndex === 0) {
122
+ markdown += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
123
+ }
124
+ });
125
+
126
+ return markdown + '\n';
127
+ },
128
+ });
129
+
130
+ // Convert the HTML
131
+ let html;
132
+ if (typeof htmlContent === 'string') {
133
+ html = htmlContent;
134
+ } else if (
135
+ htmlContent &&
136
+ (htmlContent instanceof HTMLElement ||
137
+ htmlContent.nodeType === 1 ||
138
+ htmlContent.nodeType === 9 ||
139
+ typeof htmlContent.querySelectorAll === 'function')
140
+ ) {
141
+ // Clone the element to avoid modifying the original
142
+ const clone = htmlContent.cloneNode(true);
143
+
144
+ // Convert math equations (KaTeX and data-math) to standard markdown math blocks
145
+ // 1. Process block display KaTeX
146
+ clone.querySelectorAll('.katex-display').forEach((el) => {
147
+ const annotation = el.querySelector('annotation[encoding="application/x-tex"]');
148
+ if (annotation) {
149
+ const latex = annotation.textContent.trim();
150
+ const textNode = clone.ownerDocument.createTextNode(`\n\n$$${latex}$$\n\n`);
151
+ el.parentNode.replaceChild(textNode, el);
152
+ } else {
153
+ const textNode = clone.ownerDocument.createTextNode(`\n\n$$${el.textContent.trim()}$$\n\n`);
154
+ el.parentNode.replaceChild(textNode, el);
155
+ }
156
+ });
157
+
158
+ // 2. Process inline KaTeX
159
+ clone.querySelectorAll('.katex').forEach((el) => {
160
+ if (!el.parentNode) return;
161
+ const annotation = el.querySelector('annotation[encoding="application/x-tex"]');
162
+ if (annotation) {
163
+ const latex = annotation.textContent.trim();
164
+ const textNode = clone.ownerDocument.createTextNode(`$${latex}$`);
165
+ el.parentNode.replaceChild(textNode, el);
166
+ } else {
167
+ const textNode = clone.ownerDocument.createTextNode(`$${el.textContent.trim()}$`);
168
+ el.parentNode.replaceChild(textNode, el);
169
+ }
170
+ });
171
+
172
+ // 3. Process Gemini-style data-math attributes
173
+ clone.querySelectorAll('[data-math]').forEach((el) => {
174
+ if (!el.parentNode) return;
175
+ const latex = el.getAttribute('data-math');
176
+ const isBlock = el.classList.contains('math-block') || el.tagName === 'DIV';
177
+ const replacementText = isBlock ? `\n\n$$${latex}$$\n\n` : `$${latex}$`;
178
+ const textNode = clone.ownerDocument.createTextNode(replacementText);
179
+ el.parentNode.replaceChild(textNode, el);
180
+ });
181
+
182
+ // 4. Process Google Search SGE LaTeX images with [data-xpm-latex]
183
+ clone.querySelectorAll('[data-xpm-latex]').forEach((el) => {
184
+ const copyRoot = el.closest('[data-xpm-copy-root]');
185
+ if (!copyRoot) return;
186
+ const container = el.closest('.cPGBZb') || copyRoot;
187
+ if (!container.parentNode) return;
188
+
189
+ const latex = el.getAttribute('data-xpm-latex');
190
+
191
+ // Determine if it is block math
192
+ let isBlock = false;
193
+ const parent = container.parentNode;
194
+ if (parent) {
195
+ const parentText = parent.textContent.replace(container.textContent, '').trim();
196
+ isBlock = parentText === '';
197
+ }
198
+
199
+ const replacementText = isBlock ? `\n\n$$${latex}$$\n\n` : `$${latex}$`;
200
+ const textNode = clone.ownerDocument.createTextNode(replacementText);
201
+ container.parentNode.replaceChild(textNode, container);
202
+ });
203
+
204
+ // Preprocess code blocks (pre elements) to normalize formatting and language tags
205
+ clone.querySelectorAll('pre').forEach((pre) => {
206
+ // Guard against processing detached/already-replaced pre elements
207
+ if (!clone.contains(pre)) return;
208
+
209
+ const innerPre = pre.querySelector('pre');
210
+ const codeElement = pre.querySelector('code');
211
+
212
+ if (innerPre && codeElement) {
213
+ // --- ChatGPT-style wrapped code block ---
214
+ // Find the language label from the header (usually next to an SVG icon)
215
+ let language = '';
216
+ const svg = pre.querySelector('svg');
217
+ if (svg && svg.parentElement) {
218
+ const parent = svg.parentElement;
219
+ svg.remove();
220
+ language = parent.textContent.trim().toLowerCase();
221
+ }
222
+
223
+ // Replace all <br> elements inside the code block with actual newlines
224
+ codeElement.querySelectorAll('br').forEach((br) => {
225
+ if (br.parentNode) {
226
+ const newline = clone.ownerDocument.createTextNode('\n');
227
+ br.parentNode.replaceChild(newline, br);
228
+ }
229
+ });
230
+
231
+ const codeText = codeElement.textContent;
232
+
233
+ // Create a new clean pre and code element structure
234
+ const newPre = clone.ownerDocument.createElement('pre');
235
+ const newCode = clone.ownerDocument.createElement('code');
236
+ if (language) {
237
+ newCode.className = `language-${language}`;
238
+ }
239
+ newCode.textContent = codeText;
240
+ newPre.appendChild(newCode);
241
+
242
+ // Replace the outer pre with the new clean pre
243
+ if (pre.parentNode) {
244
+ pre.parentNode.replaceChild(newPre, pre);
245
+ }
246
+ } else {
247
+ // --- Standard/SGE-style code block ---
248
+ // Extract language label if available (e.g. from Google Search SGE containers)
249
+ const code = pre.querySelector('code');
250
+ const hasLanguage =
251
+ code &&
252
+ Array.from(code.classList).some(
253
+ (cls) => cls.startsWith('language-') && cls !== 'language-',
254
+ );
255
+ if (!hasLanguage) {
256
+ const container = pre.closest('.pHpOfb') || pre.parentElement?.parentElement;
257
+ if (container) {
258
+ const langEl =
259
+ container.querySelector('.vVRw1d') || container.firstElementChild?.firstElementChild;
260
+ if (langEl && langEl !== pre) {
261
+ const language = langEl.textContent.trim().toLowerCase();
262
+ if (code) {
263
+ code.className = `language-${language}`;
264
+ }
265
+ langEl.remove();
266
+ }
267
+ }
268
+ }
269
+
270
+ // Replace all <br> elements inside the code block with actual newlines
271
+ const codeEl = pre.querySelector('code') || pre;
272
+ codeEl.querySelectorAll('br').forEach((br) => {
273
+ if (br.parentNode) {
274
+ const newline = clone.ownerDocument.createTextNode('\n');
275
+ br.parentNode.replaceChild(newline, br);
276
+ }
277
+ });
278
+ }
279
+ });
280
+
281
+ // Remove SGE "Use code with caution" text blocks
282
+ clone.querySelectorAll('*').forEach((el) => {
283
+ if (el.textContent.trim() === 'Use code with caution.') {
284
+ el.remove();
285
+ }
286
+ });
287
+
288
+ // Pre-process button-wrapped images (e.g., ChatGPT image carousels) before noise button removal
289
+ clone.querySelectorAll('button').forEach((button) => {
290
+ const img = button.querySelector('img');
291
+ if (!img) return;
292
+
293
+ let caption = 'Image';
294
+ const ariaLabel = button.getAttribute('aria-label') || '';
295
+ if (ariaLabel.toLowerCase().includes('open image details for')) {
296
+ caption = ariaLabel.replace(/^Open image details for\s*/i, '').trim();
297
+ } else if (img.getAttribute('alt') && !img.getAttribute('alt').startsWith('http')) {
298
+ caption = img.getAttribute('alt').trim();
299
+ }
300
+
301
+ const alt = img.getAttribute('alt') || '';
302
+ const src = img.getAttribute('src') || '';
303
+ const imageUrl = alt.startsWith('http://') || alt.startsWith('https://') ? alt : src;
304
+
305
+ if (imageUrl) {
306
+ const newImg = clone.ownerDocument.createElement('img');
307
+ newImg.setAttribute('src', imageUrl);
308
+ newImg.setAttribute('alt', caption);
309
+ button.parentNode.replaceChild(newImg, button);
310
+ }
311
+ });
312
+
313
+ // Remove noise elements (buttons, icons, etc.)
314
+ // Note: Exclude elements inside qwen-markdown-code to preserve Monaco editor content
315
+ const noiseSelectors = [
316
+ 'button:not(.qwen-markdown-code *)',
317
+ '.copy-button',
318
+ '[role="button"]:not(.qwen-markdown-code *)',
319
+ '.sr-only',
320
+ 'svg:not(.qwen-markdown-code *)',
321
+ '.icon:not(.qwen-markdown-code *)',
322
+ '[aria-hidden="true"]:not(.qwen-markdown-code *)',
323
+ '[id^="shrproxy"]',
324
+ '[id^="fbproxy"]',
325
+ 'div.HvurC',
326
+ '.DBd2Wb',
327
+ '.oLpkLe',
328
+ '.OUQe0e',
329
+ '.UrecDd',
330
+ '[aria-label="Share public link"]',
331
+ 'aside.L9AUvd',
332
+ 'aside.UL0w9b',
333
+ 'div.qacuz',
334
+ ];
335
+
336
+ noiseSelectors.forEach((selector) => {
337
+ clone.querySelectorAll(selector).forEach((el) => el.remove());
338
+ });
339
+
340
+ html = clone;
341
+ } else {
342
+ console.warn('Invalid input to convertToMarkdown:', htmlContent);
343
+ return '';
344
+ }
345
+
346
+ try {
347
+ const markdown = turndownService.turndown(html);
348
+ return markdown.trim();
349
+ } catch (error) {
350
+ console.error('Error converting HTML to markdown:', error);
351
+ // Fallback to plain text
352
+ const parser = new DOMParser();
353
+ const doc = parser.parseFromString(html, 'text/html');
354
+ return doc.body.innerText || doc.body.textContent || '';
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Clean up markdown text by removing common artifacts
360
+ * @param {string} markdown - Markdown text to clean
361
+ * @returns {string} Cleaned markdown
362
+ */
363
+ export function cleanMarkdown(markdown) {
364
+ return (
365
+ markdown
366
+ // Remove excessive blank lines (max 2 consecutive)
367
+ .replace(/\n{3,}/g, '\n\n')
368
+ // Remove trailing whitespace from lines
369
+ .replace(/[ \t]+$/gm, '')
370
+ // Normalize horizontal rules
371
+ .replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '* * *')
372
+ .trim()
373
+ );
374
+ }