mwoffliner 1.14.2 → 1.15.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.
Files changed (129) hide show
  1. package/README.md +19 -42
  2. package/docs/functional_architecture.md +62 -0
  3. package/eslint.config.js +24 -0
  4. package/lib/DOMUtils.js +3 -0
  5. package/lib/DOMUtils.js.map +1 -1
  6. package/lib/Downloader.d.ts +45 -17
  7. package/lib/Downloader.js +267 -94
  8. package/lib/Downloader.js.map +1 -1
  9. package/lib/Dump.d.ts +5 -5
  10. package/lib/Dump.js +8 -32
  11. package/lib/Dump.js.map +1 -1
  12. package/lib/Logger.js.map +1 -1
  13. package/lib/MediaWiki.d.ts +17 -12
  14. package/lib/MediaWiki.js +79 -45
  15. package/lib/MediaWiki.js.map +1 -1
  16. package/lib/RedisStore.d.ts +1 -284
  17. package/lib/RedisStore.js +0 -2
  18. package/lib/RedisStore.js.map +1 -1
  19. package/lib/S3.d.ts +2 -2
  20. package/lib/S3.js +3 -3
  21. package/lib/S3.js.map +1 -1
  22. package/lib/Templates.d.ts +4 -1
  23. package/lib/Templates.js +8 -1
  24. package/lib/Templates.js.map +1 -1
  25. package/lib/cli.d.ts +1 -1
  26. package/lib/cli.js +6 -11
  27. package/lib/cli.js.map +1 -1
  28. package/lib/config.d.ts +7 -1
  29. package/lib/config.js +14 -1
  30. package/lib/config.js.map +1 -1
  31. package/lib/error.manager.d.ts +25 -0
  32. package/lib/error.manager.js +202 -0
  33. package/lib/error.manager.js.map +1 -0
  34. package/lib/mutex.d.ts +3 -0
  35. package/lib/mutex.js +4 -0
  36. package/lib/mutex.js.map +1 -0
  37. package/lib/mwoffliner.lib.d.ts +0 -1
  38. package/lib/mwoffliner.lib.js +117 -118
  39. package/lib/mwoffliner.lib.js.map +1 -1
  40. package/lib/parameterList.d.ts +1 -0
  41. package/lib/parameterList.js +3 -2
  42. package/lib/parameterList.js.map +1 -1
  43. package/lib/renderers/abstract.renderer.d.ts +17 -2
  44. package/lib/renderers/abstract.renderer.js +25 -14
  45. package/lib/renderers/abstract.renderer.js.map +1 -1
  46. package/lib/renderers/abstractDesktop.render.d.ts +2 -1
  47. package/lib/renderers/abstractDesktop.render.js +20 -7
  48. package/lib/renderers/abstractDesktop.render.js.map +1 -1
  49. package/lib/renderers/abstractMobile.render.js +15 -14
  50. package/lib/renderers/abstractMobile.render.js.map +1 -1
  51. package/lib/renderers/action-parse.renderer.d.ts +9 -0
  52. package/lib/renderers/action-parse.renderer.js +110 -0
  53. package/lib/renderers/action-parse.renderer.js.map +1 -0
  54. package/lib/renderers/renderer.builder.d.ts +2 -4
  55. package/lib/renderers/renderer.builder.js +35 -8
  56. package/lib/renderers/renderer.builder.js.map +1 -1
  57. package/lib/renderers/rendering.context.d.ts +10 -0
  58. package/lib/renderers/rendering.context.js +38 -0
  59. package/lib/renderers/rendering.context.js.map +1 -0
  60. package/lib/renderers/rest-api.renderer.js +4 -4
  61. package/lib/renderers/rest-api.renderer.js.map +1 -1
  62. package/lib/renderers/visual-editor.renderer.js +21 -26
  63. package/lib/renderers/visual-editor.renderer.js.map +1 -1
  64. package/lib/renderers/wikimedia-mobile.renderer.d.ts +2 -1
  65. package/lib/renderers/wikimedia-mobile.renderer.js +11 -4
  66. package/lib/renderers/wikimedia-mobile.renderer.js.map +1 -1
  67. package/lib/sanitize-argument.js +35 -36
  68. package/lib/sanitize-argument.js.map +1 -1
  69. package/lib/util/RedisKvs.js +1 -0
  70. package/lib/util/RedisKvs.js.map +1 -1
  71. package/lib/util/Timer.js.map +1 -1
  72. package/lib/util/builders/url/action-parse.director.d.ts +9 -0
  73. package/lib/util/builders/url/action-parse.director.js +19 -0
  74. package/lib/util/builders/url/action-parse.director.js.map +1 -0
  75. package/lib/util/builders/url/api.director.d.ts +1 -0
  76. package/lib/util/builders/url/api.director.js +3 -0
  77. package/lib/util/builders/url/api.director.js.map +1 -1
  78. package/lib/util/builders/url/basic.director.js.map +1 -1
  79. package/lib/util/builders/url/url.builder.js.map +1 -1
  80. package/lib/util/categories.d.ts +3 -4
  81. package/lib/util/categories.js +11 -13
  82. package/lib/util/categories.js.map +1 -1
  83. package/lib/util/const.d.ts +0 -1
  84. package/lib/util/const.js +2 -3
  85. package/lib/util/const.js.map +1 -1
  86. package/lib/util/dump.d.ts +7 -7
  87. package/lib/util/dump.js +58 -87
  88. package/lib/util/dump.js.map +1 -1
  89. package/lib/util/metaData.js.map +1 -1
  90. package/lib/util/misc.d.ts +6 -8
  91. package/lib/util/misc.js +45 -23
  92. package/lib/util/misc.js.map +1 -1
  93. package/lib/util/mw-api.d.ts +4 -5
  94. package/lib/util/mw-api.js +50 -27
  95. package/lib/util/mw-api.js.map +1 -1
  96. package/lib/util/rewriteUrls.js +14 -20
  97. package/lib/util/rewriteUrls.js.map +1 -1
  98. package/lib/util/saveArticles.d.ts +3 -4
  99. package/lib/util/saveArticles.js +81 -89
  100. package/lib/util/saveArticles.js.map +1 -1
  101. package/lib/util/url.helper.js +2 -2
  102. package/lib/util/url.helper.js.map +1 -1
  103. package/lib/version.d.ts +1 -1
  104. package/lib/version.js +1 -1
  105. package/package.json +62 -68
  106. package/res/DMSans-Regular.ttf +0 -0
  107. package/res/article_not_found.svg +1 -0
  108. package/res/download_error_placeholder.css +165 -0
  109. package/res/external-link.svg +6 -0
  110. package/res/footer.css +15 -0
  111. package/res/script.js +3 -3
  112. package/res/templates/article_list_home.html +2 -2
  113. package/res/templates/download_error_placeholder.html +26 -0
  114. package/res/templates/footer.html +1 -1
  115. package/res/templates/pageVector2022.html +31 -0
  116. package/res/templates/pageVectorLegacy.html +25 -0
  117. package/res/vector-2022.css +15 -0
  118. package/res/vector.css +3 -0
  119. package/res/webpHandler.js +165 -0
  120. package/translation/en.json +17 -4
  121. package/translation/fr.json +3 -1
  122. package/translation/he.json +0 -1
  123. package/translation/id.json +12 -1
  124. package/translation/it.json +8 -2
  125. package/translation/ko.json +3 -1
  126. package/translation/lb.json +2 -1
  127. package/translation/qqq.json +14 -2
  128. package/translation/zh-hans.json +16 -2
  129. package/translation/zh-hant.json +12 -1
