screenshot-ready 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +238 -2
- package/dist/cjs/consent.d.ts +50 -0
- package/dist/cjs/consent.js +277 -0
- package/dist/cjs/fonts.d.ts +20 -0
- package/dist/cjs/fonts.js +64 -0
- package/dist/cjs/freeze.d.ts +22 -0
- package/dist/cjs/freeze.js +67 -0
- package/dist/cjs/images.d.ts +24 -0
- package/dist/cjs/images.js +91 -0
- package/dist/cjs/index.d.ts +15 -0
- package/dist/cjs/index.js +23 -0
- package/dist/cjs/lazy.d.ts +45 -0
- package/dist/cjs/lazy.js +145 -0
- package/dist/cjs/network.d.ts +45 -0
- package/dist/cjs/network.js +136 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/page.d.ts +31 -0
- package/dist/cjs/page.js +31 -0
- package/dist/cjs/prepare.d.ts +68 -0
- package/dist/cjs/prepare.js +93 -0
- package/dist/esm/consent.d.ts +50 -0
- package/dist/esm/consent.js +272 -0
- package/dist/esm/fonts.d.ts +20 -0
- package/dist/esm/fonts.js +61 -0
- package/dist/esm/freeze.d.ts +22 -0
- package/dist/esm/freeze.js +64 -0
- package/dist/esm/images.d.ts +24 -0
- package/dist/esm/images.js +88 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/lazy.d.ts +45 -0
- package/dist/esm/lazy.js +141 -0
- package/dist/esm/network.d.ts +45 -0
- package/dist/esm/network.js +132 -0
- package/dist/esm/page.d.ts +31 -0
- package/dist/esm/page.js +25 -0
- package/dist/esm/prepare.d.ts +68 -0
- package/dist/esm/prepare.js +89 -0
- package/package.json +86 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SnapRender
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,239 @@
|
|
|
1
|
-
#
|
|
1
|
+
# screenshot-ready
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Get a Playwright or Puppeteer page ready for `page.screenshot()`: wait for web fonts, load lazy images, hide cookie banners and freeze animations, in one call with a time budget.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install screenshot-ready
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
`page.screenshot()` looks fine on example.com. On real sites the same call keeps producing captures like these:
|
|
12
|
+
|
|
13
|
+
- **Blank boxes where images should be.** `<img loading="lazy">` and IntersectionObserver loaders only fetch images near the viewport. A full-page capture never scrolls, so everything below the first screen comes out empty.
|
|
14
|
+
- **A cookie banner over the page.** Half the sites you capture show a consent dialog, often with a dark backdrop and the page scroll locked behind it.
|
|
15
|
+
- **Text in the wrong font, or no text at all.** The load event can fire before web fonts arrive. With `font-display: block` the text is invisible until they do.
|
|
16
|
+
- **Two captures of the same page never match.** Spinners, fade-ins, carousels and the blinking caret make every capture a little different, which breaks visual diffs.
|
|
17
|
+
- **A capture taken while data is still arriving.** The page fires its API calls after load and the capture shows loading skeletons.
|
|
18
|
+
|
|
19
|
+
`screenshot-ready` fixes these with small, bounded steps that run inside the page. It works with Playwright and Puppeteer pages and has no runtime dependencies.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### Playwright
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { chromium } from 'playwright';
|
|
27
|
+
import { prepareForCapture } from 'screenshot-ready';
|
|
28
|
+
|
|
29
|
+
const browser = await chromium.launch();
|
|
30
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
31
|
+
await page.goto('https://example.com', { waitUntil: 'load' });
|
|
32
|
+
|
|
33
|
+
await prepareForCapture(page);
|
|
34
|
+
await page.screenshot({ path: 'page.png', fullPage: true });
|
|
35
|
+
|
|
36
|
+
await browser.close();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Puppeteer
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import puppeteer from 'puppeteer';
|
|
43
|
+
import { prepareForCapture } from 'screenshot-ready';
|
|
44
|
+
|
|
45
|
+
const browser = await puppeteer.launch();
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
48
|
+
await page.goto('https://example.com', { waitUntil: 'load' });
|
|
49
|
+
|
|
50
|
+
await prepareForCapture(page);
|
|
51
|
+
await page.screenshot({ path: 'page.png', fullPage: true });
|
|
52
|
+
|
|
53
|
+
await browser.close();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
CommonJS works too: `const { prepareForCapture } = require('screenshot-ready')`.
|
|
57
|
+
|
|
58
|
+
### One call
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { readyScreenshot } from 'screenshot-ready';
|
|
62
|
+
|
|
63
|
+
const png = await readyScreenshot(page, { fullPage: true, path: 'page.png', prepare: { timeout: 10000 } });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Everything except `prepare` goes to `page.screenshot()` unchanged.
|
|
67
|
+
|
|
68
|
+
## What prepareForCapture does
|
|
69
|
+
|
|
70
|
+
In this order, each step with its own cap, all inside a total budget (`timeout`, default 15 seconds):
|
|
71
|
+
|
|
72
|
+
1. **Hide consent banners** (`consent`). Known consent platforms by selector, plus fixed overlays that mention cookies or consent and offer a choice. Nothing is clicked.
|
|
73
|
+
2. **Hide your selectors** (`hide`). Chat widgets, promo bars, anything you list.
|
|
74
|
+
3. **Wait for web fonts** (`fonts`, 3 s cap).
|
|
75
|
+
4. **Scroll through the page** (`lazy`, 5 s cap, up to 20,000 px) so lazy images, IntersectionObserver loaders and scroll-reveal effects fire, then scroll back.
|
|
76
|
+
5. **Wait for the network to go quiet** (`network`, 3 s cap, 500 ms of quiet). Requests started by the scroll are counted.
|
|
77
|
+
6. **Wait for images to load and decode** (`images`, 3 s cap).
|
|
78
|
+
7. **Hide consent banners again**, for dialogs that showed up late.
|
|
79
|
+
8. **Wait for fonts again** (1 s cap), for fonts used by content that just loaded.
|
|
80
|
+
9. **Freeze animations** (`freeze`). Finite animations and transitions jump to their end state, infinite ones pause, the caret is hidden, playing media pauses.
|
|
81
|
+
|
|
82
|
+
It never throws. A step that fails or runs out of time is reported and the rest still run:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const report = await prepareForCapture(page);
|
|
86
|
+
// { ms: 1840, timedOut: false, steps: [{ step: 'consent', ms: 12, ok: true, timedOut: false, result: { hidden: 2, ... } }, ...] }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Turn any step off or tune it:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
await prepareForCapture(page, {
|
|
93
|
+
timeout: 10000,
|
|
94
|
+
consent: { selectors: ['.my-site-cookie-bar'] },
|
|
95
|
+
hide: ['#intercom-container', '.promo-ribbon'],
|
|
96
|
+
lazy: { maxHeight: 8000 },
|
|
97
|
+
network: { quietMs: 800 },
|
|
98
|
+
freeze: false,
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## API
|
|
103
|
+
|
|
104
|
+
Every helper takes a Playwright or Puppeteer `Page` as its first argument. Times are in milliseconds, sizes in CSS pixels. The helpers return a result object and report timeouts in it instead of throwing. They do throw if the page itself is gone (closed or crashed); `prepareForCapture` catches that too.
|
|
105
|
+
|
|
106
|
+
### `prepareForCapture(page, options?)`
|
|
107
|
+
|
|
108
|
+
Runs the steps above. Options:
|
|
109
|
+
|
|
110
|
+
| Option | Default | |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `timeout` | `15000` | Total budget. The last two quick steps may add up to about a second. |
|
|
113
|
+
| `consent` | `true` | `false`, or options for `hideConsentBanners` (without `timeout`). |
|
|
114
|
+
| `hide` | `[]` | Selectors to hide. |
|
|
115
|
+
| `hideMode` | `'visibility'` | `'visibility'` keeps the layout, `'display'` removes the element from it. |
|
|
116
|
+
| `fonts` | `true` | `false`, or `{ timeout }`. |
|
|
117
|
+
| `lazy` | `true` | `false`, or options for `scrollThrough`. |
|
|
118
|
+
| `network` | `true` | `false`, or options for `waitForNetworkQuiet`. |
|
|
119
|
+
| `images` | `true` | `false`, or options for `waitForImages`. |
|
|
120
|
+
| `freeze` | `true` | `false`, or options for `freezeAnimations`. |
|
|
121
|
+
|
|
122
|
+
Returns `{ ms, timedOut, steps }`, where each step is `{ step, ms, ok, timedOut, skipped?, error?, result? }`.
|
|
123
|
+
|
|
124
|
+
### `readyScreenshot(page, options?)`
|
|
125
|
+
|
|
126
|
+
`prepareForCapture(page, options.prepare)`, then `page.screenshot(rest)`. Returns what `page.screenshot()` returns.
|
|
127
|
+
|
|
128
|
+
### `hideConsentBanners(page, options?)`
|
|
129
|
+
|
|
130
|
+
| Option | Default | |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `selectors` | `[]` | More selectors to hide. |
|
|
133
|
+
| `replaceBuiltIn` | `false` | Use only your selectors. |
|
|
134
|
+
| `heuristics` | `true` | Also match fixed overlays by text and buttons. |
|
|
135
|
+
| `unlockScroll` | `true` | Reset `overflow: hidden` (and a pinned `position: fixed` body) left by the dialog. |
|
|
136
|
+
| `timeout` | `2000` | |
|
|
137
|
+
|
|
138
|
+
Returns `{ hidden, bySelector, byHeuristic, overlays, scrollUnlocked, details, timedOut }`. `details` lists what was hidden, like `div#onetrust-consent-sdk`.
|
|
139
|
+
|
|
140
|
+
Built-in selectors cover OneTrust, Cookiebot, Didomi, Quantcast Choice, TrustArc, Usercentrics, Osano, Google Funding Choices, Sourcepoint, CookieYes, Complianz, iubenda, Termly, Borlabs, Ketch, CookieConsent and Klaro. The list is exported as `CONSENT_SELECTORS`.
|
|
141
|
+
|
|
142
|
+
The heuristic pass looks for elements that are `position: fixed` (or sticky, or a dialog), visible, mention cookies or consent (English plus common European languages) and contain an accept, reject or settings button. It skips elements that look like page content (a full-screen element with `main`, `nav`, `h1`, video or several images) and menus with many links. When it hides a banner it also hides an empty full-screen dimming layer behind it.
|
|
143
|
+
|
|
144
|
+
Elements are hidden with a stylesheet rule and a `data-screenshot-ready-hidden` attribute, not removed, so page scripts that hold references to them keep working. The selector rules stay in the page, so a banner the platform injects later is hidden as well.
|
|
145
|
+
|
|
146
|
+
### `hideElements(page, selectors, options?)`
|
|
147
|
+
|
|
148
|
+
Hides elements with a stylesheet rule. `options.mode`: `'visibility'` (default) or `'display'`. Returns how many elements matched.
|
|
149
|
+
|
|
150
|
+
### `waitForFonts(page, options?)`
|
|
151
|
+
|
|
152
|
+
Waits for `document.fonts` to finish loading, including fonts that start loading during the wait. `timeout` default 3000. Returns `{ timedOut, loaded, pending, failed }` with family names.
|
|
153
|
+
|
|
154
|
+
### `scrollThrough(page, options?)`
|
|
155
|
+
|
|
156
|
+
Scrolls down in viewport-sized steps, then back to the original position. If the window does not scroll but a full-height container does (common in app layouts), it scrolls that container.
|
|
157
|
+
|
|
158
|
+
| Option | Default | |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `timeout` | `5000` | Stop scrolling after this long. |
|
|
161
|
+
| `maxHeight` | `20000` | Stop this far down (infinite feeds keep growing). |
|
|
162
|
+
| `stepRatio` | `0.85` | Step size as a share of the viewport height. |
|
|
163
|
+
| `stepDelayMs` | `0` | Extra wait per step, for pages that load on a scroll debounce. |
|
|
164
|
+
|
|
165
|
+
Returns `{ steps, reachedPx, pageHeight, scroller, timedOut }`.
|
|
166
|
+
|
|
167
|
+
### `loadLazyContent(page, options?)`
|
|
168
|
+
|
|
169
|
+
`scrollThrough`, then waits for the network to go quiet and for images to decode. Takes the `scrollThrough` options plus `loadTimeout` (default 5000).
|
|
170
|
+
|
|
171
|
+
### `waitForNetworkQuiet(page, options?)` and `trackRequests(page)`
|
|
172
|
+
|
|
173
|
+
Waits until no request has been open for `quietMs` (default 500), up to `timeout` (default 5000). After `relaxAfterMs` (default half the timeout) up to two open requests still count as quiet, so long-polls and analytics beacons do not hold the capture forever. `maxInflight` sets the strict allowance (default 0).
|
|
174
|
+
|
|
175
|
+
It listens to the page's request events, so it only sees requests that start after the call. To count everything, start tracking before you navigate:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
const tracker = trackRequests(page);
|
|
179
|
+
await page.goto(url);
|
|
180
|
+
await tracker.waitForQuiet({ quietMs: 500, timeout: 5000 });
|
|
181
|
+
tracker.stop();
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Given an object without events (a Frame), it falls back to watching Resource Timing entries inside the page.
|
|
185
|
+
|
|
186
|
+
### `waitForImages(page, options?)`
|
|
187
|
+
|
|
188
|
+
Waits for rendered `<img>` elements within `maxHeight` (default 20000) to load and decode, up to `timeout` (default 5000). Broken images count as settled. Lazy images the browser will not fetch at the current scroll position (off to the side in a carousel, or clipped inside a collapsed panel) are skipped, since they are not in the capture. Returns `{ total, ready, broken, pending, timedOut }`.
|
|
189
|
+
|
|
190
|
+
### `freezeAnimations(page, options?)`
|
|
191
|
+
|
|
192
|
+
| Option | Default | |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `caret` | `true` | Hide the text caret. |
|
|
195
|
+
| `pauseMedia` | `true` | Pause playing video and audio. |
|
|
196
|
+
|
|
197
|
+
Returns `{ finished, paused, media, timedOut }`. Playwright users can also pass `animations: 'disabled'` and `caret: 'hide'` to `page.screenshot()`; this helper does the same for Puppeteer and also covers transitions that start after the call.
|
|
198
|
+
|
|
199
|
+
## Limits
|
|
200
|
+
|
|
201
|
+
Be aware of what this does not do:
|
|
202
|
+
|
|
203
|
+
- **Consent banners inside cross-origin iframes** are hidden through their container on the page (Sourcepoint, for example), but the library cannot look inside the iframe. A platform outside the built-in list that renders only in an iframe needs your own selector.
|
|
204
|
+
- **Closed shadow roots** cannot be inspected. Open shadow roots are searched.
|
|
205
|
+
- **The heuristics are conservative on purpose.** They will miss some custom banners. Add a selector for those rather than loosening the rules.
|
|
206
|
+
- **Hiding is not consenting.** The site behaves as if the visitor has not chosen yet, so content that waits for consent (some embeds, some ads) stays empty.
|
|
207
|
+
- **JavaScript-driven motion** (a carousel on `setInterval`, canvas or WebGL animations) is not frozen. Hide it or wait for a known state.
|
|
208
|
+
- **Lazy content past `maxHeight`** is left alone, so infinite feeds stay finite. Raise `maxHeight` if your pages are taller.
|
|
209
|
+
- **Network quiet** only sees requests that start after the call, unless you use `trackRequests` before navigating.
|
|
210
|
+
- **Bot checks and logins** are out of scope. If the site serves a challenge page, that is what gets captured.
|
|
211
|
+
- Tested with Chromium in Playwright and Puppeteer. The code uses standard DOM APIs, but Firefox and WebKit are not part of the test suite yet.
|
|
212
|
+
|
|
213
|
+
## Don't want to run browsers?
|
|
214
|
+
|
|
215
|
+
Running headless browsers in production means keeping Chromium and its system libraries installed, sizing memory for it and handling crashes and timeouts. [SnapRender](https://snap-render.com) does this as a hosted API, including cookie banner removal and lazy-image loading for full-page captures (free plan: 200 captures a month).
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
const url = 'https://app.snap-render.com/v1/screenshot?url=https://example.com&full_page=true&lazy_load=true';
|
|
219
|
+
const res = await fetch(url, { headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY } });
|
|
220
|
+
await fs.promises.writeFile('page.png', Buffer.from(await res.arrayBuffer()));
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Related: [pagequiet](https://github.com/User0856/pagequiet) watches pages for visible changes and alerts only when the text a reader sees changes.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
npm install
|
|
229
|
+
npx playwright install chromium
|
|
230
|
+
npx puppeteer browsers install chrome
|
|
231
|
+
npm run build
|
|
232
|
+
npm test
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The tests drive headless Chromium through both Playwright and Puppeteer against local fixture pages (lazy images, consent banners, slow web fonts, animations) served on 127.0.0.1.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type PageLike } from './page.js';
|
|
2
|
+
/**
|
|
3
|
+
* Containers of common consent management platforms (CMPs). Hidden with a stylesheet rule, so a banner the
|
|
4
|
+
* CMP injects after the call is hidden too. Kept to IDs and class names the vendors use for the banner itself.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CONSENT_SELECTORS: readonly string[];
|
|
7
|
+
export interface HideConsentOptions {
|
|
8
|
+
/** More selectors to hide, on top of the built-in list. */
|
|
9
|
+
selectors?: string[];
|
|
10
|
+
/** Use only `selectors` (yours) instead of adding them to the built-in list. Default false. */
|
|
11
|
+
replaceBuiltIn?: boolean;
|
|
12
|
+
/** Also find consent banners by position and text (fixed overlays that mention cookies and offer a choice). Default true. */
|
|
13
|
+
heuristics?: boolean;
|
|
14
|
+
/** Undo the scroll lock (overflow hidden on html/body) a consent dialog leaves behind. Default true. */
|
|
15
|
+
unlockScroll?: boolean;
|
|
16
|
+
/** Give up after this many ms. Default 2000. */
|
|
17
|
+
timeout?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface HideConsentResult {
|
|
20
|
+
/** Elements hidden in total. */
|
|
21
|
+
hidden: number;
|
|
22
|
+
/** Elements matched by the selector list. */
|
|
23
|
+
bySelector: number;
|
|
24
|
+
/** Elements matched by the position and text heuristics. */
|
|
25
|
+
byHeuristic: number;
|
|
26
|
+
/** Empty full-screen backdrops hidden along with a banner. */
|
|
27
|
+
overlays: number;
|
|
28
|
+
/** True when overflow or position on html/body was reset. */
|
|
29
|
+
scrollUnlocked: boolean;
|
|
30
|
+
/** Short descriptions of what was hidden, like `div#onetrust-consent-sdk`. */
|
|
31
|
+
details: string[];
|
|
32
|
+
timedOut: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare const HIDDEN_ATTRIBUTE = "data-screenshot-ready-hidden";
|
|
35
|
+
/**
|
|
36
|
+
* Hides cookie and consent banners without clicking anything: known consent platforms by selector, plus
|
|
37
|
+
* fixed overlays that talk about cookies or consent and offer a choice. It never clicks "accept", so no
|
|
38
|
+
* consent is given and the page does not reload. Elements are hidden with CSS, not removed, so page scripts
|
|
39
|
+
* that reference them keep working.
|
|
40
|
+
*/
|
|
41
|
+
export declare function hideConsentBanners(page: PageLike, options?: HideConsentOptions): Promise<HideConsentResult>;
|
|
42
|
+
export interface HideElementsOptions {
|
|
43
|
+
/** 'visibility' keeps the layout (default), 'display' removes the element from the layout. */
|
|
44
|
+
mode?: 'visibility' | 'display';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Hides elements you name (chat widgets, promo bars, ads) with a stylesheet rule, so they stay hidden if they
|
|
48
|
+
* are re-rendered. Returns how many elements matched at the time of the call.
|
|
49
|
+
*/
|
|
50
|
+
export declare function hideElements(page: PageLike, selectors: string[], options?: HideElementsOptions): Promise<number>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HIDDEN_ATTRIBUTE = exports.CONSENT_SELECTORS = void 0;
|
|
4
|
+
exports.hideConsentBanners = hideConsentBanners;
|
|
5
|
+
exports.hideElements = hideElements;
|
|
6
|
+
const page_js_1 = require("./page.js");
|
|
7
|
+
/**
|
|
8
|
+
* Containers of common consent management platforms (CMPs). Hidden with a stylesheet rule, so a banner the
|
|
9
|
+
* CMP injects after the call is hidden too. Kept to IDs and class names the vendors use for the banner itself.
|
|
10
|
+
*/
|
|
11
|
+
exports.CONSENT_SELECTORS = [
|
|
12
|
+
// OneTrust
|
|
13
|
+
'#onetrust-consent-sdk',
|
|
14
|
+
'#onetrust-banner-sdk',
|
|
15
|
+
'.onetrust-pc-dark-filter',
|
|
16
|
+
// Cookiebot
|
|
17
|
+
'#CybotCookiebotDialog',
|
|
18
|
+
'#CybotCookiebotDialogBodyUnderlay',
|
|
19
|
+
// Didomi
|
|
20
|
+
'#didomi-host',
|
|
21
|
+
'#didomi-popup',
|
|
22
|
+
'#didomi-notice',
|
|
23
|
+
'.didomi-popup-backdrop',
|
|
24
|
+
// Quantcast Choice
|
|
25
|
+
'#qc-cmp2-container',
|
|
26
|
+
'.qc-cmp2-container',
|
|
27
|
+
'#qcCmpUi',
|
|
28
|
+
// TrustArc
|
|
29
|
+
'#truste-consent-track',
|
|
30
|
+
'#consent_blackbar',
|
|
31
|
+
'#trustarc-banner-overlay',
|
|
32
|
+
'.truste_overlay',
|
|
33
|
+
'.truste_box_overlay',
|
|
34
|
+
// Usercentrics
|
|
35
|
+
'#usercentrics-root',
|
|
36
|
+
'#usercentrics-cmp-ui',
|
|
37
|
+
// Osano
|
|
38
|
+
'.osano-cm-window',
|
|
39
|
+
'.osano-cm-dialog',
|
|
40
|
+
// Google Funding Choices
|
|
41
|
+
'.fc-consent-root',
|
|
42
|
+
// Sourcepoint
|
|
43
|
+
'[id^="sp_message_container"]',
|
|
44
|
+
// CookieYes
|
|
45
|
+
'.cky-consent-container',
|
|
46
|
+
'.cky-overlay',
|
|
47
|
+
// Complianz
|
|
48
|
+
'#cmplz-cookiebanner-container',
|
|
49
|
+
// iubenda
|
|
50
|
+
'#iubenda-cs-banner',
|
|
51
|
+
// Termly
|
|
52
|
+
'#termly-code-snippet-support',
|
|
53
|
+
// Borlabs Cookie
|
|
54
|
+
'#BorlabsCookieBox',
|
|
55
|
+
// Ketch
|
|
56
|
+
'#lanyard_root',
|
|
57
|
+
// CookieConsent (orestbida) v3
|
|
58
|
+
'#cc-main',
|
|
59
|
+
// Cookie Consent by Osano (open-source script)
|
|
60
|
+
'.cc-window.cc-banner',
|
|
61
|
+
'.cc-window.cc-floating',
|
|
62
|
+
// Klaro
|
|
63
|
+
'.klaro .cookie-notice',
|
|
64
|
+
'.klaro .cookie-modal',
|
|
65
|
+
];
|
|
66
|
+
exports.HIDDEN_ATTRIBUTE = 'data-screenshot-ready-hidden';
|
|
67
|
+
function consentInPage(arg) {
|
|
68
|
+
const ATTR = arg.attr;
|
|
69
|
+
const details = [];
|
|
70
|
+
const describe = (el) => {
|
|
71
|
+
const id = el.id ? `#${el.id}` : '';
|
|
72
|
+
const cls = !id && typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
73
|
+
return `${el.tagName.toLowerCase()}${id}${cls}`;
|
|
74
|
+
};
|
|
75
|
+
const roots = [document];
|
|
76
|
+
const collectShadowRoots = (root) => {
|
|
77
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
78
|
+
if (el.shadowRoot) {
|
|
79
|
+
roots.push(el.shadowRoot);
|
|
80
|
+
collectShadowRoots(el.shadowRoot);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
collectShadowRoots(document);
|
|
85
|
+
const allIn = (root) => root === document ? (document.body ? Array.from(document.body.querySelectorAll('*')) : []) : Array.from(root.querySelectorAll('*'));
|
|
86
|
+
// A stylesheet in the document plus the attribute: wins over the CMP's own inline styles and keeps applying
|
|
87
|
+
// when the CMP re-renders. Inside shadow roots the document stylesheet does not apply, so those get an
|
|
88
|
+
// inline !important style as well.
|
|
89
|
+
const hide = (el, kind) => {
|
|
90
|
+
if (el.hasAttribute(ATTR))
|
|
91
|
+
return false;
|
|
92
|
+
el.setAttribute(ATTR, kind);
|
|
93
|
+
if (el.getRootNode() !== document && el instanceof HTMLElement)
|
|
94
|
+
el.style.setProperty('display', 'none', 'important');
|
|
95
|
+
if (details.length < 50)
|
|
96
|
+
details.push(describe(el));
|
|
97
|
+
return true;
|
|
98
|
+
};
|
|
99
|
+
const styleId = 'screenshot-ready-consent-style';
|
|
100
|
+
let style = document.getElementById(styleId);
|
|
101
|
+
if (!style) {
|
|
102
|
+
style = document.createElement('style');
|
|
103
|
+
style.id = styleId;
|
|
104
|
+
(document.head || document.documentElement).appendChild(style);
|
|
105
|
+
}
|
|
106
|
+
const valid = arg.selectors.filter((sel) => {
|
|
107
|
+
try {
|
|
108
|
+
document.querySelector(sel);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// One rule per selector, so one bad selector cannot void the rest.
|
|
116
|
+
style.textContent = [...valid.map((sel) => `${sel}{display:none!important}`), `[${ATTR}]{display:none!important}`].join('\n');
|
|
117
|
+
let bySelector = 0;
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
for (const sel of valid) {
|
|
120
|
+
for (const el of Array.from(root.querySelectorAll(sel))) {
|
|
121
|
+
if (hide(el, 'cmp'))
|
|
122
|
+
bySelector += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let byHeuristic = 0;
|
|
127
|
+
let overlays = 0;
|
|
128
|
+
if (arg.heuristics) {
|
|
129
|
+
const vw = window.innerWidth;
|
|
130
|
+
const vh = window.innerHeight;
|
|
131
|
+
const consentText = /\b(cookies?|cookie-?richtlinie|consent|gdpr|dsgvo|ccpa|datenschutz\w*|einwilligung|consentement|consentimiento|consenso|toestemming|privacy (settings|preferences|choices|center)|your privacy|tracking technolog\w+)\b/i;
|
|
132
|
+
const actionText = /\b(accept|agree|allow|got it|i understand|understood|ok(ay)?|reject|decline|deny|refuse|manage|customi[sz]e|preferences|settings|necessary|essential|continue|akzeptieren|zustimmen|ablehnen|einverstanden|accepter|refuser|aceptar|rechazar|accetta|accetto|rifiuta|accepteren|weigeren|aceitar|rejeitar)\b/i;
|
|
133
|
+
const controls = 'button, a, [role="button"], input[type="button"], input[type="submit"]';
|
|
134
|
+
const visible = (el, cs) => {
|
|
135
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0)
|
|
136
|
+
return false;
|
|
137
|
+
const r = el.getBoundingClientRect();
|
|
138
|
+
return r.width > 0 && r.height > 0;
|
|
139
|
+
};
|
|
140
|
+
const hasAction = (el) => Array.from(el.querySelectorAll(controls)).some((c) => {
|
|
141
|
+
const t = (c.innerText || c.value || c.getAttribute('aria-label') || '').trim();
|
|
142
|
+
return t.length > 0 && t.length <= 40 && actionText.test(t);
|
|
143
|
+
});
|
|
144
|
+
// Things a consent banner does not contain. Guards against hiding a fixed full-screen app shell.
|
|
145
|
+
const looksLikePage = (el) => el.querySelector('main, article, nav, h1, video, canvas') !== null || el.querySelectorAll('img').length > 3;
|
|
146
|
+
// Many visible links: a menu or footer that happens to mention cookies, unless the text reads like a
|
|
147
|
+
// consent notice (some consent walls list partners and policies as links).
|
|
148
|
+
const consentSentence = /\b(we|this (site|website)|our partners)\b[^.]{0,60}\buses?\b[^.]{0,40}\bcookies\b|\bcookies? (to|for|and similar)\b|\b(your|votre|ihre|su|tua|uw) (consent|consentement|einwilligung|consentimiento|consenso|toestemming)\b|\buse of cookies\b/i;
|
|
149
|
+
const navLike = (el, text) => Array.from(el.querySelectorAll('a[href]')).filter((a) => a.getClientRects().length > 0).length >= 10 && !consentSentence.test(text);
|
|
150
|
+
const banners = [];
|
|
151
|
+
for (const root of roots) {
|
|
152
|
+
for (const el of allIn(root)) {
|
|
153
|
+
if (el.closest(`[${ATTR}]`))
|
|
154
|
+
continue;
|
|
155
|
+
const cs = getComputedStyle(el);
|
|
156
|
+
const isDialog = el.matches('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]');
|
|
157
|
+
if (cs.position !== 'fixed' && cs.position !== 'sticky' && !isDialog)
|
|
158
|
+
continue;
|
|
159
|
+
if (!visible(el, cs))
|
|
160
|
+
continue;
|
|
161
|
+
const text = (el.innerText || el.textContent || '').trim();
|
|
162
|
+
if (text.length === 0 || text.length > 3000)
|
|
163
|
+
continue;
|
|
164
|
+
if (!consentText.test(text) || !hasAction(el))
|
|
165
|
+
continue;
|
|
166
|
+
if (navLike(el, text))
|
|
167
|
+
continue;
|
|
168
|
+
const r = el.getBoundingClientRect();
|
|
169
|
+
const large = r.width * r.height >= vw * vh * 0.5;
|
|
170
|
+
if (large && looksLikePage(el))
|
|
171
|
+
continue;
|
|
172
|
+
// A sticky element is part of the page flow (headers, footers): only hide it when the text is clearly a
|
|
173
|
+
// consent notice, not a "Cookie settings" link among others.
|
|
174
|
+
if (cs.position === 'sticky' && !consentSentence.test(text))
|
|
175
|
+
continue;
|
|
176
|
+
if (hide(el, 'heuristic')) {
|
|
177
|
+
byHeuristic += 1;
|
|
178
|
+
banners.push(el);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Empty dimming layers that sit behind a consent dialog.
|
|
183
|
+
if (banners.length > 0 || bySelector > 0) {
|
|
184
|
+
for (const root of roots) {
|
|
185
|
+
for (const el of allIn(root)) {
|
|
186
|
+
if (el.closest(`[${ATTR}]`))
|
|
187
|
+
continue;
|
|
188
|
+
const cs = getComputedStyle(el);
|
|
189
|
+
if (cs.position !== 'fixed' || !visible(el, cs))
|
|
190
|
+
continue;
|
|
191
|
+
if ((el.innerText || '').trim().length > 0)
|
|
192
|
+
continue;
|
|
193
|
+
if (el.querySelector('img, svg, video, canvas, iframe, picture'))
|
|
194
|
+
continue;
|
|
195
|
+
const z = parseInt(cs.zIndex, 10);
|
|
196
|
+
if (!(z >= 10))
|
|
197
|
+
continue;
|
|
198
|
+
const r = el.getBoundingClientRect();
|
|
199
|
+
if (r.width < vw * 0.9 || r.height < vh * 0.9)
|
|
200
|
+
continue;
|
|
201
|
+
const tinted = !/rgba?\(\s*0,\s*0,\s*0,\s*0\s*\)|transparent/.test(cs.backgroundColor) || (cs.backdropFilter && cs.backdropFilter !== 'none');
|
|
202
|
+
if (!tinted)
|
|
203
|
+
continue;
|
|
204
|
+
if (hide(el, 'overlay'))
|
|
205
|
+
overlays += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let scrollUnlocked = false;
|
|
211
|
+
if (arg.unlockScroll && bySelector + byHeuristic + overlays > 0) {
|
|
212
|
+
for (const el of [document.documentElement, document.body]) {
|
|
213
|
+
if (!el)
|
|
214
|
+
continue;
|
|
215
|
+
const cs = getComputedStyle(el);
|
|
216
|
+
if (cs.overflowY === 'hidden' || cs.overflow === 'hidden') {
|
|
217
|
+
el.style.setProperty('overflow', 'visible', 'important');
|
|
218
|
+
scrollUnlocked = true;
|
|
219
|
+
}
|
|
220
|
+
// Scroll locks that pin the body in place: position fixed with a negative top.
|
|
221
|
+
if (el === document.body && cs.position === 'fixed') {
|
|
222
|
+
el.style.setProperty('position', 'static', 'important');
|
|
223
|
+
el.style.setProperty('top', 'auto', 'important');
|
|
224
|
+
scrollUnlocked = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { hidden: bySelector + byHeuristic + overlays, bySelector, byHeuristic, overlays, scrollUnlocked, details, timedOut: false };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Hides cookie and consent banners without clicking anything: known consent platforms by selector, plus
|
|
232
|
+
* fixed overlays that talk about cookies or consent and offer a choice. It never clicks "accept", so no
|
|
233
|
+
* consent is given and the page does not reload. Elements are hidden with CSS, not removed, so page scripts
|
|
234
|
+
* that reference them keep working.
|
|
235
|
+
*/
|
|
236
|
+
async function hideConsentBanners(page, options = {}) {
|
|
237
|
+
const timeoutMs = Math.max(0, options.timeout ?? 2000);
|
|
238
|
+
const selectors = [...(options.replaceBuiltIn ? [] : exports.CONSENT_SELECTORS), ...(options.selectors ?? [])];
|
|
239
|
+
const arg = { selectors, heuristics: options.heuristics ?? true, unlockScroll: options.unlockScroll ?? true, attr: exports.HIDDEN_ATTRIBUTE };
|
|
240
|
+
const result = await (0, page_js_1.withTimeout)((0, page_js_1.inPage)(page, consentInPage, arg), timeoutMs + page_js_1.GRACE_MS);
|
|
241
|
+
if (result.timedOut)
|
|
242
|
+
return { hidden: 0, bySelector: 0, byHeuristic: 0, overlays: 0, scrollUnlocked: false, details: [], timedOut: true };
|
|
243
|
+
return result.value;
|
|
244
|
+
}
|
|
245
|
+
function hideInPage(arg) {
|
|
246
|
+
const styleId = 'screenshot-ready-hide-style';
|
|
247
|
+
let style = document.getElementById(styleId);
|
|
248
|
+
if (!style) {
|
|
249
|
+
style = document.createElement('style');
|
|
250
|
+
style.id = styleId;
|
|
251
|
+
(document.head || document.documentElement).appendChild(style);
|
|
252
|
+
}
|
|
253
|
+
const rule = arg.mode === 'display' ? 'display:none!important' : 'visibility:hidden!important';
|
|
254
|
+
let matched = 0;
|
|
255
|
+
const rules = [];
|
|
256
|
+
for (const sel of arg.selectors) {
|
|
257
|
+
try {
|
|
258
|
+
matched += document.querySelectorAll(sel).length;
|
|
259
|
+
rules.push(`${sel}{${rule}}`);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// invalid selector: skip it
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
style.textContent = (style.textContent ? style.textContent + '\n' : '') + rules.join('\n');
|
|
266
|
+
return matched;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Hides elements you name (chat widgets, promo bars, ads) with a stylesheet rule, so they stay hidden if they
|
|
270
|
+
* are re-rendered. Returns how many elements matched at the time of the call.
|
|
271
|
+
*/
|
|
272
|
+
async function hideElements(page, selectors, options = {}) {
|
|
273
|
+
if (selectors.length === 0)
|
|
274
|
+
return 0;
|
|
275
|
+
const result = await (0, page_js_1.withTimeout)((0, page_js_1.inPage)(page, hideInPage, { selectors, mode: options.mode ?? 'visibility' }), 2000 + page_js_1.GRACE_MS);
|
|
276
|
+
return result.timedOut ? 0 : result.value;
|
|
277
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type PageLike } from './page.js';
|
|
2
|
+
export interface WaitForFontsOptions {
|
|
3
|
+
/** Give up after this many ms. Default 3000. */
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface WaitForFontsResult {
|
|
7
|
+
/** True when fonts were still loading when the timeout hit. */
|
|
8
|
+
timedOut: boolean;
|
|
9
|
+
/** Font faces that finished loading. */
|
|
10
|
+
loaded: number;
|
|
11
|
+
/** Families still loading at the end. */
|
|
12
|
+
pending: string[];
|
|
13
|
+
/** Families that failed to load (the browser falls back to another font for these). */
|
|
14
|
+
failed: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Waits until the page's web fonts have loaded (or failed), so text is not captured in a fallback font or
|
|
18
|
+
* invisible while a font with `font-display: block` downloads.
|
|
19
|
+
*/
|
|
20
|
+
export declare function waitForFonts(page: PageLike, options?: WaitForFontsOptions): Promise<WaitForFontsResult>;
|