@link-assistant/web-capture 1.7.4 → 1.7.6

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/src/gdocs.js CHANGED
@@ -1,17 +1,23 @@
1
- // Google Docs capture module
2
- // Supports both API-based and browser-based capture of Google Docs documents.
3
- // API-based capture uses the Google Docs export URL pattern:
1
+ /* global document, window */
2
+ // Google Docs capture module.
3
+ //
4
+ // Public export capture uses:
4
5
  // https://docs.google.com/document/d/{DOCUMENT_ID}/export?format={FORMAT}
5
- // Browser-based capture navigates to the export URL using Puppeteer or Playwright.
6
+ // Docs API capture uses:
7
+ // https://docs.googleapis.com/v1/documents/{DOCUMENT_ID}
8
+ // Browser-model capture loads /edit and extracts DOCS_modelChunk data.
6
9
 
7
10
  import fetch from 'node-fetch';
8
11
  import he from 'he';
9
- import archiver from 'archiver';
10
- import { convertHtmlToMarkdown, prettyPrintHtml } from './lib.js';
12
+ import { convertHtmlToMarkdown } from './lib.js';
13
+ import { createBrowser as defaultCreateBrowser } from './browser.js';
11
14
 
12
15
  const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
13
16
 
14
17
  const GDOCS_EXPORT_BASE = 'https://docs.google.com/document/d';
18
+ const GDOCS_API_BASE = 'https://docs.googleapis.com/v1/documents';
19
+ const DEFAULT_USER_AGENT =
20
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
15
21
 
