@mxfb/tools 0.3.0 → 1.0.2
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/TODO.md +1 -0
- package/agnostic/@publisher/index.d.ts +47 -0
- package/agnostic/@publisher/index.js +361 -0
- package/agnostic/elo/index.d.ts +23 -0
- package/agnostic/elo/index.js +28 -0
- package/agnostic/elo/index.test.d.ts +1 -0
- package/agnostic/elo/index.test.js +67 -0
- package/agnostic/index.d.ts +5 -4
- package/agnostic/index.js +5 -0
- package/agnostic/poetry/generate-rhymes/index.d.ts +1 -0
- package/agnostic/poetry/generate-rhymes/index.js +125 -0
- package/agnostic/poetry/index.d.ts +1 -0
- package/agnostic/poetry/index.js +1 -0
- package/agnostic/scrapers/@meteociel/index.d.ts +36 -0
- package/agnostic/scrapers/@meteociel/index.js +248 -0
- package/agnostic/scrapers/@meteociel/index.test.d.ts +1 -0
- package/agnostic/scrapers/@meteociel/index.test.js +43 -0
- package/agnostic/scrapers/@meteociel/locations/index.d.ts +11 -0
- package/agnostic/scrapers/@meteociel/locations/index.js +10 -0
- package/agnostic/scrapers/@meteociel/types.d.ts +1 -0
- package/agnostic/scrapers/@meteociel/types.js +1 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.d.ts +22 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.js +124 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.d.ts +1 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.js +107 -0
- package/agnostic/scrapers/@tumblr/blogs/index.d.ts +8 -0
- package/agnostic/scrapers/@tumblr/blogs/index.js +23 -0
- package/agnostic/scrapers/@tumblr/index.d.ts +73 -0
- package/agnostic/scrapers/@tumblr/index.js +180 -0
- package/agnostic/scrapers/@tumblr/index.test.d.ts +1 -0
- package/agnostic/scrapers/@tumblr/index.test.js +319 -0
- package/agnostic/scrapers/@tumblr/types.d.ts +238 -0
- package/agnostic/scrapers/@tumblr/types.js +38 -0
- package/agnostic/scrapers/@tumblr/utils.d.ts +12 -0
- package/agnostic/scrapers/@tumblr/utils.js +407 -0
- package/agnostic/scrapers/index.d.ts +2 -0
- package/agnostic/scrapers/index.js +2 -0
- package/agnostic/weather/index.d.ts +1 -0
- package/agnostic/weather/index.js +1 -0
- package/agnostic/weather/types.d.ts +181 -0
- package/agnostic/weather/types.js +8 -0
- package/components/index.d.ts +1 -1
- package/components/index.js +1 -0
- package/components/public-classnames.d.ts +5 -0
- package/components/public-classnames.js +5 -0
- package/components/utils/index.d.ts +1 -0
- package/components/utils/index.js +12 -0
- package/components/utils/types.d.ts +3 -0
- package/components/utils/types.js +1 -0
- package/index.d.ts +3 -3
- package/index.js +3 -0
- package/node/headless-browser/index.d.ts +37 -0
- package/node/headless-browser/index.js +126 -0
- package/node/headless-browser/types.d.ts +47 -0
- package/node/headless-browser/types.js +5 -0
- package/node/index.d.ts +1 -2
- package/node/index.js +1 -0
- package/node/index.test.d.ts +1 -0
- package/node/index.test.js +6 -0
- package/node/scrapers/@tumblr/index.d.ts +61 -0
- package/node/scrapers/@tumblr/index.js +182 -0
- package/node/scrapers/@tumblr/index.test.d.ts +1 -0
- package/node/scrapers/@tumblr/index.test.js +185 -0
- package/node/scrapers/@tumblr/types.d.ts +91 -0
- package/node/scrapers/@tumblr/types.js +1 -0
- package/node/scrapers/@tumblr/utils.d.ts +23 -0
- package/node/scrapers/@tumblr/utils.js +328 -0
- package/node/scrapers/index.d.ts +1 -0
- package/node/scrapers/index.js +1 -0
- package/package.json +211 -14
- package/agnostic/_WIP_tennis/index.d.ts +0 -2
- package/agnostic/_WIP_tennis/index.js +0 -62
- package/agnostic/arrays/index.d.ts +0 -11
- package/agnostic/arrays/is-array-of/index.d.ts +0 -5
- package/agnostic/arrays/is-array-of/index.js +0 -23
- package/agnostic/async/index.d.ts +0 -8
- package/agnostic/async/index.js +0 -20
- package/agnostic/async/timeout-call/index.d.ts +0 -1
- package/agnostic/async/timeout-call/index.js +0 -6
- package/agnostic/async/transitions/index.d.ts +0 -41
- package/agnostic/async/transitions/index.js +0 -7
- package/agnostic/async/wait/index.d.ts +0 -1
- package/agnostic/async/wait/index.js +0 -6
- package/agnostic/booleans/index.d.ts +0 -8
- package/agnostic/booleans/index.js +0 -17
- package/agnostic/booleans/is-falsy/index.d.ts +0 -7
- package/agnostic/booleans/is-falsy/index.js +0 -11
- package/agnostic/css/bem/BEM.d.ts +0 -29
- package/agnostic/css/bem/getNamesArr.d.ts +0 -2
- package/agnostic/css/bem/index.d.ts +0 -7
- package/agnostic/css/bem/index.js +0 -9
- package/agnostic/css/generate-nice-color/index.d.ts +0 -2
- package/agnostic/css/generate-nice-color/index.js +0 -8
- package/agnostic/css/index.d.ts +0 -14
- package/agnostic/css/index.js +0 -177
- package/agnostic/css/is-valid-css-class-name/index.d.ts +0 -2
- package/agnostic/css/is-valid-css-class-name/index.js +0 -8
- package/agnostic/css/styles-set/index.d.ts +0 -37
- package/agnostic/errors/index.d.ts +0 -30
- package/agnostic/errors/index.js +0 -6
- package/agnostic/html/get-node-ancestors/index.d.ts +0 -1
- package/agnostic/html/get-node-ancestors/index.js +0 -6
- package/agnostic/html/get-position-inside-parent/index.d.ts +0 -1
- package/agnostic/html/get-position-inside-parent/index.js +0 -6
- package/agnostic/html/index.d.ts +0 -17
- package/agnostic/html/index.js +0 -39
- package/agnostic/html/insert-node/index.d.ts +0 -2
- package/agnostic/html/insert-node/index.js +0 -6
- package/agnostic/html/placeholders/index.d.ts +0 -9
- package/agnostic/html/placeholders/index.js +0 -6
- package/agnostic/html/sanitize/index.d.ts +0 -24
- package/agnostic/html/sanitize/index.js +0 -9
- package/agnostic/html/selector-to-element/index.d.ts +0 -1
- package/agnostic/html/selector-to-element/index.js +0 -9
- package/agnostic/html/string-to-nodes/index.d.ts +0 -7
- package/agnostic/html/string-to-nodes/index.js +0 -10
- package/agnostic/misc/assert/index.d.ts +0 -5
- package/agnostic/misc/assert/index.js +0 -6
- package/agnostic/misc/cast/index.d.ts +0 -9
- package/agnostic/misc/cast/index.js +0 -8
- package/agnostic/misc/crossenv/index.d.ts +0 -13
- package/agnostic/misc/crossenv/index.js +0 -6
- package/agnostic/misc/elo/index.d.ts +0 -31
- package/agnostic/misc/elo/index.js +0 -6
- package/agnostic/misc/get-current-downlink/index.d.ts +0 -1
- package/agnostic/misc/get-current-downlink/index.js +0 -6
- package/agnostic/misc/index.d.ts +0 -6
- package/agnostic/misc/index.js +0 -16
- package/agnostic/misc/is-constructor-function/index.d.ts +0 -2
- package/agnostic/misc/is-constructor-function/index.js +0 -6
- package/agnostic/misc/is-nullish/index.d.ts +0 -4
- package/agnostic/misc/is-nullish/index.js +0 -10
- package/agnostic/misc/logs/index.d.ts +0 -55
- package/agnostic/misc/logs/index.js +0 -6
- package/agnostic/misc/random/index.d.ts +0 -10
- package/agnostic/misc/random/index.js +0 -6
- package/agnostic/misc/tennis/index.d.ts +0 -60
- package/agnostic/misc/tennis/index.js +0 -6
- package/agnostic/numbers/absolute-modulo/index.d.ts +0 -1
- package/agnostic/numbers/absolute-modulo/index.js +0 -6
- package/agnostic/numbers/clamp/index.d.ts +0 -1
- package/agnostic/numbers/clamp/index.js +0 -6
- package/agnostic/numbers/index.d.ts +0 -13
- package/agnostic/numbers/index.js +0 -30
- package/agnostic/numbers/interpolate/index.d.ts +0 -1
- package/agnostic/numbers/interpolate/index.js +0 -6
- package/agnostic/numbers/responsive-harmonics/index.d.ts +0 -10
- package/agnostic/numbers/responsive-harmonics/index.js +0 -9
- package/agnostic/numbers/round/index.d.ts +0 -1
- package/agnostic/numbers/round/index.js +0 -6
- package/agnostic/objects/enums/index.d.ts +0 -4
- package/agnostic/objects/enums/index.js +0 -7
- package/agnostic/objects/enums/is-in-enum/index.d.ts +0 -1
- package/agnostic/objects/enums/is-in-enum/index.js +0 -6
- package/agnostic/objects/flatten-getters/index.d.ts +0 -1
- package/agnostic/objects/flatten-getters/index.js +0 -9
- package/agnostic/objects/index.d.ts +0 -12
- package/agnostic/objects/index.js +0 -32
- package/agnostic/objects/is-object/index.d.ts +0 -1
- package/agnostic/objects/is-object/index.js +0 -6
- package/agnostic/objects/is-record/index.d.ts +0 -1
- package/agnostic/objects/is-record/index.js +0 -6
- package/agnostic/objects/record-format/index.d.ts +0 -11
- package/agnostic/objects/record-format/index.js +0 -16
- package/agnostic/objects/validation/index.d.ts +0 -3
- package/agnostic/objects/validation/index.js +0 -6
- package/agnostic/optim/index.d.ts +0 -7
- package/agnostic/optim/index.js +0 -18
- package/agnostic/optim/memoize/index.d.ts +0 -3
- package/agnostic/optim/memoize/index.js +0 -6
- package/agnostic/optim/throttle-debounce/index.d.ts +0 -30
- package/agnostic/optim/throttle-debounce/index.js +0 -8
- package/agnostic/optim/throttle-debounce/types.d.ts +0 -2
- package/agnostic/regexps/index.d.ts +0 -23
- package/agnostic/regexps/index.js +0 -89
- package/agnostic/strings/char-codes/index.d.ts +0 -17
- package/agnostic/strings/char-codes/index.js +0 -6
- package/agnostic/strings/index.d.ts +0 -14
- package/agnostic/strings/index.js +0 -32
- package/agnostic/strings/matches/index.d.ts +0 -3
- package/agnostic/strings/matches/index.js +0 -10
- package/agnostic/strings/normalize-indent/index.d.ts +0 -1
- package/agnostic/strings/normalize-indent/index.js +0 -6
- package/agnostic/strings/replace-all/index.d.ts +0 -1
- package/agnostic/strings/replace-all/index.js +0 -6
- package/agnostic/strings/to-alphanum/index.d.ts +0 -1
- package/agnostic/strings/to-alphanum/index.js +0 -6
- package/chunks/chunk-32IRF4OP.js +0 -9
- package/chunks/chunk-34U4HX4V.js +0 -73
- package/chunks/chunk-4GDNHUCZ.js +0 -162
- package/chunks/chunk-4QQBKBYH.js +0 -59
- package/chunks/chunk-4VC5PT5T.js +0 -13
- package/chunks/chunk-5LNUCDQ6.js +0 -132
- package/chunks/chunk-7AWTHZLY.js +0 -12
- package/chunks/chunk-7DBNMU6N.js +0 -14
- package/chunks/chunk-COVPTTAD.js +0 -10
- package/chunks/chunk-DNIOWD7K.js +0 -8
- package/chunks/chunk-DQNJQBC6.js +0 -10
- package/chunks/chunk-E6MSDKON.js +0 -13
- package/chunks/chunk-EDVNAV3G.js +0 -72
- package/chunks/chunk-EW46EXFB.js +0 -8
- package/chunks/chunk-FPEW3A27.js +0 -14
- package/chunks/chunk-GJOU3UAL.js +0 -36
- package/chunks/chunk-HC6ZOHCS.js +0 -14
- package/chunks/chunk-HGCG2J77.js +0 -122
- package/chunks/chunk-IFD7GSGK.js +0 -130
- package/chunks/chunk-JQXNEJAP.js +0 -18
- package/chunks/chunk-JWBDZPQG.js +0 -18
- package/chunks/chunk-K37RALUX.js +0 -8
- package/chunks/chunk-KIONYWA7.js +0 -168
- package/chunks/chunk-KQT5CTGF.js +0 -21
- package/chunks/chunk-LEXQ6E6L.js +0 -31
- package/chunks/chunk-LQFKUNVQ.js +0 -19
- package/chunks/chunk-MF56TTC5.js +0 -15
- package/chunks/chunk-MT74WIQR.js +0 -41
- package/chunks/chunk-NRITRUZW.js +0 -10
- package/chunks/chunk-NRJQKVQV.js +0 -31
- package/chunks/chunk-OSAXBA7G.js +0 -10
- package/chunks/chunk-PO3V3I57.js +0 -23
- package/chunks/chunk-QXAJXTXV.js +0 -19
- package/chunks/chunk-RCO57B6F.js +0 -9
- package/chunks/chunk-SQZGZ3VT.js +0 -15
- package/chunks/chunk-TTZHPIH7.js +0 -237
- package/chunks/chunk-UYTXXUU7.js +0 -54
- package/chunks/chunk-VTPRO4NJ.js +0 -10
- package/chunks/chunk-VYW4IADX.js +0 -35
- package/chunks/chunk-VZDUZTW6.js +0 -113
- package/chunks/chunk-W5A2TON3.js +0 -10
- package/chunks/chunk-W7YBGJ4H.js +0 -28
- package/chunks/chunk-WLL3FNVL.js +0 -40
- package/chunks/chunk-WOAYU6LB.js +0 -13
- package/chunks/chunk-XA4HVHJ4.js +0 -18
- package/chunks/chunk-XN3PZIVL.js +0 -38
- package/chunks/chunk-Y3OZHZUF.js +0 -237
- package/chunks/chunk-YDIBNEGA.js +0 -9
- package/chunks/chunk-ZC5RZBNY.js +0 -33
- package/components/Comp/index.d.ts +0 -1
- package/node/files/index.d.ts +0 -9
- package/node/files/index.js +0 -21
- package/node/files/is-in-directory/index.d.ts +0 -1
- package/node/files/is-in-directory/index.js +0 -6
- package/node/files/read-write/index.d.ts +0 -9
- package/node/files/read-write/index.js +0 -6
- package/node/files/subpaths/index.d.ts +0 -34
- package/node/files/subpaths/index.js +0 -7
- package/node/image/index.d.ts +0 -9
- package/node/image/index.js +0 -74
- package/node/mxfb-cli-config/index.d.ts +0 -19
- package/node/mxfb-cli-config/index.js +0 -113
- package/node/process/index.d.ts +0 -8
- package/node/process/index.js +0 -20
- package/node/process/on-exit/index.d.ts +0 -5
- package/node/process/on-exit/index.js +0 -14
- package/shared/errors/index.d.ts +0 -138
- package/shared/index.d.ts +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HeadlessBrowserOptions, HeadlessReadOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A Chrome session that reads pages an HTTP client alone cannot reach.
|
|
4
|
+
*
|
|
5
|
+
* Some hosts — every `*.tumblr.com` blog among them — answer any plain request with a 403
|
|
6
|
+
* carrying a JavaScript challenge, and only serve the real page to whoever runs it. One
|
|
7
|
+
* tab is kept per origin: the first read of an origin navigates there and waits for the
|
|
8
|
+
* challenge to clear, and both read modes then reuse that cleared tab, so the cookies it
|
|
9
|
+
* was granted keep applying.
|
|
10
|
+
*
|
|
11
|
+
* The browser starts itself on first use and must be closed by the caller.
|
|
12
|
+
*/
|
|
13
|
+
export declare class HeadlessBrowser {
|
|
14
|
+
private readonly options;
|
|
15
|
+
private readonly pagesByOrigin;
|
|
16
|
+
private browser;
|
|
17
|
+
constructor(options?: HeadlessBrowserOptions);
|
|
18
|
+
/**
|
|
19
|
+
* Reads one URL as text, starting the browser and clearing the origin's challenge when
|
|
20
|
+
* this is the first read of that origin.
|
|
21
|
+
*
|
|
22
|
+
* @param url - Absolute URL to read.
|
|
23
|
+
* @param readOptions - Overrides this session's read defaults for this call.
|
|
24
|
+
* @returns The page's text, or `null` when the URL has no readable origin, or when a
|
|
25
|
+
* `'served'` read met a response that was not a success. A `'rendered'` read returns
|
|
26
|
+
* whatever the tab ended up holding, error pages included, since that is the document
|
|
27
|
+
* the browser was given.
|
|
28
|
+
* @throws If the browser cannot be started, if a navigation times out, or if an
|
|
29
|
+
* interstitial never clears, as `challenge-not-cleared`.
|
|
30
|
+
*/
|
|
31
|
+
readText(url: string, readOptions?: HeadlessReadOptions): Promise<string | null>;
|
|
32
|
+
/** Closes every tab and the browser itself. Reading again starts a new one. */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
private openOrigin;
|
|
35
|
+
private start;
|
|
36
|
+
private waitForChallenge;
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
|
3
|
+
+ ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
|
|
4
|
+
const DEFAULT_LAUNCH_ARGS = [
|
|
5
|
+
'--no-sandbox',
|
|
6
|
+
'--disable-setuid-sandbox',
|
|
7
|
+
// What a challenge actually reads: the automation flag is a one-line giveaway, and
|
|
8
|
+
// Chrome's headless default user agent names itself `HeadlessChrome`.
|
|
9
|
+
'--disable-blink-features=AutomationControlled'
|
|
10
|
+
];
|
|
11
|
+
const DEFAULT_CHALLENGE_MARKERS = ['Checking your browser'];
|
|
12
|
+
const DEFAULT_CHALLENGE_TIMEOUT_MS = 30000;
|
|
13
|
+
const DEFAULT_NAVIGATION_TIMEOUT_MS = 60000;
|
|
14
|
+
const DEFAULT_WAIT_UNTIL = 'networkidle2';
|
|
15
|
+
const CHALLENGE_POLL_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* A Chrome session that reads pages an HTTP client alone cannot reach.
|
|
18
|
+
*
|
|
19
|
+
* Some hosts — every `*.tumblr.com` blog among them — answer any plain request with a 403
|
|
20
|
+
* carrying a JavaScript challenge, and only serve the real page to whoever runs it. One
|
|
21
|
+
* tab is kept per origin: the first read of an origin navigates there and waits for the
|
|
22
|
+
* challenge to clear, and both read modes then reuse that cleared tab, so the cookies it
|
|
23
|
+
* was granted keep applying.
|
|
24
|
+
*
|
|
25
|
+
* The browser starts itself on first use and must be closed by the caller.
|
|
26
|
+
*/
|
|
27
|
+
export class HeadlessBrowser {
|
|
28
|
+
options;
|
|
29
|
+
pagesByOrigin = new Map();
|
|
30
|
+
browser = null;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reads one URL as text, starting the browser and clearing the origin's challenge when
|
|
36
|
+
* this is the first read of that origin.
|
|
37
|
+
*
|
|
38
|
+
* @param url - Absolute URL to read.
|
|
39
|
+
* @param readOptions - Overrides this session's read defaults for this call.
|
|
40
|
+
* @returns The page's text, or `null` when the URL has no readable origin, or when a
|
|
41
|
+
* `'served'` read met a response that was not a success. A `'rendered'` read returns
|
|
42
|
+
* whatever the tab ended up holding, error pages included, since that is the document
|
|
43
|
+
* the browser was given.
|
|
44
|
+
* @throws If the browser cannot be started, if a navigation times out, or if an
|
|
45
|
+
* interstitial never clears, as `challenge-not-cleared`.
|
|
46
|
+
*/
|
|
47
|
+
async readText(url, readOptions = {}) {
|
|
48
|
+
let origin;
|
|
49
|
+
try {
|
|
50
|
+
origin = new URL(url).origin;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const resolved = { ...this.options.read, ...readOptions };
|
|
56
|
+
const page = await this.openOrigin(origin, url, resolved);
|
|
57
|
+
if (resolved.source !== 'rendered')
|
|
58
|
+
return await readServedText(page, url);
|
|
59
|
+
if (page.url() !== url) {
|
|
60
|
+
await page.goto(url, { waitUntil: resolved.waitUntil ?? DEFAULT_WAIT_UNTIL });
|
|
61
|
+
await this.waitForChallenge(page);
|
|
62
|
+
}
|
|
63
|
+
await wait(resolved.settleMs ?? 0);
|
|
64
|
+
return await page.content();
|
|
65
|
+
}
|
|
66
|
+
/** Closes every tab and the browser itself. Reading again starts a new one. */
|
|
67
|
+
async close() {
|
|
68
|
+
const browser = this.browser;
|
|
69
|
+
this.browser = null;
|
|
70
|
+
this.pagesByOrigin.clear();
|
|
71
|
+
await browser?.close();
|
|
72
|
+
}
|
|
73
|
+
async openOrigin(origin, entryUrl, readOptions) {
|
|
74
|
+
const known = this.pagesByOrigin.get(origin);
|
|
75
|
+
if (known !== undefined)
|
|
76
|
+
return known;
|
|
77
|
+
const browser = await this.start();
|
|
78
|
+
const page = await browser.newPage();
|
|
79
|
+
this.pagesByOrigin.set(origin, page);
|
|
80
|
+
page.setDefaultNavigationTimeout(this.options.navigationTimeoutMs ?? DEFAULT_NAVIGATION_TIMEOUT_MS);
|
|
81
|
+
// A challenge answers 403 until it is solved, so a failed navigation is expected here.
|
|
82
|
+
await page
|
|
83
|
+
.goto(entryUrl, { waitUntil: readOptions.waitUntil ?? DEFAULT_WAIT_UNTIL })
|
|
84
|
+
.catch(() => null);
|
|
85
|
+
await this.waitForChallenge(page);
|
|
86
|
+
return page;
|
|
87
|
+
}
|
|
88
|
+
async start() {
|
|
89
|
+
this.browser ??= await puppeteer.launch({
|
|
90
|
+
headless: true,
|
|
91
|
+
args: [
|
|
92
|
+
...this.options.launchArgs ?? DEFAULT_LAUNCH_ARGS,
|
|
93
|
+
`--user-agent=${this.options.userAgent ?? DEFAULT_USER_AGENT}`
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
return this.browser;
|
|
97
|
+
}
|
|
98
|
+
async waitForChallenge(page) {
|
|
99
|
+
const markers = this.options.challengeMarkers ?? DEFAULT_CHALLENGE_MARKERS;
|
|
100
|
+
if (markers.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const deadline = Date.now()
|
|
103
|
+
+ (this.options.challengeTimeoutMs ?? DEFAULT_CHALLENGE_TIMEOUT_MS);
|
|
104
|
+
while (Date.now() < deadline) {
|
|
105
|
+
// Reading mid-navigation throws, which only means the challenge is still working.
|
|
106
|
+
const content = await page.content().catch(() => null);
|
|
107
|
+
if (content !== null && !markers.some(marker => content.includes(marker)))
|
|
108
|
+
return;
|
|
109
|
+
await wait(CHALLENGE_POLL_MS);
|
|
110
|
+
}
|
|
111
|
+
throw new Error('challenge-not-cleared');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function readServedText(page, url) {
|
|
115
|
+
return await page.evaluate(async (target) => {
|
|
116
|
+
const response = await fetch(target, { credentials: 'include' });
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
return null;
|
|
119
|
+
return await response.text();
|
|
120
|
+
}, url);
|
|
121
|
+
}
|
|
122
|
+
async function wait(durationMs) {
|
|
123
|
+
if (durationMs <= 0)
|
|
124
|
+
return;
|
|
125
|
+
await new Promise(resolve => { setTimeout(resolve, durationMs); });
|
|
126
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PuppeteerLifeCycleEvent } from 'puppeteer';
|
|
2
|
+
/** What a read returns: the body the server sent, or the DOM once scripts have run. */
|
|
3
|
+
export declare const headlessReadSources: readonly ["served", "rendered"];
|
|
4
|
+
/** Which of the two a single read asks for. */
|
|
5
|
+
export type HeadlessReadSource = typeof headlessReadSources[number];
|
|
6
|
+
/**
|
|
7
|
+
* How one read behaves.
|
|
8
|
+
*
|
|
9
|
+
* @property source - `'served'`, the default, returns the response body verbatim — the
|
|
10
|
+
* markup a parser should see, and the only mode that reads XML or plain text correctly.
|
|
11
|
+
* `'rendered'` navigates the tab and returns `document` as Chrome built it, which is what
|
|
12
|
+
* a page assembling its content in JavaScript requires.
|
|
13
|
+
* @property waitUntil - Which lifecycle event ends a `'rendered'` navigation. Defaults to
|
|
14
|
+
* `'networkidle2'`.
|
|
15
|
+
* @property settleMs - How long to wait after that event before reading a `'rendered'`
|
|
16
|
+
* page, for the work a lifecycle event does not cover. Defaults to 0.
|
|
17
|
+
*/
|
|
18
|
+
export type HeadlessReadOptions = {
|
|
19
|
+
source?: HeadlessReadSource;
|
|
20
|
+
waitUntil?: PuppeteerLifeCycleEvent;
|
|
21
|
+
settleMs?: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* How a headless browser session behaves.
|
|
25
|
+
*
|
|
26
|
+
* @property userAgent - User agent to announce. The default is a current desktop Chrome
|
|
27
|
+
* string, because Chrome's own headless default names itself `HeadlessChrome`, which is
|
|
28
|
+
* enough for some edges to refuse every request.
|
|
29
|
+
* @property launchArgs - Replaces the default Chrome flags entirely, for a caller who
|
|
30
|
+
* needs a different sandbox, window size or locale.
|
|
31
|
+
* @property challengeMarkers - Strings whose presence in a page means an interstitial is
|
|
32
|
+
* still being shown rather than the page asked for. Defaults to Automattic's
|
|
33
|
+
* `Checking your browser`, which is what a Tumblr blog host answers with; an empty list
|
|
34
|
+
* turns the wait off for a host that challenges nothing.
|
|
35
|
+
* @property challengeTimeoutMs - How long to let such an interstitial clear itself before
|
|
36
|
+
* giving up. Defaults to 30000.
|
|
37
|
+
* @property navigationTimeoutMs - How long one navigation may take. Defaults to 60000.
|
|
38
|
+
* @property read - Defaults for every read of this session, each overridable per call.
|
|
39
|
+
*/
|
|
40
|
+
export type HeadlessBrowserOptions = {
|
|
41
|
+
userAgent?: string;
|
|
42
|
+
launchArgs?: string[];
|
|
43
|
+
challengeMarkers?: string[];
|
|
44
|
+
challengeTimeoutMs?: number;
|
|
45
|
+
navigationTimeoutMs?: number;
|
|
46
|
+
read?: HeadlessReadOptions;
|
|
47
|
+
};
|
package/node/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { MxfbCliConfig };
|
|
1
|
+
export {};
|
package/node/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { TumblrPage, TumblrPost } from '../../../agnostic/scrapers/@tumblr/types.js';
|
|
2
|
+
import type { CrawlBlogOptions, CrawlBlogResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Crawls a Tumblr blog and writes everything it reads into `dataDirPath`.
|
|
5
|
+
*
|
|
6
|
+
* The blog's sitemap is read first: it enumerates every post in a request or two, and
|
|
7
|
+
* states when each was last modified, which is the only date available on a theme that
|
|
8
|
+
* publishes none.
|
|
9
|
+
*
|
|
10
|
+
* The crawl is a frontier: it starts from the blog's sitemap and its root listing, then
|
|
11
|
+
* enqueues whatever the pages it parses reveal, keeping only the kinds named by
|
|
12
|
+
* `followedKinds`. A listing is followed to its next page for as long as it keeps
|
|
13
|
+
* yielding posts that have not been seen — which is the real stop condition, since a
|
|
14
|
+
* listing past the end of a blog answers with an empty page rather than a 404.
|
|
15
|
+
*
|
|
16
|
+
* With no `dataDirPath`, a temporary directory is created and returned, so a caller can
|
|
17
|
+
* tell its user which path to hand back to resume the crawl later.
|
|
18
|
+
*
|
|
19
|
+
* Three files are produced: `pages.ndjson` holds one parsed page per line and is the
|
|
20
|
+
* record of truth, `state.json` holds the frontier so a later run can resume, and `raw/`
|
|
21
|
+
* keeps each page's HTML so a parser fix can be replayed without fetching again.
|
|
22
|
+
*
|
|
23
|
+
* Every read goes through a headless Chrome unless a `fetcher` is given: a blog host
|
|
24
|
+
* answers a plain HTTP request with a 403 and a JavaScript challenge, so nothing — not
|
|
25
|
+
* even `robots.txt` — is readable without a browser solving it first. The browser is
|
|
26
|
+
* started for the crawl and closed with it, failures included.
|
|
27
|
+
*
|
|
28
|
+
* @param baseTumblrUrl - Any URL on the blog; only its origin is used.
|
|
29
|
+
* @param options - How the crawl should behave.
|
|
30
|
+
* @returns What the crawl achieved, and every page it could not read.
|
|
31
|
+
* @throws If `baseTumblrUrl` has no readable origin, as `invalid-base-url`. A page that
|
|
32
|
+
* cannot be fetched or parsed is reported as a failure instead, and never interrupts the
|
|
33
|
+
* crawl.
|
|
34
|
+
*/
|
|
35
|
+
export declare function crawlBlog(baseTumblrUrl: string, options: CrawlBlogOptions): Promise<CrawlBlogResult>;
|
|
36
|
+
/**
|
|
37
|
+
* Streams the pages an archive holds, in the order the crawl wrote them.
|
|
38
|
+
*
|
|
39
|
+
* Saves a consumer knowing the archive's file name and line format, and reads the dates
|
|
40
|
+
* back as `Date` rather than the strings JSON stored. A line that is not a page record is
|
|
41
|
+
* skipped rather than thrown over, so a crawl interrupted mid-write stays readable.
|
|
42
|
+
*
|
|
43
|
+
* @param dataDirPath - Directory a crawl wrote into.
|
|
44
|
+
* @returns Each page in turn.
|
|
45
|
+
* @throws If the directory holds no `pages.ndjson`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readArchivePages(dataDirPath: string): AsyncGenerator<TumblrPage>;
|
|
48
|
+
/**
|
|
49
|
+
* Reads every distinct post an archive holds, one entry per post id.
|
|
50
|
+
*
|
|
51
|
+
* A post is written once per page that mentioned it — a listing identifies it, its
|
|
52
|
+
* permalink describes it — so the records are merged: the permalink's version wins, and
|
|
53
|
+
* whatever it left empty is filled from the other, which is how a post keeps both its
|
|
54
|
+
* title and the `lastModifiedOn` only the sitemap knew.
|
|
55
|
+
*
|
|
56
|
+
* @param dataDirPath - Directory a crawl wrote into.
|
|
57
|
+
* @returns The posts, in the order their ids were first seen. Posts with no id are left
|
|
58
|
+
* out, having nothing to be merged on.
|
|
59
|
+
* @throws If the directory holds no `pages.ndjson`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readArchivePosts(dataDirPath: string): Promise<TumblrPost[]>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as Crawler from '@design-edito/tools/agnostic/misc/crawler/index.js';
|
|
2
|
+
import { fillPostDetails, parsePage } from '../../../agnostic/scrapers/@tumblr/index.js';
|
|
3
|
+
import { HeadlessBrowser } from '../../headless-browser/index.js';
|
|
4
|
+
import { appendPageRecord, buildListingPageUrls, collectSitemapLocations, createTemporaryDataDir, discoverUrlsToFollow, ensureDataDirectories, fetchRobots, fillLastModifiedFromSitemap, mergeArchivedPosts, normalizeUrl, readCrawlState, readPageRecords, resolveDelayMs, toErrorMessage, toOriginOrNull, writeCrawlState, writeRawPage } from './utils.js';
|
|
5
|
+
const DEFAULT_LIMIT = 10000;
|
|
6
|
+
const DEFAULT_DELAY_MS = 250;
|
|
7
|
+
/**
|
|
8
|
+
* Crawls a Tumblr blog and writes everything it reads into `dataDirPath`.
|
|
9
|
+
*
|
|
10
|
+
* The blog's sitemap is read first: it enumerates every post in a request or two, and
|
|
11
|
+
* states when each was last modified, which is the only date available on a theme that
|
|
12
|
+
* publishes none.
|
|
13
|
+
*
|
|
14
|
+
* The crawl is a frontier: it starts from the blog's sitemap and its root listing, then
|
|
15
|
+
* enqueues whatever the pages it parses reveal, keeping only the kinds named by
|
|
16
|
+
* `followedKinds`. A listing is followed to its next page for as long as it keeps
|
|
17
|
+
* yielding posts that have not been seen — which is the real stop condition, since a
|
|
18
|
+
* listing past the end of a blog answers with an empty page rather than a 404.
|
|
19
|
+
*
|
|
20
|
+
* With no `dataDirPath`, a temporary directory is created and returned, so a caller can
|
|
21
|
+
* tell its user which path to hand back to resume the crawl later.
|
|
22
|
+
*
|
|
23
|
+
* Three files are produced: `pages.ndjson` holds one parsed page per line and is the
|
|
24
|
+
* record of truth, `state.json` holds the frontier so a later run can resume, and `raw/`
|
|
25
|
+
* keeps each page's HTML so a parser fix can be replayed without fetching again.
|
|
26
|
+
*
|
|
27
|
+
* Every read goes through a headless Chrome unless a `fetcher` is given: a blog host
|
|
28
|
+
* answers a plain HTTP request with a 403 and a JavaScript challenge, so nothing — not
|
|
29
|
+
* even `robots.txt` — is readable without a browser solving it first. The browser is
|
|
30
|
+
* started for the crawl and closed with it, failures included.
|
|
31
|
+
*
|
|
32
|
+
* @param baseTumblrUrl - Any URL on the blog; only its origin is used.
|
|
33
|
+
* @param options - How the crawl should behave.
|
|
34
|
+
* @returns What the crawl achieved, and every page it could not read.
|
|
35
|
+
* @throws If `baseTumblrUrl` has no readable origin, as `invalid-base-url`. A page that
|
|
36
|
+
* cannot be fetched or parsed is reported as a failure instead, and never interrupts the
|
|
37
|
+
* crawl.
|
|
38
|
+
*/
|
|
39
|
+
export async function crawlBlog(baseTumblrUrl, options) {
|
|
40
|
+
const baseUrl = toOriginOrNull(baseTumblrUrl);
|
|
41
|
+
if (baseUrl === null)
|
|
42
|
+
throw new Error('invalid-base-url');
|
|
43
|
+
const dataDirPath = options.dataDirPath ?? await createTemporaryDataDir();
|
|
44
|
+
await ensureDataDirectories(dataDirPath, options.saveRawPages ?? true);
|
|
45
|
+
const browser = options.fetcher === undefined ? new HeadlessBrowser() : null;
|
|
46
|
+
const fetcher = options.fetcher ?? (async (url) => await browser?.readText(url) ?? null);
|
|
47
|
+
try {
|
|
48
|
+
return await runCrawl(baseUrl, dataDirPath, fetcher, options);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await browser?.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Streams the pages an archive holds, in the order the crawl wrote them.
|
|
56
|
+
*
|
|
57
|
+
* Saves a consumer knowing the archive's file name and line format, and reads the dates
|
|
58
|
+
* back as `Date` rather than the strings JSON stored. A line that is not a page record is
|
|
59
|
+
* skipped rather than thrown over, so a crawl interrupted mid-write stays readable.
|
|
60
|
+
*
|
|
61
|
+
* @param dataDirPath - Directory a crawl wrote into.
|
|
62
|
+
* @returns Each page in turn.
|
|
63
|
+
* @throws If the directory holds no `pages.ndjson`.
|
|
64
|
+
*/
|
|
65
|
+
export async function* readArchivePages(dataDirPath) {
|
|
66
|
+
yield* readPageRecords(dataDirPath);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reads every distinct post an archive holds, one entry per post id.
|
|
70
|
+
*
|
|
71
|
+
* A post is written once per page that mentioned it — a listing identifies it, its
|
|
72
|
+
* permalink describes it — so the records are merged: the permalink's version wins, and
|
|
73
|
+
* whatever it left empty is filled from the other, which is how a post keeps both its
|
|
74
|
+
* title and the `lastModifiedOn` only the sitemap knew.
|
|
75
|
+
*
|
|
76
|
+
* @param dataDirPath - Directory a crawl wrote into.
|
|
77
|
+
* @returns The posts, in the order their ids were first seen. Posts with no id are left
|
|
78
|
+
* out, having nothing to be merged on.
|
|
79
|
+
* @throws If the directory holds no `pages.ndjson`.
|
|
80
|
+
*/
|
|
81
|
+
export async function readArchivePosts(dataDirPath) {
|
|
82
|
+
const postsById = new Map();
|
|
83
|
+
for await (const page of readPageRecords(dataDirPath)) {
|
|
84
|
+
for (const post of page.posts) {
|
|
85
|
+
if (post.id === null)
|
|
86
|
+
continue;
|
|
87
|
+
const known = postsById.get(post.id);
|
|
88
|
+
postsById.set(post.id, known === undefined ? post : mergeArchivedPosts(known, post));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Array.from(postsById.values());
|
|
92
|
+
}
|
|
93
|
+
async function runCrawl(baseUrl, dataDirPath, fetcher, options) {
|
|
94
|
+
const { followedKinds = ['index', 'permalink'], pages, limit = DEFAULT_LIMIT, saveRawPages = true, resume = false, useSitemap = true, onPageCrawled } = options;
|
|
95
|
+
const restoredState = resume ? await readCrawlState(dataDirPath, baseUrl) : null;
|
|
96
|
+
const robots = useSitemap ? await fetchRobots(baseUrl, fetcher) : null;
|
|
97
|
+
const sitemapLocations = useSitemap
|
|
98
|
+
? await collectSitemapLocations(baseUrl, robots, fetcher)
|
|
99
|
+
: new Map();
|
|
100
|
+
const knownPostIds = new Set(restoredState?.knownPostIds ?? []);
|
|
101
|
+
const failures = [];
|
|
102
|
+
let blog = null;
|
|
103
|
+
let pageCount = 0;
|
|
104
|
+
const reportFailure = (url, error) => {
|
|
105
|
+
const report = {
|
|
106
|
+
url,
|
|
107
|
+
kind: null,
|
|
108
|
+
postCount: 0,
|
|
109
|
+
newPostCount: 0,
|
|
110
|
+
error
|
|
111
|
+
};
|
|
112
|
+
failures.push(report);
|
|
113
|
+
onPageCrawled?.(report);
|
|
114
|
+
};
|
|
115
|
+
const crawler = Crawler.create({
|
|
116
|
+
limit,
|
|
117
|
+
delayMs: options.delayMs ?? resolveDelayMs(robots, DEFAULT_DELAY_MS),
|
|
118
|
+
fetcher,
|
|
119
|
+
processor: async (url, pageContent, { push }) => {
|
|
120
|
+
if (pageContent === null)
|
|
121
|
+
return reportFailure(url, 'fetch-failed');
|
|
122
|
+
let parsedPage;
|
|
123
|
+
try {
|
|
124
|
+
parsedPage = await fillPostDetails(parsePage(pageContent, url));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return reportFailure(url, toErrorMessage(error));
|
|
128
|
+
}
|
|
129
|
+
const page = fillLastModifiedFromSitemap(parsedPage, sitemapLocations, baseUrl);
|
|
130
|
+
pageCount = pageCount + 1;
|
|
131
|
+
blog ??= page.blog;
|
|
132
|
+
if (saveRawPages)
|
|
133
|
+
await writeRawPage(dataDirPath, url, pageContent);
|
|
134
|
+
await appendPageRecord(dataDirPath, page);
|
|
135
|
+
let newPostCount = 0;
|
|
136
|
+
for (const post of page.posts) {
|
|
137
|
+
if (post.id === null || knownPostIds.has(post.id))
|
|
138
|
+
continue;
|
|
139
|
+
knownPostIds.add(post.id);
|
|
140
|
+
newPostCount = newPostCount + 1;
|
|
141
|
+
}
|
|
142
|
+
push(...discoverUrlsToFollow(page, followedKinds, newPostCount, baseUrl));
|
|
143
|
+
onPageCrawled?.({
|
|
144
|
+
url,
|
|
145
|
+
kind: page.kind,
|
|
146
|
+
postCount: page.posts.length,
|
|
147
|
+
newPostCount,
|
|
148
|
+
error: null
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
for (const processedUrl of restoredState?.processedUrls ?? [])
|
|
153
|
+
crawler.processed.add(processedUrl);
|
|
154
|
+
const seedUrls = pages === undefined
|
|
155
|
+
? [baseUrl]
|
|
156
|
+
: buildListingPageUrls(baseUrl, pages.from, pages.to);
|
|
157
|
+
if (followedKinds.includes('permalink'))
|
|
158
|
+
seedUrls.push(...sitemapLocations.keys());
|
|
159
|
+
seedUrls.push(...restoredState?.waitlistUrls ?? []);
|
|
160
|
+
const pendingSeedUrls = Array.from(new Set(seedUrls
|
|
161
|
+
.map(seedUrl => normalizeUrl(seedUrl, baseUrl))
|
|
162
|
+
.filter((seedUrl) => seedUrl !== null)))
|
|
163
|
+
.filter(seedUrl => !crawler.processed.has(seedUrl));
|
|
164
|
+
const lastSeedUrl = pendingSeedUrls[pendingSeedUrls.length - 1];
|
|
165
|
+
if (lastSeedUrl !== undefined) {
|
|
166
|
+
crawler.push(...pendingSeedUrls.slice(0, -1));
|
|
167
|
+
await crawler.crawl(lastSeedUrl);
|
|
168
|
+
}
|
|
169
|
+
await writeCrawlState(dataDirPath, {
|
|
170
|
+
baseUrl,
|
|
171
|
+
processedUrls: Array.from(crawler.processed),
|
|
172
|
+
waitlistUrls: crawler.waitlist,
|
|
173
|
+
knownPostIds: Array.from(knownPostIds)
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
dataDirPath,
|
|
177
|
+
blog,
|
|
178
|
+
pageCount,
|
|
179
|
+
postCount: knownPostIds.size,
|
|
180
|
+
failures
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { crawlBlog, readArchivePages, readArchivePosts } from './index.js';
|
|
6
|
+
const blogUrl = 'https://somebody.tumblr.com';
|
|
7
|
+
function listingPage(canonicalPath, postIds, nextPath) {
|
|
8
|
+
const nextLink = nextPath === null ? '' : `<link rel="next" href="${nextPath}" />`;
|
|
9
|
+
const postLinks = postIds
|
|
10
|
+
.map(postId => `<a href="/post/${postId}/slug">${postId}</a>`)
|
|
11
|
+
.join('');
|
|
12
|
+
return `<!doctype html>
|
|
13
|
+
<html>
|
|
14
|
+
<head>
|
|
15
|
+
<link rel="canonical" href="${blogUrl}${canonicalPath}" />
|
|
16
|
+
<meta property="og:site_name" content="Somebody" />
|
|
17
|
+
<meta name="description" content="A blog about nothing" />
|
|
18
|
+
${nextLink}
|
|
19
|
+
</head>
|
|
20
|
+
<body>${postLinks}</body>
|
|
21
|
+
</html>`;
|
|
22
|
+
}
|
|
23
|
+
function permalinkPage(postId) {
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html>
|
|
26
|
+
<head>
|
|
27
|
+
<link rel="canonical" href="${blogUrl}/post/${postId}/slug" />
|
|
28
|
+
<meta property="og:site_name" content="Somebody" />
|
|
29
|
+
<meta property="og:title" content="Post ${postId}" />
|
|
30
|
+
<meta property="article:tag" content="tagged" />
|
|
31
|
+
</head>
|
|
32
|
+
<body></body>
|
|
33
|
+
</html>`;
|
|
34
|
+
}
|
|
35
|
+
const responsesByUrl = {
|
|
36
|
+
[`${blogUrl}/robots.txt`]: [
|
|
37
|
+
'Sitemap: https://somebody.tumblr.com/sitemap.xml',
|
|
38
|
+
'User-agent: *',
|
|
39
|
+
'Crawl-delay: 0'
|
|
40
|
+
].join('\n'),
|
|
41
|
+
[`${blogUrl}/sitemap.xml`]: `<?xml version="1.0" encoding="UTF-8"?>
|
|
42
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
43
|
+
<url>
|
|
44
|
+
<loc>${blogUrl}/post/1001/slug</loc>
|
|
45
|
+
<lastmod>2019-01-31T16:39:53+00:00</lastmod>
|
|
46
|
+
</url>
|
|
47
|
+
</urlset>`,
|
|
48
|
+
[`${blogUrl}/`]: listingPage('/', ['1001', '1002'], '/page/2'),
|
|
49
|
+
[`${blogUrl}/page/2`]: listingPage('/page/2', ['1003'], '/page/3'),
|
|
50
|
+
[`${blogUrl}/page/3`]: listingPage('/page/3', [], null),
|
|
51
|
+
[`${blogUrl}/post/1001/slug`]: permalinkPage('1001'),
|
|
52
|
+
[`${blogUrl}/post/1002/slug`]: permalinkPage('1002'),
|
|
53
|
+
[`${blogUrl}/post/1003/slug`]: permalinkPage('1003')
|
|
54
|
+
};
|
|
55
|
+
// The crawl reads through this rather than through a headless Chrome, which is what
|
|
56
|
+
// `crawlBlog` starts when no fetcher is given.
|
|
57
|
+
const fetcher = async (url) => responsesByUrl[url] ?? null;
|
|
58
|
+
let dataDirPath;
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
dataDirPath = await fs.mkdtemp(path.join(os.tmpdir(), 'mf-tumblr-'));
|
|
61
|
+
});
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
await fs.rm(dataDirPath, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
describe('scrapers/@tumblr/crawlBlog', () => {
|
|
66
|
+
it('walks the listings, follows the permalinks and records every post once', async () => {
|
|
67
|
+
const result = await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
68
|
+
expect(result.blog?.name).toBe('somebody');
|
|
69
|
+
expect(result.postCount).toBe(3);
|
|
70
|
+
expect(result.pageCount).toBe(6);
|
|
71
|
+
expect(result.failures).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
it('writes one record per parsed page, plus the state and the raw pages', async () => {
|
|
74
|
+
await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
75
|
+
const records = await fs.readFile(path.join(dataDirPath, 'pages.ndjson'), 'utf-8');
|
|
76
|
+
const lines = records.split('\n').filter(line => line.trim() !== '');
|
|
77
|
+
expect(lines).toHaveLength(6);
|
|
78
|
+
const state = await fs.readFile(path.join(dataDirPath, 'state.json'), 'utf-8');
|
|
79
|
+
expect(JSON.parse(state).knownPostIds.sort()).toEqual(['1001', '1002', '1003']);
|
|
80
|
+
const rawFiles = await fs.readdir(path.join(dataDirPath, 'raw'));
|
|
81
|
+
expect(rawFiles).toHaveLength(6);
|
|
82
|
+
});
|
|
83
|
+
it('stops walking listings once a page yields no post it has not seen', async () => {
|
|
84
|
+
const crawledUrls = [];
|
|
85
|
+
await crawlBlog(blogUrl, {
|
|
86
|
+
dataDirPath,
|
|
87
|
+
delayMs: 0,
|
|
88
|
+
fetcher,
|
|
89
|
+
onPageCrawled: report => { crawledUrls.push(report.url); }
|
|
90
|
+
});
|
|
91
|
+
expect(crawledUrls).toContain(`${blogUrl}/page/3`);
|
|
92
|
+
expect(crawledUrls).not.toContain(`${blogUrl}/page/4`);
|
|
93
|
+
});
|
|
94
|
+
it('keeps nothing but listings when permalinks are not followed', async () => {
|
|
95
|
+
const result = await crawlBlog(blogUrl, {
|
|
96
|
+
dataDirPath,
|
|
97
|
+
delayMs: 0,
|
|
98
|
+
fetcher,
|
|
99
|
+
followedKinds: ['index'],
|
|
100
|
+
useSitemap: false
|
|
101
|
+
});
|
|
102
|
+
expect(result.pageCount).toBe(3);
|
|
103
|
+
expect(result.postCount).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
it('reports a page it cannot fetch as a failure without interrupting the crawl', async () => {
|
|
106
|
+
const result = await crawlBlog(blogUrl, {
|
|
107
|
+
dataDirPath,
|
|
108
|
+
delayMs: 0,
|
|
109
|
+
fetcher,
|
|
110
|
+
pages: { from: 1, to: 2 },
|
|
111
|
+
useSitemap: false,
|
|
112
|
+
followedKinds: []
|
|
113
|
+
});
|
|
114
|
+
expect(result.failures.map(failure => failure.url)).toEqual([`${blogUrl}/page/1`]);
|
|
115
|
+
expect(result.failures[0]?.error).toBe('fetch-failed');
|
|
116
|
+
expect(result.pageCount).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
it('dates a post from the sitemap, which is the only date the theme offers', async () => {
|
|
119
|
+
await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
120
|
+
const records = await fs.readFile(path.join(dataDirPath, 'pages.ndjson'), 'utf-8');
|
|
121
|
+
const posts = records
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter(line => line.trim() !== '')
|
|
124
|
+
.flatMap(line => JSON.parse(line).posts);
|
|
125
|
+
const dated = posts.filter((post) => post.lastModifiedOn !== null);
|
|
126
|
+
expect(dated).not.toHaveLength(0);
|
|
127
|
+
expect(dated[0].lastModifiedOn).toBe('2019-01-31T16:39:53.000Z');
|
|
128
|
+
expect(dated.every((post) => post.url.includes('/post/1001/'))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
it('creates a temporary directory of its own, and says which one', async () => {
|
|
131
|
+
const result = await crawlBlog(blogUrl, { delayMs: 0, fetcher });
|
|
132
|
+
try {
|
|
133
|
+
expect(result.dataDirPath.startsWith(os.tmpdir())).toBe(true);
|
|
134
|
+
expect(path.basename(result.dataDirPath)).toMatch(/^[0-9a-f]{16}$/);
|
|
135
|
+
const records = await fs.readFile(path.join(result.dataDirPath, 'pages.ndjson'), 'utf-8');
|
|
136
|
+
expect(records.split('\n').filter(line => line.trim() !== '')).toHaveLength(6);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await fs.rm(result.dataDirPath, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
it('reports back the directory it was given', async () => {
|
|
143
|
+
const result = await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
144
|
+
expect(result.dataDirPath).toBe(dataDirPath);
|
|
145
|
+
});
|
|
146
|
+
it('fetches nothing on a resumed crawl that already covered the blog', async () => {
|
|
147
|
+
await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
148
|
+
const resumed = await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher, resume: true });
|
|
149
|
+
expect(resumed.pageCount).toBe(0);
|
|
150
|
+
expect(resumed.postCount).toBe(3);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('scrapers/@tumblr/readArchivePages & readArchivePosts', () => {
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
await crawlBlog(blogUrl, { dataDirPath, delayMs: 0, fetcher });
|
|
156
|
+
});
|
|
157
|
+
it('streams every page the crawl wrote', async () => {
|
|
158
|
+
const urls = [];
|
|
159
|
+
for await (const page of readArchivePages(dataDirPath))
|
|
160
|
+
urls.push(page.url);
|
|
161
|
+
expect(urls).toHaveLength(6);
|
|
162
|
+
expect(urls).toContain(`${blogUrl}/post/1001/slug`);
|
|
163
|
+
});
|
|
164
|
+
it('reads the dates back as dates rather than the strings JSON stored', async () => {
|
|
165
|
+
const posts = await readArchivePosts(dataDirPath);
|
|
166
|
+
const post = posts.find(candidate => candidate.id === '1001');
|
|
167
|
+
expect(post?.lastModifiedOn).toBeInstanceOf(Date);
|
|
168
|
+
expect(post?.lastModifiedOn?.toISOString()).toBe('2019-01-31T16:39:53.000Z');
|
|
169
|
+
for await (const page of readArchivePages(dataDirPath)) {
|
|
170
|
+
expect(page.collectedOn).toBeInstanceOf(Date);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
it('merges a post written once per page into a single entry', async () => {
|
|
174
|
+
const posts = await readArchivePosts(dataDirPath);
|
|
175
|
+
expect(posts.map(post => post.id)).toEqual(['1001', '1002', '1003']);
|
|
176
|
+
const post = posts.find(candidate => candidate.id === '1001');
|
|
177
|
+
// Its title came from the permalink page, its lastModifiedOn from the sitemap.
|
|
178
|
+
expect(post?.title).toBe('Post 1001');
|
|
179
|
+
expect(post?.tags).toEqual(['tagged']);
|
|
180
|
+
expect(post?.lastModifiedOn).not.toBeNull();
|
|
181
|
+
});
|
|
182
|
+
it('throws when the directory holds no archive', async () => {
|
|
183
|
+
await expect(readArchivePosts(path.join(dataDirPath, 'nowhere'))).rejects.toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|