mwoffliner 1.17.4 → 2.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.
Files changed (227) hide show
  1. package/README.md +355 -62
  2. package/docs/functional_architecture.md +34 -38
  3. package/extensions/wiktionary_fr.js +18 -17
  4. package/jest.config.cjs +2 -2
  5. package/lib/DOMUtils.js +0 -1
  6. package/lib/DOMUtils.js.map +1 -1
  7. package/lib/Downloader.d.ts +39 -31
  8. package/lib/Downloader.js +418 -349
  9. package/lib/Downloader.js.map +1 -1
  10. package/lib/Dump.d.ts +24 -10
  11. package/lib/Dump.js +95 -50
  12. package/lib/Dump.js.map +1 -1
  13. package/lib/Gadgets.d.ts +2 -1
  14. package/lib/Gadgets.js +7 -5
  15. package/lib/Gadgets.js.map +1 -1
  16. package/lib/Logger.d.ts +6 -6
  17. package/lib/Logger.js +33 -33
  18. package/lib/Logger.js.map +1 -1
  19. package/lib/MediaWiki.d.ts +14 -31
  20. package/lib/MediaWiki.js +119 -172
  21. package/lib/MediaWiki.js.map +1 -1
  22. package/lib/RedisStore.d.ts +3 -3
  23. package/lib/RedisStore.js +21 -23
  24. package/lib/RedisStore.js.map +1 -1
  25. package/lib/S3.js +1 -1
  26. package/lib/S3.js.map +1 -1
  27. package/lib/Templates.d.ts +3 -10
  28. package/lib/Templates.js +5 -14
  29. package/lib/Templates.js.map +1 -1
  30. package/lib/cli.js +7 -6
  31. package/lib/cli.js.map +1 -1
  32. package/lib/config.d.ts +10 -16
  33. package/lib/config.js +33 -55
  34. package/lib/config.js.map +1 -1
  35. package/lib/error.manager.d.ts +2 -2
  36. package/lib/error.manager.js +42 -49
  37. package/lib/error.manager.js.map +1 -1
  38. package/lib/i18n.d.ts +2 -0
  39. package/lib/i18n.js +53 -0
  40. package/lib/i18n.js.map +1 -0
  41. package/lib/mutex.d.ts +2 -1
  42. package/lib/mutex.js +2 -1
  43. package/lib/mutex.js.map +1 -1
  44. package/lib/mwoffliner.lib.js +370 -222
  45. package/lib/mwoffliner.lib.js.map +1 -1
  46. package/lib/parameterList.d.ts +20 -8
  47. package/lib/parameterList.js +30 -18
  48. package/lib/parameterList.js.map +1 -1
  49. package/lib/renderers/abstract.renderer.d.ts +48 -55
  50. package/lib/renderers/abstract.renderer.js +377 -98
  51. package/lib/renderers/abstract.renderer.js.map +1 -1
  52. package/lib/renderers/action-parse.renderer.d.ts +22 -3
  53. package/lib/renderers/action-parse.renderer.js +123 -81
  54. package/lib/renderers/action-parse.renderer.js.map +1 -1
  55. package/lib/renderers/renderer.builder.js +6 -69
  56. package/lib/renderers/renderer.builder.js.map +1 -1
  57. package/lib/renderers/rendering.context.d.ts +2 -3
  58. package/lib/renderers/rendering.context.js +6 -14
  59. package/lib/renderers/rendering.context.js.map +1 -1
  60. package/lib/sanitize-argument.d.ts +7 -4
  61. package/lib/sanitize-argument.js +99 -41
  62. package/lib/sanitize-argument.js.map +1 -1
  63. package/lib/util/FileManager.d.ts +36 -0
  64. package/lib/util/FileManager.js +246 -0
  65. package/lib/util/FileManager.js.map +1 -0
  66. package/lib/util/RedisKvs.js +4 -4
  67. package/lib/util/RedisKvs.js.map +1 -1
  68. package/lib/util/RedisQueue.js +1 -1
  69. package/lib/util/RedisQueue.js.map +1 -1
  70. package/lib/util/builders/url/action-parse.director.d.ts +2 -2
  71. package/lib/util/builders/url/action-parse.director.js +7 -7
  72. package/lib/util/builders/url/action-parse.director.js.map +1 -1
  73. package/lib/util/builders/url/api.director.d.ts +2 -4
  74. package/lib/util/builders/url/api.director.js +10 -17
  75. package/lib/util/builders/url/api.director.js.map +1 -1
  76. package/lib/util/builders/url/base.director.d.ts +0 -4
  77. package/lib/util/builders/url/base.director.js +0 -25
  78. package/lib/util/builders/url/base.director.js.map +1 -1
  79. package/lib/util/builders/url/basic.director.d.ts +1 -1
  80. package/lib/util/builders/url/basic.director.js +1 -1
  81. package/lib/util/builders/url/web.director.d.ts +1 -1
  82. package/lib/util/builders/url/web.director.js +2 -2
  83. package/lib/util/builders/url/web.director.js.map +1 -1
  84. package/lib/util/categories.d.ts +5 -5
  85. package/lib/util/categories.js +175 -174
  86. package/lib/util/categories.js.map +1 -1
  87. package/lib/util/const.d.ts +4 -8
  88. package/lib/util/const.js +9 -17
  89. package/lib/util/const.js.map +1 -1
  90. package/lib/util/customCssJs.d.ts +6 -0
  91. package/lib/util/customCssJs.js +70 -0
  92. package/lib/util/customCssJs.js.map +1 -0
  93. package/lib/util/dump.d.ts +15 -4
  94. package/lib/util/dump.js +282 -86
  95. package/lib/util/dump.js.map +1 -1
  96. package/lib/util/index.d.ts +1 -1
  97. package/lib/util/index.js +1 -1
  98. package/lib/util/index.js.map +1 -1
  99. package/lib/util/metaData.js.map +1 -1
  100. package/lib/util/misc.d.ts +23 -13
  101. package/lib/util/misc.js +90 -59
  102. package/lib/util/misc.js.map +1 -1
  103. package/lib/util/mw-api.d.ts +7 -5
  104. package/lib/util/mw-api.js +167 -221
  105. package/lib/util/mw-api.js.map +1 -1
  106. package/lib/util/pageListMainPage.d.ts +3 -0
  107. package/lib/util/pageListMainPage.js +10 -0
  108. package/lib/util/pageListMainPage.js.map +1 -0
  109. package/lib/util/pages.d.ts +30 -0
  110. package/lib/util/pages.js +146 -0
  111. package/lib/util/pages.js.map +1 -0
  112. package/lib/util/rewriteUrls.d.ts +2 -2
  113. package/lib/util/rewriteUrls.js +33 -31
  114. package/lib/util/rewriteUrls.js.map +1 -1
  115. package/lib/util/savePages.d.ts +8 -0
  116. package/lib/util/savePages.js +182 -0
  117. package/lib/util/savePages.js.map +1 -0
  118. package/lib/version.d.ts +1 -1
  119. package/lib/version.js +1 -1
  120. package/lib/version.js.map +1 -1
  121. package/offliner-definition.json +178 -90
  122. package/package.json +50 -49
  123. package/res/page_list_home.js +20 -0
  124. package/res/{article_not_found.svg → page_not_found.svg} +1 -1
  125. package/res/script.js +30 -45
  126. package/res/style.css +52 -68
  127. package/res/templates/download_error_placeholder.html +7 -8
  128. package/res/templates/javaScript.html +19 -0
  129. package/res/templates/pageFallback.html +9 -7
  130. package/res/templates/pageVector2022.html +9 -7
  131. package/res/templates/pageVectorLegacy.html +9 -7
  132. package/res/templates/{article_list_home.html → page_list_home.html} +1 -2
  133. package/translation/ar.json +21 -21
  134. package/translation/bn.json +1 -1
  135. package/translation/de.json +46 -19
  136. package/translation/en.json +54 -21
  137. package/translation/es.json +17 -19
  138. package/translation/fi.json +23 -3
  139. package/translation/fr.json +57 -21
  140. package/translation/he.json +8 -6
  141. package/translation/ia.json +19 -19
  142. package/translation/id.json +13 -13
  143. package/translation/it.json +13 -8
  144. package/translation/ko.json +55 -3
  145. package/translation/lb.json +5 -2
  146. package/translation/mk.json +5 -5
  147. package/translation/nl.json +57 -22
  148. package/translation/or.json +2 -2
  149. package/translation/pt.json +2 -2
  150. package/translation/qqq.json +55 -22
  151. package/translation/ru.json +2 -2
  152. package/translation/sc.json +2 -2
  153. package/translation/sk.json +62 -0
  154. package/translation/sl.json +54 -21
  155. package/translation/sv.json +20 -19
  156. package/translation/sw.json +45 -2
  157. package/translation/zh-hans.json +56 -22
  158. package/translation/zh-hant.json +26 -19
  159. package/lib/renderers/abstractDesktop.render.d.ts +0 -12
  160. package/lib/renderers/abstractDesktop.render.js +0 -67
  161. package/lib/renderers/abstractDesktop.render.js.map +0 -1
  162. package/lib/renderers/abstractMobile.render.d.ts +0 -12
  163. package/lib/renderers/abstractMobile.render.js +0 -54
  164. package/lib/renderers/abstractMobile.render.js.map +0 -1
  165. package/lib/renderers/rest-api.renderer.d.ts +0 -7
  166. package/lib/renderers/rest-api.renderer.js +0 -68
  167. package/lib/renderers/rest-api.renderer.js.map +0 -1
  168. package/lib/renderers/visual-editor.renderer.d.ts +0 -7
  169. package/lib/renderers/visual-editor.renderer.js +0 -76
  170. package/lib/renderers/visual-editor.renderer.js.map +0 -1
  171. package/lib/renderers/wikimedia-desktop.renderer.d.ts +0 -4
  172. package/lib/renderers/wikimedia-desktop.renderer.js +0 -7
  173. package/lib/renderers/wikimedia-desktop.renderer.js.map +0 -1
  174. package/lib/renderers/wikimedia-mobile.renderer.d.ts +0 -19
  175. package/lib/renderers/wikimedia-mobile.renderer.js +0 -243
  176. package/lib/renderers/wikimedia-mobile.renderer.js.map +0 -1
  177. package/lib/util/articleListMainPage.d.ts +0 -3
  178. package/lib/util/articleListMainPage.js +0 -10
  179. package/lib/util/articleListMainPage.js.map +0 -1
  180. package/lib/util/articles.d.ts +0 -26
  181. package/lib/util/articles.js +0 -102
  182. package/lib/util/articles.js.map +0 -1
  183. package/lib/util/builders/url/desktop.director.d.ts +0 -8
  184. package/lib/util/builders/url/desktop.director.js +0 -15
  185. package/lib/util/builders/url/desktop.director.js.map +0 -1
  186. package/lib/util/builders/url/mobile.director.d.ts +0 -8
  187. package/lib/util/builders/url/mobile.director.js +0 -15
  188. package/lib/util/builders/url/mobile.director.js.map +0 -1
  189. package/lib/util/builders/url/rest-api.director.d.ts +0 -8
  190. package/lib/util/builders/url/rest-api.director.js +0 -18
  191. package/lib/util/builders/url/rest-api.director.js.map +0 -1
  192. package/lib/util/builders/url/visual-editor.director.d.ts +0 -9
  193. package/lib/util/builders/url/visual-editor.director.js +0 -18
  194. package/lib/util/builders/url/visual-editor.director.js.map +0 -1
  195. package/lib/util/saveArticles.d.ts +0 -8
  196. package/lib/util/saveArticles.js +0 -411
  197. package/lib/util/saveArticles.js.map +0 -1
  198. package/res/article_list_home.js +0 -24
  199. package/res/content.parsoid.css +0 -196
  200. package/res/inserted_style.css +0 -45
  201. package/res/templates/categories.html +0 -11
  202. package/res/templates/lead_section_wrapper.html +0 -6
  203. package/res/templates/pageWikimediaDesktop.html +0 -24
  204. package/res/templates/pageWikimediaMobile.html +0 -24
  205. package/res/templates/section_wrapper.html +0 -5
  206. package/res/templates/subcategories.html +0 -23
  207. package/res/templates/subpages.html +0 -17
  208. package/res/templates/subsection_wrapper.html +0 -5
  209. package/res/webpHandler.js +0 -165
  210. package/res/wm_mobile_override_script.js +0 -15
  211. package/res/wm_mobile_override_style.css +0 -20
  212. package/translation/br.json +0 -8
  213. package/translation/dag.json +0 -9
  214. package/translation/es-formal.json +0 -29
  215. package/translation/ha.json +0 -10
  216. package/translation/hi.json +0 -9
  217. package/translation/ig.json +0 -10
  218. package/translation/kaa.json +0 -9
  219. package/translation/nb.json +0 -9
  220. package/translation/nqo.json +0 -8
  221. package/translation/pt-br.json +0 -10
  222. package/translation/ro.json +0 -9
  223. package/translation/scn.json +0 -8
  224. package/translation/sq.json +0 -9
  225. package/translation/te.json +0 -9
  226. package/translation/tn.json +0 -9
  227. package/translation/tr.json +0 -10
