@link-assistant/web-capture 1.7.5 → 1.7.7
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/CHANGELOG.md +12 -0
- package/README.md +7 -4
- package/bin/web-capture.js +369 -115
- package/package.json +2 -1
- package/src/browser.js +15 -0
- package/src/gdocs.js +827 -12
- package/src/lib.js +9 -1
- package/src/localize-images.js +17 -2
- package/tests/unit/gdocs.test.js +161 -0
- package/tests/unit/html2md.test.js +45 -1
- package/tests/unit/localize-images.test.js +36 -0
package/src/lib.js
CHANGED
|
@@ -312,6 +312,7 @@ export function convertRelativeUrls(html, baseUrl) {
|
|
|
312
312
|
* @param {boolean} [options.extractMetadata=true] - Extract article metadata
|
|
313
313
|
* @param {boolean} [options.postProcess=true] - Apply post-processing pipeline
|
|
314
314
|
* @param {boolean} [options.detectCodeLanguage=true] - Detect/correct code languages
|
|
315
|
+
* @param {boolean} [options.preserveCodeWhitespace=false] - Keep original whitespace inside code blocks
|
|
315
316
|
* @returns {Object} Result with { markdown, metadata }
|
|
316
317
|
*/
|
|
317
318
|
export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
@@ -320,6 +321,7 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
320
321
|
extractMetadata: shouldExtractMetadata = true,
|
|
321
322
|
postProcess = true,
|
|
322
323
|
detectCodeLanguage = true,
|
|
324
|
+
preserveCodeWhitespace = false,
|
|
323
325
|
} = options;
|
|
324
326
|
|
|
325
327
|
// Ensure all URLs are absolute before Markdown conversion
|
|
@@ -422,7 +424,7 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
422
424
|
// Handle code language detection/correction
|
|
423
425
|
if (detectCodeLanguage) {
|
|
424
426
|
$('pre code').each(function () {
|
|
425
|
-
const codeText = $(this).text()
|
|
427
|
+
const codeText = $(this).text();
|
|
426
428
|
const language =
|
|
427
429
|
$(this)
|
|
428
430
|
.attr('class')
|
|
@@ -444,6 +446,12 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
444
446
|
});
|
|
445
447
|
}
|
|
446
448
|
|
|
449
|
+
if (preserveCodeWhitespace) {
|
|
450
|
+
$('pre code').each(function () {
|
|
451
|
+
$(this).text($(this).text().replace(/\r\n?/g, '\n'));
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
447
455
|
// Preprocess ARIA tables (same as original)
|
|
448
456
|
$('[role="table"]').each(function () {
|
|
449
457
|
const $table = $(this);
|
package/src/localize-images.js
CHANGED
|
@@ -24,7 +24,8 @@ import { retry } from './retry.js';
|
|
|
24
24
|
* @returns {Object[]} Array of {fullMatch, altText, url}
|
|
25
25
|
*/
|
|
26
26
|
export function extractImageReferences(markdownText) {
|
|
27
|
-
const imageRegex =
|
|
27
|
+
const imageRegex =
|
|
28
|
+
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
28
29
|
const images = [];
|
|
29
30
|
let match;
|
|
30
31
|
|
|
@@ -73,6 +74,18 @@ export function generateLocalFilename(url, index) {
|
|
|
73
74
|
return `image-${String(index + 1).padStart(2, '0')}${ext}`;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Generate a stable figure-numbered filename.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} url - Image URL
|
|
81
|
+
* @param {number} index - Zero-based index
|
|
82
|
+
* @returns {string} Local filename (e.g., 'figure-1.png')
|
|
83
|
+
*/
|
|
84
|
+
export function generateFigureFilename(url, index) {
|
|
85
|
+
const ext = getExtensionFromUrl(url);
|
|
86
|
+
return `figure-${index + 1}${ext}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* Localize images in markdown text by downloading external images
|
|
78
91
|
* and replacing URLs with local paths.
|
|
@@ -83,6 +96,7 @@ export function generateLocalFilename(url, index) {
|
|
|
83
96
|
* @param {boolean} [options.dryRun=false] - Only report what would be done
|
|
84
97
|
* @param {Function} [options.onProgress] - Callback(index, total, status, url)
|
|
85
98
|
* @param {string[]} [options.excludeDomains] - Domains to skip (already local)
|
|
99
|
+
* @param {Function} [options.filenameGenerator] - Callback(url, index, image) for local filenames
|
|
86
100
|
* @returns {Promise<Object>} Result with updated markdown and metadata
|
|
87
101
|
*/
|
|
88
102
|
export async function localizeImages(markdownText, options = {}) {
|
|
@@ -91,6 +105,7 @@ export async function localizeImages(markdownText, options = {}) {
|
|
|
91
105
|
dryRun = false,
|
|
92
106
|
onProgress,
|
|
93
107
|
excludeDomains = [],
|
|
108
|
+
filenameGenerator = generateLocalFilename,
|
|
94
109
|
} = options;
|
|
95
110
|
|
|
96
111
|
const allImages = extractImageReferences(markdownText);
|
|
@@ -128,7 +143,7 @@ export async function localizeImages(markdownText, options = {}) {
|
|
|
128
143
|
|
|
129
144
|
for (let i = 0; i < externalImages.length; i++) {
|
|
130
145
|
const image = externalImages[i];
|
|
131
|
-
const localFilename =
|
|
146
|
+
const localFilename = filenameGenerator(image.url, i, image);
|
|
132
147
|
const relativePath = `${imagesDir}/${localFilename}`;
|
|
133
148
|
|
|
134
149
|
if (onProgress) {
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -3,7 +3,13 @@ import {
|
|
|
3
3
|
isGoogleDocsUrl,
|
|
4
4
|
extractDocumentId,
|
|
5
5
|
buildExportUrl,
|
|
6
|
+
buildEditUrl,
|
|
7
|
+
buildDocsApiUrl,
|
|
6
8
|
extractBase64Images,
|
|
9
|
+
parseGoogleDocsModelChunks,
|
|
10
|
+
renderGoogleDocsCapture,
|
|
11
|
+
renderDocsApiDocument,
|
|
12
|
+
selectGoogleDocsCaptureMethod,
|
|
7
13
|
GDOCS_EXPORT_FORMATS,
|
|
8
14
|
} from '../../src/gdocs.js';
|
|
9
15
|
import {
|
|
@@ -149,6 +155,161 @@ describe('gdocs', () => {
|
|
|
149
155
|
});
|
|
150
156
|
});
|
|
151
157
|
|
|
158
|
+
describe('capture method selection (issue #72)', () => {
|
|
159
|
+
it('honors explicit browser capture for Google Docs URLs', () => {
|
|
160
|
+
expect(selectGoogleDocsCaptureMethod('browser')).toBe('browser-model');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('uses public export for API capture without token', () => {
|
|
164
|
+
expect(selectGoogleDocsCaptureMethod('api')).toBe('public-export');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('uses Docs REST API for API capture with token', () => {
|
|
168
|
+
expect(selectGoogleDocsCaptureMethod('api', 'token-123')).toBe(
|
|
169
|
+
'docs-api'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('buildEditUrl', () => {
|
|
175
|
+
it('builds the fresh Google Docs editor URL', () => {
|
|
176
|
+
expect(buildEditUrl('abc123')).toBe(
|
|
177
|
+
'https://docs.google.com/document/d/abc123/edit'
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('buildDocsApiUrl', () => {
|
|
183
|
+
it('builds the Google Docs REST API URL', () => {
|
|
184
|
+
expect(buildDocsApiUrl('abc123')).toBe(
|
|
185
|
+
'https://docs.googleapis.com/v1/documents/abc123'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('parseGoogleDocsModelChunks (issue #72)', () => {
|
|
191
|
+
it('includes regular and suggested text from DOCS_modelChunk data', () => {
|
|
192
|
+
const capture = parseGoogleDocsModelChunks([
|
|
193
|
+
{
|
|
194
|
+
chunk: [
|
|
195
|
+
{ ty: 'is', s: 'Stable ' },
|
|
196
|
+
{ ty: 'iss', s: 'suggested\n' },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
expect(capture.text).toContain('Stable suggested');
|
|
202
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toContain(
|
|
203
|
+
'Stable suggested'
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('extracts table cells and suggested images from DOCS_modelChunk data', () => {
|
|
208
|
+
const chunks = [
|
|
209
|
+
{
|
|
210
|
+
chunk: [
|
|
211
|
+
{ ty: 'is', s: String.fromCharCode(0x10) },
|
|
212
|
+
{ ty: 'is', s: String.fromCharCode(0x12) },
|
|
213
|
+
{ ty: 'is', s: String.fromCharCode(0x1c) },
|
|
214
|
+
{ ty: 'is', s: 'Cell A' },
|
|
215
|
+
{ ty: 'is', s: String.fromCharCode(0x1c) },
|
|
216
|
+
{ ty: 'is', s: '*' },
|
|
217
|
+
{ ty: 'is', s: '\n' },
|
|
218
|
+
{ ty: 'is', s: String.fromCharCode(0x11) },
|
|
219
|
+
{
|
|
220
|
+
ty: 'ase',
|
|
221
|
+
id: 'suggested-image',
|
|
222
|
+
epm: { ee_eo: { i_cid: 'cid_12345678901234567890' } },
|
|
223
|
+
},
|
|
224
|
+
{ ty: 'ste', id: 'suggested-image', spi: 10 },
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
const capture = parseGoogleDocsModelChunks(chunks, {
|
|
229
|
+
cid_12345678901234567890:
|
|
230
|
+
'https://docs.google.com/docs-images-rt/image-id',
|
|
231
|
+
});
|
|
232
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
233
|
+
|
|
234
|
+
expect(capture.tables).toHaveLength(1);
|
|
235
|
+
expect(capture.tables[0].rows[0].cells).toHaveLength(2);
|
|
236
|
+
expect(markdown).toContain('Cell A');
|
|
237
|
+
expect(markdown).toContain(
|
|
238
|
+
''
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('renderDocsApiDocument (issue #72)', () => {
|
|
244
|
+
it('renders Google Docs REST API paragraphs, tables, and inline images', () => {
|
|
245
|
+
const apiDocument = {
|
|
246
|
+
title: 'API Doc',
|
|
247
|
+
body: {
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
paragraph: {
|
|
251
|
+
elements: [{ textRun: { content: 'Intro paragraph\n' } }],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
table: {
|
|
256
|
+
tableRows: [
|
|
257
|
+
{
|
|
258
|
+
tableCells: [
|
|
259
|
+
{
|
|
260
|
+
content: [
|
|
261
|
+
{
|
|
262
|
+
paragraph: {
|
|
263
|
+
elements: [{ textRun: { content: 'Name\n' } }],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
content: [
|
|
270
|
+
{
|
|
271
|
+
paragraph: {
|
|
272
|
+
elements: [
|
|
273
|
+
{
|
|
274
|
+
inlineObjectElement: {
|
|
275
|
+
inlineObjectId: 'image-1',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
inlineObjects: {
|
|
291
|
+
'image-1': {
|
|
292
|
+
inlineObjectProperties: {
|
|
293
|
+
embeddedObject: {
|
|
294
|
+
title: 'Diagram',
|
|
295
|
+
imageProperties: {
|
|
296
|
+
contentUri: 'https://example.com/diagram.png',
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const rendered = renderDocsApiDocument(apiDocument);
|
|
305
|
+
|
|
306
|
+
expect(rendered.markdown).toContain('Intro paragraph');
|
|
307
|
+
expect(rendered.markdown).toContain('| Name | ![Diagram]');
|
|
308
|
+
expect(rendered.html).toContain('<table>');
|
|
309
|
+
expect(rendered.html).toContain('src="https://example.com/diagram.png"');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
152
313
|
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
153
314
|
it('contains all expected formats', () => {
|
|
154
315
|
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
convertHtmlToMarkdown,
|
|
3
|
+
convertHtmlToMarkdownEnhanced,
|
|
4
|
+
} from '../../src/lib.js';
|
|
2
5
|
|
|
3
6
|
describe('convertHtmlToMarkdown', () => {
|
|
4
7
|
it('removes empty links and anchor headings', () => {
|
|
@@ -608,3 +611,44 @@ describe('convertHtmlToMarkdown', () => {
|
|
|
608
611
|
);
|
|
609
612
|
});
|
|
610
613
|
});
|
|
614
|
+
|
|
615
|
+
describe('convertHtmlToMarkdownEnhanced Habr compatibility', () => {
|
|
616
|
+
it('preserves article code line breaks and extracts formulas from a Habr fixture', () => {
|
|
617
|
+
const html = `
|
|
618
|
+
<article>
|
|
619
|
+
<h1>The Links Theory 0.0.2</h1>
|
|
620
|
+
<div class="article-formatted-body">
|
|
621
|
+
<p>A formula <img class="formula" source="\\mathbf{L}: L \\to L^2" alt="formula"> remains inline.</p>
|
|
622
|
+
<figure>
|
|
623
|
+
<img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/abc.png" alt="Figure 1">
|
|
624
|
+
<figcaption>Figure 1. Link multiplication.</figcaption>
|
|
625
|
+
</figure>
|
|
626
|
+
<pre><code>L = { 1 , 2 }
|
|
627
|
+
L x L = {
|
|
628
|
+
(1, 1),
|
|
629
|
+
(1, 2),
|
|
630
|
+
}</code></pre>
|
|
631
|
+
</div>
|
|
632
|
+
</article>
|
|
633
|
+
`;
|
|
634
|
+
|
|
635
|
+
const { markdown } = convertHtmlToMarkdownEnhanced(
|
|
636
|
+
html,
|
|
637
|
+
'https://habr.com/en/articles/895896/',
|
|
638
|
+
{ preserveCodeWhitespace: true }
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
expect(markdown).toContain('$\\\\mathbf{L}: L \\\\to L^2$');
|
|
642
|
+
expect(markdown).toContain(
|
|
643
|
+
''
|
|
644
|
+
);
|
|
645
|
+
expect(markdown).toContain(`\`\`\`
|
|
646
|
+
L = { 1 , 2 }
|
|
647
|
+
L x L = {
|
|
648
|
+
(1, 1),
|
|
649
|
+
(1, 2),
|
|
650
|
+
}
|
|
651
|
+
\`\`\``);
|
|
652
|
+
expect(markdown).not.toContain('L = { 1 , 2 }L x L');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
extractImageReferences,
|
|
3
3
|
getExtensionFromUrl,
|
|
4
4
|
generateLocalFilename,
|
|
5
|
+
generateFigureFilename,
|
|
6
|
+
localizeImages,
|
|
5
7
|
} from '../../src/localize-images.js';
|
|
6
8
|
|
|
7
9
|
describe('localize-images module', () => {
|
|
@@ -75,4 +77,38 @@ describe('localize-images module', () => {
|
|
|
75
77
|
);
|
|
76
78
|
});
|
|
77
79
|
});
|
|
80
|
+
|
|
81
|
+
describe('generateFigureFilename', () => {
|
|
82
|
+
it('generates figure-numbered filenames while preserving extensions', () => {
|
|
83
|
+
expect(
|
|
84
|
+
generateFigureFilename('https://habrastorage.org/getpro/habr/a.png', 0)
|
|
85
|
+
).toBe('figure-1.png');
|
|
86
|
+
expect(
|
|
87
|
+
generateFigureFilename('https://habrastorage.org/getpro/habr/a.jpg', 9)
|
|
88
|
+
).toBe('figure-10.jpg');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('localizeImages', () => {
|
|
93
|
+
it('accepts a custom filename generator for archive-compatible paths', async () => {
|
|
94
|
+
const markdown =
|
|
95
|
+
'';
|
|
96
|
+
|
|
97
|
+
const result = await localizeImages(markdown, {
|
|
98
|
+
dryRun: true,
|
|
99
|
+
filenameGenerator: generateFigureFilename,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.markdown).toBe('');
|
|
103
|
+
expect(result.metadata).toEqual([
|
|
104
|
+
{
|
|
105
|
+
index: 1,
|
|
106
|
+
originalUrl:
|
|
107
|
+
'https://habrastorage.org/r/w1560/getpro/habr/figure.png',
|
|
108
|
+
altText: 'Figure 1',
|
|
109
|
+
localPath: 'images/figure-1.png',
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
78
114
|
});
|