@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.
- package/CHANGELOG.md +17 -14
- package/README.md +256 -146
- package/bin/web-capture.js +403 -6
- package/package.json +11 -4
- package/src/animation.js +283 -0
- package/src/archive.js +212 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +333 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +226 -1
- package/src/localize-images.js +202 -0
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +265 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +203 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +108 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
package/src/metadata.js
ADDED
|
@@ -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
|
+
}
|