@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.0.0",
3
+ "version": "16.0.1",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -1,4 +1,5 @@
1
- import puppeteer from 'puppeteer-extra';
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
- puppeteer.use(stealth);
133
- puppeteer.use(userAgentOverride({ locale }));
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
- browser = await puppeteer.launch(options);
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
- browser.proxyCredentials = proxyCredentials;
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 setupPdfInterception(client) {
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"/);