16
22
  /**
17
23
  * Supported Google Docs export formats.
@@ -67,7 +73,47 @@ export function buildExportUrl(documentId, format = 'html') {
67
73
  }
68
74
 
69
75
  /**
70
- * Fetch a Google Docs document via the export URL (API-based capture).
76
+ * Build the Google Docs editor URL used for browser-model capture.
77
+ *
78
+ * @param {string} documentId - Google Docs document ID
79
+ * @returns {string} Edit URL
80
+ */
81
+ export function buildEditUrl(documentId) {
82
+ return `${GDOCS_EXPORT_BASE}/${documentId}/edit`;
83
+ }
84
+
85
+ /**
86
+ * Build the Google Docs REST API URL.
87
+ *
88
+ * @param {string} documentId - Google Docs document ID
89
+ * @returns {string} Docs API URL
90
+ */
91
+ export function buildDocsApiUrl(documentId) {
92
+ return `${GDOCS_API_BASE}/${documentId}`;
93
+ }
94
+
95
+ /**
96
+ * Select the Google Docs capture backend for CLI --capture behavior.
97
+ *
98
+ * @param {string} [capture='browser'] - Capture flag value
99
+ * @param {string} [apiToken] - Optional token
100
+ * @returns {'browser-model'|'public-export'|'docs-api'}
101
+ */
102
+ export function selectGoogleDocsCaptureMethod(capture = 'browser', apiToken) {
103
+ const normalized = (capture || 'browser').toLowerCase();
104
+ if (normalized === 'browser') {
105
+ return 'browser-model';
106
+ }
107
+ if (normalized === 'api') {
108
+ return apiToken ? 'docs-api' : 'public-export';
109
+ }
110
+ throw new Error(
111
+ `Unsupported Google Docs capture method "${capture}". Use "browser" or "api".`
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Fetch a Google Docs document via the public export URL.
71
117
  *
72
118
  * For public documents, no authentication is needed.
73
119
  * For private documents, pass an API token via the apiToken option.
@@ -79,7 +125,7 @@ export function buildExportUrl(documentId, format = 'html') {
79
125
  * @returns {Promise<{content: string, format: string, documentId: string, exportUrl: string}>}
80
126
  */
81
127
  export async function fetchGoogleDoc(url, options = {}) {
82
- const { format = 'html', apiToken } = options;
128
+ const { format = 'html', apiToken, log } = options;
83
129
 
84
130
  const documentId = extractDocumentId(url);
85
131
  if (!documentId) {
@@ -87,22 +133,35 @@ export async function fetchGoogleDoc(url, options = {}) {
87
133
  }
88
134
 
89
135
  const exportUrl = buildExportUrl(documentId, format);
136
+ log?.debug?.(() => ({
137
+ event: 'gdocs.public-export.request',
138
+ documentId,
139
+ format,
140
+ exportUrl,
141
+ hasApiToken: Boolean(apiToken),
142
+ }));
90
143
 
91
144
  const headers = {
92
- 'User-Agent':
93
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
145
+ 'User-Agent': DEFAULT_USER_AGENT,
94
146
  'Accept-Charset': 'utf-8',
95
147
  'Accept-Language': 'en-US,en;q=0.9',
96
148
  };
97
149
 
98
150
  if (apiToken) {
99
- headers['Authorization'] = `Bearer ${apiToken}`;
151
+ headers.Authorization = `Bearer ${apiToken}`;
100
152
  }
101
153
 
102
154
  const response = await fetch(exportUrl, {
103
155
  headers,
104
156
  redirect: 'follow',
105
157
  });
158
+ log?.debug?.(() => ({
159
+ event: 'gdocs.public-export.response',
160
+ documentId,
161
+ status: response.status,
162
+ ok: response.ok,
163
+ contentType: response.headers.get('content-type'),
164
+ }));
106
165
 
107
166
  if (!response.ok) {
108
167
  const statusText = response.statusText || 'Unknown error';
@@ -112,6 +171,11 @@ export async function fetchGoogleDoc(url, options = {}) {
112
171
  }
113
172
 
114
173
  const rawContent = await response.text();
174
+ log?.debug?.(() => ({
175
+ event: 'gdocs.public-export.body',
176
+ documentId,
177
+ bytes: Buffer.byteLength(rawContent),
178
+ }));
115
179
 
116
180
  // Decode HTML entities to unicode for text-based formats
117
181
  const content =
@@ -127,6 +191,77 @@ export async function fetchGoogleDoc(url, options = {}) {
127
191
  };
128
192
  }
129
193
 
194
+ /**
195
+ * Fetch a Google Docs document using the Google Docs REST API.
196
+ *
197
+ * @param {string} url - Google Docs URL
198
+ * @param {Object} options - Options
199
+ * @param {string} options.apiToken - OAuth bearer token
200
+ * @returns {Promise<{content: string, markdown: string, html: string, text: string, document: Object, documentId: string, exportUrl: string}>}
201
+ */
202
+ export async function fetchGoogleDocFromDocsApi(url, options = {}) {
203
+ const { apiToken, log } = options;
204
+ if (!apiToken) {
205
+ throw new Error('Google Docs REST API capture requires --apiToken');
206
+ }
207
+
208
+ const documentId = extractDocumentId(url);
209
+ if (!documentId) {
210
+ throw new Error(`Not a valid Google Docs URL: ${url}`);
211
+ }
212
+
213
+ const apiUrl = buildDocsApiUrl(documentId);
214
+ log?.debug?.(() => ({
215
+ event: 'gdocs.docs-api.request',
216
+ documentId,
217
+ apiUrl,
218
+ hasApiToken: Boolean(apiToken),
219
+ }));
220
+ const response = await fetch(apiUrl, {
221
+ headers: {
222
+ Authorization: `Bearer ${apiToken}`,
223
+ Accept: 'application/json',
224
+ 'User-Agent': DEFAULT_USER_AGENT,
225
+ },
226
+ redirect: 'follow',
227
+ });
228
+ log?.debug?.(() => ({
229
+ event: 'gdocs.docs-api.response',
230
+ documentId,
231
+ status: response.status,
232
+ ok: response.ok,
233
+ contentType: response.headers.get('content-type'),
234
+ }));
235
+
236
+ if (!response.ok) {
237
+ const statusText = response.statusText || 'Unknown error';
238
+ throw new Error(
239
+ `Failed to fetch Google Doc via Docs API (${response.status} ${statusText}): ${apiUrl}`
240
+ );
241
+ }
242
+
243
+ const document = await response.json();
244
+ const rendered = renderDocsApiDocument(document);
245
+ log?.debug?.(() => ({
246
+ event: 'gdocs.docs-api.rendered',
247
+ documentId,
248
+ title: document.title,
249
+ bodyElements: document.body?.content?.length || 0,
250
+ inlineObjects: Object.keys(document.inlineObjects || {}).length,
251
+ markdownBytes: Buffer.byteLength(rendered.markdown),
252
+ htmlBytes: Buffer.byteLength(rendered.html),
253
+ textBytes: Buffer.byteLength(rendered.text),
254
+ }));
255
+
256
+ return {
257
+ ...rendered,
258
+ content: rendered.markdown,
259
+ document,
260
+ documentId,
261
+ exportUrl: apiUrl,
262
+ };
263
+ }
264
+
130
265
  /**
131
266
  * Fetch a Google Docs document and convert to Markdown.
132
267
  *
@@ -139,15 +274,21 @@ export async function fetchGoogleDoc(url, options = {}) {
139
274
  * @returns {Promise<{markdown: string, documentId: string, exportUrl: string}>}
140
275
  */
141
276
  export async function fetchGoogleDocAsMarkdown(url, options = {}) {
142
- const { apiToken } = options;
277
+ const { apiToken, log } = options;
143
278
 
144
279
  // Always fetch as HTML first, then convert to Markdown
145
280
  const result = await fetchGoogleDoc(url, {
146
281
  format: 'html',
147
282
  apiToken,
283
+ log,
148
284
  });
149
285
 
150
286
  const markdown = convertHtmlToMarkdown(result.content, result.exportUrl);
287
+ log?.debug?.(() => ({
288
+ event: 'gdocs.public-export.markdown',
289
+ documentId: result.documentId,
290
+ markdownBytes: Buffer.byteLength(markdown),
291
+ }));
151
292
 
152
293
  return {
153
294
  markdown,
@@ -156,6 +297,672 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
156
297
  };
157
298
  }
158
299
 
300
+ /**
301
+ * Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
302
+ *
303
+ * @param {string} url - Google Docs URL
304
+ * @param {Object} [options] - Capture options
305
+ * @param {string} [options.engine='playwright'] - Browser engine
306
+ * @param {string} [options.apiToken] - Optional Authorization header token
307
+ * @param {number} [options.waitMs=8000] - Post-load wait for chunks
308
+ * @param {Function} [options.createBrowser] - Browser factory for tests
309
+ * @returns {Promise<{capture: Object, markdown: string, html: string, text: string, documentId: string, exportUrl: string}>}
310
+ */
311
+ export async function captureGoogleDocWithBrowser(url, options = {}) {
312
+ const {
313
+ engine = 'playwright',
314
+ apiToken,
315
+ waitMs = 8000,
316
+ createBrowser = defaultCreateBrowser,
317
+ log,
318
+ } = options;
319
+ const documentId = extractDocumentId(url);
320
+ if (!documentId) {
321
+ throw new Error(`Not a valid Google Docs URL: ${url}`);
322
+ }
323
+
324
+ const editUrl = buildEditUrl(documentId);
325
+ log?.debug?.(() => ({
326
+ event: 'gdocs.browser-model.start',
327
+ documentId,
328
+ editUrl,
329
+ engine,
330
+ waitMs,
331
+ hasApiToken: Boolean(apiToken),
332
+ }));
333
+ const browser = await createBrowser(engine);
334
+ let page;
335
+ try {
336
+ page = await browser.newPage();
337
+ await installDocsModelCapture(page);
338
+ await page.setUserAgent(DEFAULT_USER_AGENT);
339
+ await page.setExtraHTTPHeaders({
340
+ 'Accept-Language': 'en-US,en;q=0.9',
341
+ 'Accept-Charset': 'utf-8',
342
+ ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
343
+ });
344
+ await page.setViewport({ width: 1280, height: 800 });
345
+ await page.goto(editUrl, {
346
+ waitUntil: 'domcontentloaded',
347
+ timeout: 60000,
348
+ });
349
+ log?.debug?.(() => ({
350
+ event: 'gdocs.browser-model.loaded',
351
+ documentId,
352
+ editUrl,
353
+ }));
354
+ await waitForPage(page, waitMs);
355
+
356
+ const modelData = await evaluateOnPage(page, () => {
357
+ const chunks = window.__captured_chunks || [];
358
+ const cidUrlMap = {};
359
+ const scripts = document.querySelectorAll('script');
360
+ for (const script of scripts) {
361
+ const text = script.textContent || '';
362
+ if (!text.includes('docs-images-rt')) {
363
+ continue;
364
+ }
365
+ const regex =
366
+ /"([A-Za-z0-9_-]{20,})"\s*:\s*"(https:\/\/docs\.google\.com\/docs-images-rt\/[^"]+)"/g;
367
+ let match;
368
+ while ((match = regex.exec(text)) !== null) {
369
+ cidUrlMap[match[1]] = match[2]
370
+ .replace(/\\u003d/g, '=')
371
+ .replace(/\\u0026/g, '&')
372
+ .replace(/\\\//g, '/');
373
+ }
374
+ }
375
+ return { chunks, cidUrlMap };
376
+ });
377
+
378
+ const capture = parseGoogleDocsModelChunks(
379
+ modelData.chunks,
380
+ modelData.cidUrlMap
381
+ );
382
+ log?.debug?.(() => ({
383
+ event: 'gdocs.browser-model.parsed',
384
+ documentId,
385
+ chunks: modelData.chunks?.length || 0,
386
+ cidUrls: Object.keys(modelData.cidUrlMap || {}).length,
387
+ blocks: capture.blocks.length,
388
+ tables: capture.tables.length,
389
+ images: capture.images.length,
390
+ textBytes: Buffer.byteLength(capture.text || ''),
391
+ }));
392
+ if (capture.blocks.length === 0) {
393
+ throw new Error(
394
+ 'Google Docs editor page did not expose DOCS_modelChunk data'
395
+ );
396
+ }
397
+ return {
398
+ capture,
399
+ markdown: renderGoogleDocsCapture(capture, 'markdown'),
400
+ html: renderGoogleDocsCapture(capture, 'html'),
401
+ text: renderGoogleDocsCapture(capture, 'txt'),
402
+ documentId,
403
+ exportUrl: editUrl,
404
+ };
405
+ } finally {
406
+ if (page) {
407
+ await page.close();
408
+ }
409
+ await browser.close();
410
+ }
411
+ }
412
+
413
+ async function installDocsModelCapture(page) {
414
+ const initScript = () => {
415
+ window.__captured_chunks = [];
416
+ Object.defineProperty(window, 'DOCS_modelChunk', {
417
+ set(value) {
418
+ try {
419
+ window.__captured_chunks.push(JSON.parse(JSON.stringify(value)));
420
+ } catch {
421
+ window.__captured_chunks.push(value);
422
+ }
423
+ window.__DOCS_modelChunk_latest = value;
424
+ },
425
+ get() {
426
+ return window.__DOCS_modelChunk_latest;
427
+ },
428
+ configurable: true,
429
+ });
430
+ };
431
+
432
+ const rawPage = page.rawPage || page;
433
+ if (typeof rawPage.addInitScript === 'function') {
434
+ await rawPage.addInitScript(initScript);
435
+ } else if (typeof rawPage.evaluateOnNewDocument === 'function') {
436
+ await rawPage.evaluateOnNewDocument(initScript);
437
+ } else if (typeof page.addInitScript === 'function') {
438
+ await page.addInitScript(initScript);
439
+ }
440
+ }
441
+
442
+ async function waitForPage(page, waitMs) {
443
+ if (waitMs <= 0) {
444
+ return;
445
+ }
446
+ const rawPage = page.rawPage || page;
447
+ if (typeof rawPage.waitForTimeout === 'function') {
448
+ await rawPage.waitForTimeout(waitMs);
449
+ return;
450
+ }
451
+ if (typeof page.waitForTimeout === 'function') {
452
+ await page.waitForTimeout(waitMs);
453
+ return;
454
+ }
455
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
456
+ }
457
+
458
+ async function evaluateOnPage(page, fn) {
459
+ const rawPage = page.rawPage || page;
460
+ if (rawPage !== page && typeof rawPage.evaluate === 'function') {
461
+ return await rawPage.evaluate(fn);
462
+ }
463
+ return await page.evaluate(fn);
464
+ }
465
+
466
+ /**
467
+ * Extract a CID-to-image URL map from Google Docs editor HTML.
468
+ *
469
+ * @param {string} html - Editor HTML
470
+ * @returns {Object<string,string>} CID URL map
471
+ */
472
+ export function extractCidUrlMapFromHtml(html) {
473
+ const cidUrlMap = {};
474
+ const regex =
475
+ /"([A-Za-z0-9_-]{20,})"\s*:\s*"(https:\/\/docs\.google\.com\/docs-images-rt\/[^"]+)"/g;
476
+ let match;
477
+ while ((match = regex.exec(html)) !== null) {
478
+ cidUrlMap[match[1]] = match[2]
479
+ .replace(/\\u003d/g, '=')
480
+ .replace(/\\u0026/g, '&')
481
+ .replace(/\\\//g, '/');
482
+ }
483
+ return cidUrlMap;
484
+ }
485
+
486
+ /**
487
+ * Parse captured Google Docs model chunks into blocks, tables, and text.
488
+ *
489
+ * @param {Array} chunks - Captured DOCS_modelChunk values
490
+ * @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
491
+ * @returns {{blocks: Array, tables: Array, images: Array, text: string}}
492
+ */
493
+ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
494
+ const items = collectModelItems(chunks);
495
+ const fullText = items
496
+ .filter((item) => item.ty === 'is' || item.ty === 'iss')
497
+ .map((item) => item.s || '')
498
+ .join('');
499
+
500
+ const positions = new Map();
501
+ for (const item of items) {
502
+ if ((item.ty === 'te' || item.ty === 'ste') && item.id) {
503
+ positions.set(item.id, Number(item.spi));
504
+ }
505
+ }
506
+
507
+ const imagesByPos = new Map();
508
+ const images = [];
509
+ for (const item of items) {
510
+ if (item.ty !== 'ae' && item.ty !== 'ase') {
511
+ continue;
512
+ }
513
+ const pos = positions.get(item.id);
514
+ if (!Number.isFinite(pos)) {
515
+ continue;
516
+ }
517
+
518
+ const cid = item.epm?.ee_eo?.i_cid || null;
519
+ const image = {
520
+ type: 'image',
521
+ cid,
522
+ url: cid ? cidUrlMap[cid] || null : null,
523
+ width: item.epm?.ee_eo?.i_wth,
524
+ height: item.epm?.ee_eo?.i_ht,
525
+ isSuggestion: item.ty === 'ase',
526
+ alt: item.ty === 'ase' ? 'suggested image' : 'image',
527
+ };
528
+ imagesByPos.set(pos, image);
529
+ images.push(image);
530
+ }
531
+
532
+ const blocks = [];
533
+ const tables = [];
534
+ let paragraph = [];
535
+ let table = null;
536
+ let row = null;
537
+ let cell = null;
538
+
539
+ const flushParagraph = () => {
540
+ const text = contentToText(paragraph).trim();
541
+ if (text || paragraph.some((node) => node.type === 'image')) {
542
+ blocks.push({ type: 'paragraph', content: paragraph });
543
+ }
544
+ paragraph = [];
545
+ };
546
+
547
+ const flushCell = () => {
548
+ if (cell && row) {
549
+ row.cells.push(cell);
550
+ }
551
+ cell = null;
552
+ };
553
+
554
+ const flushRow = () => {
555
+ flushCell();
556
+ if (row && table) {
557
+ table.rows.push(row);
558
+ }
559
+ row = null;
560
+ };
561
+
562
+ const flushTable = () => {
563
+ flushRow();
564
+ if (table) {
565
+ tables.push(table);
566
+ blocks.push(table);
567
+ }
568
+ table = null;
569
+ };
570
+
571
+ const targetContent = () => {
572
+ if (table) {
573
+ if (!row) {
574
+ row = { cells: [] };
575
+ }
576
+ if (!cell) {
577
+ cell = { content: [] };
578
+ }
579
+ return cell.content;
580
+ }
581
+ return paragraph;
582
+ };
583
+
584
+ for (let i = 0; i < fullText.length; i++) {
585
+ const code = fullText.charCodeAt(i);
586
+ if (code === 0x10) {
587
+ flushParagraph();
588
+ table = { type: 'table', rows: [] };
589
+ continue;
590
+ }
591
+ if (code === 0x11) {
592
+ flushTable();
593
+ continue;
594
+ }
595
+ if (code === 0x12) {
596
+ flushRow();
597
+ row = { cells: [] };
598
+ continue;
599
+ }
600
+ if (code === 0x1c) {
601
+ flushCell();
602
+ if (!row) {
603
+ row = { cells: [] };
604
+ }
605
+ cell = { content: [] };
606
+ continue;
607
+ }
608
+ if (code === 0x0a) {
609
+ if (table) {
610
+ flushRow();
611
+ } else {
612
+ flushParagraph();
613
+ }
614
+ continue;
615
+ }
616
+ if (code === 0x0b) {
617
+ appendText(targetContent(), '\n');
618
+ continue;
619
+ }
620
+
621
+ const image = imagesByPos.get(i);
622
+ if (image) {
623
+ targetContent().push(image);
624
+ if (fullText[i] === '*') {
625
+ continue;
626
+ }
627
+ }
628
+
629
+ appendText(targetContent(), fullText[i]);
630
+ }
631
+
632
+ if (table) {
633
+ flushTable();
634
+ }
635
+ flushParagraph();
636
+
637
+ return {
638
+ blocks,
639
+ tables,
640
+ images,
641
+ text: contentBlocksToText(blocks),
642
+ };
643
+ }
644
+
645
+ function collectModelItems(chunks) {
646
+ const items = [];
647
+ for (const chunk of chunks || []) {
648
+ if (Array.isArray(chunk)) {
649
+ items.push(...chunk);
650
+ } else if (Array.isArray(chunk?.chunk)) {
651
+ items.push(...chunk.chunk);
652
+ }
653
+ }
654
+ return items;
655
+ }
656
+
657
+ /**
658
+ * Render a parsed Google Docs model capture.
659
+ *
660
+ * @param {Object} capture - Parsed capture
661
+ * @param {string} [format='markdown'] - markdown, html, or txt
662
+ * @returns {string} Rendered output
663
+ */
664
+ export function renderGoogleDocsCapture(capture, format = 'markdown') {
665
+ const normalized = (format || 'markdown').toLowerCase();
666
+ if (normalized === 'html') {
667
+ return renderBlocksHtml(capture.blocks);
668
+ }
669
+ if (normalized === 'txt' || normalized === 'text') {
670
+ return contentBlocksToText(capture.blocks);
671
+ }
672
+ return renderBlocksMarkdown(capture.blocks);
673
+ }
674
+
675
+ /**
676
+ * Render a Google Docs REST API document as Markdown, HTML, and text.
677
+ *
678
+ * @param {Object} document - docs.googleapis.com v1 documents.get response
679
+ * @returns {{markdown: string, html: string, text: string}}
680
+ */
681
+ export function renderDocsApiDocument(document) {
682
+ const inlineObjects = document.inlineObjects || {};
683
+ const blocks = structuralElementsToBlocks(
684
+ document.body?.content || [],
685
+ inlineObjects
686
+ );
687
+ return {
688
+ markdown: renderBlocksMarkdown(blocks),
689
+ html: renderBlocksHtml(blocks),
690
+ text: contentBlocksToText(blocks),
691
+ };
692
+ }
693
+
694
+ function structuralElementsToBlocks(elements, inlineObjects) {
695
+ const blocks = [];
696
+ for (const element of elements || []) {
697
+ if (element.paragraph) {
698
+ const content = paragraphToContent(element.paragraph, inlineObjects);
699
+ if (
700
+ contentToText(content).trim() ||
701
+ content.some((n) => n.type === 'image')
702
+ ) {
703
+ blocks.push({
704
+ type: 'paragraph',
705
+ style: element.paragraph.paragraphStyle?.namedStyleType,
706
+ content,
707
+ });
708
+ }
709
+ } else if (element.table) {
710
+ blocks.push(tableToBlock(element.table, inlineObjects));
711
+ }
712
+ }
713
+ return blocks;
714
+ }
715
+
716
+ function tableToBlock(table, inlineObjects) {
717
+ return {
718
+ type: 'table',
719
+ rows: (table.tableRows || []).map((tableRow) => ({
720
+ cells: (tableRow.tableCells || []).map((tableCell) => ({
721
+ content: structuralElementsToInlineContent(
722
+ tableCell.content || [],
723
+ inlineObjects
724
+ ),
725
+ })),
726
+ })),
727
+ };
728
+ }
729
+
730
+ function structuralElementsToInlineContent(elements, inlineObjects) {
731
+ const content = [];
732
+ for (const element of elements || []) {
733
+ if (element.paragraph) {
734
+ const paragraphContent = paragraphToContent(
735
+ element.paragraph,
736
+ inlineObjects
737
+ );
738
+ if (content.length > 0 && paragraphContent.length > 0) {
739
+ appendText(content, '\n');
740
+ }
741
+ content.push(...paragraphContent);
742
+ } else if (element.table) {
743
+ appendText(
744
+ content,
745
+ renderBlocksMarkdown([tableToBlock(element.table, inlineObjects)])
746
+ );
747
+ }
748
+ }
749
+ return content;
750
+ }
751
+
752
+ function paragraphToContent(paragraph, inlineObjects) {
753
+ const content = [];
754
+ for (const element of paragraph.elements || []) {
755
+ if (element.textRun) {
756
+ appendText(content, element.textRun.content.replace(/\n$/u, ''));
757
+ } else if (element.inlineObjectElement) {
758
+ const image = inlineObjectToImage(
759
+ element.inlineObjectElement.inlineObjectId,
760
+ inlineObjects
761
+ );
762
+ if (image) {
763
+ content.push(image);
764
+ }
765
+ }
766
+ }
767
+ return content;
768
+ }
769
+
770
+ function inlineObjectToImage(inlineObjectId, inlineObjects) {
771
+ const embedded =
772
+ inlineObjects[inlineObjectId]?.inlineObjectProperties?.embeddedObject;
773
+ const url =
774
+ embedded?.imageProperties?.contentUri ||
775
+ embedded?.imageProperties?.sourceUri ||
776
+ null;
777
+ if (!url) {
778
+ return null;
779
+ }
780
+ return {
781
+ type: 'image',
782
+ url,
783
+ alt: embedded.title || embedded.description || 'image',
784
+ isSuggestion: false,
785
+ };
786
+ }
787
+
788
+ function appendText(content, text) {
789
+ if (!text) {
790
+ return;
791
+ }
792
+ const last = content[content.length - 1];
793
+ if (last?.type === 'text') {
794
+ last.text += text;
795
+ } else {
796
+ content.push({ type: 'text', text });
797
+ }
798
+ }
799
+
800
+ function renderBlocksMarkdown(blocks) {
801
+ return blocks
802
+ .map((block) => {
803
+ if (block.type === 'table') {
804
+ return renderTableMarkdown(block);
805
+ }
806
+ return renderParagraphMarkdown(block);
807
+ })
808
+ .filter(Boolean)
809
+ .join('\n\n')
810
+ .trimEnd();
811
+ }
812
+
813
+ function renderParagraphMarkdown(block) {
814
+ const text = renderContentMarkdown(block.content).trim();
815
+ const style = block.style || '';
816
+ const headingMatch = style.match(/^HEADING_(\d)$/u);
817
+ if (headingMatch) {
818
+ return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
819
+ }
820
+ if (style === 'TITLE') {
821
+ return `# ${text}`;
822
+ }
823
+ if (style === 'SUBTITLE') {
824
+ return `## ${text}`;
825
+ }
826
+ return text;
827
+ }
828
+
829
+ function renderTableMarkdown(table) {
830
+ if (!table.rows.length) {
831
+ return '';
832
+ }
833
+ const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
834
+ const rows = table.rows.map((row) =>
835
+ Array.from({ length: width }, (_, idx) =>
836
+ escapeMarkdownTableCell(
837
+ renderContentMarkdown(row.cells[idx]?.content || [])
838
+ )
839
+ )
840
+ );
841
+ const separator = Array.from({ length: width }, () => '---');
842
+ return [rows[0], separator, ...rows.slice(1)]
843
+ .map((row) => `| ${row.join(' | ')} |`)
844
+ .join('\n');
845
+ }
846
+
847
+ function renderContentMarkdown(content = []) {
848
+ return content
849
+ .map((node) => {
850
+ if (node.type === 'image') {
851
+ return node.url ? `![${node.alt || 'image'}](${node.url})` : '';
852
+ }
853
+ return node.text || '';
854
+ })
855
+ .join('');
856
+ }
857
+
858
+ function renderBlocksHtml(blocks) {
859
+ return `<!doctype html><html><body>${blocks
860
+ .map((block) => {
861
+ if (block.type === 'table') {
862
+ return renderTableHtml(block);
863
+ }
864
+ return renderParagraphHtml(block);
865
+ })
866
+ .join('')}</body></html>`;
867
+ }
868
+
869
+ function renderParagraphHtml(block) {
870
+ const tag = paragraphTag(block.style);
871
+ return `<${tag}>${renderContentHtml(block.content)}</${tag}>`;
872
+ }
873
+
874
+ function renderTableHtml(table) {
875
+ return `<table>${table.rows
876
+ .map(
877
+ (row) =>
878
+ `<tr>${row.cells
879
+ .map((cell) => `<td>${renderContentHtml(cell.content)}</td>`)
880
+ .join('')}</tr>`
881
+ )
882
+ .join('')}</table>`;
883
+ }
884
+
885
+ function renderContentHtml(content = []) {
886
+ return content
887
+ .map((node) => {
888
+ if (node.type === 'image') {
889
+ if (!node.url) {
890
+ return '';
891
+ }
892
+ const width = node.width ? ` width="${escapeHtml(node.width)}"` : '';
893
+ const height = node.height
894
+ ? ` height="${escapeHtml(node.height)}"`
895
+ : '';
896
+ return `<img src="${escapeHtml(node.url)}" alt="${escapeHtml(node.alt || 'image')}"${width}${height}>`;
897
+ }
898
+ return escapeHtml(node.text || '').replace(/\n/g, '<br>');
899
+ })
900
+ .join('');
901
+ }
902
+
903
+ function paragraphTag(style = '') {
904
+ const headingMatch = style.match(/^HEADING_([1-6])$/u);
905
+ if (headingMatch) {
906
+ return `h${headingMatch[1]}`;
907
+ }
908
+ if (style === 'TITLE') {
909
+ return 'h1';
910
+ }
911
+ if (style === 'SUBTITLE') {
912
+ return 'h2';
913
+ }
914
+ return 'p';
915
+ }
916
+
917
+ function contentBlocksToText(blocks) {
918
+ return blocks
919
+ .map((block) => {
920
+ if (block.type === 'table') {
921
+ return block.rows
922
+ .map((row) =>
923
+ row.cells.map((cell) => contentToText(cell.content)).join('\t')
924
+ )
925
+ .join('\n');
926
+ }
927
+ return contentToText(block.content);
928
+ })
929
+ .filter(Boolean)
930
+ .join('\n')
931
+ .trimEnd();
932
+ }
933
+
934
+ function contentToText(content = []) {
935
+ return content
936
+ .map((node) => {
937
+ if (node.type === 'image') {
938
+ return node.url ? `[${node.alt || 'image'}]` : '';
939
+ }
940
+ return node.text || '';
941
+ })
942
+ .join('');
943
+ }
944
+
945
+ function escapeHtml(value) {
946
+ return String(value).replace(/[&<>"']/g, (char) => {
947
+ switch (char) {
948
+ case '&':
949
+ return '&amp;';
950
+ case '<':
951
+ return '&lt;';
952
+ case '>':
953
+ return '&gt;';
954
+ case '"':
955
+ return '&quot;';
956
+ default:
957
+ return '&#39;';
958
+ }
959
+ });
960
+ }
961
+
962
+ function escapeMarkdownTableCell(value) {
963
+ return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
964
+ }
965
+
159
966
  /**
160
967
  * Extract base64 data URI images from HTML content.
161
968
  *
@@ -204,16 +1011,23 @@ export function extractBase64Images(html) {
204
1011
  * @returns {Promise<{documentId: string, exportUrl: string, createArchive: function}>}
205
1012
  */
206
1013
  export async function fetchGoogleDocAsArchive(url, options = {}) {
207
- const { apiToken } = options;
1014
+ const { apiToken, log } = options;
208
1015
 
209
1016
  // Fetch HTML with embedded base64 images
210
- const result = await fetchGoogleDoc(url, { format: 'html', apiToken });
1017
+ const result = await fetchGoogleDoc(url, { format: 'html', apiToken, log });
211
1018
 
212
1019
  // Extract base64 images from HTML
213
1020
  const { html: localHtml, images } = extractBase64Images(result.content);
214
1021
 
215
1022
  // Convert the localized HTML to Markdown
216
1023
  const markdown = convertHtmlToMarkdown(localHtml);
1024
+ log?.debug?.(() => ({
1025
+ event: 'gdocs.public-export.archive',
1026
+ documentId: result.documentId,
1027
+ images: images.length,
1028
+ htmlBytes: Buffer.byteLength(localHtml),
1029
+ markdownBytes: Buffer.byteLength(markdown),
1030
+ }));
217
1031
 
218
1032
  return {
219
1033
  documentId: result.documentId,
@@ -223,113 +1037,3 @@ export async function fetchGoogleDocAsArchive(url, options = {}) {
223
1037
  images,
224
1038
  };
225
1039
  }
226
-
227
- /**
228
- * Express handler for the /gdocs API endpoint.
229
- *
230
- * Query parameters:
231
- * - url (required): Google Docs URL
232
- * - format (optional): Export format (html, markdown, md, txt, pdf, docx)
233
- * - apiToken (optional): API token for private documents
234
- *
235
- * The API token can also be provided via:
236
- * - Authorization header: Bearer <token>
237
- * - X-Api-Token header: <token>
238
- *
239
- * @param {import('express').Request} req
240
- * @param {import('express').Response} res
241
- */
242
- export async function gdocsHandler(req, res) {
243
- const url = req.query.url;
244
- if (!url) {
245
- return res.status(400).send('Missing `url` parameter');
246
- }
247
-
248
- if (!isGoogleDocsUrl(url)) {
249
- return res.status(400).send('URL is not a Google Docs document URL');
250
- }
251
-
252
- // Resolve API token from query, headers, or environment
253
- const apiToken =
254
- req.query.apiToken ||
255
- extractBearerToken(req.headers.authorization) ||
256
- req.headers['x-api-token'] ||
257
- undefined;
258
-
259
- const format = (req.query.format || 'markdown').toLowerCase();
260
-
261
- try {
262
- if (format === 'archive' || format === 'zip') {
263
- return await sendGDocsArchive(res, url, apiToken);
264
- }
265
-
266
- if (format === 'markdown' || format === 'md') {
267
- const result = await fetchGoogleDocAsMarkdown(url, { apiToken });
268
- return res.type('text/markdown').send(result.markdown);
269
- }
270
-
271
- return await sendGDocsExport(res, url, format, apiToken);
272
- } catch (err) {
273
- console.error('Google Docs capture error:', err.message);
274
- return res.status(500).send(`Error capturing Google Doc: ${err.message}`);
275
- }
276
- }
277
-
278
- async function sendGDocsArchive(res, url, apiToken) {
279
- const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken });
280
-
281
- res.set('Content-Type', 'application/zip');
282
- res.set(
283
- 'Content-Disposition',
284
- `attachment; filename="gdoc-${archiveResult.documentId}.zip"`
285
- );
286
-
287
- const archive = archiver('zip', { zlib: { level: 9 } });
288
- archive.pipe(res);
289
- archive.append(archiveResult.markdown, { name: 'document.md' });
290
- archive.append(prettyPrintHtml(archiveResult.html), {
291
- name: 'document.html',
292
- });
293
- for (const img of archiveResult.images) {
294
- archive.append(img.data, { name: `images/${img.filename}` });
295
- }
296
- await archive.finalize();
297
- }
298
-
299
- const FORMAT_CONTENT_TYPES = {
300
- html: 'text/html',
301
- txt: 'text/plain',
302
- pdf: 'application/pdf',
303
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
304
- };
305
-
306
- const FORMAT_DISPOSITIONS = {
307
- pdf: 'inline; filename="document.pdf"',
308
- docx: 'inline; filename="document.docx"',
309
- };
310
-
311
- async function sendGDocsExport(res, url, format, apiToken) {
312
- const result = await fetchGoogleDoc(url, { format, apiToken });
313
- const contentType = FORMAT_CONTENT_TYPES[format] || 'text/html';
314
- const disposition = FORMAT_DISPOSITIONS[format];
315
-
316
- res.type(contentType);
317
- if (disposition) {
318
- res.set('Content-Disposition', disposition);
319
- }
320
- return res.send(result.content);
321
- }
322
-
323
- /**
324
- * Extract Bearer token from Authorization header value.
325
- *
326
- * @param {string|undefined} authHeader - Authorization header value
327
- * @returns {string|undefined} Token or undefined
328
- */
329
- function extractBearerToken(authHeader) {
330
- if (!authHeader || typeof authHeader !== 'string') {
331
- return undefined;
332
- }
333
- const match = authHeader.match(/^Bearer\s+(.+)$/i);
334
- return match ? match[1] : undefined;
335
- }