@link-assistant/web-capture 1.1.2 → 1.7.1

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -13
  2. package/Dockerfile +26 -21
  3. package/README.md +290 -160
  4. package/bin/web-capture.js +622 -17
  5. package/package.json +15 -4
  6. package/src/animation.js +283 -0
  7. package/src/archive.js +225 -0
  8. package/src/batch.js +180 -0
  9. package/src/browser.js +150 -111
  10. package/src/docx.js +182 -0
  11. package/src/extract-images.js +120 -0
  12. package/src/figures.js +208 -0
  13. package/src/gdocs.js +335 -0
  14. package/src/image.js +65 -8
  15. package/src/index.js +15 -0
  16. package/src/latex.js +127 -0
  17. package/src/lib.js +281 -1
  18. package/src/localize-images.js +202 -0
  19. package/src/markdown.js +7 -2
  20. package/src/metadata.js +333 -0
  21. package/src/pdf.js +76 -0
  22. package/src/popups.js +171 -0
  23. package/src/postprocess.js +263 -0
  24. package/src/retry.js +48 -0
  25. package/src/themed-image.js +167 -0
  26. package/src/verify.js +312 -0
  27. package/tests/e2e/docker.test.js +51 -12
  28. package/tests/e2e/process.test.js +34 -10
  29. package/tests/fixtures/habr-articles.js +45 -0
  30. package/tests/integration/api-endpoints.test.js +231 -0
  31. package/tests/integration/habr-article.test.js +337 -0
  32. package/tests/mock/index.test.js +5 -3
  33. package/tests/unit/animation.test.js +33 -0
  34. package/tests/unit/batch.test.js +114 -0
  35. package/tests/unit/browser.test.js +38 -2
  36. package/tests/unit/extract-images.test.js +233 -0
  37. package/tests/unit/figures.test.js +74 -0
  38. package/tests/unit/gdocs.test.js +278 -0
  39. package/tests/unit/heading-image.test.js +49 -0
  40. package/tests/unit/latex.test.js +123 -0
  41. package/tests/unit/localize-images.test.js +78 -0
  42. package/tests/unit/metadata.test.js +140 -0
  43. package/tests/unit/postprocess.test.js +110 -0
  44. package/tests/unit/retry.test.js +111 -0
  45. package/tests/unit/verify.test.js +182 -0
  46. package/.github/workflows/release.yml +0 -294
  47. package/ARCHITECTURE.md +0 -982
  48. package/LICENSE +0 -24
  49. package/apply.sh +0 -166
  50. package/docs/market-analysis-2025-05-13.md +0 -64
  51. package/scripts/changeset-version.mjs +0 -38
  52. package/scripts/create-github-release.mjs +0 -93
  53. package/scripts/create-manual-changeset.mjs +0 -88
  54. package/scripts/format-github-release.mjs +0 -83
  55. package/scripts/format-release-notes.mjs +0 -218
  56. package/scripts/instant-version-bump.mjs +0 -121
  57. package/scripts/publish-to-npm.mjs +0 -128
  58. package/scripts/setup-npm.mjs +0 -37
  59. package/scripts/validate-changeset.mjs +0 -106
  60. package/scripts/version-and-commit.mjs +0 -237
