@opentermsarchive/engine 16.0.0 → 16.0.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.
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import puppeteer from 'puppeteer
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
import { addExtra } from 'puppeteer-extra';
|
|
2
3
|
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
3
4
|
import navigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';
|
|
4
5
|
import userAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
|
|
@@ -39,7 +40,7 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
39
40
|
page = await context.newPage();
|
|
40
41
|
client = await page.createCDPSession();
|
|
41
42
|
|
|
42
|
-
await configurePage(page, config);
|
|
43
|
+
await configurePage(page, client, config);
|
|
43
44
|
|
|
44
45
|
const selectors = [].concat(cssSelectors).filter(Boolean);
|
|
45
46
|
|
|
@@ -47,7 +48,7 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
47
48
|
let handled = null;
|
|
48
49
|
|
|
49
50
|
if (!selectors.length) { // CSS selectors are specified only for HTML content and omitted when fetching a PDF
|
|
50
|
-
({ pdf, handled } = setupPdfInterception(client));
|
|
51
|
+
({ pdf, handled } = await setupPdfInterception(client));
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
let response;
|
|
@@ -125,13 +126,13 @@ export async function launchHeadlessBrowser(language) {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
const { locale, languages } = parseLanguage(language);
|
|
129
|
+
const puppeteerExtra = addExtra(puppeteer); // Fresh instance for each launch, as plugins registered on the shared instance accumulate across launches and their hooks would run once per copy on every page
|
|
128
130
|
const stealth = stealthPlugin();
|
|
129
131
|
|
|
130
132
|
stealth.enabledEvasions.delete('user-agent-override');
|
|
131
133
|
stealth.enabledEvasions.delete('navigator.languages');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
puppeteer.use(navigatorLanguages({ languages }));
|
|
134
|
+
puppeteerExtra.use(stealth);
|
|
135
|
+
puppeteerExtra.use(navigatorLanguages({ languages }));
|
|
135
136
|
|
|
136
137
|
const options = {
|
|
137
138
|
args: [],
|
|
@@ -156,12 +157,21 @@ export async function launchHeadlessBrowser(language) {
|
|
|
156
157
|
options.args.push('--disable-setuid-sandbox');
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
|
|
160
|
+
const launchedBrowser = await puppeteerExtra.launch(options);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
launchedBrowser.userAgentOverride = await captureUserAgentOverride(launchedBrowser, locale); // Computed once since the user agent is the same for every page of the browser
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await launchedBrowser.close().catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
160
168
|
|
|
161
169
|
if (proxyCredentials) {
|
|
162
|
-
|
|
170
|
+
launchedBrowser.proxyCredentials = proxyCredentials;
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
browser = launchedBrowser; // Set only once fully configured, so that no caller gets a browser without its user agent override
|
|
174
|
+
|
|
165
175
|
return browser;
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -184,22 +194,44 @@ function isValidHttpStatus(status) {
|
|
|
184
194
|
return (status >= 200 && status < 300) || status === 304;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
async function configurePage(page, config) {
|
|
197
|
+
async function configurePage(page, client, config) {
|
|
188
198
|
await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
|
|
189
199
|
await page.setDefaultNavigationTimeout(config.navigationTimeout);
|
|
200
|
+
await client.send('Network.setUserAgentOverride', browser.userAgentOverride);
|
|
190
201
|
|
|
191
202
|
if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
|
|
192
203
|
await page.authenticate(browser.proxyCredentials);
|
|
193
204
|
}
|
|
194
205
|
}
|
|
195
206
|
|
|
196
|
-
function
|
|
207
|
+
async function captureUserAgentOverride(browser, locale) { // The stealth evasion sends the override without awaiting it, so a failure would crash the process on an unhandled rejection; running it once on a stand-in page records the override for the fetcher to send itself
|
|
208
|
+
const evasion = userAgentOverride({ locale });
|
|
209
|
+
let override;
|
|
210
|
+
|
|
211
|
+
await evasion.beforeLaunch({ headless: true }); // Otherwise the evasion leaves Accept-Language to browser preferences
|
|
212
|
+
await evasion.onPageCreated({ // The stand-in page exposes only what the evasion reads
|
|
213
|
+
browser: () => browser,
|
|
214
|
+
_client: () => ({
|
|
215
|
+
send: (method, params) => {
|
|
216
|
+
if (method === 'Network.setUserAgentOverride') {
|
|
217
|
+
override = params;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!override?.userAgent) {
|
|
224
|
+
throw new Error('Could not capture the user agent override from the stealth user-agent-override evasion');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return override;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function setupPdfInterception(client) {
|
|
197
231
|
const pdf = { content: null, status: null };
|
|
198
232
|
let onHandled;
|
|
199
233
|
const handled = new Promise(resolve => { onHandled = resolve; });
|
|
200
234
|
|
|
201
|
-
client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it's handled by the PDF viewer
|
|
202
|
-
|
|
203
235
|
client.on('Fetch.requestPaused', async ({ requestId, resourceType, responseHeaders, responseStatusCode }) => {
|
|
204
236
|
try {
|
|
205
237
|
const contentType = responseHeaders?.find(header => header.name.toLowerCase() === 'content-type')?.value;
|
|
@@ -234,6 +266,8 @@ function setupPdfInterception(client) {
|
|
|
234
266
|
}
|
|
235
267
|
});
|
|
236
268
|
|
|
269
|
+
await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it is handled by the PDF viewer; enabled once the listener is registered so that no paused request is missed
|
|
270
|
+
|
|
237
271
|
return { pdf, handled };
|
|
238
272
|
}
|
|
239
273
|
|
|
@@ -17,7 +17,7 @@ use(chaiAsPromised);
|
|
|
17
17
|
const dynamicHTML = '<!DOCTYPE html><html><head><title>Dynamic Page</title><script>setTimeout(() => { document.body.innerHTML += "<div class=\'dynamic\'>Loaded</div>"; }, 100);</script></head><body></body></html>';
|
|
18
18
|
const delayedContentHTML = '<!DOCTYPE html><html><head><title>Delayed Content</title><script>setTimeout(() => { document.querySelector(".content").textContent = "Final content"; }, 100);</script></head><body><div class="content"></div></body></html>';
|
|
19
19
|
const langEchoHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-language", navigator.language); document.body.setAttribute("data-languages", navigator.languages.join(","));</script></body></html>';
|
|
20
|
-
const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
|
|
20
|
+
const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-platform", navigator.platform); document.body.setAttribute("data-ua-brands", navigator.userAgentData ? navigator.userAgentData.brands.map(entry => entry.brand).join(",") : ""); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
|
|
21
21
|
|
|
22
22
|
describe('Full DOM Fetcher', function () {
|
|
23
23
|
this.timeout(60000);
|
|
@@ -40,6 +40,11 @@ describe('Full DOM Fetcher', function () {
|
|
|
40
40
|
|
|
41
41
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body data-accept-language="${acceptLanguage}"></body></html>`);
|
|
42
42
|
}
|
|
43
|
+
if (request.url === '/client-hints-header') {
|
|
44
|
+
const clientHintsBrands = request.headers['sec-ch-ua'] || '';
|
|
45
|
+
|
|
46
|
+
response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body>${clientHintsBrands}</body></html>`);
|
|
47
|
+
}
|
|
43
48
|
if (request.url === '/lang-echo') {
|
|
44
49
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(langEchoHTML);
|
|
45
50
|
}
|
|
@@ -76,6 +81,16 @@ describe('Full DOM Fetcher', function () {
|
|
|
76
81
|
|
|
77
82
|
expect(browser1).to.equal(browser2);
|
|
78
83
|
});
|
|
84
|
+
|
|
85
|
+
it('registers plugin hooks once per launch', async () => {
|
|
86
|
+
const initialListenerCount = (await launchHeadlessBrowser('en')).listenerCount('targetcreated');
|
|
87
|
+
|
|
88
|
+
await stopHeadlessBrowser();
|
|
89
|
+
|
|
90
|
+
const relaunchedBrowser = await launchHeadlessBrowser('en');
|
|
91
|
+
|
|
92
|
+
expect(relaunchedBrowser.listenerCount('targetcreated')).to.equal(initialListenerCount);
|
|
93
|
+
});
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
describe('#fetch', () => {
|
|
@@ -136,6 +151,25 @@ describe('Full DOM Fetcher', function () {
|
|
|
136
151
|
expect(content.equals(expectedPDFContent)).to.be.true;
|
|
137
152
|
});
|
|
138
153
|
});
|
|
154
|
+
|
|
155
|
+
context('when the user agent override cannot be applied', () => {
|
|
156
|
+
let browser;
|
|
157
|
+
let userAgentOverride;
|
|
158
|
+
|
|
159
|
+
before(async () => {
|
|
160
|
+
browser = await launchHeadlessBrowser('en');
|
|
161
|
+
({ userAgentOverride } = browser);
|
|
162
|
+
browser.userAgentOverride = {}; // Rejected by Chrome since the user agent is mandatory
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
after(() => {
|
|
166
|
+
browser.userAgentOverride = userAgentOverride;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects with the protocol error', async () => {
|
|
170
|
+
await expect(fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, [], config)).to.be.rejectedWith('Network.setUserAgentOverride');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
139
173
|
});
|
|
140
174
|
|
|
141
175
|
describe('Language configuration', () => {
|
|
@@ -201,6 +235,23 @@ describe('Full DOM Fetcher', function () {
|
|
|
201
235
|
expect(content).not.to.match(/HeadlessChrome/);
|
|
202
236
|
});
|
|
203
237
|
|
|
238
|
+
it('removes HeadlessChrome from navigator.userAgentData brands', () => {
|
|
239
|
+
expect(content).to.match(/data-ua-brands="[^"]*Google Chrome[^"]*"/);
|
|
240
|
+
expect(content).not.to.match(/data-ua-brands="[^"]*Headless[^"]*"/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('sends the Sec-CH-UA header without HeadlessChrome', async () => {
|
|
244
|
+
const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/client-hints-header`, [], config);
|
|
245
|
+
|
|
246
|
+
expect(result.content).to.match(/"Google Chrome";v="\d+"/);
|
|
247
|
+
expect(result.content).not.to.match(/Headless/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('does not expose a Linux host through navigator.platform', () => {
|
|
251
|
+
expect(content).to.match(/data-platform="[^"]+"/);
|
|
252
|
+
expect(content).not.to.match(/data-platform="Linux/);
|
|
253
|
+
});
|
|
254
|
+
|
|
204
255
|
it('uses a realistic viewport instead of Puppeteer default', () => {
|
|
205
256
|
expect(content).to.match(/data-viewport-width="1920"/);
|
|
206
257
|
expect(content).to.match(/data-viewport-height="1080"/);
|