@link-assistant/web-capture 1.7.17 → 1.7.18

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.18
4
+
5
+ ### Patch Changes
6
+
7
+ - 2c69a73: Fix Google Docs browser capture list semantics and continuation paragraph handling.
8
+
3
9
  ## 1.7.17
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.17",
3
+ "version": "1.7.18",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/gdocs.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import fetch from 'node-fetch';
11
11
  import he from 'he';
12
+ import * as cheerio from 'cheerio';
12
13
  import { convertHtmlToMarkdown } from './lib.js';
13
14
  import { createBrowser as defaultCreateBrowser } from './browser.js';
14
15
  import {
@@ -200,11 +201,10 @@ export async function fetchGoogleDoc(url, options = {}) {
200
201
  bytes: Buffer.byteLength(rawContent),
201
202
  }));
202
203
 
203
- // Decode HTML entities to unicode for text-based formats
204
+ // Keep HTML markup escaped so literal examples such as `<ol>` do not
205
+ // become real tags before the HTML parser sees the document.
204
206
  const content =
205
- format === 'html' || format === 'txt' || format === 'md'
206
- ? he.decode(rawContent)
207
- : rawContent;
207
+ format === 'txt' || format === 'md' ? he.decode(rawContent) : rawContent;
208
208
 
209
209
  return {
210
210
  content,
@@ -430,11 +430,16 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
430
430
  documentId,
431
431
  log,
432
432
  });
433
+ const exportHtml = await fetchGoogleDocsExportHtmlFromPage(
434
+ page,
435
+ documentId,
436
+ log
437
+ );
433
438
 
434
439
  const capture = parseGoogleDocsModelChunks(
435
440
  modelData.chunks,
436
441
  modelData.cidUrlMap,
437
- { log }
442
+ { log, exportHtml }
438
443
  );
