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.
- package/README.md +304 -82
- package/docs/functional_architecture.md +34 -38
- package/extensions/wiktionary_fr.js +18 -17
- package/jest.config.cjs +2 -2
- package/lib/DOMUtils.js +0 -1
- package/lib/DOMUtils.js.map +1 -1
- package/lib/Downloader.d.ts +39 -31
- package/lib/Downloader.js +417 -349
- package/lib/Downloader.js.map +1 -1
- package/lib/Dump.d.ts +24 -10
- package/lib/Dump.js +95 -50
- package/lib/Dump.js.map +1 -1
- package/lib/Gadgets.d.ts +2 -1
- package/lib/Gadgets.js +7 -5
- package/lib/Gadgets.js.map +1 -1
- package/lib/Logger.d.ts +6 -6
- package/lib/Logger.js +33 -33
- package/lib/Logger.js.map +1 -1
- package/lib/MediaWiki.d.ts +14 -31
- package/lib/MediaWiki.js +119 -172
- package/lib/MediaWiki.js.map +1 -1
- package/lib/RedisStore.d.ts +3 -3
- package/lib/RedisStore.js +21 -23
- package/lib/RedisStore.js.map +1 -1
- package/lib/S3.js +1 -1
- package/lib/S3.js.map +1 -1
- package/lib/Templates.d.ts +3 -10
- package/lib/Templates.js +5 -14
- package/lib/Templates.js.map +1 -1
- package/lib/cli.js +7 -6
- package/lib/cli.js.map +1 -1
- package/lib/config.d.ts +10 -16
- package/lib/config.js +33 -55
- package/lib/config.js.map +1 -1
- package/lib/error.manager.d.ts +2 -2
- package/lib/error.manager.js +31 -51
- package/lib/error.manager.js.map +1 -1
- package/lib/i18n.d.ts +2 -0
- package/lib/i18n.js +53 -0
- package/lib/i18n.js.map +1 -0
- package/lib/mutex.d.ts +2 -1
- package/lib/mutex.js +2 -1
- package/lib/mutex.js.map +1 -1
- package/lib/mwoffliner.lib.js +370 -222
- package/lib/mwoffliner.lib.js.map +1 -1
- package/lib/parameterList.d.ts +20 -8
- package/lib/parameterList.js +30 -18
- package/lib/parameterList.js.map +1 -1
- package/lib/renderers/abstract.renderer.d.ts +48 -55
- package/lib/renderers/abstract.renderer.js +377 -98
- package/lib/renderers/abstract.renderer.js.map +1 -1
- package/lib/renderers/action-parse.renderer.d.ts +22 -3
- package/lib/renderers/action-parse.renderer.js +123 -81
- package/lib/renderers/action-parse.renderer.js.map +1 -1
- package/lib/renderers/renderer.builder.js +6 -69
- package/lib/renderers/renderer.builder.js.map +1 -1
- package/lib/renderers/rendering.context.d.ts +2 -3
- package/lib/renderers/rendering.context.js +6 -14
- package/lib/renderers/rendering.context.js.map +1 -1
- package/lib/sanitize-argument.d.ts +7 -4
- package/lib/sanitize-argument.js +99 -41
- package/lib/sanitize-argument.js.map +1 -1
- package/lib/util/FileManager.d.ts +36 -0
- package/lib/util/FileManager.js +246 -0
- package/lib/util/FileManager.js.map +1 -0
- package/lib/util/RedisKvs.js +4 -4
- package/lib/util/RedisKvs.js.map +1 -1
- package/lib/util/RedisQueue.js +1 -1
- package/lib/util/RedisQueue.js.map +1 -1
- package/lib/util/builders/url/action-parse.director.d.ts +2 -2
- package/lib/util/builders/url/action-parse.director.js +7 -7
- package/lib/util/builders/url/action-parse.director.js.map +1 -1
- package/lib/util/builders/url/api.director.d.ts +2 -4
- package/lib/util/builders/url/api.director.js +10 -17
- package/lib/util/builders/url/api.director.js.map +1 -1
- package/lib/util/builders/url/base.director.d.ts +0 -4
- package/lib/util/builders/url/base.director.js +0 -25
- package/lib/util/builders/url/base.director.js.map +1 -1
- package/lib/util/builders/url/basic.director.d.ts +1 -1
- package/lib/util/builders/url/basic.director.js +1 -1
- package/lib/util/builders/url/web.director.d.ts +1 -1
- package/lib/util/builders/url/web.director.js +2 -2
- package/lib/util/builders/url/web.director.js.map +1 -1
- package/lib/util/categories.d.ts +5 -5
- package/lib/util/categories.js +175 -174
- package/lib/util/categories.js.map +1 -1
- package/lib/util/const.d.ts +4 -8
- package/lib/util/const.js +9 -17
- package/lib/util/const.js.map +1 -1
- package/lib/util/customCssJs.d.ts +6 -0
- package/lib/util/customCssJs.js +70 -0
- package/lib/util/customCssJs.js.map +1 -0
- package/lib/util/dump.d.ts +15 -4
- package/lib/util/dump.js +282 -86
- package/lib/util/dump.js.map +1 -1
- package/lib/util/index.d.ts +1 -1
- package/lib/util/index.js +1 -1
- package/lib/util/index.js.map +1 -1
- package/lib/util/metaData.js.map +1 -1
- package/lib/util/misc.d.ts +23 -13
- package/lib/util/misc.js +90 -59
- package/lib/util/misc.js.map +1 -1
- package/lib/util/mw-api.d.ts +7 -5
- package/lib/util/mw-api.js +167 -221
- package/lib/util/mw-api.js.map +1 -1
- package/lib/util/pageListMainPage.d.ts +3 -0
- package/lib/util/pageListMainPage.js +10 -0
- package/lib/util/pageListMainPage.js.map +1 -0
- package/lib/util/pages.d.ts +30 -0
- package/lib/util/{articles.js → pages.js} +58 -14
- package/lib/util/pages.js.map +1 -0
- package/lib/util/rewriteUrls.d.ts +2 -2
- package/lib/util/rewriteUrls.js +33 -31
- package/lib/util/rewriteUrls.js.map +1 -1
- package/lib/util/savePages.d.ts +8 -0
- package/lib/util/savePages.js +182 -0
- package/lib/util/savePages.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/offliner-definition.json +176 -89
- package/package.json +49 -49
- package/res/page_list_home.js +20 -0
- package/res/{article_not_found.svg → page_not_found.svg} +1 -1
- package/res/script.js +30 -45
- package/res/style.css +52 -68
- package/res/templates/download_error_placeholder.html +7 -8
- package/res/templates/javaScript.html +19 -0
- package/res/templates/pageFallback.html +9 -7
- package/res/templates/pageVector2022.html +9 -7
- package/res/templates/pageVectorLegacy.html +9 -7
- package/res/templates/{article_list_home.html → page_list_home.html} +1 -2
- package/translation/ar.json +21 -21
- package/translation/bn.json +1 -1
- package/translation/de.json +46 -19
- package/translation/en.json +54 -22
- package/translation/es.json +17 -19
- package/translation/fi.json +23 -3
- package/translation/fr.json +57 -21
- package/translation/he.json +8 -6
- package/translation/ia.json +19 -20
- package/translation/id.json +10 -12
- package/translation/it.json +13 -8
- package/translation/ko.json +55 -3
- package/translation/lb.json +5 -2
- package/translation/mk.json +5 -5
- package/translation/nl.json +56 -23
- package/translation/or.json +2 -2
- package/translation/pt.json +2 -2
- package/translation/qqq.json +55 -23
- package/translation/ru.json +2 -2
- package/translation/sc.json +2 -2
- package/translation/sk.json +62 -0
- package/translation/sl.json +54 -21
- package/translation/sv.json +20 -19
- package/translation/sw.json +45 -2
- package/translation/zh-hans.json +56 -23
- package/translation/zh-hant.json +26 -19
- package/lib/renderers/abstractDesktop.render.d.ts +0 -12
- package/lib/renderers/abstractDesktop.render.js +0 -67
- package/lib/renderers/abstractDesktop.render.js.map +0 -1
- package/lib/renderers/abstractMobile.render.d.ts +0 -12
- package/lib/renderers/abstractMobile.render.js +0 -54
- package/lib/renderers/abstractMobile.render.js.map +0 -1
- package/lib/renderers/rest-api.renderer.d.ts +0 -7
- package/lib/renderers/rest-api.renderer.js +0 -68
- package/lib/renderers/rest-api.renderer.js.map +0 -1
- package/lib/renderers/visual-editor.renderer.d.ts +0 -7
- package/lib/renderers/visual-editor.renderer.js +0 -76
- package/lib/renderers/visual-editor.renderer.js.map +0 -1
- package/lib/renderers/wikimedia-desktop.renderer.d.ts +0 -4
- package/lib/renderers/wikimedia-desktop.renderer.js +0 -7
- package/lib/renderers/wikimedia-desktop.renderer.js.map +0 -1
- package/lib/renderers/wikimedia-mobile.renderer.d.ts +0 -19
- package/lib/renderers/wikimedia-mobile.renderer.js +0 -243
- package/lib/renderers/wikimedia-mobile.renderer.js.map +0 -1
- package/lib/util/articleListMainPage.d.ts +0 -3
- package/lib/util/articleListMainPage.js +0 -10
- package/lib/util/articleListMainPage.js.map +0 -1
- package/lib/util/articles.d.ts +0 -26
- package/lib/util/articles.js.map +0 -1
- package/lib/util/builders/url/desktop.director.d.ts +0 -8
- package/lib/util/builders/url/desktop.director.js +0 -15
- package/lib/util/builders/url/desktop.director.js.map +0 -1
- package/lib/util/builders/url/mobile.director.d.ts +0 -8
- package/lib/util/builders/url/mobile.director.js +0 -15
- package/lib/util/builders/url/mobile.director.js.map +0 -1
- package/lib/util/builders/url/rest-api.director.d.ts +0 -8
- package/lib/util/builders/url/rest-api.director.js +0 -18
- package/lib/util/builders/url/rest-api.director.js.map +0 -1
- package/lib/util/builders/url/visual-editor.director.d.ts +0 -9
- package/lib/util/builders/url/visual-editor.director.js +0 -18
- package/lib/util/builders/url/visual-editor.director.js.map +0 -1
- package/lib/util/saveArticles.d.ts +0 -8
- package/lib/util/saveArticles.js +0 -437
- package/lib/util/saveArticles.js.map +0 -1
- package/res/article_list_home.js +0 -24
- package/res/content.parsoid.css +0 -196
- package/res/inserted_style.css +0 -45
- package/res/templates/categories.html +0 -11
- package/res/templates/lead_section_wrapper.html +0 -6
- package/res/templates/pageWikimediaDesktop.html +0 -24
- package/res/templates/pageWikimediaMobile.html +0 -24
- package/res/templates/section_wrapper.html +0 -5
- package/res/templates/subcategories.html +0 -23
- package/res/templates/subpages.html +0 -17
- package/res/templates/subsection_wrapper.html +0 -5
- package/res/webpHandler.js +0 -165
- package/res/wm_mobile_override_script.js +0 -15
- package/res/wm_mobile_override_style.css +0 -20
- package/translation/br.json +0 -8
- package/translation/dag.json +0 -9
- package/translation/ha.json +0 -10
- package/translation/hi.json +0 -9
- package/translation/ig.json +0 -10
- package/translation/kaa.json +0 -9
- package/translation/nb.json +0 -9
- package/translation/nqo.json +0 -8
- package/translation/pt-br.json +0 -10
- package/translation/ro.json +0 -9
- package/translation/scn.json +0 -8
- package/translation/sq.json +0 -9
- package/translation/te.json +0 -9
- package/translation/tn.json +0 -9
- 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 {
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
71
|
-
|
|
76
|
+
_workers;
|
|
77
|
+
_pageRequestInterval;
|
|
72
78
|
_webp = false;
|
|
73
79
|
_requestTimeout;
|
|
74
80
|
_basicRequestOptions;
|
|
75
81
|
_arrayBufferRequestOptions;
|
|
76
82
|
_jsonRequestOptions;
|
|
77
83
|
_streamRequestOptions;
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
mainPageUrlDirector;
|
|
98
|
+
pageUrlDirector;
|
|
88
99
|
insecure = false;
|
|
89
|
-
get
|
|
90
|
-
return this.
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
137
|
-
logger.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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: '
|
|
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.
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
246
|
-
this.
|
|
247
|
-
this.mainPageUrlDirector = this.getUrlDirector(mainPageRenderer);
|
|
278
|
+
setUrlsDirectors(pagesRenderer) {
|
|
279
|
+
this.pageUrlDirector = this.getUrlDirector(pagesRenderer);
|
|
248
280
|
}
|
|
249
|
-
|
|
250
|
-
return this.
|
|
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
|
|
290
|
+
async getPagesByTitle(pageTitles, shouldGetThumbnail = false, followRedirects = true) {
|
|
262
291
|
let continuation;
|
|
263
|
-
let
|
|
292
|
+
let allPages = {};
|
|
293
|
+
const visitedUrls = new Set();
|
|
264
294
|
while (true) {
|
|
265
295
|
const queryOpts = {
|
|
266
|
-
...(await this.
|
|
267
|
-
titles:
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
326
|
+
return Object.values(allPages);
|
|
294
327
|
}
|
|
295
|
-
async
|
|
296
|
-
let queryContinuation;
|
|
297
|
-
let
|
|
298
|
-
|
|
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.
|
|
334
|
+
...(await this.getPageQueryOpts()),
|
|
302
335
|
...((await MediaWiki.hasCoordinates()) ? { colimit: 'max' } : {}),
|
|
303
336
|
...(MediaWiki.getCategories
|
|
304
|
-
?
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
342
|
-
gapContinue:
|
|
380
|
+
pages: Object.values(allPages),
|
|
381
|
+
gapContinue: queryContinuation?.allpages?.gapcontinue || null,
|
|
343
382
|
};
|
|
344
383
|
}
|
|
345
|
-
async getLogEvents(letype,
|
|
346
|
-
const logEventsData = await this.getJSON(this.apiUrlDirector.buildLogEventsQuery(letype,
|
|
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
|
|
350
|
-
logger.
|
|
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,
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
360
|
-
// probably did not existed when listing
|
|
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.
|
|
365
|
-
RedisStore.
|
|
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
|
|
422
|
+
return await pageRenderer.render({
|
|
369
423
|
data,
|
|
370
424
|
moduleDependencies,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
articleDetail,
|
|
425
|
+
pageTitle,
|
|
426
|
+
pageDetail,
|
|
374
427
|
displayTitle,
|
|
375
|
-
|
|
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(`
|
|
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.
|
|
416
|
-
dump.status.
|
|
417
|
-
dump.status.
|
|
418
|
-
if (dump.
|
|
419
|
-
throw new Error('Too many
|
|
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.
|
|
424
|
-
dump.status.
|
|
425
|
-
dump.status.
|
|
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.
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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
|
|
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.
|
|
586
|
+
logger.info(`Got error from MW Query ${JSON.stringify(resp.warnings, null, '\t')}`);
|
|
525
587
|
}
|
|
526
|
-
async
|
|
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
|
-
|
|
536
|
-
logger.
|
|
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
|
-
|
|
551
|
-
|
|
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,
|
|
607
|
+
handler(null, data);
|
|
555
608
|
}
|
|
556
609
|
})
|
|
557
|
-
.catch((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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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.
|
|
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' ?
|
|
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),
|
|
727
|
+
.downloadBlob(stripHttpFromUrl(url), cacheVersion)
|
|
640
728
|
// Handle the cache response and act accordingly
|
|
641
729
|
.then(async (s3Resp) => {
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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.
|
|
826
|
+
logger.debug(`Error while downloading content for ${url} due to ${err} ; might be retried`);
|
|
702
827
|
handler(err);
|
|
703
828
|
}
|
|
704
|
-
async
|
|
829
|
+
async getCategoryMembers(pageTitle, categoryMembers) {
|
|
705
830
|
const apiUrlDirector = new ApiURLDirector(MediaWiki.actionApiUrl.href);
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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();
|