mwoffliner 1.17.5 → 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 (225) hide show
  1. package/README.md +304 -82
  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 +417 -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 +31 -51
  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/{articles.js → pages.js} +58 -14
  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 +176 -89
  122. package/package.json +49 -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 -22
  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 -20
  142. package/translation/id.json +10 -12
  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 +56 -23
  148. package/translation/or.json +2 -2
  149. package/translation/pt.json +2 -2
  150. package/translation/qqq.json +55 -23
  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 -23
  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.map +0 -1
  182. package/lib/util/builders/url/desktop.director.d.ts +0 -8
  183. package/lib/util/builders/url/desktop.director.js +0 -15
  184. package/lib/util/builders/url/desktop.director.js.map +0 -1
  185. package/lib/util/builders/url/mobile.director.d.ts +0 -8
  186. package/lib/util/builders/url/mobile.director.js +0 -15
  187. package/lib/util/builders/url/mobile.director.js.map +0 -1
  188. package/lib/util/builders/url/rest-api.director.d.ts +0 -8
  189. package/lib/util/builders/url/rest-api.director.js +0 -18
  190. package/lib/util/builders/url/rest-api.director.js.map +0 -1
  191. package/lib/util/builders/url/visual-editor.director.d.ts +0 -9
  192. package/lib/util/builders/url/visual-editor.director.js +0 -18
  193. package/lib/util/builders/url/visual-editor.director.js.map +0 -1
  194. package/lib/util/saveArticles.d.ts +0 -8
  195. package/lib/util/saveArticles.js +0 -437
  196. package/lib/util/saveArticles.js.map +0 -1
  197. package/res/article_list_home.js +0 -24
  198. package/res/content.parsoid.css +0 -196
  199. package/res/inserted_style.css +0 -45
  200. package/res/templates/categories.html +0 -11
  201. package/res/templates/lead_section_wrapper.html +0 -6
  202. package/res/templates/pageWikimediaDesktop.html +0 -24
  203. package/res/templates/pageWikimediaMobile.html +0 -24
  204. package/res/templates/section_wrapper.html +0 -5
  205. package/res/templates/subcategories.html +0 -23
  206. package/res/templates/subpages.html +0 -17
  207. package/res/templates/subsection_wrapper.html +0 -5
  208. package/res/webpHandler.js +0 -165
  209. package/res/wm_mobile_override_script.js +0 -15
  210. package/res/wm_mobile_override_style.css +0 -20
  211. package/translation/br.json +0 -8
  212. package/translation/dag.json +0 -9
  213. package/translation/ha.json +0 -10
  214. package/translation/hi.json +0 -9
  215. package/translation/ig.json +0 -10
  216. package/translation/kaa.json +0 -9
  217. package/translation/nb.json +0 -9
  218. package/translation/nqo.json +0 -8
  219. package/translation/pt-br.json +0 -10
  220. package/translation/ro.json +0 -9
  221. package/translation/scn.json +0 -8
  222. package/translation/sq.json +0 -9
  223. package/translation/te.json +0 -9
  224. package/translation/tn.json +0 -9
  225. 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,17 +145,21 @@ 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 &&
@@ -152,25 +172,25 @@ class Downloader {
152
172
  'internal_api_error_Wikimedia\\Assert\\InvariantException',
153
173
  'internal_api_error_Wikimedia\\Parsoid\\Core\\ResourceLimitExceededException',
154
174
  ].includes(err.responseData?.error?.code)) {
155
- 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`);
156
176
  return true; // retry these Mediawiki codes which are known to be transient
157
177
  }
158
178
  return false; // don't retry other errors
159
179
  },
160
180
  backoffHandler: (number, delay) => {
161
- logger.info(`[backoff] #${number} after ${delay} ms`);
181
+ logger.debug(`[backoff] #${number} after ${delay} ms`);
162
182
  },
163
183
  ...backoffOptions,
164
184
  };