package/lib/Downloader.js CHANGED
@@ -1,17 +1,11 @@
1
1
  import * as backoff from 'backoff';
2
2
  import { config } from './config.js';
3
- import { contains, normalizeMwResponse, DB_ERROR, WEAK_ETAG_REGEX, stripHttpFromUrl, isBitmapImageMimeType, isWebpCandidateImageMimeType } from './util/index.js';
4
- import deepmerge from 'deepmerge';
5
- import * as domino from 'domino';
6
- import { default as imagemin } from 'imagemin';
7
- import imageminAdvPng from 'imagemin-advpng';
3
+ import { filterRedirects, DB_ERROR, WEAK_ETAG_REGEX, stripHttpFromUrl, isBitmapImageMimeType, isWebpCandidateImageMimeType, makeZimPath } from './util/index.js';
8
4
  import axios, { AxiosError } from 'axios';
9
- import { default as imageminPngquant } from 'imagemin-pngquant';
10
- import imageminGifsicle from 'imagemin-gifsicle';
11
- import imageminJpegoptim from 'imagemin-jpegoptim';
12
- import imageminWebp from 'imagemin-webp';
13
5
  import sharp from 'sharp';
14
- import { fileTypeFromBuffer } from 'file-type';
6
+ import apng from 'sharp-apng';
7
+ import { FileTypeParser } from 'file-type';
8
+ import { detectXml } from '@file-type/xml';
15
9
  import { HttpCookieAgent, HttpsCookieAgent } from 'http-cookie-agent/http';
16
10
  import { CookieJar } from 'tough-cookie';
17
11
  import * as logger from './Logger.js';
@@ -20,24 +14,36 @@ import ApiURLDirector from './util/builders/url/api.director.js';
20
14
  import urlHelper from './util/url.helper.js';
21
15
  import { findFirstMatchingRule, renderDownloadError } from './error.manager.js';
22
16
  import RedisStore from './RedisStore.js';
23
- const imageminOptions = new Map();
24
- imageminOptions.set('default', new Map());
25
- imageminOptions.set('webp', new Map());
26
- imageminOptions.get('default').set('image/png', {
27
- plugins: [imageminPngquant({ speed: 3, strip: true, dithering: 0 }), imageminAdvPng({ optimizationLevel: 4, iterations: 5 })],
28
- });
29
- imageminOptions.get('default').set('image/jpeg', {
30
- plugins: [imageminJpegoptim({ max: 60, stripAll: true })],
31
- });
32
- imageminOptions.get('default').set('image/gif', {
33
- plugins: [imageminGifsicle({ optimizationLevel: 3, colors: 64 })],
34
- });
35
- imageminOptions.get('webp').set('image/png', {
36
- plugins: [imageminWebp({ quality: 50, method: 6 })],
37
- });
38
- imageminOptions.get('webp').set('image/jpeg', {
39
- plugins: [imageminWebp({ quality: 50, method: 6 })],
40
- });
17
+ import deepmerge from 'deepmerge';
18
+ import semver from 'semver';
19
+ // create file-type parser with add-on to detect XML documents content (typically SVG)
20
+ // Nota: file-type is capable to detect only binary content mime-type without the custom
21
+ // detector
22
+ const fileTypeParser = new FileTypeParser({ customDetectors: [detectXml] });
23
+ function isJsonContentType(contentType) {
24
+ return typeof contentType === 'string' && /\b(?:application\/json|[\w.-]+\+json)\b/i.test(contentType);
25
+ }
26
+ function isMalformedJsonResponseError(err) {
27
+ if (err instanceof SyntaxError) {
28
+ return true;
29
+ }
30
+ if (!axios.isAxiosError(err) || err.code !== AxiosError.ERR_BAD_RESPONSE) {
31
+ return false;
32
+ }
33
+ return err.cause instanceof SyntaxError;
34
+ }
35
+ function parseJsonResponse(url, status, contentType, body) {
36
+ if (body !== null && typeof body === 'object') {
37
+ return body;
38
+ }
39
+ if (typeof body !== 'string') {
40
+ return body;
41
+ }
42
+ if (!isJsonContentType(contentType)) {
43
+ throw new DownloadError('Unexpected non-JSON response while calling API', url, status, contentType, body);
44
+ }
45
+ return JSON.parse(body);
46
+ }
41
47
  export class DownloadError extends Error {
42
48
  urlCalled;
43
49
  httpReturnCode;
@@ -67,27 +73,35 @@ class Downloader {
67
73
  }
68
74
  return Downloader.instance;
69
75
  }
