@link-assistant/web-capture 1.1.2 → 1.4.2

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 (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. package/scripts/version-and-commit.mjs +0 -237
@@ -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
+ }
package/src/popups.js ADDED
@@ -0,0 +1,171 @@
1
+ /* eslint-disable no-undef */
2
+ /**
3
+ * Popup/modal/cookie banner detection and auto-close utilities.
4
+ *
5
+ * Handles multiple types of overlays including:
6
+ * - Google Funding Choices (FC) consent dialogs
7
+ * - Habr cookie banners (tm-cookie-banner)
8
+ * - Generic modals, overlays, and notification popups
9
+ *
10
+ * Based on best practices from https://github.com/link-foundation/meta-theory
11
+ */
12
+
13
+ /**
14
+ * Close any popup overlays/modals on a page adapter (from browser.js).
15
+ * Should be called after page content has loaded and before taking screenshots.
16
+ *
17
+ * Uses browser-commander APIs:
18
+ * - page.onDialog() – dismiss browser-level alert/confirm dialogs (v0.7.0+)
19
+ * - page.keyboard – press Escape to dismiss remaining popups (v0.7.0+)
20
+ * - page.evaluate() – run JS in the browser context to click close buttons
21
+ *
22
+ * @param {Object} page - A PageAdapter from browser.js
23
+ */
24
+ export async function dismissPopups(page) {
25
+ // Handle browser-level dialog events (alert, confirm, prompt) using
26
+ // browser-commander's dialog API (v0.7.0+)
27
+ page.onDialog(async (dialog) => {
28
+ try {
29
+ await dialog.dismiss();
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ });
34
+
35
+ // Try to click the Google Funding Choices "Consent" button first
36
+ const fcConsentClicked = await page.evaluate(() => {
37
+ const consentBtn = document.querySelector('.fc-cta-consent');
38
+ if (consentBtn) {
39
+ try {
40
+ consentBtn.click();
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ return false;
47
+ });
48
+
49
+ if (fcConsentClicked) {
50
+ await sleep(page, 1000);
51
+ }
52
+
53
+ // Click all known close/dismiss buttons and remove overlay elements
54
+ await page.evaluate(() => {
55
+ const closeSelectors = [
56
+ // Google Funding Choices (FC) consent dialog
57
+ '.fc-cta-consent',
58
+ '.fc-close',
59
+ '.fc-dismiss-button',
60
+ // Cookie consent
61
+ '.tm-cookie-banner__close',
62
+ '.cookie-banner__close',
63
+ '[data-test-id="cookie-banner-close"]',
64
+ // Consent popup buttons
65
+ '.consent-popup__close',
66
+ '.consent__close',
67
+ 'button[data-testid="consent-close"]',
68
+ 'button[data-testid="consent-reject"]',
69
+ // Generic close buttons
70
+ '.tm-popup__close',
71
+ '.popup__close',
72
+ '.modal__close',
73
+ '.overlay__close',
74
+ '[aria-label="Close"]',
75
+ '[aria-label="Закрыть"]',
76
+ '.tm-base-modal__close',
77
+ '.tm-notification__close',
78
+ ];
79
+
80
+ for (const selector of closeSelectors) {
81
+ const els = document.querySelectorAll(selector);
82
+ for (const el of els) {
83
+ try {
84
+ el.click();
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+
91
+ // Remove Google FC consent dialog elements from DOM
92
+ for (const sel of ['.fc-consent-root', '.fc-dialog-overlay']) {
93
+ const el = document.querySelector(sel);
94
+ if (el) {
95
+ try {
96
+ el.remove();
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+ }
102
+
103
+ // Hide fixed-position overlays that cover content
104
+ const allEls = document.querySelectorAll('*');
105
+ for (const el of allEls) {
106
+ const style = getComputedStyle(el);
107
+ if (style.position === 'fixed' && el.offsetParent !== null) {
108
+ const rect = el.getBoundingClientRect();
109
+ if (
110
+ rect.height > 200 ||
111
+ (el.className &&
112
+ el.className.match &&
113
+ el.className.match(
114
+ /popup|modal|overlay|banner|cookie|notification|consent|fc-/i
115
+ ))
116
+ ) {
117
+ el.style.display = 'none';
118
+ }
119
+ }
120
+ }
121
+ });
122
+
123
+ await sleep(page, 500);
124
+
125
+ // Press Escape to dismiss remaining popups using browser-commander's keyboard API (v0.7.0+)
126
+ try {
127
+ await page.keyboard.press('Escape');
128
+ await sleep(page, 300);
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Scroll through the page to trigger lazy-loaded content.
136
+ * Makes multiple passes for media-heavy pages.
137
+ *
138
+ * @param {Object} page - PageAdapter from browser.js
139
+ * @param {Object} options
140
+ * @param {number} options.passes - Number of scroll passes (default: 2)
141
+ * @param {number} options.stepDelay - Delay between scroll steps in ms (default: 100)
142
+ * @param {number} options.passDelay - Delay between passes in ms (default: 1000)
143
+ */
144
+ export async function scrollToLoadContent(page, options = {}) {
145
+ const { passes = 2, stepDelay = 100, passDelay = 1000 } = options;
146
+
147
+ for (let pass = 0; pass < passes; pass++) {
148
+ await page.evaluate(
149
+ async ({ delay }) => {
150
+ const scrollHeight = document.documentElement.scrollHeight;
151
+ const viewportHeight = window.innerHeight;
152
+ const scrollSteps = Math.ceil(scrollHeight / viewportHeight);
153
+ for (let i = 0; i < scrollSteps; i++) {
154
+ window.scrollTo(0, i * viewportHeight);
155
+ await new Promise((resolve) => setTimeout(resolve, delay));
156
+ }
157
+ window.scrollTo(0, 0);
158
+ },
159
+ { delay: stepDelay }
160
+ );
161
+ await sleep(page, passDelay);
162
+ }
163
+
164
+ // Extra wait for images to finish loading
165
+ await sleep(page, 2000);
166
+ }
167
+
168
+ /** Portable sleep using standard Promise-based timeout */
169
+ async function sleep(_page, ms) {
170
+ await new Promise((resolve) => setTimeout(resolve, ms));
171
+ }