package/lib/Downloader.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import * as backoff from 'backoff';
2
2
  import { config } from './config.js';
3
- import { contains } from './util/index.js';
3
+ import { contains, normalizeMwResponse, DB_ERROR, WEAK_ETAG_REGEX, stripHttpFromUrl, isBitmapImageMimeType, isWebpCandidateImageMimeType } from './util/index.js';
4
4
  import deepmerge from 'deepmerge';
5
5
  import * as domino from 'domino';
6
6
  import { default as imagemin } from 'imagemin';
7
7
  import imageminAdvPng from 'imagemin-advpng';
8
- import axios from 'axios';
8
+ import axios, { AxiosError } from 'axios';
9
9
  import { default as imageminPngquant } from 'imagemin-pngquant';
10
10
  import imageminGifsicle from 'imagemin-gifsicle';
11
11
  import imageminJpegoptim from 'imagemin-jpegoptim';
@@ -14,11 +14,12 @@ import sharp from 'sharp';
14
14
  import http from 'http';
15
15
  import https from 'https';
16
16
  import { fileTypeFromBuffer } from 'file-type';
17
- import { normalizeMwResponse, DB_ERROR, WEAK_ETAG_REGEX, stripHttpFromUrl, isBitmapImageMimeType, isWebpCandidateImageMimeType } from './util/index.js';
18
17
  import * as logger from './Logger.js';
