@opentermsarchive/engine 12.0.2 → 13.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "12.0.2",
3
+ "version": "13.0.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -92,7 +92,7 @@
92
92
  "morgan": "^1.10.1",
93
93
  "node-fetch": "^3.3.2",
94
94
  "octokit": "^5.0.3",
95
- "puppeteer": "^24.22.0",
95
+ "puppeteer": "^25.1.0",
96
96
  "puppeteer-extra": "^3.3.6",
97
97
  "puppeteer-extra-plugin-stealth": "^2.11.2",
98
98
  "simple-git": "^3.28.0",
@@ -51,9 +51,11 @@ export default async options => {
51
51
  const filePath = path.join(declarationsPath, `${serviceId}.json`);
52
52
  const historyFilePath = path.join(declarationsPath, `${serviceId}.history.json`);
53
53
 
54
- before(launchHeadlessBrowser);
54
+ if (!schemaOnly) {
55
+ before(() => launchHeadlessBrowser(config.get('@opentermsarchive/engine.fetcher.language')));
55
56
 
56
- after(stopHeadlessBrowser);
57
+ after(stopHeadlessBrowser);
58
+ }
57
59
 
58
60
  context(serviceId, () => {
59
61
  before(function () {
@@ -1,13 +1,31 @@
1
1
  import puppeteer from 'puppeteer-extra';
2
2
  import stealthPlugin from 'puppeteer-extra-plugin-stealth';
3
+ import navigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';
4
+ import userAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
3
5
 
4
6
  import { resolveProxyConfiguration, extractProxyCredentials } from './proxyUtils.js';
5
7
 
6
8
  let browser;
7
9
 
8
- export default async function fetch(url, cssSelectors, config) {
9
- puppeteer.use(stealthPlugin({ locale: config.language }));
10
+ export function parseLanguage(value) {
11
+ if (typeof value !== 'string') {
12
+ throw new Error(`Fetcher language must be a string; received ${JSON.stringify(value)}. Pass the value of "@opentermsarchive/engine.fetcher.language" explicitly.`);
13
+ }
14
+
15
+ if (value.toLowerCase().includes(';q=')) {
16
+ throw new Error(`Quality factors are not supported in fetcher language configuration; received "${value}". Provide a comma-separated list of BCP 47 tags in priority order, for example "en-IE,en-GB,en".`);
17
+ }
18
+
19
+ const languages = value.split(',').map(tag => tag.trim()).filter(Boolean);
20
+
21
+ if (!languages.length) {
22
+ throw new Error(`Fetcher language must contain at least one tag; received "${value}".`);
23
+ }
10
24
 
25
+ return { locale: languages.join(','), languages };
26
+ }
27
+
28
+ export default async function fetch(url, cssSelectors, config) {
11
29
  if (!browser) {
12
30
  throw new Error('The headless browser should be controlled manually with "launchHeadlessBrowser" and "stopHeadlessBrowser".');
13
31
  }
@@ -21,7 +39,7 @@ export default async function fetch(url, cssSelectors, config) {
21
39
  page = await context.newPage();
22
40
  client = await page.createCDPSession();
23
41
 
24
- await configurePage(page, client, config);
42
+ await configurePage(page, config);
25
43
 
26
44
  const selectors = [].concat(cssSelectors).filter(Boolean);
27
45
 
@@ -97,14 +115,24 @@ export default async function fetch(url, cssSelectors, config) {
97
115
  /**
98
116
  * Launches a headless browser instance using Puppeteer if one is not already running. Returns the existing browser instance if one is already running, otherwise creates and returns a new instance.
99
117
  * @function launchHeadlessBrowser
100
- * @returns {Promise<puppeteer.Browser>} The Puppeteer browser instance.
118
+ * @param {string} language Accept-Language header value applied to the browser context
119
+ * @returns {Promise<puppeteer.Browser>} The Puppeteer browser instance.
101
120
  * @async
102
121
  */
103
- export async function launchHeadlessBrowser() {
122
+ export async function launchHeadlessBrowser(language) {
104
123
  if (browser) {
105
124
  return browser;
106
125
  }
107
126
 
127
+ const { locale, languages } = parseLanguage(language);
128
+ const stealth = stealthPlugin();
129
+
130
+ stealth.enabledEvasions.delete('user-agent-override');
131
+ stealth.enabledEvasions.delete('navigator.languages');
132
+ puppeteer.use(stealth);
133
+ puppeteer.use(userAgentOverride({ locale }));
134
+ puppeteer.use(navigatorLanguages({ languages }));
135
+
108
136
  const options = {
109
137
  args: [],
110
138
  headless: !process.env.OTA_ENGINE_FETCHER_NO_HEADLESS,
@@ -156,16 +184,9 @@ function isValidHttpStatus(status) {
156
184
  return (status >= 200 && status < 300) || status === 304;
157
185
  }
158
186
 
159
- async function configurePage(page, client, config) {
187
+ async function configurePage(page, config) {
160
188
  await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
161
189
  await page.setDefaultNavigationTimeout(config.navigationTimeout);
162
- await page.setExtraHTTPHeaders({ 'Accept-Language': config.language });
163
-
164
- // Use CDP to ensure browser language is set correctly (see https://zirkelc.dev/posts/puppeteer-language-experiment)
165
- await client.send('Network.setUserAgentOverride', {
166
- userAgent: await browser.userAgent(),
167
- acceptLanguage: config.language,
168
- });
169
190
 
170
191
  if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
171
192
  await page.authenticate(browser.proxyCredentials);
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
6
6
  import { expect, use } from 'chai';
7
7
  import chaiAsPromised from 'chai-as-promised';
8
8
 
9
- import fetch, { launchHeadlessBrowser, stopHeadlessBrowser } from './fullDomFetcher.js';
9
+ import fetch, { launchHeadlessBrowser, stopHeadlessBrowser, parseLanguage } from './fullDomFetcher.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
 
@@ -16,6 +16,8 @@ use(chaiAsPromised);
16
16
 
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
+ 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>';
19
21
 
20
22
  describe('Full DOM Fetcher', function () {
21
23
  this.timeout(60000);
@@ -24,7 +26,7 @@ describe('Full DOM Fetcher', function () {
24
26
  let expectedPDFContent;
25
27
 
26
28
  before(async () => {
27
- await launchHeadlessBrowser();
29
+ await launchHeadlessBrowser('en');
28
30
 
29
31
  temporaryServer = http.createServer((request, response) => {
30
32
  if (request.url === '/dynamic') {
@@ -33,6 +35,17 @@ describe('Full DOM Fetcher', function () {
33
35
  if (request.url === '/delayed-content') {
34
36
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(delayedContentHTML);
35
37
  }
38
+ if (request.url === '/lang-header') {
39
+ const acceptLanguage = request.headers['accept-language'] || '';
40
+
41
+ response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body data-accept-language="${acceptLanguage}"></body></html>`);
42
+ }
43
+ if (request.url === '/lang-echo') {
44
+ response.writeHead(200, { 'Content-Type': 'text/html' }).write(langEchoHTML);
45
+ }
46
+ if (request.url === '/stealth-probe') {
47
+ response.writeHead(200, { 'Content-Type': 'text/html' }).write(stealthProbeHTML);
48
+ }
36
49
  if (request.url === '/terms.pdf') {
37
50
  expectedPDFContent = fs.readFileSync(path.resolve(__dirname, '../../../test/fixtures/terms.pdf'));
38
51
  response.writeHead(200, { 'Content-Type': 'application/pdf' }).write(expectedPDFContent);
@@ -54,12 +67,12 @@ describe('Full DOM Fetcher', function () {
54
67
  await stopHeadlessBrowser();
55
68
  await expect(fetch('http://example.com', [], { navigationTimeout: 5000, waitForElementsTimeout: 5000, language: 'en' }))
56
69
  .to.be.rejectedWith('The headless browser should be controlled manually');
57
- await launchHeadlessBrowser();
70
+ await launchHeadlessBrowser('en');
58
71
  });
59
72
 
60
73
  it('reuses existing browser instance', async () => {
61
- const browser1 = await launchHeadlessBrowser();
62
- const browser2 = await launchHeadlessBrowser();
74
+ const browser1 = await launchHeadlessBrowser('en');
75
+ const browser2 = await launchHeadlessBrowser('en');
63
76
 
64
77
  expect(browser1).to.equal(browser2);
65
78
  });
@@ -81,6 +94,12 @@ describe('Full DOM Fetcher', function () {
81
94
  await expect(fetch(url, ['.non-existent'], { ...config, navigationTimeout: timeout })).to.be.rejectedWith(`Timed out after ${timeout / 1000} seconds when trying to fetch '${url}'`);
82
95
  });
83
96
 
97
+ it('sends the configured language as Accept-Language header', async () => {
98
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/lang-header`, [], config);
99
+
100
+ expect(result.content).to.match(/data-accept-language="en"/);
101
+ });
102
+
84
103
  context('when a DOM element exists but its content is loaded asynchronously', () => {
85
104
  it('waits for the element content to be fully loaded', async () => {
86
105
  const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/delayed-content`, ['.content'], config);
@@ -118,4 +137,145 @@ describe('Full DOM Fetcher', function () {
118
137
  });
119
138
  });
120
139
  });
140
+
141
+ describe('Language configuration', () => {
142
+ context('with a regional locale such as fr-FR,fr', () => {
143
+ const language = 'fr-FR,fr';
144
+ const config = { navigationTimeout: 5000, waitForElementsTimeout: 5000, language };
145
+
146
+ before(async () => {
147
+ await stopHeadlessBrowser();
148
+ await launchHeadlessBrowser(language);
149
+ });
150
+
151
+ it('exposes the primary tag through navigator.language', async () => {
152
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/lang-echo`, [], config);
153
+
154
+ expect(result.content).to.match(/data-language="fr-FR"/);
155
+ });
156
+
157
+ it('exposes every tag through navigator.languages', async () => {
158
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/lang-echo`, [], config);
159
+
160
+ expect(result.content).to.match(/data-languages="fr-FR,fr"/);
161
+ });
162
+
163
+ it('sends every configured tag as Accept-Language header', async () => {
164
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/lang-header`, [], config);
165
+
166
+ expect(result.content).to.match(/data-accept-language="fr-FR,fr[^"]*"/);
167
+ });
168
+ });
169
+
170
+ context('with multiple comma-separated tags such as en-IE,en-GB,en', () => {
171
+ const language = 'en-IE,en-GB,en';
172
+ const config = { navigationTimeout: 5000, waitForElementsTimeout: 5000, language };
173
+
174
+ before(async () => {
175
+ await stopHeadlessBrowser();
176
+ await launchHeadlessBrowser(language);
177
+ });
178
+
179
+ it('derives Accept-Language quality factors from tag order', async () => {
180
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/lang-header`, [], config);
181
+
182
+ expect(result.content).to.match(/data-accept-language="en-IE,en-GB;q=0\.9,en;q=0\.8"/);
183
+ });
184
+ });
185
+ });
186
+
187
+ describe('Stealth evasions', () => {
188
+ // These assertions guard against a class of regressions where the puppeteer-extra-plugin-stealth fails to be registered before puppeteer.launch(): if it is registered later, puppeteer-extra never binds its onPageCreated hooks and all evasions stay inactive, leaving navigator.webdriver === true and HeadlessChrome in the UA.
189
+ const config = { navigationTimeout: 5000, waitForElementsTimeout: 5000, language: 'en' };
190
+ let content;
191
+
192
+ before(async () => {
193
+ ({ content } = await fetch(`http://127.0.0.1:${SERVER_PORT}/stealth-probe`, [], config));
194
+ });
195
+
196
+ it('hides navigator.webdriver', () => {
197
+ expect(content).to.match(/data-webdriver="false"/);
198
+ });
199
+
200
+ it('removes HeadlessChrome from the user agent', () => {
201
+ expect(content).not.to.match(/HeadlessChrome/);
202
+ });
203
+
204
+ it('uses a realistic viewport instead of Puppeteer default', () => {
205
+ expect(content).to.match(/data-viewport-width="1920"/);
206
+ expect(content).to.match(/data-viewport-height="1080"/);
207
+ });
208
+
209
+ it('exposes a non-empty navigator.plugins list', () => {
210
+ const match = content.match(/data-plugin-count="(\d+)"/);
211
+
212
+ expect(match).to.not.be.null;
213
+ expect(Number(match[1])).to.be.greaterThan(0);
214
+ });
215
+
216
+ it('hides headless WebGL vendor and renderer signature', () => {
217
+ expect(content).to.not.match(/data-webgl-vendor="[^"]*Google[^"]*"/);
218
+ expect(content).to.not.match(/data-webgl-renderer="[^"]*(?:SwiftShader|ANGLE)[^"]*"/);
219
+ });
220
+ });
221
+ });
222
+
223
+ describe('parseLanguage', () => {
224
+ context('with a single tag', () => {
225
+ it('returns the tag as both locale and a singleton languages array', () => {
226
+ expect(parseLanguage('en')).to.deep.equal({ locale: 'en', languages: ['en'] });
227
+ });
228
+
229
+ it('preserves the region of a regional tag', () => {
230
+ expect(parseLanguage('fr-FR')).to.deep.equal({ locale: 'fr-FR', languages: ['fr-FR'] });
231
+ });
232
+ });
233
+
234
+ context('with a comma-separated priority list', () => {
235
+ it('splits every tag into the languages array and rejoins them as locale', () => {
236
+ expect(parseLanguage('en-IE,en-GB,en')).to.deep.equal({ locale: 'en-IE,en-GB,en', languages: [ 'en-IE', 'en-GB', 'en' ] });
237
+ });
238
+
239
+ it('trims whitespace surrounding each tag', () => {
240
+ expect(parseLanguage('fr-FR, fr')).to.deep.equal({ locale: 'fr-FR,fr', languages: [ 'fr-FR', 'fr' ] });
241
+ });
242
+
243
+ it('drops empty entries produced by consecutive commas', () => {
244
+ expect(parseLanguage('en,,fr')).to.deep.equal({ locale: 'en,fr', languages: [ 'en', 'fr' ] });
245
+ });
246
+
247
+ it('drops a trailing comma', () => {
248
+ expect(parseLanguage('en,fr,')).to.deep.equal({ locale: 'en,fr', languages: [ 'en', 'fr' ] });
249
+ });
250
+ });
251
+
252
+ context('with quality factors', () => {
253
+ it('throws for a priority list carrying quality factors', () => {
254
+ expect(() => parseLanguage('en-IE,en-GB;q=0.9,en;q=0.8')).to.throw('Quality factors are not supported');
255
+ });
256
+
257
+ it('throws for a single tag carrying a quality factor', () => {
258
+ expect(() => parseLanguage('en;q=1.0')).to.throw('Quality factors are not supported');
259
+ });
260
+
261
+ it('throws regardless of quality-factor letter case', () => {
262
+ expect(() => parseLanguage('en-GB;Q=0.9,en')).to.throw('Quality factors are not supported');
263
+ });
264
+ });
265
+
266
+ context('with invalid input', () => {
267
+ it('throws when the value is not a string', () => {
268
+ expect(() => parseLanguage(undefined)).to.throw('must be a string');
269
+ expect(() => parseLanguage(42)).to.throw('must be a string');
270
+ });
271
+
272
+ it('throws when the value is empty or blank', () => {
273
+ expect(() => parseLanguage('')).to.throw('must contain at least one tag');
274
+ expect(() => parseLanguage(' ')).to.throw('must contain at least one tag');
275
+ });
276
+
277
+ it('throws when the value contains only separators', () => {
278
+ expect(() => parseLanguage(',,')).to.throw('must contain at least one tag');
279
+ });
280
+ });
121
281
  });
@@ -21,7 +21,7 @@ export const FETCHER_TYPES = {
21
21
  * @param {string|Array} [params.cssSelectors] List of CSS selectors to await when loading the resource in a headless browser. Can be a CSS selector or an array of CSS selectors. Only relevant when `executeClientScripts` is enabled
22
22
  * @param {object} [params.config] Fetcher configuration
23
23
  * @param {number} [params.config.navigationTimeout] Maximum time (in milliseconds) to wait before considering the fetch failed
24
- * @param {string} [params.config.language] Language (in [ISO 639-1 format](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)) to be passed in request headers
24
+ * @param {string} [params.config.language] Accept-Language value applied to the browser context. Accepts a comma-separated list of [BCP 47](https://www.rfc-editor.org/rfc/rfc5646) language tags in priority order, without quality factors (for example `fr`, `en-US`, or `en-IE,en-GB,en`). The tag list drives `navigator.language` and `navigator.languages` in the headless browser, and the browser derives the `Accept-Language` HTTP header from the tag order
25
25
  * @param {number} [params.config.waitForElementsTimeout] Maximum time (in milliseconds) to wait for selectors to exist on page before considering the fetch failed. Only relevant when `executeClientScripts` is enabled
26
26
  * @returns {Promise<{ mimeType: string, content: string | Buffer, fetcher: string }>} Promise containing the fetched resource's MIME type, content, and fetcher type
27
27
  * @throws {FetchDocumentError} When the fetch operation fails
@@ -21,7 +21,7 @@ const termsWithOtherCharsetHTML = '<!DOCTYPE html><html><head><meta http-equiv="
21
21
  describe('Fetcher', function () {
22
22
  this.timeout(60000);
23
23
 
24
- before(launchHeadlessBrowser);
24
+ before(() => launchHeadlessBrowser('en'));
25
25
 
26
26
  after(stopHeadlessBrowser);
27
27
 
@@ -45,6 +45,7 @@ export default class Archivist extends events.EventEmitter {
45
45
 
46
46
  constructor({ recorderConfig, fetcherConfig }) {
47
47
  super();
48
+ this.fetcherConfig = fetcherConfig;
48
49
  this.recorder = new Recorder(recorderConfig);
49
50
  this.fetch = params => fetch({ ...params, config: fetcherConfig });
50
51
  this.extract = extract;
@@ -151,7 +152,7 @@ export default class Archivist extends events.EventEmitter {
151
152
 
152
153
  this.emit('trackingStarted', servicesIds.length, numberOfTerms, technicalUpgradeOnly);
153
154
 
154
- await Promise.all([ launchHeadlessBrowser(), this.recorder.initialize() ]);
155
+ await Promise.all([ launchHeadlessBrowser(this.fetcherConfig.language), this.recorder.initialize() ]);
155
156
 
156
157
  this.trackingQueue.concurrency = concurrency;
157
158