70
- _speed;
71
- cssDependenceUrls = {};
76
+ _workers;
77
+ _pageRequestInterval;
72
78
  _webp = false;
73
79
  _requestTimeout;
74
80
  _basicRequestOptions;
75
81
  _arrayBufferRequestOptions;
76
82
  _jsonRequestOptions;
77
83
  _streamRequestOptions;
78
- wikimediaMobileJsDependenciesList = [];
79
- wikimediaMobileStyleDependenciesList = [];
84
+ trustedJs = [];
85
+ customCssUrls = [];
86
+ customJsUrls = [];
87
+ mathJaxSource = null;
88
+ mathJaxConfig = null;
89
+ mathJaxConfigScript = null;
90
+ mathJaxEntryPoint = 'MathJax.js';
91
+ mathJaxAllPages = false;
80
92
  uaString;
81
93
  backoffOptions;
82
94
  optimisationCacheUrl;
83
95
  s3;
84
96
  _apiUrlDirector;
85
97
  cookierJar;
86
- articleUrlDirector;
87
- mainPageUrlDirector;
98
+ pageUrlDirector;
88
99
  insecure = false;
89
- get speed() {
90
- return this._speed;
100
+ get workers() {
101
+ return this._workers;
102
+ }
103
+ get pageRequestInterval() {
104
+ return this._pageRequestInterval;
91
105
  }
92
106
  get webp() {
93
107
  return this._webp;
@@ -110,13 +124,15 @@ class Downloader {
110
124
  get apiUrlDirector() {
111
125
  return this._apiUrlDirector;
112
126
  }
113
- set init({ uaString, speed, reqTimeout, optimisationCacheUrl, s3, webp, backoffOptions, insecure }) {
127
+ set init({ uaString, workers, pageRequestInterval, reqTimeout, optimisationCacheUrl, s3, webp, trustedJs = config.output.mw.js_trusted.slice(), backoffOptions, insecure, }) {
114
128
  this.reset();
115
129
  this.uaString = uaString;
116
- this._speed = speed;
130
+ this._workers = workers;
131
+ this._pageRequestInterval = pageRequestInterval;
117
132
  this._requestTimeout = reqTimeout;
118
133
  this.optimisationCacheUrl = optimisationCacheUrl;
119
134
  this._webp = webp;
135
+ this.trustedJs = trustedJs;
120
136
  this.s3 = s3;
121
137
  this._apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
122
138
  this.insecure = insecure;
@@ -129,47 +145,52 @@ class Downloader {
129
145
  failAfter: 10,
130
146
  retryIf: (err) => {
131
147
  const requestedUrl = err.urlCalled || err.config?.url || 'unknown';
148
+ if (isMalformedJsonResponseError(err)) {
149
+ logger.info(`Retrying ${requestedUrl} due to malformed JSON response`);
150
+ return true;
151
+ }
132
152
  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`);
153
+ logger.info(`Retrying ${requestedUrl} URL due to ${err.code} error`);
134
154
  return true; // retry all connection issues
135
155
  }
136
- if (err.responseData?.error?.code == 'maxlag') {
137
- logger.log(`Mediawiki server is lagging ${err.responseData?.error?.lag}s; retrying in few seconds`);
156
+ if (err.responseData?.error?.code === 'maxlag') {
157
+ logger.info(`Mediawiki server is lagging ${err.responseData?.error?.lag}s; retrying in few seconds`);
138
158
  return true; // note that we do not honor Retry-After header value because it is not possible in current code architecture
139
159
  }
140
160
  const httpReturnCode = err.response?.status || err.httpReturnCode;
141
161
  if ([429, 500, 502, 503, 504, 524].includes(httpReturnCode)) {
142
- logger.log(`Retrying ${requestedUrl} URL due to HTTP ${httpReturnCode} error`);
162
+ logger.info(`Retrying ${requestedUrl} URL due to HTTP ${httpReturnCode} error`);
143
163
  return true; // retry these HTTP status codes
144
164
  }
145
165
  if (err.responseData?.error?.code &&
146
166
  ![
147
167
  'missingtitle',
148
168
  'readapidenied',
169
+ 'permissiondenied',
149
170
  'internal_api_error_MediaWiki\\Revision\\BadRevisionException',
150
171
  'internal_api_error_Wikimedia\\Assert\\UnreachableException',
151
172
  'internal_api_error_Wikimedia\\Assert\\InvariantException',
152
173
  'internal_api_error_Wikimedia\\Parsoid\\Core\\ResourceLimitExceededException',
153
174
  ].includes(err.responseData?.error?.code)) {
154
- logger.log(`Retrying ${requestedUrl} URL due to ${err.responseData?.error?.code} Mediawiki error`);
175
+ logger.info(`Retrying ${requestedUrl} URL due to ${err.responseData?.error?.code} Mediawiki error`);
155
176
  return true; // retry these Mediawiki codes which are known to be transient
156
177
  }
157
178
  return false; // don't retry other errors
158
179
  },
159
180
  backoffHandler: (number, delay) => {
160
- logger.info(`[backoff] #${number} after ${delay} ms`);
181
+ logger.debug(`[backoff] #${number} after ${delay} ms`);
161
182
  },
162
183
  ...backoffOptions,
163
184
  };
164
185
  this._basicRequestOptions = {
165
186
  // HTTP agent pools with 'keepAlive' to reuse TCP connections, so it's faster
166
- // Set cookie jar and use special Http(s)CookieAgent so that cookies are automatically intercepted and persited across calls
187
+ // Set cookie jar and use special Http(s)CookieAgent so that cookies are automatically intercepted and persisted across calls
167
188
  httpAgent: new HttpCookieAgent({ cookies: { jar: this.cookierJar }, keepAlive: true }),
168
189
  httpsAgent: new HttpsCookieAgent({ cookies: { jar: this.cookierJar }, keepAlive: true, rejectUnauthorized: !this.insecure }), // rejectUnauthorized: false disables TLS
169
190
  timeout: this.requestTimeout,
170
191
  headers: {
171
192
  // Use the base domain of the wiki being scraped as the Referer header, so that we can
172
- // successfully scrap WMF map tiles.
193
+ // successfully scrape WMF map tiles.
173
194
  Referer: MediaWiki.baseUrl.href,
174
195
  'cache-control': 'public, max-stale=86400',
175
196
  'user-agent': this.uaString,
@@ -190,7 +211,7 @@ class Downloader {
190
211
  accept: 'application/json',
191
212
  'accept-encoding': 'gzip, deflate',
192
213
  },
193
- responseType: 'json',
214
+ responseType: 'text',
194
215
  method: 'GET',
195
216
  };
196
217
  this._streamRequestOptions = {
@@ -206,10 +227,12 @@ class Downloader {
206
227
  }
207
228
  reset() {
208
229
  this.uaString = undefined;
209
- this._speed = undefined;
230
+ this._workers = undefined;
231
+ this._pageRequestInterval = undefined;
210
232
  this._requestTimeout = undefined;
211
233
  this.optimisationCacheUrl = undefined;
212
234
  this._webp = false;
235
+ this.trustedJs = [];
213
236
  this.s3 = undefined;
214
237
  this._apiUrlDirector = undefined;
215
238
  this.insecure = false;
@@ -218,22 +241,33 @@ class Downloader {
218
241
  this._arrayBufferRequestOptions = undefined;
219
242
  this._jsonRequestOptions = undefined;
220
243
  this._streamRequestOptions = undefined;
221
- this.cssDependenceUrls = {};
222
- this.wikimediaMobileJsDependenciesList = [];
223
- this.wikimediaMobileStyleDependenciesList = [];
224
- this.articleUrlDirector = undefined;
225
- this.mainPageUrlDirector = undefined;
244
+ this.pageUrlDirector = undefined;
245
+ }
246
+ /**
247
+ * Destroys HTTP agents to properly close all connections.
248
+ * This is required for tests to prevent "Jest environment has been torn down" errors.
249
+ * Safe to call multiple times (idempotent).
250
+ */
251
+ async destroy() {
252
+ try {
253
+ if (this._basicRequestOptions?.httpAgent && typeof this._basicRequestOptions.httpAgent.destroy === 'function') {
254
+ this._basicRequestOptions.httpAgent.destroy();
255
+ }
256
+ }
257
+ catch {
258
+ // Ignore errors from destroying agents
259
+ }
260
+ try {
261
+ if (this._basicRequestOptions?.httpsAgent && typeof this._basicRequestOptions.httpsAgent.destroy === 'function') {
262
+ this._basicRequestOptions.httpsAgent.destroy();
263
+ }
264
+ }
265
+ catch {
266
+ // Ignore errors from destroying agents
267
+ }
226
268
  }
227
269
  getUrlDirector(renderer) {
228
270
  switch (renderer.constructor.name) {
229
- case 'WikimediaDesktopRenderer':
230
- return MediaWiki.wikimediaDesktopUrlDirector;
231
- case 'VisualEditorRenderer':
232
- return MediaWiki.visualEditorUrlDirector;
233
- case 'WikimediaMobileRenderer':
234
- return MediaWiki.wikimediaMobileUrlDirector;
235
- case 'RestApiRenderer':
236
- return MediaWiki.restApiUrlDirector;
237
271
  case 'ActionParseRenderer':
238
272
  return MediaWiki.actionParseUrlDirector;
239
273
  /* istanbul ignore next */
@@ -241,15 +275,11 @@ class Downloader {
241
275
  throw new Error(`Unknown renderer ${renderer.constructor.name}`);
242
276
  }
243
277
  }
244
- setUrlsDirectors(mainPageRenderer, articlesRenderer) {
245
- this.articleUrlDirector = this.getUrlDirector(articlesRenderer);
246
- this.mainPageUrlDirector = this.getUrlDirector(mainPageRenderer);
278
+ setUrlsDirectors(pagesRenderer) {
279
+ this.pageUrlDirector = this.getUrlDirector(pagesRenderer);
247
280
  }
248
- getArticleUrl(articleId, articleUrlOpts = {}) {
249
- return this.articleUrlDirector.buildArticleURL(articleId, articleUrlOpts);
250
- }
251
- getMainPageUrl(articleId) {
252
- return this.mainPageUrlDirector.buildArticleURL(articleId);
281
+ getPageUrl(pageTitle, pageUrlOpts = {}) {
282
+ return this.pageUrlDirector.buildPageUrl(pageTitle, pageUrlOpts);
253
283
  }
254
284
  removeEtagWeakPrefix(etag) {
255
285
  return etag && etag.replace(WEAK_ETAG_REGEX, '');
@@ -257,13 +287,14 @@ class Downloader {
257
287
  querySiteInfo() {
258
288
  return this.getJSON(this.apiUrlDirector.buildSiteInfoURL());
259
289
  }
260
- async getArticleDetailsIds(articleIds, shouldGetThumbnail = false) {
290
+ async getPagesByTitle(pageTitles, shouldGetThumbnail = false, followRedirects = true) {
261
291
  let continuation;
262
- let finalProcessedResp;
292
+ let allPages = {};
293
+ const visitedUrls = new Set();
263
294
  while (true) {
264
295
  const queryOpts = {
265
- ...(await this.getArticleQueryOpts(shouldGetThumbnail, true)),
266
- titles: articleIds.join('|'),
296
+ ...(await this.getPageQueryOpts(shouldGetThumbnail, followRedirects)),
297
+ titles: pageTitles.join('|'),
267
298
  ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
268
299
  ...(MediaWiki.getCategories
269
300
  ? {
@@ -274,43 +305,50 @@ class Downloader {
274
305
  ...continuation,
275
306
  };
276
307
  const reqUrl = this.apiUrlDirector.buildQueryURL(queryOpts);
308
+ if (visitedUrls.has(reqUrl)) {
309
+ throw new Error(`Detected continuation cycle while fetching page details by IDs. ` + `visitedUrls=[\n${[...visitedUrls].join('\n')}\n]`);
310
+ }
311
+ visitedUrls.add(reqUrl);
277
312
  const resp = await this.getJSON(reqUrl);
278
313
  Downloader.handleMWWarningsAndErrors(resp);
279
- let processedResponse = resp.query?.pages ? normalizeMwResponse(resp.query) : {};
280
- if (resp.continue) {
281
- continuation = resp.continue;
282
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
283
- }
284
- else {
285
- if (MediaWiki.getCategories) {
286
- processedResponse = await this.setArticleSubCategories(processedResponse);
287
- }
288
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
314
+ const pages = Object.values(resp.query?.pages);
315
+ pages.forEach((page) => filterRedirects(page));
316
+ /* deepmerge because a single page data might be split over multiple API results pages */
317
+ allPages = deepmerge(allPages, pages.reduce((acc, page) => {
318
+ acc[page.title] = page;
319
+ return acc;
320
+ }, {}));
321
+ continuation = resp.continue;
322
+ if (!continuation) {
289
323
  break;
290
324
  }
291
325
  }
292
- return finalProcessedResp;
326
+ return Object.values(allPages);
293
327
  }
294
- async getArticleDetailsNS(ns, gapcontinue = '') {
295
- let queryContinuation;
296
- let finalProcessedResp;
297
- let gCont = null;
328
+ async getPagesByNamespace(namespace, requestGapcontinue = '') {
329
+ let queryContinuation = null;
330
+ let allPages = {};
331
+ const visitedUrls = new Set();
298
332
  while (true) {
299
333
  const queryOpts = {
300
- ...(await this.getArticleQueryOpts()),
334
+ ...(await this.getPageQueryOpts()),
301
335
  ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
302
336
  ...(MediaWiki.getCategories
303
- ? {
304
- cllimit: 'max',
305
- clshow: '!hidden',
306
- }
337
+ ? semver.satisfies(MediaWiki.metaData.versionMw, '>=1.46')
338
+ ? {
339
+ cllimit: 'max',
340
+ }
341
+ : {
342
+ cllimit: 'max',
343
+ clshow: '!hidden',
344
+ }
307
345
  : {}),
308
346
  rawcontinue: 'true',
309
347
  generator: 'allpages',
310
348
  gapfilterredir: 'nonredirects',
311
349
  gaplimit: 'max',
312
- gapnamespace: String(ns),
313
- gapcontinue,
350
+ gapnamespace: String(namespace),
351
+ gapcontinue: requestGapcontinue,
314
352
  };
315
353
  if (queryContinuation) {
316
354
  queryOpts.cocontinue = queryContinuation?.coordinates?.cocontinue ?? queryOpts.cocontinue;
@@ -319,59 +357,77 @@ class Downloader {
319
357
  queryOpts.rdcontinue = queryContinuation?.redirects?.rdcontinue ?? queryOpts.rdcontinue;
320
358
  }
321
359
  const reqUrl = this.apiUrlDirector.buildQueryURL(queryOpts);
360
+ if (visitedUrls.has(reqUrl)) {
361
+ throw new Error(`Detected continuation cycle while fetching pages details in namespace ${namespace}. ` + `visitedUrls=[\n${[...visitedUrls].join('\n')}\n]`);
362
+ }
363
+ visitedUrls.add(reqUrl);
322
364
  const resp = await this.getJSON(reqUrl);
323
365
  Downloader.handleMWWarningsAndErrors(resp);
324
- let processedResponse = normalizeMwResponse(resp.query);
325
- gCont = resp['query-continue']?.allpages?.gapcontinue ?? gCont;
326
- const queryComplete = Object.keys(resp['query-continue'] || {}).filter((key) => key !== 'allpages').length === 0;
327
- if (!queryComplete) {
328
- queryContinuation = resp['query-continue'];
329
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
330
- }
331
- else {
332
- if (MediaWiki.getCategories) {
333
- processedResponse = await this.setArticleSubCategories(processedResponse);
334
- }
335
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
366
+ const pages = Object.values(resp.query?.pages || {});
367
+ pages.forEach((page) => filterRedirects(page));
368
+ /* deepmerge because a single page data might be split over multiple API results pages */
369
+ allPages = deepmerge(allPages, pages.reduce((acc, page) => {
370
+ acc[page.title] = page;
371
+ return acc;
372
+ }, {}));
373
+ queryContinuation = resp['query-continue'];
374
+ const queryComplete = Object.keys(queryContinuation || {}).filter((key) => key !== 'allpages').length === 0;
375
+ if (queryComplete) {
336
376
  break;
337
377
  }
338
378
  }
339
379
  return {
340
- articleDetails: finalProcessedResp,
341
- gapContinue: gCont,
380
+ pages: Object.values(allPages),
381
+ gapContinue: queryContinuation?.allpages?.gapcontinue || null,
342
382
  };
343
383
  }
344
- async getLogEvents(letype, articleId) {
345
- const logEventsData = await this.getJSON(this.apiUrlDirector.buildLogEventsQuery(letype, articleId));
384
+ async getLogEvents(letype, pageTitle) {
385
+ const logEventsData = await this.getJSON(this.apiUrlDirector.buildLogEventsQuery(letype, pageTitle));
346
386
  return logEventsData.query?.logevents;
347
387
  }
348
- async getArticle(articleId, articleDetailXId, articleRenderer, articleUrl, dump, articleDetail) {
349
- logger.info(`Getting article [${articleId}] from ${articleUrl}`);
388
+ async getPage(pageTitle, pageRenderer, pageUrl, dump, pageDetail) {
389
+ logger.debug(`Getting page [${pageTitle}] from ${pageUrl}`);
350
390
  try {
351
- const { data, moduleDependencies, redirects, displayTitle, articleSubtitle, bodyCssClass, htmlCssClass } = await articleRenderer.download({
352
- articleId,
353
- articleUrl,
354
- articleDetail,
391
+ const { data, moduleDependencies, redirects, displayTitle, subtitle, categoriesHtml = '', bodyCssClass, htmlCssClass, } = await pageRenderer.download({
392
+ pageTitle,
393
+ pageUrl,
394
+ pageDetail,
395
+ langVar: dump.langVar,
355
396
  });
356
397
  // Cope with the fact that the page we are fetching might have been moved and replaced by a redirect
357
398
  // In such a case, the download above is expected to follow the redirect so that we have proper original
358
- // content at original article path, but we probably need to add a redirect since new article location
359
- // probably did not existed when listing articles (might have existed if the move occured during article
399
+ // content at original page path, but we probably need to add a redirect since new page location
400
+ // probably did not existed when listing pages (might have existed if the move occured during page
360
401
  // listing). The redirect we add in hence in the "opposite" direction than usual, i.e. it will redirect
361
402
  // from new location to original location. Note that only ActionParse API gives proper redirects info.
362
403
  for (const redirect of redirects) {
363
- if (!(await RedisStore.articleDetailXId.exists(redirect.to)) && !(await RedisStore.redirectsXId.exists(redirect.to))) {
364
- RedisStore.redirectsXId.set(redirect.to, { targetId: redirect.from, title: redirect.to, fragment: '' });
404
+ if (!(await RedisStore.pagesStore.exists(redirect.to)) && !(await RedisStore.redirectsStore.exists(redirect.to))) {
405
+ await RedisStore.redirectsStore.set(redirect.to, { to: redirect.from, from: redirect.to, fragment: '' });
406
+ }
407
+ }
408
+ const categoryMembers = {
409
+ categoryinfo: pageDetail.categoryinfo,
410
+ subcats: [],
411
+ pages: [],
412
+ files: [],
413
+ };
414
+ if (pageDetail.categoryinfo?.size) {
415
+ await this.getCategoryMembers(pageTitle, categoryMembers);
416
+ if (MediaWiki.getCategories) {
417
+ categoryMembers.categoryinfo.subcats = categoryMembers.subcats.length;
418
+ categoryMembers.categoryinfo.pages = categoryMembers.pages.length;
419
+ categoryMembers.categoryinfo.files = categoryMembers.files.length;
365
420
  }
366
421
  }
367
- return await articleRenderer.render({
422
+ return await pageRenderer.render({
368
423
  data,
369
424
  moduleDependencies,
370
- articleId,
371
- articleDetailXId,
372
- articleDetail,
425
+ pageTitle,
426
+ pageDetail,
373
427
  displayTitle,
374
- articleSubtitle,
428
+ subtitle,
429
+ categoryMembers,
430
+ categoriesHtml,
375
431
  bodyCssClass,
376
432
  htmlCssClass,
377
433
  dump,
@@ -400,7 +456,7 @@ class Downloader {
400
456
  if (!downloadErrorContext) {
401
457
  throw err;
402
458
  }
403
- logger.warn(`Article ${articleId} failed to download from '${downloadErrorContext.urlCalled}' with ` +
459
+ logger.warn(`Page ${pageTitle} failed to download from '${downloadErrorContext.urlCalled}' with ` +
404
460
  `'${downloadErrorContext.errorCode}' error code, ` +
405
461
  `'${downloadErrorContext.httpReturnCode}' HTTP return code ` +
406
462
  `and '${downloadErrorContext.responseContentType}' content-type ` +
@@ -411,43 +467,45 @@ class Downloader {
411
467
  throw err;
412
468
  }
413
469
  if (errorRule.isHardFailure) {
414
- logger.log(`This is a hard ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
415
- dump.status.articles.hardFail += 1;
416
- dump.status.articles.hardFailedArticleIds.push(articleId);
417
- if (dump.maxHardFailedArticles > 0 && dump.status.articles.hardFail > dump.maxHardFailedArticles) {
418
- throw new Error('Too many articles failed to download');
470
+ logger.info(`This is a hard ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
471
+ dump.status.pages.hardFail += 1;
472
+ dump.status.pages.hardFailedPages.push(pageTitle);
473
+ if (dump.maxHardFailedPages > 0 && dump.status.pages.hardFail > dump.maxHardFailedPages) {
474
+ throw new Error('Too many pages failed to download'); // eslint-disable-line preserve-caught-error
419
475
  }
420
476
  }
421
477
  else {
422
- logger.log(`This is a soft ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
423
- dump.status.articles.softFail += 1;
424
- dump.status.articles.softFailedArticleIds.push(articleId);
478
+ logger.info(`This is a soft ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
479
+ dump.status.pages.softFail += 1;
480
+ dump.status.pages.softFailedPages.push(pageTitle);
425
481
  }
426
- RedisStore.articleDetailXId.delete(articleId); // Remove article from list so that we stop creating links to this placeholder
427
- const articleTitle = articleId.replace(/_/g, ' ');
428
- const errorPlaceholderHtml = renderDownloadError(errorRule, dump, articleId, articleTitle);
429
- return [
430
- {
431
- articleId,
432
- displayTitle: articleTitle,
433
- html: errorPlaceholderHtml,
434
- imageDependencies: [],
435
- videoDependencies: [],
436
- mediaDependencies: [],
437
- moduleDependencies: [],
438
- staticFiles: config.output.downloadErrorResources,
439
- subtitles: [],
440
- },
441
- ];
482
+ await RedisStore.pagesStore.delete(pageTitle); // Remove page from list so that we stop creating links to this placeholder
483
+ const errorPlaceholderHtml = renderDownloadError(errorRule, dump, pageTitle);
484
+ return {
485
+ items: [
486
+ {
487
+ pageTitle,
488
+ zimPath: makeZimPath(pageTitle),
489
+ zimTitle: '', // we do not want these failed downloads to end-up in suggestion search
490
+ htmlContent: errorPlaceholderHtml,
491
+ },
492
+ ],
493
+ mediaDependencies: [],
494
+ imageDependencies: [],
495
+ videoDependencies: [],
496
+ moduleDependencies: [],
497
+ subtitles: [],
498
+ needsDownloadErrorStaticFiles: true,
499
+ };
442
500
  }
443
501
  }
444
502
  async getJSON(_url) {
445
503
  const url = urlHelper.deserializeUrl(_url);
446
504
  return new Promise((resolve, reject) => {
447
- this.backoffCall(this.getJSONCb, url, 'json', (err, val) => {
505
+ this.backoffCall(this.getJSONCb, url, 'json', undefined, (err, val) => {
448
506
  if (err) {
449
507
  const httpStatus = (err.response && err.response.status) || err.httpReturnCode;
450
- logger.info(`Failed to get [${url}] [status=${httpStatus}]`);
508
+ logger.debug(`Failed to get [${url}] [status=${httpStatus}]`);
451
509
  reject(err);
452
510
  }
453
511
  else {
@@ -457,6 +515,10 @@ class Downloader {
457
515
  });
458
516
  }
459
517
  async request(config) {
518
+ // axios@1.20's typings resolve the `request` return type through a conditional type keyed on
519
+ // an internal sentinel default; since our own `R` generic is not that sentinel but TS cannot
520
+ // prove it at this level, the conditional type doesn't reduce and the (correct) result needs
521
+ // an explicit cast back to `R`.
460
522
  return axios.request({
461
523
  ...this._basicRequestOptions,
462
524
  ...config,
@@ -473,7 +535,7 @@ class Downloader {
473
535
  async post(url, data, config) {
474
536
  return this.request({ url, data, method: 'POST', ...config });
475
537
  }
476
- async downloadContent(_url, kind, retry = true) {
538
+ async downloadContent(_url, kind, retry = true, requestedWidth) {
477
539
  if (!_url) {
478
540
  throw new Error(`Parameter [${_url}] is not a valid url`);
479
541
  }
@@ -492,15 +554,16 @@ class Downloader {
492
554
  }
493
555
  };
494
556
  if (retry) {
495
- this.backoffCall(this.getContentCb, url, kind, cb);
557
+ this.backoffCall(this.getContentCb, url, kind, requestedWidth, cb);
496
558
  }
497
559
  else {
498
- this.getContentCb(url, kind, cb);
560
+ this.getContentCb(url, kind, requestedWidth, cb);
499
561
  }
500
562
  });
501
563
  }
502
564
  catch (err) {
503
- const httpStatus = err.response && err.response.status;
565
+ const e = err;
566
+ const httpStatus = e.response && e.response.status;
504
567
  logger.warn(`Failed to get [${url}] [status=${httpStatus}]`);
505
568
  throw err;
506
569
  }
@@ -520,10 +583,10 @@ class Downloader {
520
583
  if (resp.error?.code === DB_ERROR)
521
584
  throw new Error(`Got error from MW Query ${JSON.stringify(resp.error, null, '\t')}`);
522
585
  if (resp.error)
523
- logger.log(`Got error from MW Query ${JSON.stringify(resp.warnings, null, '\t')}`);
586
+ logger.info(`Got error from MW Query ${JSON.stringify(resp.warnings, null, '\t')}`);
524
587
  }
525
- async getArticleQueryOpts(includePageimages = false, followRedirects = false) {
526
- const prop = `${includePageimages ? '|pageimages' : ''}${(await MediaWiki.hasCoordinates()) ? '|coordinates' : ''}${MediaWiki.getCategories ? '|categories' : ''}`;
588
+ async getPageQueryOpts(includePageimages = false, followRedirects = false) {
589
+ const prop = `${includePageimages ? '|pageimages' : ''}${(await MediaWiki.hasCoordinates()) ? '|coordinates' : ''}${MediaWiki.getCategories ? '|categories' : ''}${(await MediaWiki.hasFlaggedRevs()) ? '|flagged' : ''}`;
527
590
  return {
528
591
  ...MediaWiki.queryOpts,
529
592
  prop: MediaWiki.queryOpts.prop.concat(prop),
@@ -531,89 +594,112 @@ class Downloader {
531
594
  redirects: followRedirects ? true : undefined,
532
595
  };
533
596
  }
534
- async setArticleSubCategories(articleDetails) {
535
- logger.info('Getting subCategories');
536
- for (const [articleId, articleDetail] of Object.entries(articleDetails)) {
537
- const isCategoryArticle = articleDetail.ns === 14;
538
- if (isCategoryArticle) {
539
- const categoryMembers = await this.getSubCategories(articleId);
540
- articleDetails[articleId].subCategories = categoryMembers.slice();
541
- }
542
- }
543
- return articleDetails;
544
- }
545
- getJSONCb = (url, kind, handler) => {
546
- logger.info(`Getting JSON from [${url}]`);
597
+ getJSONCb = (url, kind, _requestedWidth, handler) => {
598
+ logger.debug(`Getting JSON from [${url}]`);
547
599
  this.request({ url, method: 'GET', ...this.jsonRequestOptions })
548
600
  .then((val) => {
549
- if (val.data.error) {
550
- handler(new DownloadError(`Error returned while calling API`, url, val.status, val.headers['content-type'].toString(), val.data));
601
+ const contentType = val.headers['content-type']?.toString() || null;
602
+ const data = parseJsonResponse(url, val.status, contentType, val.data);
603
+ if (data.error) {
604
+ handler(new DownloadError(`Error returned while calling API`, url, val.status, contentType, data));
551
605
  }
552
606
  else {
553
- handler(null, val.data);
607
+ handler(null, data);
554
608
  }
555
609
  })
556
- .catch((err) => handler(err));
610
+ .catch((err) => {
611
+ if (isMalformedJsonResponseError(err)) {
612
+ logger.warn(`Malformed JSON from [${url}] → ${err.message}`);
613
+ }
614
+ handler(err);
615
+ });
557
616
  };
558
617
  async getImageMimeType(data) {
559
- const fileType = await fileTypeFromBuffer(data);
560
- if (fileType && fileType.mime === 'application/xml') {
561
- // File type is known to be wrong, might be SVG
562
- return null;
618
+ // get mime-type of a binary file, with XML addon to detect SVGs
619
+ return (await fileTypeParser.fromBuffer(data)).mime;
620
+ }
621
+ async getSharpObject(input, contentType) {
622
+ if (contentType == 'image/apng') {
623
+ try {
624
+ // resolveWithObject is not passed, so sharpFromApng always resolves to a Sharp instance, never ImageData
625
+ return (await apng.sharpFromApng(input.data));
626
+ }
627
+ catch {
628
+ logger.warn(`Failed to read animated PNG at ${input.context}: static image will be used instead of animation`);
629
+ return sharp(input.data, { animated: true });
630
+ }
631
+ }
632
+ else {
633
+ // { animated: true } reads all frames of an animated pictures; it is a no-op for static images
634
+ return sharp(input.data, { animated: true });
563
635
  }
564
- return fileType ? fileType.mime : null;
565
636
  }
566
- async getCompressedBody(input) {
637
+ async compressDefault(sharpObject, context, contentType) {
638
+ if (contentType == 'image/png') {
639
+ return sharpObject.png({ palette: true, quality: 60, effort: 7 }).toBuffer();
640
+ }
641
+ else if (contentType == 'image/apng') {
642
+ logger.warn(`Cannot compress image at ${context}: recompressing APNG to APNG is not supported ATM, using original data`);
643
+ return sharpObject.toBuffer();
644
+ }
645
+ else if (contentType === 'image/jpeg') {
646
+ return sharpObject.jpeg({ quality: 60, mozjpeg: true }).toBuffer();
647
+ }
648
+ else if (contentType === 'image/gif') {
649
+ return sharpObject.gif({ colours: 64, effort: 7 }).toBuffer();
650
+ }
651
+ else {
652
+ logger.warn(`Cannot compress image at ${context}: unknown content-type ${contentType}, using original data`);
653
+ return sharpObject.toBuffer();
654
+ }
655
+ }
656
+ async getCompressedBody(input, requestedWidth) {
567
657
  const contentType = await this.getImageMimeType(input.data);
568
658
  if (isBitmapImageMimeType(contentType)) {
659
+ // Resize down with sharp before compression when the source is wider than the requested
660
+ // display width. { animated: true } reads all frames so animated GIFs/APNGs are resized
661
+ // frame-by-frame and stay animated, instead of only keeping their first frame.
662
+ let sharpData = await this.getSharpObject(input, contentType);
663
+ if (requestedWidth) {
664
+ try {
665
+ const metadata = await sharpData.metadata();
666
+ if (metadata.width && metadata.width > requestedWidth) {
667
+ sharpData = sharpData.resize({ width: requestedWidth, withoutEnlargement: true });
668
+ }
669
+ }
670
+ catch (err) {
671
+ logger.warn(`Failed to resize image from ${input.context} to ${requestedWidth}px, proceeding without resize: ${err.message}`);
672
+ }
673
+ }
569
674
  if (this.webp && isWebpCandidateImageMimeType(contentType)) {
570
- return {
571
- data: await imagemin
572
- .buffer(input.data, imageminOptions.get('webp').get(contentType))
573
- .catch(async (err) => {
574
- if (/Unsupported color conversion request/.test(err.stderr)) {
575
- return imagemin
576
- .buffer(await sharp(input.data).toColorspace('srgb').toBuffer(), imageminOptions.get('webp').get(contentType))
577
- .catch(() => {
578
- return input.data;
579
- })
580
- .then((data) => {
581
- return data;
582
- });
583
- }
584
- else {
585
- return imagemin.buffer(input.data, imageminOptions.get('default').get(contentType)).catch(() => {
586
- return input.data;
587
- });
588
- }
589
- })
590
- .then((data) => {
591
- return data;
592
- }),
593
- };
675
+ try {
676
+ return await sharpData.webp({ quality: 50, effort: 6 }).toBuffer();
677
+ }
678
+ catch {
679
+ logger.warn(`Failed to compress ${input.context} to WEBP as requested, falling back to simple recompression of original format`);
680
+ return await this.compressDefault(sharpData, input.context, contentType);
681
+ }
594
682
  }
595
683
  else {
596
- return {
597
- data: await imagemin.buffer(input.data, imageminOptions.get('default').get(contentType)).catch(() => {
598
- return input.data;
599
- }),
600
- };
684
+ return await this.compressDefault(sharpData, input.context, contentType);
601
685
  }
602
686
  }
603
- return {
604
- data: input.data,
605
- };
687
+ return input.data;
606
688
  }
607
- getContentCb = async (url, kind, handler) => {
608
- logger.info(`Downloading [${url}]`);
689
+ getContentCb = async (url, kind, requestedWidth, handler) => {
690
+ logger.debug(`Downloading [${url}]`);
609
691
  try {
610
692
  if (this.optimisationCacheUrl && kind === 'image') {
611
- this.downloadImage(url, handler);
693
+ this.downloadImage(url, handler, requestedWidth);
612
694
  }
613
695
  else {
614
696
  const resp = await this.request({ url, method: 'GET', ...this.arrayBufferRequestOptions });
615
697
  // If content is an image, we might benefit from compressing it
616
- const content = kind === 'image' ? (await this.getCompressedBody({ data: resp.data })).data : resp.data;
698
+ const content = kind === 'image' ? await this.getCompressedBody({ data: resp.data, context: url }, requestedWidth) : resp.data;
699
+ // Check content really exists
700
+ if (!content?.length) {
701
+ logger.warn(`Content for ${url} is missing or empty (${typeof content})`);
702
+ }
617
703
  // compute content-type from content, since getCompressedBody might have modified it
618
704
  const contentType = kind === 'image' ? (await this.getImageMimeType(content)) || resp.headers['content-type'] : resp.headers['content-type'];
619
705
  handler(null, {
@@ -631,62 +717,102 @@ class Downloader {
631
717
  }
632
718
  }
633
719
  };
634
- async downloadImage(url, handler) {
720
+ async downloadImage(url, handler, requestedWidth) {
721
+ // Build a width-aware cache version token so the same URL at different
722
+ // requested widths does not reuse an undersized cached image.
723
+ const cacheVersion = this.webp ? (requestedWidth ? `webp-w${requestedWidth}` : 'webp') : requestedWidth ? `1-w${requestedWidth}` : '1';
635
724
  try {
636
725
  this.s3
637
726
  // Check first if we have an entry in the (object storage) cache for this URL
638
- .downloadBlob(stripHttpFromUrl(url), this.webp ? 'webp' : '1')
727
+ .downloadBlob(stripHttpFromUrl(url), cacheVersion)
639
728
  // Handle the cache response and act accordingly
640
729
  .then(async (s3Resp) => {
641
- // 'Versioning' of image is made via HTTP ETag. We should
642
- // check if we have the proper version by requesting proper
643
- // ETag from upstream MediaWiki.
644
- if (s3Resp?.Metadata?.etag) {
645
- this.arrayBufferRequestOptions.headers['If-None-Match'] = this.removeEtagWeakPrefix(s3Resp.Metadata.etag);
646
- }
647
- // Use the base domain of the wiki being scraped as the Referer header, so that we can
648
- // successfully scrap WMF map tiles.
649
- const mwResp = await this.request({ url, method: 'GET', ...this.arrayBufferRequestOptions });
650
- // Most of the images, after having been uploaded once to the
651
- // cache, will always have 304 status, until modified. If cache
652
- // is up to date, return cached image. We always have an s3
653
- // response when mwResp is 304, since this can only happen
654
- // when we have an eTag coming from s3.
655
- if (mwResp.status === 304) {
656
- // Proceed with image
657
- const data = (await this.streamToBuffer(s3Resp.Body));
658
- const contentType = await this.getImageMimeType(data);
659
- logger.info(`Using S3-cached image for ${url} (contentType: ${contentType})`);
660
- handler(null, {
661
- contentType,
662
- content: data,
663
- });
664
- return;
665
- }
666
- // Destroy the Readable so that socket is freed and returned to the pool
667
- if (s3Resp?.Body) {
668
- s3Resp.Body.destroy();
669
- }
670
- // Compress content because image blob comes from upstream MediaWiki
671
- const compressedData = (await this.getCompressedBody({ data: mwResp.data })).data;
672
- // Check for the ETag and upload to cache
673
- const etag = this.removeEtagWeakPrefix(mwResp.headers.etag);
674
- if (etag) {
675
- await this.s3.uploadBlob(stripHttpFromUrl(url), compressedData, etag, this.webp ? 'webp' : '1');
676
- }
677
- // get contentType from image, with fallback to response headers should the image be unsupported at all (e.g. SVG)
678
- const contentType = (await this.getImageMimeType(compressedData)) || mwResp.headers['content-type'];
679
- if (s3Resp) {
680
- logger.info(`Using image downloaded from upstream for ${url} (S3-cached image is outdated, contentType: ${contentType})`);
681
- }
682
- else {
683
- logger.info(`Using image downloaded from upstream for ${url} (no S3-cached image found, contentType: ${contentType})`);
684
- }
685
- // Proceed with image
686
- handler(null, {
687
- contentType,
688
- content: compressedData,
689
- });
730
+ // While we might still need s3Resp.Body (below, or in the 304 branch), forward any
731
+ // stream error (e.g. ECONNRESET) into this handler's rejection path so it is retried
732
+ // like any other transient download error, instead of hanging (an errored AWS SDK
733
+ // stream never emits the 'data'/'end' that a later reader would wait for) or crashing
734
+ // the process (an AWS SDK stream left without an 'error' listener throws on a late
735
+ // socket-close error).
736
+ let onBodyError;
737
+ const bodyError = s3Resp?.Body
738
+ ? new Promise((_resolve, reject) => {
739
+ onBodyError = reject;
740
+ s3Resp.Body.on('error', onBodyError);
741
+ })
742
+ : new Promise(() => { });
743
+ return Promise.race([
744
+ bodyError,
745
+ (async () => {
746
+ // 'Versioning' of image is made via HTTP ETag. We should
747
+ // check if we have the proper version by requesting proper
748
+ // ETag from upstream MediaWiki.
749
+ // Headers are cloned per-request: concurrent downloads share this.arrayBufferRequestOptions,
750
+ // so mutating its headers in place would leak one image's If-None-Match onto another's request.
751
+ const requestOptions = {
752
+ ...this.arrayBufferRequestOptions,
753
+ headers: { ...this.arrayBufferRequestOptions.headers },
754
+ };
755
+ // Display message when ignoring empty S3 objects
756
+ if (s3Resp && !s3Resp.ContentLength) {
757
+ logger.warn(`Ignoring empty S3 object for ${url}`);
758
+ }
759
+ // If S3 object has content and etag, check if upstream did changed
760
+ if (s3Resp?.ContentLength && s3Resp?.Metadata?.etag) {
761
+ requestOptions.headers['If-None-Match'] = this.removeEtagWeakPrefix(s3Resp.Metadata.etag);
762
+ }
763
+ // Use the base domain of the wiki being scraped as the Referer header, so that we can
764
+ // successfully scrape WMF map tiles.
765
+ const mwResp = await this.request({ url, method: 'GET', ...requestOptions });
766
+ // Most of the images, after having been uploaded once to the
767
+ // cache, will always have 304 status, until modified. If cache
768
+ // is up to date, return cached image. We always have an s3
769
+ // response when mwResp is 304, since this can only happen
770
+ // when we have an eTag coming from s3.
771
+ if (mwResp.status === 304) {
772
+ // Proceed with image
773
+ const data = (await this.streamToBuffer(s3Resp.Body));
774
+ const contentType = await this.getImageMimeType(data);
775
+ logger.debug(`Using S3-cached image for ${url} (contentType: ${contentType})`);
776
+ handler(null, {
777
+ contentType,
778
+ content: data,
779
+ });
780
+ return;
781
+ }
782
+ // From here we no longer need s3Resp.Body: stop treating its errors as fatal (we
783
+ // already have what we need from upstream MediaWiki), then destroy the Readable so
784
+ // the socket is freed and returned to the pool.
785
+ if (s3Resp?.Body) {
786
+ s3Resp.Body.off('error', onBodyError);
787
+ s3Resp.Body.on('error', () => { });
788
+ s3Resp.Body.destroy();
789
+ }
790
+ // Compress content because image blob comes from upstream MediaWiki
791
+ const compressedData = await this.getCompressedBody({ data: mwResp.data, context: url }, requestedWidth);
792
+ // Check content really exists
793
+ if (!compressedData?.length) {
794
+ throw new Error(`Content for ${url} is missing or empty (${typeof compressedData})`);
795
+ }
796
+ // Check for the ETag and upload to cache
797
+ const etag = this.removeEtagWeakPrefix(mwResp.headers.etag);
798
+ if (etag) {
799
+ await this.s3.uploadBlob(stripHttpFromUrl(url), compressedData, etag, cacheVersion);
800
+ }
801
+ // get contentType from image, with fallback to response headers should the image be unsupported at all (e.g. SVG)
802
+ const contentType = (await this.getImageMimeType(compressedData)) || mwResp.headers['content-type'];
803
+ if (s3Resp) {
804
+ logger.debug(`Using image downloaded from upstream for ${url} (S3-cached image is outdated, contentType: ${contentType})`);
805
+ }
806
+ else {
807
+ logger.debug(`Using image downloaded from upstream for ${url} (no S3-cached image found, contentType: ${contentType})`);
808
+ }
809
+ // Proceed with image
810
+ handler(null, {
811
+ contentType,
812
+ content: compressedData,
813
+ });
814
+ })(),
815
+ ]);
690
816
  })
691
817
  .catch((err) => {
692
818
  this.errHandler(err, url, handler);
@@ -697,79 +823,40 @@ class Downloader {
697
823
  }
698
824
  }
699
825
  errHandler(err, url, handler) {
700
- logger.log(`Not able to download content for ${url} due to ${err}`);
826
+ logger.debug(`Error while downloading content for ${url} due to ${err} ; might be retried`);
701
827
  handler(err);
702
828
  }
703
- async getSubCategories(articleId, continueStr = '') {
829
+ async getCategoryMembers(pageTitle, categoryMembers) {
704
830
  const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
705
- const { query, continue: cont } = await this.getJSON(apiUrlDirector.buildSubCategoriesURL(articleId, continueStr));
706
- const items = query.categorymembers.filter((a) => a && a.title);
707
- if (cont && cont.cmcontinue) {
708
- const nextItems = await this.getSubCategories(articleId, cont.cmcontinue);
709
- return items.concat(nextItems);
710
- }
711
- else {
712
- return items;
831
+ let continueStr = '';
832
+ while (true) {
833
+ const { query, continue: cont } = await this.getJSON(apiUrlDirector.buildCategoryMembersURL(pageTitle, continueStr));
834
+ const items = query.categorymembers.filter((a) => {
835
+ const sortkey = a.sortkeyprefix + ((a.ns && a.title.split(':').slice(1).join(':')) || a.title);
836
+ a.sortkeyprefix = [...sortkey][0];
837
+ return a && a.title;
838
+ });
839
+ const pagesInZim = MediaWiki.getCategories ? await RedisStore.pagesStore.existsMany(items.map((a) => a.title)) : null;
840
+ categoryMembers.subcats.push(...items.filter((a) => a.type === 'subcat' && (pagesInZim ? pagesInZim[a.title] : true)));
841
+ categoryMembers.pages.push(...items.filter((a) => a.type === 'page' && (pagesInZim ? pagesInZim[a.title] : true)));
842
+ categoryMembers.files.push(...items.filter((a) => a.type === 'file' && (pagesInZim ? pagesInZim[a.title] : true)));
843
+ if (cont && cont.cmcontinue) {
844
+ continueStr = cont.cmcontinue;
845
+ }
846
+ else {
847
+ break;
848
+ }
713
849
  }
714
850
  }
715
- backoffCall(handler, url, kind, callback) {
851
+ backoffCall(handler, url, kind, requestedWidth, callback) {
716
852
  this.backoffOptions.strategy.reset(); // reset delay to initial one at each call
717
- const call = backoff.call(handler, url, kind, callback);
853
+ const call = backoff.call(handler, url, kind, requestedWidth, callback);
718
854
  call.setStrategy(this.backoffOptions.strategy);
719
855
  call.retryIf(this.backoffOptions.retryIf);
720
856
  call.failAfter(this.backoffOptions.failAfter);
721
857
  call.on('backoff', this.backoffOptions.backoffHandler);
722
858
  call.start();
723
859
  }
724
- async getModuleDependencies(title) {
725
- const genericJsModules = config.output.mw.js;
726
- const genericCssModules = config.output.mw.css;
727
- const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
728
- const articleApiUrl = apiUrlDirector.buildArticleApiURL(title);
729
- const articleData = await this.getJSON(articleApiUrl);
730
- if (articleData.error) {
731
- const errorMessage = `Unable to retrieve js/css dependencies for article '${title}': ${articleData.error.code}`;
732
- logger.error(errorMessage);
733
- /* If article is missing (for example because it just has been deleted) */
734
- if (articleData.error.code === 'missingtitle') {
735
- return { jsConfigVars: '', jsDependenciesList: [], styleDependenciesList: [] };
736
- }
737
- /* Something went wrong in modules retrieval at app level (no HTTP error) */
738
- throw new Error(errorMessage);
739
- }
740
- const { parse: { modules, modulescripts, modulestyles, headhtml }, } = articleData;
741
- const jsDependenciesList = genericJsModules.concat(modules, modulescripts).filter((a) => a);
742
- const styleDependenciesList = []
743
- .concat(modules, modulestyles, genericCssModules)
744
- .filter((a) => a)
745
- .filter((oneStyleDep) => !contains(config.filters.blackListCssModules, oneStyleDep));
746
- logger.info(`Js dependencies of ${title} : ${jsDependenciesList}`);
747
- logger.info(`Css dependencies of ${title} : ${styleDependenciesList}`);
748
- const jsConfigVars = Downloader.extractJsConfigVars(headhtml);
749
- // Download mobile page dependencies only once
750
- if ((await MediaWiki.hasWikimediaMobileApi()) && this.wikimediaMobileJsDependenciesList.length === 0 && this.wikimediaMobileStyleDependenciesList.length === 0) {
751
- try {
752
- // TODO: An arbitrary title can be placed since all Wikimedia wikis have the same mobile offline resources
753
- const mobileModulesData = await this.getJSON(`${MediaWiki.mobileModulePath}Test`);
754
- mobileModulesData.forEach((module) => {
755
- if (module.includes('javascript')) {
756
- this.wikimediaMobileJsDependenciesList.push(module);
757
- }
758
- else if (module.includes('css')) {
759
- this.wikimediaMobileStyleDependenciesList.push(module);
760
- }
761
- });
762
- }
763
- catch (err) {
764
- throw new Error(`Error getting mobile modules ${err.message}`);
765
- }
766
- }
767
- return {
768
- jsConfigVars,
769
- jsDependenciesList: jsDependenciesList.concat(this.wikimediaMobileJsDependenciesList),
770
- styleDependenciesList: styleDependenciesList.concat(this.wikimediaMobileStyleDependenciesList),
771
- };
772
- }
773
860
  // Solution to handle aws js sdk v3 from https://github.com/aws/aws-sdk-js-v3/issues/1877
774
861
  async streamToBuffer(stream) {
775
862
  return new Promise((resolve, reject) => {
@@ -779,24 +866,6 @@ class Downloader {
779
866
  stream.on('end', () => resolve(Buffer.concat(chunks)));
780
867
  });
781
868
  }
782
- static extractJsConfigVars(headhtml) {
783
- let jsConfigVars = '';
784
- // Saving, as a js module, the jsconfigvars that are set in the header of a wikipedia page
785
- // the script below extracts the config with a regex executed on the page header returned from the api
786
- const scriptTags = domino.createDocument(`${headhtml}</body></html>`).getElementsByTagName('script');
787
- const regex = /mw\.config\.set\(\{.*?\}\);/gm;
788
- for (let i = 0; i < scriptTags.length; i += 1) {
789
- if (scriptTags[i].text.includes('mw.config.set')) {
790
- jsConfigVars = regex.exec(scriptTags[i].text)[0] || '';
791
- jsConfigVars = `(window.RLQ=window.RLQ||[]).push(function() {${jsConfigVars}});`;
792
- }
793
- else if (scriptTags[i].text.includes('RLCONF') || scriptTags[i].text.includes('RLSTATE') || scriptTags[i].text.includes('RLPAGEMODULES')) {
794
- jsConfigVars = scriptTags[i].text;
795
- }
796
- }
797
- jsConfigVars = jsConfigVars.replace('nosuchaction', 'view'); // to replace the wgAction config that is set to 'nosuchaction' from api but should be 'view'
798
- return jsConfigVars;
799
- }
800
869
  }
801
870
  export { Downloader as DownloaderClass };
802
871
  const dl = Downloader.getInstance();