@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
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Markdown image localization module (R5).
3
+ *
4
+ * Post-processing tool that:
5
+ * 1. Reads markdown files
6
+ * 2. Extracts all external image URLs
7
+ * 3. Downloads images to local directory
8
+ * 4. Updates markdown to reference local paths
9
+ *
10
+ * Based on reference implementation from:
11
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/download-markdown-images.mjs
12
+ *
13
+ * @module localize-images
14
+ */
15
+
16
+ import fetch from 'node-fetch';
17
+ import { URL } from 'url';
18
+ import { retry } from './retry.js';
19
+
20
+ /**
21
+ * Extract image references from markdown text.
22
+ *
23
+ * @param {string} markdownText - Markdown content
24
+ * @returns {Object[]} Array of {fullMatch, altText, url}
25
+ */
26
+ export function extractImageReferences(markdownText) {
27
+ const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
28
+ const images = [];
29
+ let match;
30
+
31
+ while ((match = imageRegex.exec(markdownText)) !== null) {
32
+ images.push({
33
+ fullMatch: match[0],
34
+ altText: match[1],
35
+ url: match[2],
36
+ });
37
+ }
38
+
39
+ return images;
40
+ }
41
+
42
+ /**
43
+ * Get file extension from URL.
44
+ *
45
+ * @param {string} url - Image URL
46
+ * @returns {string} File extension with dot (e.g., '.png')
47
+ */
48
+ export function getExtensionFromUrl(url) {
49
+ try {
50
+ const pathname = new URL(url).pathname.split('?')[0];
51
+ const ext = pathname.match(/\.(\w+)$/);
52
+ if (ext) {
53
+ const lower = ext[1].toLowerCase();
54
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(lower)) {
55
+ return `.${lower}`;
56
+ }
57
+ }
58
+ } catch {
59
+ // ignore
60
+ }
61
+ return '.png';
62
+ }
63
+
64
+ /**
65
+ * Generate local filename for a downloaded image.
66
+ *
67
+ * @param {string} url - Image URL
68
+ * @param {number} index - Zero-based index
69
+ * @returns {string} Local filename (e.g., 'image-01.png')
70
+ */
71
+ export function generateLocalFilename(url, index) {
72
+ const ext = getExtensionFromUrl(url);
73
+ return `image-${String(index + 1).padStart(2, '0')}${ext}`;
74
+ }
75
+
76
+ /**
77
+ * Localize images in markdown text by downloading external images
78
+ * and replacing URLs with local paths.
79
+ *
80
+ * @param {string} markdownText - Input markdown
81
+ * @param {Object} [options] - Options
82
+ * @param {string} [options.imagesDir='images'] - Local images directory name
83
+ * @param {boolean} [options.dryRun=false] - Only report what would be done
84
+ * @param {Function} [options.onProgress] - Callback(index, total, status, url)
85
+ * @param {string[]} [options.excludeDomains] - Domains to skip (already local)
86
+ * @returns {Promise<Object>} Result with updated markdown and metadata
87
+ */
88
+ export async function localizeImages(markdownText, options = {}) {
89
+ const {
90
+ imagesDir = 'images',
91
+ dryRun = false,
92
+ onProgress,
93
+ excludeDomains = [],
94
+ } = options;
95
+
96
+ const allImages = extractImageReferences(markdownText);
97
+
98
+ // Filter to only external images not already localized
99
+ const externalImages = allImages.filter((img) => {
100
+ if (!img.url.startsWith('http')) {
101
+ return false;
102
+ }
103
+ if (img.url.includes(`${imagesDir}/`)) {
104
+ return false;
105
+ }
106
+ for (const domain of excludeDomains) {
107
+ if (img.url.includes(domain)) {
108
+ return false;
109
+ }
110
+ }
111
+ return true;
112
+ });
113
+
114
+ if (externalImages.length === 0) {
115
+ return {
116
+ markdown: markdownText,
117
+ downloaded: 0,
118
+ total: 0,
119
+ replacements: [],
120
+ metadata: [],
121
+ };
122
+ }
123
+
124
+ const replacements = [];
125
+ const metadata = [];
126
+ let downloadedCount = 0;
127
+ let updatedMarkdown = markdownText;
128
+
129
+ for (let i = 0; i < externalImages.length; i++) {
130
+ const image = externalImages[i];
131
+ const localFilename = generateLocalFilename(image.url, i);
132
+ const relativePath = `${imagesDir}/${localFilename}`;
133
+
134
+ if (onProgress) {
135
+ onProgress(i + 1, externalImages.length, 'downloading', image.url);
136
+ }
137
+
138
+ if (dryRun) {
139
+ replacements.push({
140
+ from: image.fullMatch,
141
+ to: `![${image.altText}](${relativePath})`,
142
+ });
143
+ metadata.push({
144
+ index: i + 1,
145
+ originalUrl: image.url,
146
+ altText: image.altText,
147
+ localPath: relativePath,
148
+ });
149
+ continue;
150
+ }
151
+
152
+ try {
153
+ const resp = await retry(() => fetch(image.url), {
154
+ retries: 3,
155
+ baseDelay: 1000,
156
+ });
157
+
158
+ if (!resp.ok) {
159
+ throw new Error(`HTTP ${resp.status}`);
160
+ }
161
+
162
+ const buffer = await resp.buffer();
163
+ downloadedCount++;
164
+
165
+ replacements.push({
166
+ from: image.fullMatch,
167
+ to: `![${image.altText}](${relativePath})`,
168
+ buffer,
169
+ filename: localFilename,
170
+ });
171
+
172
+ metadata.push({
173
+ index: i + 1,
174
+ originalUrl: image.url,
175
+ altText: image.altText,
176
+ localPath: relativePath,
177
+ });
178
+
179
+ if (onProgress) {
180
+ onProgress(i + 1, externalImages.length, 'downloaded', image.url);
181
+ }
182
+ } catch {
183
+ if (onProgress) {
184
+ onProgress(i + 1, externalImages.length, 'failed', image.url);
185
+ }
186
+ // Keep original URL if download fails
187
+ }
188
+ }
189
+
190
+ // Apply replacements to markdown
191
+ for (const replacement of replacements) {
192
+ updatedMarkdown = updatedMarkdown.replace(replacement.from, replacement.to);
193
+ }
194
+
195
+ return {
196
+ markdown: updatedMarkdown,
197
+ downloaded: downloadedCount,
198
+ total: externalImages.length,
199
+ replacements,
200
+ metadata,
201
+ };
202
+ }
package/src/markdown.js CHANGED
@@ -1,14 +1,19 @@
1
1
  import { fetchHtml, convertHtmlToMarkdown } from './lib.js';