package/src/batch.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Batch processing and configuration module (R7).
3
+ *
4
+ * Supports processing multiple URLs from a configuration file.
5
+ * Configuration format matches the articles-config pattern from meta-theory.
6
+ *
7
+ * Based on reference implementation from:
8
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/articles-config.mjs
9
+ *
10
+ * @module batch
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import { URL } from 'url';
15
+
16
+ /**
17
+ * @typedef {Object} ArticleConfig
18
+ * @property {string} url - Article URL
19
+ * @property {string} [title] - Article title
20
+ * @property {string} [language] - Article language (e.g., 'en', 'ru')
21
+ * @property {string} [archivePath] - Local archive directory path
22
+ * @property {string} [markdownFile] - Output markdown filename
23
+ * @property {string} [screenshotLightFile] - Light theme screenshot filename
24
+ * @property {string} [screenshotDarkFile] - Dark theme screenshot filename
25
+ * @property {string} [imagesDir] - Images directory name
26
+ * @property {boolean} [hasLocalImages] - Whether to download images locally
27
+ * @property {number} [expectedFigures] - Expected number of figure images
28
+ * @property {string} [format] - Output format override
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} BatchConfig
33
+ * @property {Object<string, ArticleConfig>} articles - Map of version/id to article config
34
+ * @property {Object} [defaults] - Default options applied to all articles
35
+ */
36
+
37
+ /**
38
+ * Load batch configuration from a JSON or JavaScript file.
39
+ *
40
+ * Supports:
41
+ * - JSON files (.json)
42
+ * - JavaScript/ESM files (.mjs, .js) with default or named exports
43
+ *
44
+ * @param {string} configPath - Path to configuration file
45
+ * @returns {Promise<BatchConfig>} Loaded configuration
46
+ */
47
+ export async function loadConfig(configPath) {
48
+ const ext = configPath.split('.').pop().toLowerCase();
49
+
50
+ if (ext === 'json') {
51
+ const content = fs.readFileSync(configPath, 'utf-8');
52
+ return JSON.parse(content);
53
+ }
54
+
55
+ if (ext === 'mjs' || ext === 'js') {
56
+ // Dynamic import for ESM modules
57
+ const fileUrl = new URL(`file://${configPath}`);
58
+ const module = await import(fileUrl.href);
59
+
60
+ // Support both default export and named exports
61
+ if (module.default) {
62
+ return module.default;
63
+ }
64
+
65
+ // If module exports ARTICLES (matching meta-theory pattern)
66
+ if (module.ARTICLES) {
67
+ return { articles: module.ARTICLES };
68
+ }
69
+
70
+ return module;
71
+ }
72
+
73
+ throw new Error(
74
+ `Unsupported config format: .${ext}. Use .json, .mjs, or .js`
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Get article configuration by version/id.
80
+ *
81
+ * @param {BatchConfig} config - Batch configuration
82
+ * @param {string} version - Article version/id
83
+ * @returns {ArticleConfig} Article configuration
84
+ */
85
+ export function getArticle(config, version) {
86
+ const article = config.articles[version];
87
+ if (!article) {
88
+ throw new Error(
89
+ `Unknown article version: ${version}. Available: ${Object.keys(config.articles).join(', ')}`
90
+ );
91
+ }
92
+ // Merge defaults
93
+ return { ...config.defaults, ...article };
94
+ }
95
+
96
+ /**
97
+ * Get all article versions from configuration.
98
+ *
99
+ * @param {BatchConfig} config - Batch configuration
100
+ * @returns {string[]} Array of version/id strings
101
+ */
102
+ export function getAllVersions(config) {
103
+ return Object.keys(config.articles);
104
+ }
105
+
106
+ /**
107
+ * Get all article configurations.
108
+ *
109
+ * @param {BatchConfig} config - Batch configuration
110
+ * @returns {ArticleConfig[]} Array of article configs (with defaults merged)
111
+ */
112
+ export function getAllArticles(config) {
113
+ return Object.values(config.articles).map((article) => ({
114
+ ...config.defaults,
115
+ ...article,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Create a default batch configuration for a list of URLs.
121
+ *
122
+ * @param {string[]} urls - Array of URLs to process
123
+ * @param {Object} [defaults] - Default options for all articles
124
+ * @returns {BatchConfig} Generated configuration
125
+ */
126
+ export function createConfigFromUrls(urls, defaults = {}) {
127
+ const articles = {};
128
+
129
+ urls.forEach((url, index) => {
130
+ const id = String(index + 1);
131
+ let hostname;
132
+ try {
133
+ hostname = new URL(url).hostname;
134
+ } catch {
135
+ hostname = 'article';
136
+ }
137
+
138
+ articles[id] = {
139
+ url,
140
+ title: `Article ${id}`,
141
+ archivePath: `archive/${hostname.replace(/\./g, '-')}/${id}`,
142
+ markdownFile: 'document.md',
143
+ screenshotLightFile: 'document-light.png',
144
+ screenshotDarkFile: 'document-dark.png',
145
+ imagesDir: 'images',
146
+ hasLocalImages: true,
147
+ };
148
+ });
149
+
150
+ return { articles, defaults };
151
+ }
152
+
153
+ /**
154
+ * Validate a batch configuration.
155
+ *
156
+ * @param {BatchConfig} config - Configuration to validate
157
+ * @returns {Object} Validation result with {valid, errors}
158
+ */
159
+ export function validateConfig(config) {
160
+ const errors = [];
161
+
162
+ if (!config.articles || typeof config.articles !== 'object') {
163
+ errors.push('Configuration must have an "articles" object');
164
+ return { valid: false, errors };
165
+ }
166
+
167
+ for (const [id, article] of Object.entries(config.articles)) {
168
+ if (!article.url) {
169
+ errors.push(`Article "${id}" missing required "url" field`);
170
+ } else {
171
+ try {
172
+ new URL(article.url);
173
+ } catch {
174
+ errors.push(`Article "${id}" has invalid URL: ${article.url}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ return { valid: errors.length === 0, errors };
180
+ }
package/src/browser.js CHANGED
@@ -1,17 +1,37 @@
1
- // Browser abstraction layer to support both Puppeteer and Playwright
2
- import puppeteer from 'puppeteer';
3
- import playwright from 'playwright';
1
+ // Browser abstraction layer using browser-commander for all browser operations
2
+ // See: https://github.com/link-foundation/browser-commander
3
+
4
+ import {
5
+ launchBrowser,
6
+ makeBrowserCommander,
7
+ emulateMedia,
8
+ } from 'browser-commander';
9
+ import os from 'os';
10
+ import path from 'path';
4
11
 
5
12
  /**
6
- * Unified browser interface that works with both Puppeteer and Playwright
7
- * @typedef {Object} BrowserAdapter
8
- * @property {Function} newPage - Create a new page
9
- * @property {Function} close - Close the browser
10
- * @property {string} type - Browser type ('puppeteer' or 'playwright')
13
+ * Additional Chrome args needed for headless server environments
14
+ * These are appended to browser-commander's default CHROME_ARGS
11
15
  */
16
+ const SERVER_CHROME_ARGS = [
17
+ '--no-sandbox',
18
+ '--disable-setuid-sandbox',
19
+ '--disable-dev-shm-usage',
20
+ ];
12
21
 
13
22
  /**
14
- * Unified page interface
23
+ * Unified page interface wrapping browser-commander's makeBrowserCommander.
24
+ *
25
+ * Exposes:
26
+ * - setExtraHTTPHeaders / setUserAgent / setViewport / goto / content / screenshot / close
27
+ * (via the underlying raw page for cross-engine compatibility)
28
+ * - commander.pdf() – PDF generation (browser-commander v0.8.0)
29
+ * - commander.emulateMedia() – Color scheme emulation (browser-commander v0.7.0)
30
+ * - commander.keyboard – Keyboard interaction (browser-commander v0.7.0)
31
+ * - commander.onDialog() – Dialog event handling (browser-commander v0.7.0)
32
+ * - commander.evaluate() – Page evaluation (browser-commander)
33
+ * All other browser-commander APIs are available on the returned commander object.
34
+ *
15
35
  * @typedef {Object} PageAdapter
16
36
  * @property {Function} setExtraHTTPHeaders - Set HTTP headers
17
37
  * @property {Function} setUserAgent - Set user agent
@@ -20,158 +40,177 @@ import playwright from 'playwright';
20
40
  * @property {Function} content - Get page HTML content
21
41
  * @property {Function} screenshot - Take screenshot
22
42
  * @property {Function} close - Close the page
23
- * @property {Object} _page - Original page object
24
- * @property {string} _type - Browser type
43
+ * @property {Function} pdf - Generate PDF (browser-commander v0.8.0)
44
+ * @property {Function} emulateMedia - Color scheme emulation (browser-commander v0.7.0)
45
+ * @property {Object} keyboard - Keyboard interaction (browser-commander v0.7.0)
46
+ * @property {Function} onDialog - Dialog event handling (browser-commander v0.7.0)
47
+ * @property {Function} evaluate - Page evaluation
48
+ * @property {string} type - Browser type ('puppeteer' or 'playwright')
25
49
  */
26
50
 
27
51
  /**
28
- * Create a browser instance using the specified engine
52
+ * Create a browser instance using the specified engine.
53
+ * Uses browser-commander's launchBrowser + makeBrowserCommander facade.
54
+ *
29
55
  * @param {string} engine - 'puppeteer' or 'playwright' (defaults to puppeteer)
30
56
  * @param {Object} options - Browser launch options
31
- * @returns {Promise<BrowserAdapter>}
57
+ * @param {string} [options.colorScheme] - Color scheme: 'light', 'dark', or 'no-preference'
58
+ * @returns {Promise<Object>} - Browser handle with newPage() and close()
32
59
  */
33
60
  export async function createBrowser(engine = 'puppeteer', options = {}) {
34
61
  const normalizedEngine = engine.toLowerCase();
62
+ const engineType =
63
+ normalizedEngine === 'playwright' || normalizedEngine === 'pw'
64
+ ? 'playwright'
65
+ : 'puppeteer';
35
66
 
36
- if (normalizedEngine === 'playwright') {
37
- return createPlaywrightBrowser(options);
38
- } else {
39
- return createPuppeteerBrowser(options);
40
- }
41
- }
67
+ const { colorScheme, ...launchOpts } = options;
42
68
 
43
- /**
44
- * Create a Puppeteer browser instance
45
- * @param {Object} options - Puppeteer launch options
46
- * @returns {Promise<BrowserAdapter>}
47
- */
48
- async function createPuppeteerBrowser(options = {}) {
49
- const defaultOptions = {
50
- args: [
51
- '--no-sandbox',
52
- '--disable-setuid-sandbox',
53
- '--disable-dev-shm-usage',
54
- ],
55
- };
69
+ // Generate unique userDataDir for this session to avoid conflicts
70
+ const userDataDir = path.join(
71
+ os.tmpdir(),
72
+ `web-capture-${engineType}-${Date.now()}`
73
+ );
74
+
75
+ // Use browser-commander's launchBrowser with server-specific args
76
+ const { browser, page: initialPage } = await launchBrowser({
77
+ engine: engineType,
78
+ args: SERVER_CHROME_ARGS,
79
+ headless: true,
80
+ userDataDir,
81
+ slowMo: 0,
82
+ ...launchOpts,
83
+ });
56
84
 
57
- const browser = await puppeteer.launch({ ...defaultOptions, ...options });
85
+ // Close the initial page since we'll create new ones via newPage()
86
+ await initialPage.close();
58
87
 
59
88
  return {
60
89
  async newPage() {
61
- const page = await browser.newPage();
62
- return createPuppeteerPageAdapter(page);
63
- },
64
- async close() {
65
- await browser.close();
66
- },
67
- type: 'puppeteer',
68
- _browser: browser,
69
- };
70
- }
90
+ const rawPage = await browser.newPage();
71
91
 
72
- /**
73
- * Create a Playwright browser instance
74
- * @param {Object} options - Playwright launch options
75
- * @returns {Promise<BrowserAdapter>}
76
- */
77
- async function createPlaywrightBrowser(options = {}) {
78
- const defaultOptions = {
79
- args: [
80
- '--no-sandbox',
81
- '--disable-setuid-sandbox',
82
- '--disable-dev-shm-usage',
83
- ],
84
- };
92
+ // Apply color scheme emulation using browser-commander's unified API (v0.7.0+)
93
+ if (colorScheme) {
94
+ try {
95
+ await emulateMedia({
96
+ page: rawPage,
97
+ engine: engineType,
98
+ colorScheme,
99
+ });
100
+ } catch {
101
+ /* emulateMedia not available in all environments */
102
+ }
103
+ }
85
104
 
86
- // Playwright uses chromium by default
87
- const browser = await playwright.chromium.launch({
88
- ...defaultOptions,
89
- ...options,
90
- });
105
+ // Wrap with the full browser-commander facade for unified API access
106
+ const commander = makeBrowserCommander({ page: rawPage });
91
107
 
92
- return {
93
- async newPage() {
94
- const page = await browser.newPage();
95
- return createPlaywrightPageAdapter(page);
108
+ // Build a unified page adapter that exposes both:
109
+ // 1. The familiar cross-engine adapter interface (setExtraHTTPHeaders, goto, etc.)
110
+ // 2. All browser-commander v0.7.0+ APIs (pdf, emulateMedia, keyboard, onDialog, evaluate)
111
+ return createPageAdapter(rawPage, commander, engineType);
96
112
  },
97
113
  async close() {
98
114
  await browser.close();
99
115
  },
100
- type: 'playwright',
101
- _browser: browser,
116
+ type: engineType,
117
+ rawBrowser: browser,
102
118
  };
103
119
  }
104
120
 
105
121
  /**
106
- * Create a page adapter for Puppeteer
107
- * @param {Object} page - Puppeteer page object
122
+ * Create a unified page adapter from a raw page and its browser-commander facade.
123
+ * Provides a stable cross-engine interface plus all new browser-commander APIs.
124
+ *
125
+ * @param {Object} rawPage - Raw Playwright or Puppeteer page
126
+ * @param {Object} commander - browser-commander facade from makeBrowserCommander
127
+ * @param {string} engineType - 'puppeteer' or 'playwright'
108
128
  * @returns {PageAdapter}
109
129
  */
110
- function createPuppeteerPageAdapter(page) {
130
+ function createPageAdapter(rawPage, commander, engineType) {
111
131
  return {
132
+ // --- Cross-engine adapter methods (stable interface) ---
133
+
112
134
  async setExtraHTTPHeaders(headers) {
113
- await page.setExtraHTTPHeaders(headers);
135
+ await rawPage.setExtraHTTPHeaders(headers);
114
136
  },
115
137
  async setUserAgent(userAgent) {
116
- await page.setUserAgent(userAgent);
138
+ if (engineType === 'playwright') {
139
+ // Playwright doesn't have page.setUserAgent, use extraHTTPHeaders instead
140
+ await rawPage.setExtraHTTPHeaders({ 'User-Agent': userAgent });
141
+ } else {
142
+ await rawPage.setUserAgent(userAgent);
143
+ }
117
144
  },
118
145
  async setViewport(viewport) {
119
- await page.setViewport(viewport);
146
+ if (engineType === 'playwright') {
147
+ // Playwright uses setViewportSize instead of setViewport
148
+ await rawPage.setViewportSize(viewport);
149
+ } else {
150
+ await rawPage.setViewport(viewport);
151
+ }
120
152
  },
121
153
  async goto(url, options = {}) {
122
- await page.goto(url, options);
154
+ if (engineType === 'playwright') {
155
+ // Convert Puppeteer waitUntil options to Playwright equivalents
156
+ const playwrightOptions = { ...options };
157
+ if (playwrightOptions.waitUntil === 'networkidle0') {
158
+ playwrightOptions.waitUntil = 'networkidle';
159
+ }
160
+ await rawPage.goto(url, playwrightOptions);
161
+ } else {
162
+ await rawPage.goto(url, options);
163
+ }
123
164
  },
124
165
  async content() {
125
- return await page.content();
166
+ return await rawPage.content();
126
167
  },
127
168
  async screenshot(options = {}) {
128
- return await page.screenshot(options);
169
+ return await rawPage.screenshot(options);
129
170
  },
130
171
  async close() {
131
- await page.close();
172
+ await commander.destroy();
173
+ await rawPage.close();
132
174
  },
133
- _page: page,
134
- _type: 'puppeteer',
135
- };
136
- }
137
175
 
138
- /**
139
- * Create a page adapter for Playwright
140
- * @param {Object} page - Playwright page object
141
- * @returns {PageAdapter}
142
- */
143
- function createPlaywrightPageAdapter(page) {
144
- return {
145
- async setExtraHTTPHeaders(headers) {
146
- await page.setExtraHTTPHeaders(headers);
176
+ // --- browser-commander v0.8.0+: PDF generation ---
177
+ // Usage: await page.pdf({ pdfOptions: { format: 'A4', printBackground: true } })
178
+ async pdf(opts = {}) {
179
+ return await commander.pdf(opts);
147
180
  },
148
- async setUserAgent(userAgent) {
149
- // Playwright doesn't have page.setUserAgent, use extraHTTPHeaders instead
150
- await page.setExtraHTTPHeaders({ 'User-Agent': userAgent });
181
+
182
+ // --- browser-commander v0.7.0+: Color scheme emulation ---
183
+ // Usage: await page.emulateMedia({ colorScheme: 'dark' })
184
+ async emulateMedia(opts = {}) {
185
+ return await commander.emulateMedia(opts);
151
186
  },
152
- async setViewport(viewport) {
153
- // Playwright uses setViewportSize instead of setViewport
154
- await page.setViewportSize(viewport);
187
+
188
+ // --- browser-commander v0.7.0+: Keyboard interaction ---
189
+ // Usage: await page.keyboard.press('Escape')
190
+ get keyboard() {
191
+ return commander.keyboard;
155
192
  },
156
- async goto(url, options = {}) {
157
- // Convert Puppeteer waitUntil options to Playwright equivalents
158
- const playwrightOptions = { ...options };
159
- if (playwrightOptions.waitUntil === 'networkidle0') {
160
- playwrightOptions.waitUntil = 'networkidle';
161
- }
162
- await page.goto(url, playwrightOptions);
193
+
194
+ // --- browser-commander v0.7.0+: Dialog event handling ---
195
+ // Usage: page.onDialog(async (dialog) => { await dialog.dismiss(); })
196
+ onDialog(handler) {
197
+ return commander.onDialog(handler);
163
198
  },
164
- async content() {
165
- return await page.content();
199
+ offDialog(handler) {
200
+ return commander.offDialog(handler);
166
201
  },
167
- async screenshot(options = {}) {
168
- return await page.screenshot(options);
202
+ clearDialogHandlers() {
203
+ return commander.clearDialogHandlers();
169
204
  },
170
- async close() {
171
- await page.close();
205
+
206
+ // --- browser-commander: Page evaluation ---
207
+ // Usage: await page.evaluate(() => window.scrollTo(0, 0))
208
+ async evaluate(fn, ...args) {
209
+ return await commander.evaluate({ fn, args });
172
210
  },
173
- _page: page,
174
- _type: 'playwright',
211
+
212
+ // Engine type identifier
213
+ type: engineType,
175
214
  };
176
215
  }
177
216
 
package/src/docx.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * DOCX export handler.
3
+ *
4
+ * Converts a web page to a DOCX document with embedded images.
5
+ * Uses the page's markdown conversion and the `docx` library to build the document.
6
+ *
7
+ * Query parameters:
8
+ * url (required) - URL to convert
9
+ * engine - 'puppeteer' or 'playwright' (only used for browser rendering fallback)
10
+ */
11
+
12
+ import fetch from 'node-fetch';
13
+ import * as cheerio from 'cheerio';
14
+ import { URL } from 'url';
15
+ import {
16
+ Document,
17
+ Packer,
18
+ Paragraph,
19
+ TextRun,
20
+ HeadingLevel,
21
+ ImageRun,
22
+ ExternalHyperlink,
23
+ } from 'docx';
24
+ import { fetchHtml, convertRelativeUrls } from './lib.js';
25
+
26
+ export async function docxHandler(req, res) {
27
+ const url = req.query.url;
28
+ if (!url) {
29
+ return res.status(400).send('Missing `url` parameter');
30
+ }
31
+
32
+ try {
33
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
34
+ const html = await fetchHtml(absoluteUrl);
35
+ const absHtml = convertRelativeUrls(html, absoluteUrl);
36
+ const $ = cheerio.load(absHtml);
37
+
38
+ // Remove unwanted elements
39
+ $('style, script, noscript, nav, footer, header').remove();
40
+
41
+ // Build DOCX sections from HTML structure
42
+ const children = [];
43
+
44
+ // Extract title
45
+ const title = $('h1').first().text().trim() || $('title').text().trim();
46
+ if (title) {
47
+ children.push(
48
+ new Paragraph({ text: title, heading: HeadingLevel.TITLE })
49
+ );
50
+ }
51
+
52
+ // Process body content
53
+ const body = $('article').length ? $('article') : $('body');
54
+ for (const el of body.children().toArray()) {
55
+ const tagName = (el.tagName || '').toLowerCase();
56
+ const text = $(el).text().trim();
57
+
58
+ if (!text && tagName !== 'img' && tagName !== 'figure') {
59
+ continue;
60
+ }
61
+
62
+ if (tagName === 'h1') {
63
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_1 }));
64
+ } else if (tagName === 'h2') {
65
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_2 }));
66
+ } else if (tagName === 'h3') {
67
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_3 }));
68
+ } else if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') {
69
+ children.push(new Paragraph({ text, heading: HeadingLevel.HEADING_4 }));
70
+ } else if (tagName === 'figure' || tagName === 'img') {
71
+ const img = tagName === 'img' ? $(el) : $(el).find('img').first();
72
+ const src = img.attr('src');
73
+ if (src && !src.startsWith('data:')) {
74
+ try {
75
+ const imgUrl = new URL(src, absoluteUrl).href;
76
+ const imgResp = await fetch(imgUrl);
77
+ if (imgResp.ok) {
78
+ const buffer = await imgResp.buffer();
79
+ children.push(
80
+ new Paragraph({
81
+ children: [
82
+ new ImageRun({
83
+ data: buffer,
84
+ transformation: { width: 600, height: 400 },
85
+ type: guessImageType(imgUrl),
86
+ }),
87
+ ],
88
+ })
89
+ );
90
+ }
91
+ } catch {
92
+ /* skip failed image embeds */
93
+ }
94
+ }
95
+ // Add caption if present
96
+ const caption = $(el).find('figcaption').text().trim();
97
+ if (caption) {
98
+ children.push(
99
+ new Paragraph({
100
+ children: [new TextRun({ text: caption, italics: true })],
101
+ })
102
+ );
103
+ }
104
+ } else if (tagName === 'ul' || tagName === 'ol') {
105
+ $(el)
106
+ .find('li')
107
+ .each(function () {
108
+ const liText = $(this).text().trim();
109
+ if (liText) {
110
+ children.push(
111
+ new Paragraph({
112
+ text: liText,
113
+ bullet: { level: 0 },
114
+ })
115
+ );
116
+ }
117
+ });
118
+ } else if (tagName === 'pre' || tagName === 'code') {
119
+ children.push(
120
+ new Paragraph({
121
+ children: [new TextRun({ text, font: 'Courier New', size: 20 })],
122
+ })
123
+ );
124
+ } else if (tagName === 'a') {
125
+ const href = $(el).attr('href');
126
+ if (href && text) {
127
+ children.push(
128
+ new Paragraph({
129
+ children: [
130
+ new ExternalHyperlink({
131
+ children: [
132
+ new TextRun({
133
+ text,
134
+ style: 'Hyperlink',
135
+ }),
136
+ ],
137
+ link: href,
138
+ }),
139
+ ],
140
+ })
141
+ );
142
+ }
143
+ } else {
144
+ // Default: treat as paragraph
145
+ if (text) {
146
+ children.push(new Paragraph({ text }));
147
+ }
148
+ }
149
+ }
150
+
151
+ if (children.length === 0) {
152
+ children.push(new Paragraph({ text: 'No content extracted.' }));
153
+ }
154
+
155
+ const doc = new Document({
156
+ sections: [{ children }],
157
+ });
158
+
159
+ const buffer = await Packer.toBuffer(doc);
160
+
161
+ res.set(
162
+ 'Content-Type',
163
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
164
+ );
165
+ res.set('Content-Disposition', 'attachment; filename="page.docx"');
166
+ res.end(buffer);
167
+ } catch (err) {
168
+ console.error(err);
169
+ res.status(500).send('Error generating DOCX');
170
+ }
171
+ }
172
+
173
+ function guessImageType(url) {
174
+ const pathname = new URL(url).pathname.toLowerCase();
175
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
176
+ return 'jpg';
177
+ }
178
+ if (pathname.endsWith('.gif')) {
179
+ return 'gif';
180
+ }
181
+ return 'png';
182
+ }