439
444
  log?.debug?.(() => ({
440
445
  event: 'gdocs.browser-model.parsed',
@@ -567,6 +572,7 @@ export function extractCidUrlMapFromHtml(html) {
567
572
  * @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
568
573
  * @param {Object} [options] - Parse options
569
574
  * @param {Object} [options.log] - Optional verbose logger
575
+ * @param {string} [options.exportHtml] - Optional Google Docs export HTML used for list and quote semantics
570
576
  * @returns {{blocks: Array, tables: Array, images: Array, text: string}}
571
577
  */
572
578
  export function parseGoogleDocsModelChunks(
@@ -813,12 +819,17 @@ export function parseGoogleDocsModelChunks(
813
819
  }
814
820
  }
815
821
 
816
- return {
822
+ const capture = {
817
823
  blocks,
818
824
  tables,
819
825
  images,
820
826
  text: contentBlocksToText(blocks),
821
827
  };
828
+ if (options.exportHtml) {
829
+ applyGoogleDocsExportSemanticHints(capture, options.exportHtml);
830
+ capture.text = contentBlocksToText(capture.blocks);
831
+ }
832
+ return capture;
822
833
  }
823
834
 
824
835
  function cellIsEmpty(cell) {
@@ -889,13 +900,13 @@ function applyInlineTextStyle(inlineStyles, start, end, patch) {
889
900
 
890
901
  function textStyleFromModel(item) {
891
902
  const style = {};
892
- if (item.sm?.ts_bd) {
903
+ if (item.sm?.ts_bd && item.sm?.ts_bd_i !== true) {
893
904
  style.bold = true;
894
905
  }
895
- if (item.sm?.ts_it) {
906
+ if (item.sm?.ts_it && item.sm?.ts_it_i !== true) {
896
907
  style.italic = true;
897
908
  }
898
- if (item.sm?.ts_st) {
909
+ if (item.sm?.ts_st && item.sm?.ts_st_i !== true) {
899
910
  style.strike = true;
900
911
  }
901
912
  return style;
@@ -944,19 +955,136 @@ function paragraphMetaForEndPosition(styleMaps, endPos, text) {
944
955
  }
945
956
 
946
957
  function inferOrderedList(list, text) {
947
- // Google Docs does not expose imported DOCX list marker type in the public
948
- // model chunk for this document. Prefer ordered markers for list IDs and
949
- // item labels that are demonstrably numbered in the reference document.
950
- if (
951
- /ordered|^\d+(?:\.|\))|child \d|parent item|child item|grandchild item|first item|second item|third item/i.test(
952
- text
953
- )
954
- ) {
955
- return /^kix\.list\.(7|8|9|10|11|13)$/u.test(list.id);
956
- }
958
+ void list;
959
+ void text;
957
960
  return false;
958
961
  }
959
962
 
963
+ async function fetchGoogleDocsExportHtmlFromPage(page, documentId, log) {
964
+ const exportUrl = buildExportUrl(documentId, 'html');
965
+ try {
966
+ const result = await page.evaluate(async (url) => {
967
+ const response = await fetch(url, {
968
+ credentials: 'include',
969
+ redirect: 'follow',
970
+ });
971
+ return {
972
+ ok: response.ok,
973
+ status: response.status,
974
+ statusText: response.statusText,
975
+ contentType: response.headers.get('content-type'),
976
+ body: await response.text(),
977
+ };
978
+ }, exportUrl);
979
+ log?.debug?.(() => ({
980
+ event: 'gdocs.browser-model.export-hints.response',
981
+ documentId,
982
+ exportUrl,
983
+ status: result.status,
984
+ ok: result.ok,
985
+ contentType: result.contentType,
986
+ bytes: Buffer.byteLength(result.body || ''),
987
+ }));
988
+ return result.ok ? result.body || '' : null;
989
+ } catch (error) {
990
+ log?.debug?.(() => ({
991
+ event: 'gdocs.browser-model.export-hints.failed',
992
+ documentId,
993
+ exportUrl,
994
+ error: error?.message || String(error),
995
+ }));
996
+ return null;
997
+ }
998
+ }
999
+
1000
+ function applyGoogleDocsExportSemanticHints(capture, exportHtml) {
1001
+ const hints = extractGoogleDocsExportSemanticHints(exportHtml);
1002
+ let cursor = 0;
1003
+ for (const block of capture.blocks || []) {
1004
+ if (block.type !== 'paragraph') {
1005
+ continue;
1006
+ }
1007
+ const text = normalizeSemanticText(contentToText(block.content));
1008
+ if (!text) {
1009
+ continue;
1010
+ }
1011
+ const match = findNextSemanticHint(
1012
+ hints,
1013
+ text,
1014
+ cursor,
1015
+ Boolean(block.list)
1016
+ );
1017
+ if (!match) {
1018
+ continue;
1019
+ }
1020
+ cursor = match.index + 1;
1021
+ if (block.list && match.hint.listOrdered !== null) {
1022
+ block.list.ordered = match.hint.listOrdered;
1023
+ } else if (!block.list) {
1024
+ block.quote = match.hint.quote;
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ function findNextSemanticHint(hints, text, cursor, needsListHint) {
1030
+ for (let index = cursor; index < hints.length; index++) {
1031
+ const hint = hints[index];
1032
+ if (hint.text !== text) {
1033
+ continue;
1034
+ }
1035
+ if (needsListHint && hint.listOrdered === null) {
1036
+ continue;
1037
+ }
1038
+ if (!needsListHint && hint.listOrdered !== null) {
1039
+ continue;
1040
+ }
1041
+ return { index, hint };
1042
+ }
1043
+ return null;
1044
+ }
1045
+
1046
+ function extractGoogleDocsExportSemanticHints(exportHtml) {
1047
+ const preprocessed = preprocessGoogleDocsExportHtml(exportHtml).html;
1048
+ const $ = cheerio.load(preprocessed);
1049
+ const hints = [];
1050
+ $('body')
1051
+ .find('h1,h2,h3,h4,h5,h6,p,li')
1052
+ .each((_, element) => {
1053
+ const tag = element.tagName?.toLowerCase();
1054
+ const text = getExportElementSemanticText($, element, tag);
1055
+ if (!text) {
1056
+ return;
1057
+ }
1058
+ const parentList = $(element).parents('ol,ul').first();
1059
+ const listOrdered =
1060
+ tag === 'li' && parentList.length
1061
+ ? parentList[0].tagName?.toLowerCase() === 'ol'
1062
+ : null;
1063
+ hints.push({
1064
+ text,
1065
+ listOrdered,
1066
+ quote: tag !== 'li' && $(element).parents('blockquote').length > 0,
1067
+ });
1068
+ });
1069
+ return hints;
1070
+ }
1071
+
1072
+ function getExportElementSemanticText($, element, tag) {
1073
+ if (tag !== 'li') {
1074
+ return normalizeSemanticText($(element).text());
1075
+ }
1076
+ const clone = $(element).clone();
1077
+ clone.find('ol,ul').remove();
1078
+ return normalizeSemanticText(clone.text());
1079
+ }
1080
+
1081
+ function normalizeSemanticText(text) {
1082
+ return String(text || '')
1083
+ .replace(/\u00a0/g, ' ')
1084
+ .replace(/\s+/g, ' ')
1085
+ .trim();
1086
+ }
1087
+
960
1088
  function collectModelItems(chunks) {
961
1089
  const items = [];
962
1090
  for (const chunk of chunks || []) {
@@ -86,6 +86,33 @@ export const PUBLIC_TEST_DOCUMENT = {
86
86
  ],
87
87
  };
88
88
 
89
+ // The issue #108 v2 document extends the public reference fixture with Section
90
+ // 15, covering browser-model list type and continuation-paragraph regressions.
91
+ export const PUBLIC_TEST_DOCUMENT_V2 = {
92
+ id: '1Rvaod_u2wgkAUNdXG-e29yQem-P36KAQ',
93
+ canonicalUrl:
94
+ 'https://docs.google.com/document/d/1Rvaod_u2wgkAUNdXG-e29yQem-P36KAQ/edit',
95
+ };
96
+
97
+ function expectIssue108Markdown(markdown) {
98
+ expect(markdown).toContain(
99
+ '15. List & Blockquote Edge Cases (browser-mode reproducers)'
100
+ );
101
+ expect(markdown).toMatch(/1\.\s+Apple/u);
102
+ expect(markdown).toMatch(/2\.\s+Banana/u);
103
+ expect(markdown).toMatch(/3\.\s+Cherry/u);
104
+ expect(markdown).not.toMatch(/^-\s+Apple$/mu);
105
+ expect(markdown).toContain(
106
+ 'Continuation paragraph that is not a list item. Same indent as Step one.'
107
+ );
108
+ expect(markdown).not.toContain(
109
+ '> Continuation paragraph that is not a list item.'
110
+ );
111
+ expect(markdown).toMatch(/^-\s+Red$/mu);
112
+ expect(markdown).toMatch(/^-\s+Green$/mu);
113
+ expect(markdown).toMatch(/^-\s+Blue$/mu);
114
+ }
115
+
89
116
  describe('Google Docs public test document (issue #90)', () => {
90
117
  describe('URL variations', () => {
91
118
  it.each(PUBLIC_TEST_DOCUMENT.urlVariations)(
@@ -181,11 +208,19 @@ describe('Google Docs public test document (issue #90)', () => {
181
208
  baseDelay: 2000,
182
209
  });
183
210
 
211
+ const browserEngine =
212
+ process.env.GDOCS_BROWSER_ENGINE || process.env.BROWSER_ENGINE;
184
213
  const captureBrowserWithRetry = (url) =>
185
- retry(() => captureGoogleDocWithBrowser(url), {
186
- retries: 2,
187
- baseDelay: 2000,
188
- });
214
+ retry(
215
+ () =>
216
+ captureGoogleDocWithBrowser(url, {
217
+ ...(browserEngine ? { engine: browserEngine } : {}),
218
+ }),
219
+ {
220
+ retries: 2,
221
+ baseDelay: 2000,
222
+ }
223
+ );
189
224
 
190
225
  it('fetches the public document via --capture api and returns markdown', async () => {
191
226
  const { markdown, documentId, exportUrl } = await fetchWithRetry(
@@ -252,5 +287,29 @@ describe('Google Docs public test document (issue #90)', () => {
252
287
  expect(markdown).toContain('docs-images-rt/');
253
288
  expect(markdown).toContain('---');
254
289
  });
290
+
291
+ it('fetches the issue #108 v2 document via --capture api and preserves Section 15 semantics', async () => {
292
+ const { markdown, documentId, exportUrl } = await fetchWithRetry(
293
+ PUBLIC_TEST_DOCUMENT_V2.canonicalUrl
294
+ );
295
+
296
+ expect(documentId).toBe(PUBLIC_TEST_DOCUMENT_V2.id);
297
+ expect(exportUrl).toBe(
298
+ buildExportUrl(PUBLIC_TEST_DOCUMENT_V2.id, 'html')
299
+ );
300
+ expectIssue108Markdown(markdown);
301
+ });
302
+
303
+ it('captures the issue #108 v2 document via --capture browser and preserves Section 15 semantics', async () => {
304
+ const { markdown, documentId, exportUrl, capture } =
305
+ await captureBrowserWithRetry(PUBLIC_TEST_DOCUMENT_V2.canonicalUrl);
306
+
307
+ expect(documentId).toBe(PUBLIC_TEST_DOCUMENT_V2.id);
308
+ expect(exportUrl).toBe(buildEditUrl(PUBLIC_TEST_DOCUMENT_V2.id));
309
+ expect(capture.blocks.length).toBeGreaterThan(30);
310
+ expectIssue108Markdown(markdown);
311
+ expect(markdown).not.toContain('*Apple*');
312
+ expect(markdown).not.toContain('*Red*');
313
+ });
255
314
  });
256
315
  });
@@ -431,7 +431,16 @@ describe('gdocs', () => {
431
431
  ],
432
432
  },
433
433
  ];
434
- const capture = parseGoogleDocsModelChunks(chunks);
434
+ const exportHtml = `
435
+ <html><body>
436
+ <ol>
437
+ <li>First item</li>
438
+ <li>Second item</li>
439
+ <li>Third item</li>
440
+ </ol>
441
+ </body></html>
442
+ `;
443
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
435
444
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
436
445
  const lines = markdown.split('\n');
437
446
 
@@ -465,7 +474,15 @@ describe('gdocs', () => {
465
474
  ],
466
475
  },
467
476
  ];
468
- const capture = parseGoogleDocsModelChunks(chunks);
477
+ const exportHtml = `
478
+ <html><body>
479
+ <ol>
480
+ <li>First item</li>
481
+ <li>Second item</li>
482
+ </ol>
483
+ </body></html>
484
+ `;
485
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
469
486
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
470
487
 
471
488
  expect(markdown).not.toMatch(/First item\n\n2\. Second item/u);
@@ -540,7 +557,10 @@ describe('gdocs', () => {
540
557
  ],
541
558
  },
542
559
  ];
543
- const capture = parseGoogleDocsModelChunks(chunks);
560
+ const exportHtml = `
561
+ <html><body><ol><li>Parent item 1<ol><li>Child item 1.1</li><li>Child item 1.2<ol><li>Grandchild item 1.2.1</li><li>Grandchild item 1.2.2</li></ol></li><li>Child item 1.3</li></ol></li><li>Parent item 2</li></ol></body></html>
562
+ `;
563
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
544
564
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
545
565
 
546
566
  expect(markdown).toContain(
@@ -558,6 +578,91 @@ describe('gdocs', () => {
558
578
  expect(markdown).not.toContain('- Child item 1.1');
559
579
  });
560
580
 
581
+ it('uses export semantics for ambiguous browser-model lists and continuation paragraphs (issue #108)', () => {
582
+ const text = [
583
+ 'Apple',
584
+ 'Banana',
585
+ 'Cherry',
586
+ 'Step one',
587
+ 'Continuation paragraph that is not a list item. Same indent as Step one.',
588
+ 'Step two',
589
+ 'Red',
590
+ 'Green',
591
+ 'Blue',
592
+ '',
593
+ ].join('\n');
594
+ const lineEnd = (needle) => text.indexOf('\n', text.indexOf(needle)) + 1;
595
+ const listStyle = (needle, id) => ({
596
+ ty: 'as',
597
+ st: 'list',
598
+ si: lineEnd(needle),
599
+ ei: lineEnd(needle),
600
+ sm: { ls_id: id },
601
+ });
602
+ const paragraphStyle = (needle, indentStart, indentFirstLine) => ({
603
+ ty: 'as',
604
+ st: 'paragraph',
605
+ si: lineEnd(needle),
606
+ ei: lineEnd(needle),
607
+ sm: { ps_il: indentStart, ps_ifl: indentFirstLine },
608
+ });
609
+ const chunks = [
610
+ {
611
+ chunk: [
612
+ { ty: 'is', s: text },
613
+ listStyle('Apple', 'kix.ordered'),
614
+ listStyle('Banana', 'kix.ordered'),
615
+ listStyle('Cherry', 'kix.ordered'),
616
+ listStyle('Step one', 'kix.step-one'),
617
+ paragraphStyle(
618
+ 'Continuation paragraph that is not a list item',
619
+ 36,
620
+ 36
621
+ ),
622
+ listStyle('Step two', 'kix.step-two'),
623
+ listStyle('Red', 'kix.bullets'),
624
+ listStyle('Green', 'kix.bullets'),
625
+ listStyle('Blue', 'kix.bullets'),
626
+ {
627
+ ty: 'as',
628
+ st: 'text',
629
+ si: 1,
630
+ ei: text.length - 1,
631
+ sm: { ts_it: true, ts_it_i: true },
632
+ },
633
+ ],
634
+ },
635
+ ];
636
+ const exportHtml = `
637
+ <html><body>
638
+ <ol><li>Apple</li><li>Banana</li><li>Cherry</li></ol>
639
+ <ol><li>Step one</li></ol>
640
+ <p>Continuation paragraph that is not a list item. Same indent as Step one.</p>
641
+ <ol><li>Step two</li></ol>
642
+ <ul><li>Red</li><li>Green</li><li>Blue</li></ul>
643
+ </body></html>
644
+ `;
645
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
646
+ const markdown = renderGoogleDocsCapture(capture, 'markdown');
647
+ const html = renderGoogleDocsCapture(capture, 'html');
648
+
649
+ expect(markdown).toContain('1. Apple\n2. Banana\n3. Cherry');
650
+ expect(markdown).not.toContain('- Apple');
651
+ expect(markdown).toContain(
652
+ 'Continuation paragraph that is not a list item. Same indent as Step one.'
653
+ );
654
+ expect(markdown).not.toContain(
655
+ '> Continuation paragraph that is not a list item.'
656
+ );
657
+ expect(markdown).toContain('- Red\n- Green\n- Blue');
658
+ expect(markdown).not.toContain('*Apple*');
659
+ expect(html).toContain('<ol><li>Apple</li></ol>');
660
+ expect(html).toContain('<ul><li>Red</li></ul>');
661
+ expect(html).not.toContain(
662
+ '<blockquote>Continuation paragraph that is not a list item.'
663
+ );
664
+ });
665
+
561
666
  it('renders model style records for headings, inline formatting, links, lists, blockquotes, rules, and images', () => {
562
667
  const text = [
563
668
  'Title',