2
+ import { stripBase64Images } from './extract-images.js';
2
3
 
3
4
  export async function markdownHandler(req, res) {
4
5
  const url = req.query.url;
5
6
  if (!url) {
6
7
  return res.status(400).send('Missing `url` parameter');
7
8
  }
9
+ const embedImages = req.query.embedImages === 'true';
8
10
  try {
9
11
  const html = await fetchHtml(url);
10
- // Pass baseUrl to convertHtmlToMarkdown so all URLs are absolute
11
- const markdown = convertHtmlToMarkdown(html, url);
12
+ let markdown = convertHtmlToMarkdown(html, url);
13
+ if (!embedImages) {
14
+ const strip = stripBase64Images(markdown);
15
+ markdown = strip.markdown;
16
+ }
12
17
  res.type('text/markdown').send(markdown);
13
18
  } catch (err) {
14
19
  console.error(err);
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Article metadata extraction module.
3
+ *
4
+ * Extracts metadata from web pages including:
5
+ * - Author information (name, URL, karma)
6
+ * - Publication date and modification date
7
+ * - Reading time and difficulty
8
+ * - Views, votes, bookmarks, comments
9
+ * - Hubs and tags (with URLs)
10
+ * - Translation information
11
+ * - LD+JSON structured data
12
+ *
13
+ * Based on reference implementation from:
14
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/download-article.mjs
15
+ */
16
+
17
+ /**
18
+ * Extract article metadata from HTML using Cheerio.
19
+ * Works without a browser by parsing the HTML directly.
20
+ *
21
+ * @param {Object} $ - Cheerio instance loaded with the page HTML
22
+ * @returns {Object} Extracted metadata
23
+ */
24
+ export function extractMetadata($) {
25
+ const meta = {};
26
+
27
+ // Author
28
+ const authorEl = $('.tm-user-info__username');
29
+ if (authorEl.length > 0) {
30
+ meta.author = authorEl.text().trim();
31
+ meta.authorUrl = authorEl.attr('href') || null;
32
+ }
33
+
34
+ // Publication date
35
+ const timeEl = $('time[datetime]').first();
36
+ if (timeEl.length > 0) {
37
+ meta.publishDate = timeEl.attr('datetime');
38
+ meta.publishDateText = timeEl.text().trim();
39
+ }
40
+
41
+ // Reading time
42
+ const readTimeEl = $('.tm-article-reading-time__label');
43
+ if (readTimeEl.length > 0) {
44
+ meta.readingTime = readTimeEl.text().trim();
45
+ }
46
+
47
+ // Difficulty level
48
+ const diffEl = $('.tm-article-complexity__label');
49
+ if (diffEl.length > 0) {
50
+ meta.difficulty = diffEl.text().trim();
51
+ }
52
+
53
+ // Views
54
+ const viewsEl = $('.tm-icon-counter__value');
55
+ if (viewsEl.length > 0) {
56
+ meta.views = viewsEl.attr('title') || viewsEl.text().trim();
57
+ }
58
+
59
+ // Hubs (use specific hub link selector to avoid duplicates)
60
+ const hubEls = $('.tm-publication-hub__link');
61
+ if (hubEls.length > 0) {
62
+ meta.hubs = [];
63
+ meta.hubUrls = [];
64
+ hubEls.each(function () {
65
+ const nameSpan = $(this).find('span:first-child');
66
+ const name = nameSpan.length
67
+ ? nameSpan.text().trim()
68
+ : $(this)
69
+ .text()
70
+ .trim()
71
+ .replace(/\s*\*\s*$/, '');
72
+ meta.hubs.push(name);
73
+ meta.hubUrls.push({
74
+ name,
75
+ url: $(this).attr('href') || null,
76
+ });
77
+ });
78
+ }
79
+
80
+ // Tags from meta keywords
81
+ const keywordsMeta = $('meta[name="keywords"]');
82
+ if (keywordsMeta.length > 0) {
83
+ const content = keywordsMeta.attr('content');
84
+ if (content) {
85
+ meta.tags = content
86
+ .split(',')
87
+ .map((t) => t.trim())
88
+ .filter(Boolean);
89
+ }
90
+ }
91
+
92
+ // Tags with URLs (from article footer)
93
+ const tagEls = $('.tm-article-body__tags-item a, .tm-tags-list__link');
94
+ if (tagEls.length > 0) {
95
+ meta.tagLinks = [];
96
+ tagEls.each(function () {
97
+ meta.tagLinks.push({
98
+ name: $(this).text().trim(),
99
+ url: $(this).attr('href') || null,
100
+ });
101
+ });
102
+ }
103
+
104
+ // Translation badge
105
+ const translationLabelEl = $('.tm-publication-label_variant-translation');
106
+ if (translationLabelEl.length > 0) {
107
+ meta.isTranslation = true;
108
+ meta.translationLabel = translationLabelEl.text().trim();
109
+ }
110
+
111
+ // Original article link
112
+ const originLinkEl = $('.tm-article-presenter__origin-link');
113
+ if (originLinkEl.length > 0) {
114
+ meta.originalArticleUrl = originLinkEl.attr('href') || null;
115
+ const authorSpan = originLinkEl.find('span');
116
+ if (authorSpan.length > 0) {
117
+ meta.originalAuthors = authorSpan.text().trim();
118
+ }
119
+ meta.originalAuthorText = originLinkEl.text().trim();
120
+ }
121
+
122
+ // LD+JSON structured data
123
+ const ldJsonScript = $('script[type="application/ld+json"]').first();
124
+ if (ldJsonScript.length > 0) {
125
+ try {
126
+ const ldData = JSON.parse(ldJsonScript.html());
127
+ if (ldData.dateModified) {
128
+ meta.dateModified = ldData.dateModified;
129
+ }
130
+ if (ldData.author?.name) {
131
+ meta.authorFullName = ldData.author.name;
132
+ }
133
+ } catch {
134
+ // ignore parse errors
135
+ }
136
+ }
137
+
138
+ // Votes
139
+ const votesEl = $('.tm-votes-meter__value');
140
+ if (votesEl.length > 0) {
141
+ meta.votes = votesEl.text().trim();
142
+ }
143
+
144
+ // Comments count
145
+ const commentsEl = $('.tm-article-comments-counter-link__value');
146
+ if (commentsEl.length > 0) {
147
+ meta.comments = commentsEl.text().trim();
148
+ }
149
+
150
+ // Bookmarks count
151
+ const bookmarksEl = $('.bookmarks-button__counter');
152
+ if (bookmarksEl.length > 0) {
153
+ meta.bookmarks = bookmarksEl.text().trim();
154
+ }
155
+
156
+ // Author karma
157
+ const karmaEl = $('.tm-karma__votes');
158
+ if (karmaEl.length > 0) {
159
+ meta.authorKarma = karmaEl.text().trim();
160
+ }
161
+
162
+ return meta;
163
+ }
164
+
165
+ /**
166
+ * Format metadata as a markdown header block.
167
+ * Placed after the title in the output markdown.
168
+ *
169
+ * @param {Object} metadata - Extracted metadata object
170
+ * @returns {string[]} Array of markdown lines
171
+ */
172
+ export function formatMetadataBlock(metadata) {
173
+ if (!metadata) {
174
+ return [];
175
+ }
176
+ const lines = [];
177
+
178
+ // Author line
179
+ if (metadata.author) {
180
+ const authorName = metadata.authorFullName
181
+ ? `${metadata.authorFullName} (${metadata.author})`
182
+ : metadata.author;
183
+ const authorLink = metadata.authorUrl
184
+ ? `[${authorName}](${metadata.authorUrl})`
185
+ : authorName;
186
+ lines.push(`**Author:** ${authorLink}`);
187
+ }
188
+
189
+ // Article type (translation)
190
+ if (metadata.isTranslation) {
191
+ lines.push(`**Type:** ${metadata.translationLabel || 'Translation'}`);
192
+ }
193
+
194
+ // Original article link
195
+ if (metadata.originalAuthors) {
196
+ const authorsText = metadata.originalAuthors;
197
+ if (metadata.originalArticleUrl) {
198
+ lines.push(
199
+ `**Original article:** [${authorsText}](${metadata.originalArticleUrl})`
200
+ );
201
+ } else {
202
+ lines.push(`**Original authors:** ${authorsText}`);
203
+ }
204
+ }
205
+
206
+ // Publication date
207
+ if (metadata.publishDate) {
208
+ const date = new Date(metadata.publishDate);
209
+ const formatted = date.toLocaleDateString('en-US', {
210
+ year: 'numeric',
211
+ month: 'long',
212
+ day: 'numeric',
213
+ });
214
+ let dateLine = `**Published:** ${formatted}`;
215
+ if (metadata.dateModified) {
216
+ const modDate = new Date(metadata.dateModified);
217
+ const modFormatted = modDate.toLocaleDateString('en-US', {
218
+ year: 'numeric',
219
+ month: 'long',
220
+ day: 'numeric',
221
+ });
222
+ if (modFormatted !== formatted) {
223
+ dateLine += ` (updated ${modFormatted})`;
224
+ }
225
+ }
226
+ lines.push(dateLine);
227
+ }
228
+
229
+ // Reading time and difficulty
230
+ const infoItems = [];
231
+ if (metadata.readingTime) {
232
+ infoItems.push(`Reading time: ${metadata.readingTime}`);
233
+ }
234
+ if (metadata.difficulty) {
235
+ infoItems.push(`Difficulty: ${metadata.difficulty}`);
236
+ }
237
+ if (metadata.views) {
238
+ infoItems.push(`Views: ${metadata.views}`);
239
+ }
240
+ if (infoItems.length > 0) {
241
+ lines.push(`**${infoItems.join(' | ')}**`);
242
+ }
243
+
244
+ // Hubs
245
+ if (metadata.hubs && metadata.hubs.length > 0) {
246
+ lines.push(`**Hubs:** ${metadata.hubs.join(', ')}`);
247
+ }
248
+
249
+ // Tags
250
+ if (metadata.tags && metadata.tags.length > 0) {
251
+ lines.push(`**Tags:** ${metadata.tags.join(', ')}`);
252
+ }
253
+
254
+ return lines;
255
+ }
256
+
257
+ /**
258
+ * Format footer metadata block.
259
+ * Placed at the end of the article, matching Habr article footer.
260
+ *
261
+ * @param {Object} metadata - Extracted metadata object
262
+ * @returns {string[]} Array of markdown lines
263
+ */
264
+ export function formatFooterBlock(metadata) {
265
+ if (!metadata) {
266
+ return [];
267
+ }
268
+ const lines = [];
269
+
270
+ lines.push('---');
271
+ lines.push('');
272
+
273
+ // Tags with links
274
+ if (metadata.tagLinks && metadata.tagLinks.length > 0) {
275
+ const tagStrings = metadata.tagLinks.map((t) =>
276
+ t.url ? `[${t.name}](${t.url})` : t.name
277
+ );
278
+ lines.push(`**Tags:** ${tagStrings.join(', ')}`);
279
+ lines.push('');
280
+ } else if (metadata.tags && metadata.tags.length > 0) {
281
+ lines.push(`**Tags:** ${metadata.tags.join(', ')}`);
282
+ lines.push('');
283
+ }
284
+
285
+ // Hubs with links
286
+ if (metadata.hubUrls && metadata.hubUrls.length > 0) {
287
+ const hubStrings = metadata.hubUrls.map((h) =>
288
+ h.url ? `[${h.name}](${h.url})` : h.name
289
+ );
290
+ lines.push(`**Hubs:** ${hubStrings.join(', ')}`);
291
+ lines.push('');
292
+ } else if (metadata.hubs && metadata.hubs.length > 0) {
293
+ lines.push(`**Hubs:** ${metadata.hubs.join(', ')}`);
294
+ lines.push('');
295
+ }
296
+
297
+ // Article stats
298
+ const stats = [];
299
+ if (metadata.votes) {
300
+ stats.push(`Votes: ${metadata.votes}`);
301
+ }
302
+ if (metadata.views) {
303
+ stats.push(`Views: ${metadata.views}`);
304
+ }
305
+ if (metadata.bookmarks) {
306
+ stats.push(`Bookmarks: ${metadata.bookmarks}`);
307
+ }
308
+ if (metadata.comments) {
309
+ stats.push(`Comments: ${metadata.comments}`);
310
+ }
311
+ if (stats.length > 0) {
312
+ lines.push(`**${stats.join(' | ')}**`);
313
+ lines.push('');
314
+ }
315
+
316
+ // Author info
317
+ if (metadata.author) {
318
+ const authorName = metadata.authorFullName
319
+ ? `${metadata.authorFullName} (${metadata.author})`
320
+ : metadata.author;
321
+ const authorLink = metadata.authorUrl
322
+ ? `[${authorName}](${metadata.authorUrl})`
323
+ : authorName;
324
+ let authorLine = `**Author:** ${authorLink}`;
325
+ if (metadata.authorKarma) {
326
+ authorLine += ` | Karma: ${metadata.authorKarma}`;
327
+ }
328
+ lines.push(authorLine);
329
+ lines.push('');
330
+ }
331
+
332
+ return lines;
333
+ }
package/src/pdf.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * PDF export handler.
3
+ *
4
+ * Renders a web page to PDF using the browser's native print-to-PDF.
5
+ * All images are embedded automatically by the browser engine.
6
+ *
7
+ * Query parameters:
8
+ * url (required) - URL to capture
9
+ * engine - 'puppeteer' or 'playwright'
10
+ * width - viewport width in px (default 1280)
11
+ * height - viewport height in px (default 800)
12
+ * theme - 'light' or 'dark'
13
+ * dismissPopups - 'true' (default) to auto-close popups before export
14
+ */
15
+
16
+ import { createBrowser, getBrowserEngine } from './browser.js';
17
+ import { dismissPopups, scrollToLoadContent } from './popups.js';
18
+
19
+ export async function pdfHandler(req, res) {
20
+ const url = req.query.url;
21
+ if (!url) {
22
+ return res.status(400).send('Missing `url` parameter');
23
+ }
24
+
25
+ const width = parseInt(req.query.width, 10) || 1280;
26
+ const height = parseInt(req.query.height, 10) || 800;
27
+ const theme = req.query.theme;
28
+ const shouldDismissPopups = req.query.dismissPopups !== 'false';
29
+
30
+ try {
31
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
32
+ const engine = getBrowserEngine(req);
33
+ const browserOpts = theme ? { colorScheme: theme } : {};
34
+ const browser = await createBrowser(engine, browserOpts);
35
+ try {
36
+ const page = await browser.newPage();
37
+ await page.setExtraHTTPHeaders({
38
+ 'Accept-Language': 'en-US,en;q=0.9',
39
+ 'Accept-Charset': 'utf-8',
40
+ });
41
+ await page.setUserAgent(
42
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
43
+ );
44
+ await page.setViewport({ width, height });
45
+ await page.goto(absoluteUrl, {
46
+ waitUntil: 'networkidle0',
47
+ timeout: 30000,
48
+ });
49
+
50
+ await new Promise((resolve) => setTimeout(resolve, 5000));
51
+ await scrollToLoadContent(page);
52
+
53
+ if (shouldDismissPopups) {
54
+ await dismissPopups(page);
55
+ }
56
+
57
+ // Generate PDF using browser-commander's unified pdf() API (v0.8.0+)
58
+ const pdfBuffer = await page.pdf({
59
+ pdfOptions: {
60
+ format: 'A4',
61
+ printBackground: true,
62
+ margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
63
+ },
64
+ });
65
+
66
+ res.set('Content-Type', 'application/pdf');
67
+ res.set('Content-Disposition', 'inline; filename="page.pdf"');
68
+ res.end(pdfBuffer);
69
+ } finally {
70
+ await browser.close();
71
+ }
72
+ } catch (err) {
73
+ console.error(err);
74
+ res.status(500).send('Error generating PDF');
75
+ }
76
+ }