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