19
18
  import MediaWiki from './MediaWiki.js';
20
19
  import ApiURLDirector from './util/builders/url/api.director.js';
21
20
  import urlHelper from './util/url.helper.js';
21
+ import { findFirstMatchingRule, renderDownloadError } from './error.manager.js';
22
+ import RedisStore from './RedisStore.js';
22
23
  const imageminOptions = new Map();
23
24
  imageminOptions.set('default', new Map());
24
25
  imageminOptions.set('webp', new Map());
@@ -37,19 +38,44 @@ imageminOptions.get('webp').set('image/png', {
37
38
  imageminOptions.get('webp').set('image/jpeg', {
38
39
  plugins: [imageminWebp({ quality: 50, method: 6 })],
39
40
  });
41
+ export class DownloadError extends Error {
42
+ urlCalled;
43
+ httpReturnCode;
44
+ responseContentType;
45
+ responseData;
46
+ constructor(message, urlCalled, httpReturnCode, responseContentType, responseData) {
47
+ super(message);
48
+ this.name = 'DownloadError';
49
+ this.urlCalled = urlCalled;
50
+ this.httpReturnCode = httpReturnCode;
51
+ this.responseContentType = responseContentType;
52
+ this.responseData = responseData;
53
+ if ('captureStackTrace' in Error) {
54
+ // Avoid DownloadError itself in the stack trace
55
+ Error.captureStackTrace(this, DownloadError);
56
+ }
57
+ }
58
+ }
40
59
  /**
41
60
  * Downloader is a class providing content retrieval functionalities for both Mediawiki and S3 remote instances.
42
61
  */
43
62
  class Downloader {
63
+ static instance;
64
+ static getInstance() {
65
+ if (!Downloader.instance) {
66
+ Downloader.instance = new Downloader();
67
+ }
68
+ return Downloader.instance;
69
+ }
44
70
  loginCookie = '';
45
- speed;
71
+ _speed;
46
72
  cssDependenceUrls = {};
47
- webp = false;
48
- requestTimeout;
49
- basicRequestOptions;
50
- arrayBufferRequestOptions;
51
- jsonRequestOptions;
52
- streamRequestOptions;
73
+ _webp = false;
74
+ _requestTimeout;
75
+ _basicRequestOptions;
76
+ _arrayBufferRequestOptions;
77
+ _jsonRequestOptions;
78
+ _streamRequestOptions;
53
79
  wikimediaMobileJsDependenciesList = [];
54
80
  wikimediaMobileStyleDependenciesList = [];
55
81
  uaString;
@@ -62,30 +88,75 @@ class Downloader {
62
88
  articleUrlDirector;
63
89
  mainPageUrlDirector;
64
90
  insecure = false;
65
- constructor({ uaString, speed, reqTimeout, optimisationCacheUrl, s3, webp, backoffOptions, insecure }) {
91
+ get speed() {
92
+ return this._speed;
93
+ }
94
+ get webp() {
95
+ return this._webp;
96
+ }
97
+ get requestTimeout() {
98
+ return this._requestTimeout;
99
+ }
100
+ get basicRequestOptions() {
101
+ return this._basicRequestOptions;
102
+ }
103
+ get arrayBufferRequestOptions() {
104
+ return this._arrayBufferRequestOptions;
105
+ }
106
+ get jsonRequestOptions() {
107
+ return this._jsonRequestOptions;
108
+ }
109
+ get streamRequestOptions() {
110
+ return this._streamRequestOptions;
111
+ }
112
+ set init({ uaString, speed, reqTimeout, optimisationCacheUrl, s3, webp, backoffOptions, insecure }) {
113
+ this.reset();
66
114
  this.uaString = uaString;
67
- this.speed = speed;
115
+ this._speed = speed;
68
116
  this.maxActiveRequests = speed * 10;
69
- this.requestTimeout = reqTimeout;
117
+ this._requestTimeout = reqTimeout;
70
118
  this.loginCookie = '';
71
119
  this.optimisationCacheUrl = optimisationCacheUrl;
72
- this.webp = webp;
120
+ this._webp = webp;
73
121
  this.s3 = s3;
74
122
  this.apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
75
123
  this.insecure = insecure;
76
124
  this.backoffOptions = {
77
- strategy: new backoff.ExponentialStrategy(),
78
- failAfter: 7,
79
- retryIf: (err) => err.code === 'ECONNABORTED' || ![400, 403, 404].includes(err.response?.status),
125
+ // retry up to 10 times, with a minimum of 1sec, maximum of 1min
126
+ // this means we retry for up to about 6 mins, which is supposed
127
+ // to be sufficient for backend to recover from transient errors
128
+ strategy: new backoff.ExponentialStrategy({ initialDelay: 1000, maxDelay: 60000 }),
129
+ failAfter: 10,
130
+ retryIf: (err) => {
131
+ const requestedUrl = err.urlCalled || err.config?.url || 'unknown';
132
+ if (err instanceof AxiosError && err.code && !['ERR_BAD_REQUEST', 'ERR_BAD_RESPONSE'].includes(err.code)) {
133
+ logger.log(`Retrying ${requestedUrl} URL due to ${err.code} error`);
134
+ return true; // retry all connection issues
135
+ }
136
+ const httpReturnCode = err.response?.status || err.httpReturnCode;
137
+ if ([429].includes(httpReturnCode)) {
138
+ logger.log(`Retrying ${requestedUrl} URL due to HTTP ${httpReturnCode} error`);
139
+ return true; // retry these HTTP status codes
140
+ }
141
+ if ([
142
+ 'internal_api_error_DBConnectionError',
143
+ 'internal_api_error_DBUnexpectedError',
144
+ 'internal_api_error_Wikibase\\DataModel\\Services\\Lookup\\EntityLookupException',
145
+ ].includes(err.responseData?.error?.code)) {
146
+ logger.log(`Retrying ${requestedUrl} URL due to ${err.responseData?.error?.code} Mediawiki error`);
147
+ return true; // retry these Mediawiki codes which are known to be transient
148
+ }
149
+ return false; // don't retry other errors
150
+ },
80
151
  backoffHandler: (number, delay) => {
81
152
  logger.info(`[backoff] #${number} after ${delay} ms`);
82
153
  },
83
154
  ...backoffOptions,
84
155
  };
85
- this.basicRequestOptions = {
156
+ this._basicRequestOptions = {
86
157
  // HTTP agent pools with 'keepAlive' to reuse TCP connections, so it's faster
87
158
  httpAgent: new http.Agent({ keepAlive: true }),
88
- httpsAgent: new https.Agent({ keepAlive: true, rejectUnauthorized: !this.insecure }),
159
+ httpsAgent: new https.Agent({ keepAlive: true, rejectUnauthorized: !this.insecure }), // rejectUnauthorized: false disables TLS
89
160
  timeout: this.requestTimeout,
90
161
  headers: {
91
162
  'cache-control': 'public, max-stale=86400',
@@ -95,12 +166,12 @@ class Downloader {
95
166
  return (status >= 200 && status < 300) || status === 304;
96
167
  },
97
168
  };
98
- this.arrayBufferRequestOptions = {
169
+ this._arrayBufferRequestOptions = {
99
170
  ...this.basicRequestOptions,
100
171
  responseType: 'arraybuffer',
101
172
  method: 'GET',
102
173
  };
103
- this.jsonRequestOptions = {
174
+ this._jsonRequestOptions = {
104
175
  ...this.basicRequestOptions,
105
176
  headers: {
106
177
  ...this.basicRequestOptions.headers,
@@ -110,7 +181,7 @@ class Downloader {
110
181
  responseType: 'json',
111
182
  method: 'GET',
112
183
  };
113
- this.streamRequestOptions = {
184
+ this._streamRequestOptions = {
114
185
  ...this.basicRequestOptions,
115
186
  headers: {
116
187
  ...this.basicRequestOptions.headers,
@@ -121,6 +192,30 @@ class Downloader {
121
192
  method: 'GET',
122
193
  };
123
194
  }
195
+ reset() {
196
+ this.uaString = undefined;
197
+ this._speed = undefined;
198
+ this.maxActiveRequests = undefined;
199
+ this._requestTimeout = undefined;
200
+ this.loginCookie = '';
201
+ this.optimisationCacheUrl = undefined;
202
+ this._webp = false;
203
+ this.s3 = undefined;
204
+ this.apiUrlDirector = undefined;
205
+ this.insecure = false;
206
+ this.backoffOptions = undefined;
207
+ this._basicRequestOptions = undefined;
208
+ this._arrayBufferRequestOptions = undefined;
209
+ this._jsonRequestOptions = undefined;
210
+ this._streamRequestOptions = undefined;
211
+ this.cssDependenceUrls = {};
212
+ this.wikimediaMobileJsDependenciesList = [];
213
+ this.wikimediaMobileStyleDependenciesList = [];
214
+ this.activeRequests = 0;
215
+ this.maxActiveRequests = 1;
216
+ this.articleUrlDirector = undefined;
217
+ this.mainPageUrlDirector = undefined;
218
+ }
124
219
  getUrlDirector(renderer) {
125
220
  switch (renderer.constructor.name) {
126
221
  case 'WikimediaDesktopRenderer':
@@ -131,15 +226,16 @@ class Downloader {
131
226
  return MediaWiki.wikimediaMobileUrlDirector;
132
227
  case 'RestApiRenderer':
133
228
  return MediaWiki.restApiUrlDirector;
229
+ case 'ActionParseRenderer':
230
+ return MediaWiki.actionParseUrlDirector;
231
+ /* istanbul ignore next */
232
+ default:
233
+ throw new Error(`Unknown renderer ${renderer.constructor.name}`);
134
234
  }
135
235
  }
136
236
  setUrlsDirectors(mainPageRenderer, articlesRenderer) {
137
- if (!this.articleUrlDirector) {
138
- this.articleUrlDirector = this.getUrlDirector(articlesRenderer);
139
- }
140
- if (!this.mainPageUrlDirector) {
141
- this.mainPageUrlDirector = this.getUrlDirector(mainPageRenderer);
142
- }
237
+ this.articleUrlDirector = this.getUrlDirector(articlesRenderer);
238
+ this.mainPageUrlDirector = this.getUrlDirector(mainPageRenderer);
143
239
  }
144
240
  getArticleUrl(articleId) {
145
241
  return this.articleUrlDirector.buildArticleURL(articleId);
@@ -160,14 +256,14 @@ class Downloader {
160
256
  const queryOpts = {
161
257
  ...(await this.getArticleQueryOpts(shouldGetThumbnail, true)),
162
258
  titles: articleIds.join('|'),
163
- ...((await MediaWiki.hasCoordinates(this)) ? { colimit: 'max' } : {}),
259
+ ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
164
260
  ...(MediaWiki.getCategories
165
261
  ? {
166
262
  cllimit: 'max',
167
263
  clshow: '!hidden',
168
264
  }
169
265
  : {}),
170
- ...(continuation || {}),
266
+ ...continuation,
171
267
  };
172
268
  const reqUrl = this.apiUrlDirector.buildQueryURL(queryOpts);
173
269
  const resp = await this.getJSON(reqUrl);
@@ -194,7 +290,7 @@ class Downloader {
194
290
  while (true) {
195
291
  const queryOpts = {
196
292
  ...(await this.getArticleQueryOpts()),
197
- ...((await MediaWiki.hasCoordinates(this)) ? { colimit: 'max' } : {}),
293
+ ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
198
294
  ...(MediaWiki.getCategories
199
295
  ? {
200
296
  cllimit: 'max',
@@ -237,22 +333,103 @@ class Downloader {
237
333
  gapContinue: gCont,
238
334
  };
239
335
  }
240
- async getArticle(webp, _moduleDependencies, articleId, articleDetailXId, articleRenderer, articleUrl, dump, articleDetail, isMainPage) {
336
+ async getLogEvents(letype, articleId) {
337
+ const logEventsData = await this.getJSON(this.apiUrlDirector.buildLogEventsQuery(letype, articleId));
338
+ return logEventsData.query?.logevents;
339
+ }
340
+ async getArticle(articleId, articleDetailXId, articleRenderer, articleUrl, dump, articleDetail, isMainPage) {
241
341
  logger.info(`Getting article [${articleId}] from ${articleUrl}`);
242
- const data = await this.getJSON(articleUrl);
243
- if (data.error) {
244
- throw data.error;
342
+ try {
343
+ const { data, moduleDependencies, redirects } = await articleRenderer.download({
344
+ articleId,
345
+ articleUrl,
346
+ articleDetail,
347
+ });
348
+ // Cope with the fact that the page we are fetching might have been moved and replaced by a redirect
349
+ // In such a case, the download above is expected to follow the redirect so that we have proper original
350
+ // content at original article path, but we probably need to add a redirect since new article location
351
+ // probably did not existed when listing articles (might have existed if the move occured during article
352
+ // listing). The redirect we add in hence in the "opposite" direction than usual, i.e. it will redirect
353
+ // from new location to original location. Note that only ActionParse API gives proper redirects info.
354
+ for (const redirect of redirects) {
355
+ if (!(await RedisStore.articleDetailXId.exists(redirect.to)) && !(await RedisStore.redirectsXId.exists(redirect.to))) {
356
+ RedisStore.redirectsXId.set(redirect.to, { targetId: redirect.from, title: redirect.to });
357
+ }
358
+ }
359
+ return await articleRenderer.render({
360
+ data,
361
+ moduleDependencies,
362
+ articleId,
363
+ articleDetailXId,
364
+ articleDetail,
365
+ isMainPage,
366
+ dump,
367
+ });
368
+ }
369
+ catch (err) {
370
+ let downloadErrorContext;
371
+ if (err instanceof AxiosError) {
372
+ downloadErrorContext = {
373
+ errorCode: err.code,
374
+ urlCalled: err.config.url,
375
+ httpReturnCode: err.status,
376
+ responseContentType: err.response ? err.response.headers['content-type'].toString() : null,
377
+ responseData: err.response?.data,
378
+ };
379
+ }
380
+ else if (err instanceof DownloadError) {
381
+ downloadErrorContext = {
382
+ errorCode: null,
383
+ urlCalled: err.urlCalled,
384
+ httpReturnCode: err.httpReturnCode,
385
+ responseContentType: err.responseContentType,
386
+ responseData: err.responseData,
387
+ };
388
+ }
389
+ if (!downloadErrorContext) {
390
+ throw err;
391
+ }
392
+ logger.warn(`Article ${articleId} failed to download from '${downloadErrorContext.urlCalled}' with ` +
393
+ `'${downloadErrorContext.errorCode}' error code, ` +
394
+ `'${downloadErrorContext.httpReturnCode}' HTTP return code ` +
395
+ `and '${downloadErrorContext.responseContentType}' content-type ` +
396
+ `returned instead:\n${JSON.stringify(downloadErrorContext.responseData)}`);
397
+ const errorRule = findFirstMatchingRule(downloadErrorContext);
398
+ if (errorRule === null) {
399
+ logger.error('This is a fatal download error, aborting');
400
+ throw err;
401
+ }
402
+ if (errorRule.isHardFailure) {
403
+ logger.log(`This is a hard ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
404
+ dump.status.articles.hardFail += 1;
405
+ dump.status.articles.hardFailedArticleIds.push(articleId);
406
+ if (dump.maxHardFailedArticles > 0 && dump.status.articles.hardFail > dump.maxHardFailedArticles) {
407
+ logger.error('Too many articles failed to download, aborting');
408
+ throw err;
409
+ }
410
+ }
411
+ else {
412
+ logger.log(`This is a soft ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
413
+ dump.status.articles.softFail += 1;
414
+ dump.status.articles.softFailedArticleIds.push(articleId);
415
+ }
416
+ RedisStore.articleDetailXId.delete(articleId); // Remove article from list so that we stop creating links to this placeholder
417
+ const articleTitle = articleId.replace(/_/g, ' ');
418
+ const errorPlaceholderHtml = renderDownloadError(errorRule, dump, articleId, articleTitle);
419
+ return [
420
+ {
421
+ articleId,
422
+ displayTitle: articleTitle,
423
+ html: errorPlaceholderHtml,
424
+ imageDependencies: [],
425
+ videoDependencies: [],
426
+ mediaDependencies: [],
427
+ moduleDependencies: [],
428
+ staticFiles: config.output.downloadErrorResources,
429
+ subtitles: [],
430
+ },
431
+ ];
245
432
  }
246
- return articleRenderer.render({
247
- data,
248
- webp,
249
- _moduleDependencies,
250
- articleId,
251
- articleDetailXId,
252
- articleDetail,
253
- isMainPage,
254
- dump,
255
- });
256
433
  }
257
434
  async getJSON(_url) {
258
435
  const url = urlHelper.deserializeUrl(_url);
@@ -261,8 +438,8 @@ class Downloader {
261
438
  this.backoffCall(this.getJSONCb, url, 'json', (err, val) => {
262
439
  this.releaseRequest();
263
440
  if (err) {
264
- const httpStatus = err.response && err.response.status;
265
- logger.warn(`Failed to get [${url}] [status=${httpStatus}]`);
441
+ const httpStatus = (err.response && err.response.status) || err.httpReturnCode;
442
+ logger.info(`Failed to get [${url}] [status=${httpStatus}]`);
266
443
  reject(err);
267
444
  }
268
445
  else {
@@ -297,7 +474,10 @@ class Downloader {
297
474
  if (!_url) {
298
475
  throw new Error(`Parameter [${_url}] is not a valid url`);
299
476
  }
300
- const url = urlHelper.deserializeUrl(_url);
477
+ let url = urlHelper.deserializeUrl(_url);
478
+ if (url.startsWith('//')) {
479
+ url = `${MediaWiki.baseUrl.protocol}${url}`;
480
+ }
301
481
  await this.claimRequest();
302
482
  try {
303
483
  return new Promise((resolve, reject) => {
@@ -331,7 +511,7 @@ class Downloader {
331
511
  await this.request({ url, method: 'GET', ...this.arrayBufferRequestOptions });
332
512
  return true;
333
513
  }
334
- catch (err) {
514
+ catch {
335
515
  return false;
336
516
  }
337
517
  }
@@ -345,7 +525,7 @@ class Downloader {
345
525
  }
346
526
  async getArticleQueryOpts(includePageimages = false, redirects = false) {
347
527
  const validNamespaceIds = MediaWiki.namespacesToMirror.map((ns) => MediaWiki.namespaces[ns].num);
348
- const prop = `${includePageimages ? '|pageimages' : ''}${(await MediaWiki.hasCoordinates(this)) ? '|coordinates' : ''}${MediaWiki.getCategories ? '|categories' : ''}`;
528
+ const prop = `${includePageimages ? '|pageimages' : ''}${(await MediaWiki.hasCoordinates()) ? '|coordinates' : ''}${MediaWiki.getCategories ? '|categories' : ''}`;
349
529
  return {
350
530
  ...MediaWiki.queryOpts,
351
531
  prop: MediaWiki.queryOpts.prop.concat(prop),
@@ -384,25 +564,15 @@ class Downloader {
384
564
  getJSONCb = (url, kind, handler) => {
385
565
  logger.info(`Getting JSON from [${url}]`);
386
566
  this.request({ url, method: 'GET', ...this.jsonRequestOptions })
387
- .then((a) => handler(null, a.data), handler)
388
- .catch((err) => {
389
- try {
390
- if (err.response && err.response.status === 429) {
391
- logger.log('Received a [status=429], slowing down');
392
- const newMaxActiveRequests = Math.max(this.maxActiveRequests - 1, 1);
393
- logger.log(`Setting maxActiveRequests from [${this.maxActiveRequests}] to [${newMaxActiveRequests}]`);
394
- this.maxActiveRequests = newMaxActiveRequests;
395
- return this.getJSONCb(url, kind, handler);
396
- }
397
- else if (err.response && err.response.status === 404) {
398
- handler(err);
399
- }
567
+ .then((val) => {
568
+ if (val.data.error) {
569
+ handler(new DownloadError(`Error returned while calling API`, url, val.status, val.headers['content-type'].toString(), val.data));
400
570
  }
401
- catch (a) {
402
- logger.log('ERR', err);
403
- handler(err);
571
+ else {
572
+ handler(null, val.data);
404
573
  }
405
- });
574
+ })
575
+ .catch((err) => handler(err));
406
576
  };
407
577
  async getImageMimeType(data) {
408
578
  const fileType = await fileTypeFromBuffer(data);
@@ -471,7 +641,7 @@ class Downloader {
471
641
  try {
472
642
  this.errHandler(err, url, handler);
473
643
  }
474
- catch (a) {
644
+ catch {
475
645
  handler(err);
476
646
  }
477
647
  }
@@ -574,11 +744,6 @@ class Downloader {
574
744
  async getModuleDependencies(title) {
575
745
  const genericJsModules = config.output.mw.js;
576
746
  const genericCssModules = config.output.mw.css;
577
- /* These vars will store the list of js and css dependencies for
578
- the article we are downloading. */
579
- let jsConfigVars = '';
580
- let jsDependenciesList = [];
581
- let styleDependenciesList = [];
582
747
  const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
583
748
  const articleApiUrl = apiUrlDirector.buildArticleApiURL(title);
584
749
  const articleData = await this.getJSON(articleApiUrl);
@@ -587,34 +752,22 @@ class Downloader {
587
752
  logger.error(errorMessage);
588
753
  /* If article is missing (for example because it just has been deleted) */
589
754
  if (articleData.error.code === 'missingtitle') {
590
- return { jsConfigVars, jsDependenciesList, styleDependenciesList };
755
+ return { jsConfigVars: '', jsDependenciesList: [], styleDependenciesList: [] };
591
756
  }
592
757
  /* Something went wrong in modules retrieval at app level (no HTTP error) */
593
758
  throw new Error(errorMessage);
594
759
  }
595
760
  const { parse: { modules, modulescripts, modulestyles, headhtml }, } = articleData;
596
- jsDependenciesList = genericJsModules.concat(modules, modulescripts).filter((a) => a);
597
- styleDependenciesList = [].concat(modules, modulestyles, genericCssModules).filter((a) => a);
598
- styleDependenciesList = styleDependenciesList.filter((oneStyleDep) => !contains(config.filters.blackListCssModules, oneStyleDep));
761
+ const jsDependenciesList = genericJsModules.concat(modules, modulescripts).filter((a) => a);
762
+ const styleDependenciesList = []
763
+ .concat(modules, modulestyles, genericCssModules)
764
+ .filter((a) => a)
765
+ .filter((oneStyleDep) => !contains(config.filters.blackListCssModules, oneStyleDep));
599
766
  logger.info(`Js dependencies of ${title} : ${jsDependenciesList}`);
600
767
  logger.info(`Css dependencies of ${title} : ${styleDependenciesList}`);
601
- // Saving, as a js module, the jsconfigvars that are set in the header of a wikipedia page
602
- // the script below extracts the config with a regex executed on the page header returned from the api
603
- const scriptTags = domino.createDocument(`${headhtml}</body></html>`).getElementsByTagName('script');
604
- const regex = /mw\.config\.set\(\{.*?\}\);/gm;
605
- // eslint-disable-next-line @typescript-eslint/prefer-for-of
606
- for (let i = 0; i < scriptTags.length; i += 1) {
607
- if (scriptTags[i].text.includes('mw.config.set')) {
608
- jsConfigVars = regex.exec(scriptTags[i].text)[0] || '';
609
- jsConfigVars = `(window.RLQ=window.RLQ||[]).push(function() {${jsConfigVars}});`;
610
- }
611
- else if (scriptTags[i].text.includes('RLCONF') || scriptTags[i].text.includes('RLSTATE') || scriptTags[i].text.includes('RLPAGEMODULES')) {
612
- jsConfigVars = scriptTags[i].text;
613
- }
614
- }
615
- jsConfigVars = jsConfigVars.replace('nosuchaction', 'view'); // to replace the wgAction config that is set to 'nosuchaction' from api but should be 'view'
768
+ const jsConfigVars = Downloader.extractJsConfigVars(headhtml);
616
769
  // Download mobile page dependencies only once
617
- if ((await MediaWiki.hasWikimediaMobileApi(this)) && this.wikimediaMobileJsDependenciesList.length === 0 && this.wikimediaMobileStyleDependenciesList.length === 0) {
770
+ if ((await MediaWiki.hasWikimediaMobileApi()) && this.wikimediaMobileJsDependenciesList.length === 0 && this.wikimediaMobileStyleDependenciesList.length === 0) {
618
771
  try {
619
772
  // TODO: An arbitrary title can be placed since all Wikimedia wikis have the same mobile offline resources
620
773
  const mobileModulesData = await this.getJSON(`${MediaWiki.mobileModulePath}Test`);
@@ -646,6 +799,26 @@ class Downloader {
646
799
  stream.on('end', () => resolve(Buffer.concat(chunks)));
647
800
  });
648
801
  }
802
+ static extractJsConfigVars(headhtml) {
803
+ let jsConfigVars = '';
804
+ // Saving, as a js module, the jsconfigvars that are set in the header of a wikipedia page
805
+ // the script below extracts the config with a regex executed on the page header returned from the api
806
+ const scriptTags = domino.createDocument(`${headhtml}</body></html>`).getElementsByTagName('script');
807
+ const regex = /mw\.config\.set\(\{.*?\}\);/gm;
808
+ for (let i = 0; i < scriptTags.length; i += 1) {
809
+ if (scriptTags[i].text.includes('mw.config.set')) {
810
+ jsConfigVars = regex.exec(scriptTags[i].text)[0] || '';
811
+ jsConfigVars = `(window.RLQ=window.RLQ||[]).push(function() {${jsConfigVars}});`;
812
+ }
813
+ else if (scriptTags[i].text.includes('RLCONF') || scriptTags[i].text.includes('RLSTATE') || scriptTags[i].text.includes('RLPAGEMODULES')) {
814
+ jsConfigVars = scriptTags[i].text;
815
+ }
816
+ }
817
+ jsConfigVars = jsConfigVars.replace('nosuchaction', 'view'); // to replace the wgAction config that is set to 'nosuchaction' from api but should be 'view'
818
+ return jsConfigVars;
819
+ }
649
820
  }
650
- export default Downloader;
821
+ export { Downloader as DownloaderClass };
822
+ const dl = Downloader.getInstance();
823
+ export default dl;
651
824
  //# sourceMappingURL=Downloader.js.map