165
185
  this._basicRequestOptions = {
166
186
  // HTTP agent pools with 'keepAlive' to reuse TCP connections, so it's faster
167
- // 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
168
188
  httpAgent: new HttpCookieAgent({ cookies: { jar: this.cookierJar }, keepAlive: true }),
169
189
  httpsAgent: new HttpsCookieAgent({ cookies: { jar: this.cookierJar }, keepAlive: true, rejectUnauthorized: !this.insecure }), // rejectUnauthorized: false disables TLS
170
190
  timeout: this.requestTimeout,
171
191
  headers: {
172
192
  // Use the base domain of the wiki being scraped as the Referer header, so that we can
173
- // successfully scrap WMF map tiles.
193
+ // successfully scrape WMF map tiles.
174
194
  Referer: MediaWiki.baseUrl.href,
175
195
  'cache-control': 'public, max-stale=86400',
176
196
  'user-agent': this.uaString,
@@ -191,7 +211,7 @@ class Downloader {
191
211
  accept: 'application/json',
192
212
  'accept-encoding': 'gzip, deflate',
193
213
  },
194
- responseType: 'json',
214
+ responseType: 'text',
195
215
  method: 'GET',
196
216
  };
197
217
  this._streamRequestOptions = {
@@ -207,10 +227,12 @@ class Downloader {
207
227
  }
208
228
  reset() {
209
229
  this.uaString = undefined;
210
- this._speed = undefined;
230
+ this._workers = undefined;
231
+ this._pageRequestInterval = undefined;
211
232
  this._requestTimeout = undefined;
212
233
  this.optimisationCacheUrl = undefined;
213
234
  this._webp = false;
235
+ this.trustedJs = [];
214
236
  this.s3 = undefined;
215
237
  this._apiUrlDirector = undefined;
216
238
  this.insecure = false;
@@ -219,22 +241,33 @@ class Downloader {
219
241
  this._arrayBufferRequestOptions = undefined;
220
242
  this._jsonRequestOptions = undefined;
221
243
  this._streamRequestOptions = undefined;
222
- this.cssDependenceUrls = {};
223
- this.wikimediaMobileJsDependenciesList = [];
224
- this.wikimediaMobileStyleDependenciesList = [];
225
- this.articleUrlDirector = undefined;
226
- 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
+ }
227
268
  }
228
269
  getUrlDirector(renderer) {
229
270
  switch (renderer.constructor.name) {
230
- case 'WikimediaDesktopRenderer':
231
- return MediaWiki.wikimediaDesktopUrlDirector;
232
- case 'VisualEditorRenderer':
233
- return MediaWiki.visualEditorUrlDirector;
234
- case 'WikimediaMobileRenderer':
235
- return MediaWiki.wikimediaMobileUrlDirector;
236
- case 'RestApiRenderer':
237
- return MediaWiki.restApiUrlDirector;
238
271
  case 'ActionParseRenderer':
239
272
  return MediaWiki.actionParseUrlDirector;
240
273
  /* istanbul ignore next */
@@ -242,15 +275,11 @@ class Downloader {
242
275
  throw new Error(`Unknown renderer ${renderer.constructor.name}`);
243
276
  }
244
277
  }
245
- setUrlsDirectors(mainPageRenderer, articlesRenderer) {
246
- this.articleUrlDirector = this.getUrlDirector(articlesRenderer);
247
- this.mainPageUrlDirector = this.getUrlDirector(mainPageRenderer);
278
+ setUrlsDirectors(pagesRenderer) {
279
+ this.pageUrlDirector = this.getUrlDirector(pagesRenderer);
248
280
  }
249
- getArticleUrl(articleId, articleUrlOpts = {}) {
250
- return this.articleUrlDirector.buildArticleURL(articleId, articleUrlOpts);
251
- }
252
- getMainPageUrl(articleId) {
253
- return this.mainPageUrlDirector.buildArticleURL(articleId);
281
+ getPageUrl(pageTitle, pageUrlOpts = {}) {
282
+ return this.pageUrlDirector.buildPageUrl(pageTitle, pageUrlOpts);
254
283
  }
255
284
  removeEtagWeakPrefix(etag) {
256
285
  return etag && etag.replace(WEAK_ETAG_REGEX, '');
@@ -258,13 +287,14 @@ class Downloader {
258
287
  querySiteInfo() {
259
288
  return this.getJSON(this.apiUrlDirector.buildSiteInfoURL());
260
289
  }
261
- async getArticleDetailsIds(articleIds, shouldGetThumbnail = false) {
290
+ async getPagesByTitle(pageTitles, shouldGetThumbnail = false, followRedirects = true) {
262
291
  let continuation;
263
- let finalProcessedResp;
292
+ let allPages = {};
293
+ const visitedUrls = new Set();
264
294
  while (true) {
265
295
  const queryOpts = {
266
- ...(await this.getArticleQueryOpts(shouldGetThumbnail, true)),
267
- titles: articleIds.join('|'),
296
+ ...(await this.getPageQueryOpts(shouldGetThumbnail, followRedirects)),
297
+ titles: pageTitles.join('|'),
268
298
  ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
269
299
  ...(MediaWiki.getCategories
270
300
  ? {
@@ -275,43 +305,50 @@ class Downloader {
275
305
  ...continuation,
276
306
  };
277
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);
278
312
  const resp = await this.getJSON(reqUrl);
279
313
  Downloader.handleMWWarningsAndErrors(resp);
280
- let processedResponse = resp.query?.pages ? normalizeMwResponse(resp.query) : {};
281
- if (resp.continue) {
282
- continuation = resp.continue;
283
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
284
- }
285
- else {
286
- if (MediaWiki.getCategories) {
287
- processedResponse = await this.setArticleSubCategories(processedResponse);
288
- }
289
- 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) {
290
323
  break;
291
324
  }
292
325
  }
293
- return finalProcessedResp;
326
+ return Object.values(allPages);
294
327
  }
295
- async getArticleDetailsNS(ns, gapcontinue = '') {
296
- let queryContinuation;
297
- let finalProcessedResp;
298
- let gCont = null;
328
+ async getPagesByNamespace(namespace, requestGapcontinue = '') {
329
+ let queryContinuation = null;
330
+ let allPages = {};
331
+ const visitedUrls = new Set();
299
332
  while (true) {
300
333
  const queryOpts = {
301
- ...(await this.getArticleQueryOpts()),
334
+ ...(await this.getPageQueryOpts()),
302
335
  ...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
303
336
  ...(MediaWiki.getCategories
304
- ? {
305
- cllimit: 'max',
306
- clshow: '!hidden',
307
- }
337
+ ? semver.satisfies(MediaWiki.metaData.versionMw, '>=1.46')
338
+ ? {
339
+ cllimit: 'max',
340
+ }
341
+ : {
342
+ cllimit: 'max',
343
+ clshow: '!hidden',
344
+ }
308
345
  : {}),
309
346
  rawcontinue: 'true',
310
347
  generator: 'allpages',
311
348
  gapfilterredir: 'nonredirects',
312
349
  gaplimit: 'max',
313
- gapnamespace: String(ns),
314
- gapcontinue,
350
+ gapnamespace: String(namespace),
351
+ gapcontinue: requestGapcontinue,
315
352
  };
316
353
  if (queryContinuation) {
317
354
  queryOpts.cocontinue = queryContinuation?.coordinates?.cocontinue ?? queryOpts.cocontinue;
@@ -320,59 +357,77 @@ class Downloader {
320
357
  queryOpts.rdcontinue = queryContinuation?.redirects?.rdcontinue ?? queryOpts.rdcontinue;
321
358
  }
322
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);
323
364
  const resp = await this.getJSON(reqUrl);
324
365
  Downloader.handleMWWarningsAndErrors(resp);
325
- let processedResponse = normalizeMwResponse(resp.query);
326
- gCont = resp['query-continue']?.allpages?.gapcontinue ?? gCont;
327
- const queryComplete = Object.keys(resp['query-continue'] || {}).filter((key) => key !== 'allpages').length === 0;
328
- if (!queryComplete) {
329
- queryContinuation = resp['query-continue'];
330
- finalProcessedResp = finalProcessedResp === undefined ? processedResponse : deepmerge(finalProcessedResp, processedResponse);
331
- }
332
- else {
333
- if (MediaWiki.getCategories) {
334
- processedResponse = await this.setArticleSubCategories(processedResponse);
335
- }
336
- 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) {
337
376
  break;
338
377
  }
339
378
  }
340
379
  return {
341
- articleDetails: finalProcessedResp,
342
- gapContinue: gCont,
380
+ pages: Object.values(allPages),
381
+ gapContinue: queryContinuation?.allpages?.gapcontinue || null,
343
382
  };
344
383
  }
345
- async getLogEvents(letype, articleId) {
346
- 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));
347
386
  return logEventsData.query?.logevents;
348
387
  }
349
- async getArticle(articleId, articleDetailXId, articleRenderer, articleUrl, dump, articleDetail) {
350
- logger.info(`Getting article [${articleId}] from ${articleUrl}`);
388
+ async getPage(pageTitle, pageRenderer, pageUrl, dump, pageDetail) {
389
+ logger.debug(`Getting page [${pageTitle}] from ${pageUrl}`);
351
390
  try {
352
- const { data, moduleDependencies, redirects, displayTitle, articleSubtitle, bodyCssClass, htmlCssClass } = await articleRenderer.download({
353
- articleId,
354
- articleUrl,
355
- articleDetail,
391
+ const { data, moduleDependencies, redirects, displayTitle, subtitle, categoriesHtml = '', bodyCssClass, htmlCssClass, } = await pageRenderer.download({
392
+ pageTitle,
393
+ pageUrl,
394
+ pageDetail,
395
+ langVar: dump.langVar,
356
396
  });
357
397
  // Cope with the fact that the page we are fetching might have been moved and replaced by a redirect
358
398
  // In such a case, the download above is expected to follow the redirect so that we have proper original
359
- // content at original article path, but we probably need to add a redirect since new article location
360
- // 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
361
401
  // listing). The redirect we add in hence in the "opposite" direction than usual, i.e. it will redirect
362
402
  // from new location to original location. Note that only ActionParse API gives proper redirects info.
363
403
  for (const redirect of redirects) {
364
- if (!(await RedisStore.articleDetailXId.exists(redirect.to)) && !(await RedisStore.redirectsXId.exists(redirect.to))) {
365
- 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;
366
420
  }
367
421
  }
368
- return await articleRenderer.render({
422
+ return await pageRenderer.render({
369
423
  data,
370
424
  moduleDependencies,
371
- articleId,
372
- articleDetailXId,
373
- articleDetail,
425
+ pageTitle,
426
+ pageDetail,
374
427
  displayTitle,
375
- articleSubtitle,
428
+ subtitle,
429
+ categoryMembers,
430
+ categoriesHtml,
376
431
  bodyCssClass,
377
432
  htmlCssClass,
378
433
  dump,
@@ -401,7 +456,7 @@ class Downloader {
401
456
  if (!downloadErrorContext) {
402
457
  throw err;
403
458
  }
404
- logger.warn(`Article ${articleId} failed to download from '${downloadErrorContext.urlCalled}' with ` +
459
+ logger.warn(`Page ${pageTitle} failed to download from '${downloadErrorContext.urlCalled}' with ` +
405
460
  `'${downloadErrorContext.errorCode}' error code, ` +
406
461
  `'${downloadErrorContext.httpReturnCode}' HTTP return code ` +
407
462
  `and '${downloadErrorContext.responseContentType}' content-type ` +
@@ -412,43 +467,45 @@ class Downloader {
412
467
  throw err;
413
468
  }
414
469
  if (errorRule.isHardFailure) {
415
- logger.log(`This is a hard ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
416
- dump.status.articles.hardFail += 1;
417
- dump.status.articles.hardFailedArticleIds.push(articleId);
418
- if (dump.maxHardFailedArticles > 0 && dump.status.articles.hardFail > dump.maxHardFailedArticles) {
419
- 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
420
475
  }
421
476
  }
422
477
  else {
423
- logger.log(`This is a soft ${errorRule.detailsMessageKey} error which will be replaced by a placeholder`);
424
- dump.status.articles.softFail += 1;
425
- 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);
426
481
  }
427
- RedisStore.articleDetailXId.delete(articleId); // Remove article from list so that we stop creating links to this placeholder
428
- const articleTitle = articleId.replace(/_/g, ' ');
429
- const errorPlaceholderHtml = renderDownloadError(errorRule, dump, articleId, articleTitle);
430
- return [
431
- {
432
- articleId,
433
- displayTitle: articleTitle,
434
- html: errorPlaceholderHtml,
435
- imageDependencies: [],
436
- videoDependencies: [],
437
- mediaDependencies: [],
438
- moduleDependencies: [],
439
- staticFiles: config.output.downloadErrorResources,
440
- subtitles: [],
441
- },
442
- ];
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
+ };
443
500
  }
444
501
  }
445
502
  async getJSON(_url) {
446
503
  const url = urlHelper.deserializeUrl(_url);
447
504
  return new Promise((resolve, reject) => {
448
- this.backoffCall(this.getJSONCb, url, 'json', (err, val) => {
505
+ this.backoffCall(this.getJSONCb, url, 'json', undefined, (err, val) => {
449
506
  if (err) {
450
507
  const httpStatus = (err.response && err.response.status) || err.httpReturnCode;
451
- logger.info(`Failed to get [${url}] [status=${httpStatus}]`);
508
+ logger.debug(`Failed to get [${url}] [status=${httpStatus}]`);
452
509
  reject(err);
453
510
  }
454
511
  else {
@@ -458,6 +515,10 @@ class Downloader {
458
515
  });
459
516
  }
460
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`.
461
522
  return axios.request({
462
523
  ...this._basicRequestOptions,
463
524
  ...config,
@@ -474,7 +535,7 @@ class Downloader {
474
535
  async post(url, data, config) {
475
536
  return this.request({ url, data, method: 'POST', ...config });
476
537
  }
477
- async downloadContent(_url, kind, retry = true) {
538
+ async downloadContent(_url, kind, retry = true, requestedWidth) {
478
539
  if (!_url) {
479
540
  throw new Error(`Parameter [${_url}] is not a valid url`);
480
541
  }
@@ -493,15 +554,16 @@ class Downloader {
493
554
  }
494
555
  };
495
556
  if (retry) {
496
- this.backoffCall(this.getContentCb, url, kind, cb);
557
+ this.backoffCall(this.getContentCb, url, kind, requestedWidth, cb);
497
558
  }
498
559
  else {
499
- this.getContentCb(url, kind, cb);
560
+ this.getContentCb(url, kind, requestedWidth, cb);
500
561
  }
501
562
  });
502
563
  }
503
564
  catch (err) {
504
- const httpStatus = err.response && err.response.status;
565
+ const e = err;
566
+ const httpStatus = e.response && e.response.status;
505
567
  logger.warn(`Failed to get [${url}] [status=${httpStatus}]`);
506
568
  throw err;
507
569
  }
@@ -521,10 +583,10 @@ class Downloader {
521
583
  if (resp.error?.code === DB_ERROR)
522
584
  throw new Error(`Got error from MW Query ${JSON.stringify(resp.error, null, '\t')}`);
523
585
  if (resp.error)
524
- 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')}`);
525
587
  }
526
- async getArticleQueryOpts(includePageimages = false, followRedirects = false) {
527
- 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' : ''}`;
528
590
  return {
529
591
  ...MediaWiki.queryOpts,
530
592
  prop: MediaWiki.queryOpts.prop.concat(prop),
@@ -532,89 +594,112 @@ class Downloader {
532
594
  redirects: followRedirects ? true : undefined,
533
595
  };
534
596
  }
535
- async setArticleSubCategories(articleDetails) {
536
- logger.info('Getting subCategories');
537
- for (const [articleId, articleDetail] of Object.entries(articleDetails)) {
538
- const isCategoryArticle = articleDetail.ns === 14;
539
- if (isCategoryArticle) {
540
- const categoryMembers = await this.getSubCategories(articleId);
541
- articleDetails[articleId].subCategories = categoryMembers.slice();
542
- }
543
- }
544
- return articleDetails;
545
- }
546
- getJSONCb = (url, kind, handler) => {
547
- logger.info(`Getting JSON from [${url}]`);
597
+ getJSONCb = (url, kind, _requestedWidth, handler) => {
598
+ logger.debug(`Getting JSON from [${url}]`);
548
599
  this.request({ url, method: 'GET', ...this.jsonRequestOptions })
549
600
  .then((val) => {
550
- if (val.data.error) {
551
- 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));
552
605
  }
553
606
  else {
554
- handler(null, val.data);
607
+ handler(null, data);
555
608
  }
556
609
  })
557
- .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
+ });
558
616
  };
559
617
  async getImageMimeType(data) {
560
- const fileType = await fileTypeFromBuffer(data);
561
- if (fileType && fileType.mime === 'application/xml') {
562
- // File type is known to be wrong, might be SVG
563
- 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 });
564
635
  }
565
- return fileType ? fileType.mime : null;
566
636
  }
567
- 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) {
568
657
  const contentType = await this.getImageMimeType(input.data);
569
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
+ }
570
674
  if (this.webp && isWebpCandidateImageMimeType(contentType)) {
571
- return {
572
- data: await imagemin
573
- .buffer(input.data, imageminOptions.get('webp').get(contentType))
574
- .catch(async (err) => {
575
- if (/Unsupported color conversion request/.test(err.stderr)) {
576
- return imagemin
577
- .buffer(await sharp(input.data).toColorspace('srgb').toBuffer(), imageminOptions.get('webp').get(contentType))
578
- .catch(() => {
579
- return input.data;
580
- })
581
- .then((data) => {
582
- return data;
583
- });
584
- }
585
- else {
586
- return imagemin.buffer(input.data, imageminOptions.get('default').get(contentType)).catch(() => {
587
- return input.data;
588
- });
589
- }
590
- })
591
- .then((data) => {
592
- return data;
593
- }),
594
- };
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
+ }
595
682
  }
596
683
  else {
597
- return {
598
- data: await imagemin.buffer(input.data, imageminOptions.get('default').get(contentType)).catch(() => {
599
- return input.data;
600
- }),
601
- };
684
+ return await this.compressDefault(sharpData, input.context, contentType);
602
685
  }
603
686
  }
604
- return {
605
- data: input.data,
606
- };
687
+ return input.data;
607
688
  }
608
- getContentCb = async (url, kind, handler) => {
609
- logger.info(`Downloading [${url}]`);
689
+ getContentCb = async (url, kind, requestedWidth, handler) => {
690
+ logger.debug(`Downloading [${url}]`);
610
691
  try {
611
692
  if (this.optimisationCacheUrl && kind === 'image') {
612
- this.downloadImage(url, handler);
693
+ this.downloadImage(url, handler, requestedWidth);
613
694
  }
614
695
  else {
615
696
  const resp = await this.request({ url, method: 'GET', ...this.arrayBufferRequestOptions });
616
697
  // If content is an image, we might benefit from compressing it
617
- 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
+ }
618
703
  // compute content-type from content, since getCompressedBody might have modified it
619
704
  const contentType = kind === 'image' ? (await this.getImageMimeType(content)) || resp.headers['content-type'] : resp.headers['content-type'];
620
705
  handler(null, {
@@ -632,62 +717,102 @@ class Downloader {
632
717
  }
633
718
  }
634
719
  };
635
- 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';
636
724
  try {
637
725
  this.s3
638
726
  // Check first if we have an entry in the (object storage) cache for this URL
639
- .downloadBlob(stripHttpFromUrl(url), this.webp ? 'webp' : '1')
727
+ .downloadBlob(stripHttpFromUrl(url), cacheVersion)
640
728
  // Handle the cache response and act accordingly
641
729
  .then(async (s3Resp) => {
642
- // 'Versioning' of image is made via HTTP ETag. We should
643
- // check if we have the proper version by requesting proper
644
- // ETag from upstream MediaWiki.
645
- if (s3Resp?.Metadata?.etag) {
646
- this.arrayBufferRequestOptions.headers['If-None-Match'] = this.removeEtagWeakPrefix(s3Resp.Metadata.etag);
647
- }
648
- // Use the base domain of the wiki being scraped as the Referer header, so that we can
649
- // successfully scrap WMF map tiles.
650
- const mwResp = await this.request({ url, method: 'GET', ...this.arrayBufferRequestOptions });
651
- // Most of the images, after having been uploaded once to the
652
- // cache, will always have 304 status, until modified. If cache
653
- // is up to date, return cached image. We always have an s3
654
- // response when mwResp is 304, since this can only happen
655
- // when we have an eTag coming from s3.
656
- if (mwResp.status === 304) {
657
- // Proceed with image
658
- const data = (await this.streamToBuffer(s3Resp.Body));
659
- const contentType = await this.getImageMimeType(data);
660
- logger.info(`Using S3-cached image for ${url} (contentType: ${contentType})`);
661
- handler(null, {
662
- contentType,
663
- content: data,
664
- });
665
- return;
666
- }
667
- // Destroy the Readable so that socket is freed and returned to the pool
668
- if (s3Resp?.Body) {
669
- s3Resp.Body.destroy();
670
- }
671
- // Compress content because image blob comes from upstream MediaWiki
672
- const compressedData = (await this.getCompressedBody({ data: mwResp.data })).data;
673
- // Check for the ETag and upload to cache
674
- const etag = this.removeEtagWeakPrefix(mwResp.headers.etag);
675
- if (etag) {
676
- await this.s3.uploadBlob(stripHttpFromUrl(url), compressedData, etag, this.webp ? 'webp' : '1');
677
- }
678
- // get contentType from image, with fallback to response headers should the image be unsupported at all (e.g. SVG)
679
- const contentType = (await this.getImageMimeType(compressedData)) || mwResp.headers['content-type'];
680
- if (s3Resp) {
681
- logger.info(`Using image downloaded from upstream for ${url} (S3-cached image is outdated, contentType: ${contentType})`);
682
- }
683
- else {
684
- logger.info(`Using image downloaded from upstream for ${url} (no S3-cached image found, contentType: ${contentType})`);
685
- }
686
- // Proceed with image
687
- handler(null, {
688
- contentType,
689
- content: compressedData,
690
- });
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
+ ]);
691
816
  })
692
817
  .catch((err) => {
693
818
  this.errHandler(err, url, handler);
@@ -698,79 +823,40 @@ class Downloader {
698
823
  }
699
824
  }
700
825
  errHandler(err, url, handler) {
701
- logger.info(`Error while downloading content for ${url} due to ${err} ; might be retried`);
826
+ logger.debug(`Error while downloading content for ${url} due to ${err} ; might be retried`);
702
827
  handler(err);
703
828
  }
704
- async getSubCategories(articleId, continueStr = '') {
829
+ async getCategoryMembers(pageTitle, categoryMembers) {
705
830
  const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
706
- const { query, continue: cont } = await this.getJSON(apiUrlDirector.buildSubCategoriesURL(articleId, continueStr));
707
- const items = query.categorymembers.filter((a) => a && a.title);
708
- if (cont && cont.cmcontinue) {
709
- const nextItems = await this.getSubCategories(articleId, cont.cmcontinue);
710
- return items.concat(nextItems);
711
- }
712
- else {
713
- 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
+ }
714
849
  }
715
850
  }
716
- backoffCall(handler, url, kind, callback) {
851
+ backoffCall(handler, url, kind, requestedWidth, callback) {
717
852
  this.backoffOptions.strategy.reset(); // reset delay to initial one at each call
718
- const call = backoff.call(handler, url, kind, callback);
853
+ const call = backoff.call(handler, url, kind, requestedWidth, callback);
719
854
  call.setStrategy(this.backoffOptions.strategy);
720
855
  call.retryIf(this.backoffOptions.retryIf);
721
856
  call.failAfter(this.backoffOptions.failAfter);
722
857
  call.on('backoff', this.backoffOptions.backoffHandler);
723
858
  call.start();
724
859
  }
725
- async getModuleDependencies(title) {
726
- const genericJsModules = config.output.mw.js;
727
- const genericCssModules = config.output.mw.css;
728
- const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
729
- const articleApiUrl = apiUrlDirector.buildArticleApiURL(title);
730
- const articleData = await this.getJSON(articleApiUrl);
731
- if (articleData.error) {
732
- const errorMessage = `Unable to retrieve js/css dependencies for article '${title}': ${articleData.error.code}`;
733
- logger.error(errorMessage);
734
- /* If article is missing (for example because it just has been deleted) or access is denied */
735
- if (articleData.error.code === 'missingtitle' || articleData.error.code === 'permissiondenied') {
736
- return { jsConfigVars: '', jsDependenciesList: [], styleDependenciesList: [] };
737
- }
738
- /* Something went wrong in modules retrieval at app level (no HTTP error) */
739
- throw new Error(errorMessage);
740
- }
741
- const { parse: { modules, modulescripts, modulestyles, headhtml }, } = articleData;
742
- const jsDependenciesList = genericJsModules.concat(modules, modulescripts).filter((a) => a);
743
- const styleDependenciesList = []
744
- .concat(modules, modulestyles, genericCssModules)
745
- .filter((a) => a)
746
- .filter((oneStyleDep) => !contains(config.filters.blackListCssModules, oneStyleDep));
747
- logger.info(`Js dependencies of ${title} : ${jsDependenciesList}`);
748
- logger.info(`Css dependencies of ${title} : ${styleDependenciesList}`);
749
- const jsConfigVars = Downloader.extractJsConfigVars(headhtml);
750
- // Download mobile page dependencies only once
751
- if ((await MediaWiki.hasWikimediaMobileApi()) && this.wikimediaMobileJsDependenciesList.length === 0 && this.wikimediaMobileStyleDependenciesList.length === 0) {
752
- try {
753
- // TODO: An arbitrary title can be placed since all Wikimedia wikis have the same mobile offline resources
754
- const mobileModulesData = await this.getJSON(`${MediaWiki.mobileModulePath}Test`);
755
- mobileModulesData.forEach((module) => {
756
- if (module.includes('javascript')) {
757
- this.wikimediaMobileJsDependenciesList.push(module);
758
- }
759
- else if (module.includes('css')) {
760
- this.wikimediaMobileStyleDependenciesList.push(module);
761
- }
762
- });
763
- }
764
- catch (err) {
765
- throw new Error(`Error getting mobile modules ${err.message}`);
766
- }
767
- }
768
- return {
769
- jsConfigVars,
770
- jsDependenciesList: jsDependenciesList.concat(this.wikimediaMobileJsDependenciesList),
771
- styleDependenciesList: styleDependenciesList.concat(this.wikimediaMobileStyleDependenciesList),
772
- };
773
- }
774
860
  // Solution to handle aws js sdk v3 from https://github.com/aws/aws-sdk-js-v3/issues/1877
775
861
  async streamToBuffer(stream) {
776
862
  return new Promise((resolve, reject) => {
@@ -780,24 +866,6 @@ class Downloader {
780
866
  stream.on('end', () => resolve(Buffer.concat(chunks)));
781
867
  });
782
868
  }
783
- static extractJsConfigVars(headhtml) {
784
- let jsConfigVars = '';
785
- // Saving, as a js module, the jsconfigvars that are set in the header of a wikipedia page
786
- // the script below extracts the config with a regex executed on the page header returned from the api
787
- const scriptTags = domino.createDocument(`${headhtml}</body></html>`).getElementsByTagName('script');
788
- const regex = /mw\.config\.set\(\{.*?\}\);/gm;
789
- for (let i = 0; i < scriptTags.length; i += 1) {
790
- if (scriptTags[i].text.includes('mw.config.set')) {
791
- jsConfigVars = regex.exec(scriptTags[i].text)[0] || '';
792
- jsConfigVars = `(window.RLQ=window.RLQ||[]).push(function() {${jsConfigVars}});`;
793
- }
794
- else if (scriptTags[i].text.includes('RLCONF') || scriptTags[i].text.includes('RLSTATE') || scriptTags[i].text.includes('RLPAGEMODULES')) {
795
- jsConfigVars = scriptTags[i].text;
796
- }
797
- }
798
- jsConfigVars = jsConfigVars.replace('nosuchaction', 'view'); // to replace the wgAction config that is set to 'nosuchaction' from api but should be 'view'
799
- return jsConfigVars;
800
- }
801
869
  }
802
870
  export { Downloader as DownloaderClass };
803
871
  const dl = Downloader.getInstance();