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