@mxfb/tools 1.0.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.js +67 -0
- package/agnostic/index.d.ts +5 -0
- 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.js +240 -186
- package/agnostic/scrapers/@meteociel/index.test.js +43 -0
- package/agnostic/scrapers/@meteociel/locations/index.js +9 -5
- 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 +2 -2
- package/agnostic/weather/types.js +8 -0
- package/components/index.d.ts +1 -0
- 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 -0
- 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.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 +197 -31
- package/chunks/chunk-JQLUR5LO.js +0 -12
- package/components/Comp/index.js +0 -0
- /package/{components/Comp/index.d.ts → agnostic/elo/index.test.d.ts} +0 -0
|
@@ -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.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { TumblrBlog, TumblrPageKind } from '../../../agnostic/scrapers/@tumblr/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How one crawl of a blog should behave.
|
|
4
|
+
*
|
|
5
|
+
* @property dataDirPath - Directory the crawl writes into, created when missing. Left
|
|
6
|
+
* out, a fresh temporary directory is made and reported back as
|
|
7
|
+
* {@link CrawlBlogResult.dataDirPath}, which is the path to hand back to resume later.
|
|
8
|
+
* @property followedKinds - Which page kinds the crawl enqueues when it meets them.
|
|
9
|
+
* Defaults to `['index', 'permalink']`: listings enumerate, permalinks extract.
|
|
10
|
+
* @property pages - Seeds `/page/from` to `/page/to` instead of discovering listings from
|
|
11
|
+
* the blog root.
|
|
12
|
+
* @property limit - Hard cap on the number of pages fetched, a safety valve rather than
|
|
13
|
+
* the stop condition. Defaults to 10000.
|
|
14
|
+
* @property delayMs - Delay between two fetches. Defaults to 250, and is raised to the
|
|
15
|
+
* blog's own `Crawl-delay` when it asks for more.
|
|
16
|
+
* @property saveRawPages - Keeps each page's raw HTML under `raw/`, so a later parser fix
|
|
17
|
+
* can be replayed without fetching anything again. Defaults to `true`.
|
|
18
|
+
* @property resume - Picks up where a previous crawl of the same directory stopped.
|
|
19
|
+
* @property useSitemap - Reads `robots.txt` and `sitemap.xml` to enumerate posts without
|
|
20
|
+
* walking listings. Defaults to `true`.
|
|
21
|
+
* @property onPageCrawled - Called once per page, successes and failures alike.
|
|
22
|
+
* @property fetcher - Reads one URL as text, or answers `null` when it could not. Left
|
|
23
|
+
* out, a headless Chrome is started for the crawl and closed with it, which is what a
|
|
24
|
+
* Tumblr blog host requires: it answers a plain request with a 403 and a JavaScript
|
|
25
|
+
* challenge.
|
|
26
|
+
*/
|
|
27
|
+
export type CrawlBlogOptions = {
|
|
28
|
+
dataDirPath?: string;
|
|
29
|
+
followedKinds?: TumblrPageKind[];
|
|
30
|
+
pages?: {
|
|
31
|
+
from: number;
|
|
32
|
+
to: number;
|
|
33
|
+
};
|
|
34
|
+
limit?: number;
|
|
35
|
+
delayMs?: number | (() => number);
|
|
36
|
+
saveRawPages?: boolean;
|
|
37
|
+
resume?: boolean;
|
|
38
|
+
useSitemap?: boolean;
|
|
39
|
+
onPageCrawled?: (report: CrawledPageReport) => void;
|
|
40
|
+
fetcher?: (url: string) => Promise<string | null>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* What became of one crawled page.
|
|
44
|
+
*
|
|
45
|
+
* @property url - The URL that was fetched.
|
|
46
|
+
* @property kind - What the page turned out to be, `null` when it could not be read.
|
|
47
|
+
* @property postCount - Posts the page carried.
|
|
48
|
+
* @property newPostCount - Posts the crawl had not seen before, which is what tells a
|
|
49
|
+
* listing worth following from one that only repeats itself.
|
|
50
|
+
* @property error - Why the page was skipped, `null` on success.
|
|
51
|
+
*/
|
|
52
|
+
export type CrawledPageReport = {
|
|
53
|
+
url: string;
|
|
54
|
+
kind: TumblrPageKind | null;
|
|
55
|
+
postCount: number;
|
|
56
|
+
newPostCount: number;
|
|
57
|
+
error: string | null;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* What one crawl achieved.
|
|
61
|
+
*
|
|
62
|
+
* @property dataDirPath - Where the crawl wrote, whether it was given or created. This
|
|
63
|
+
* is what a later run needs to resume.
|
|
64
|
+
* @property blog - The blog as the first readable page described it, `null` when no page
|
|
65
|
+
* could be read.
|
|
66
|
+
* @property pageCount - Pages successfully parsed.
|
|
67
|
+
* @property postCount - Distinct posts seen across them.
|
|
68
|
+
* @property failures - Every page that could not be fetched or parsed.
|
|
69
|
+
*/
|
|
70
|
+
export type CrawlBlogResult = {
|
|
71
|
+
dataDirPath: string;
|
|
72
|
+
blog: TumblrBlog | null;
|
|
73
|
+
pageCount: number;
|
|
74
|
+
postCount: number;
|
|
75
|
+
failures: CrawledPageReport[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A crawl's progress, as `state.json` holds it between two runs.
|
|
79
|
+
*
|
|
80
|
+
* @property baseUrl - Origin the crawl was run against.
|
|
81
|
+
* @property processedUrls - URLs already fetched, which a resumed crawl will not fetch
|
|
82
|
+
* again.
|
|
83
|
+
* @property waitlistUrls - URLs still owed a fetch when the crawl stopped.
|
|
84
|
+
* @property knownPostIds - Every post id seen so far.
|
|
85
|
+
*/
|
|
86
|
+
export type CrawlState = {
|
|
87
|
+
baseUrl: string;
|
|
88
|
+
processedUrls: string[];
|
|
89
|
+
waitlistUrls: string[];
|
|
90
|
+
knownPostIds: string[];
|
|
91
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TumblrPage, TumblrPageKind, TumblrPost, TumblrRobots } from '../../../agnostic/scrapers/@tumblr/types.js';
|
|
2
|
+
import type { CrawlState } from './types.js';
|
|
3
|
+
type PageFetcher = (url: string) => Promise<string | null>;
|
|
4
|
+
export declare function toOriginOrNull(rawUrl: string): string | null;
|
|
5
|
+
export declare function normalizeUrl(rawUrl: string, baseUrl: string | null): string | null;
|
|
6
|
+
export declare function isListingKind(kind: TumblrPageKind): boolean;
|
|
7
|
+
export declare function buildListingPageUrls(baseUrl: string, from: number, to: number): string[];
|
|
8
|
+
export declare function toErrorMessage(error: unknown): string;
|
|
9
|
+
export declare function fetchRobots(baseUrl: string, fetcher: PageFetcher): Promise<TumblrRobots | null>;
|
|
10
|
+
export declare function collectSitemapLocations(baseUrl: string, robots: TumblrRobots | null, fetcher: PageFetcher): Promise<Map<string, Date | null>>;
|
|
11
|
+
export declare function fillLastModifiedFromSitemap(page: TumblrPage, lastModifiedByUrl: Map<string, Date | null>, baseUrl: string): TumblrPage;
|
|
12
|
+
export declare function createTemporaryDataDir(): Promise<string>;
|
|
13
|
+
export declare function ensureDataDirectories(dataDirPath: string, saveRawPages: boolean): Promise<void>;
|
|
14
|
+
export declare function appendPageRecord(dataDirPath: string, page: TumblrPage): Promise<void>;
|
|
15
|
+
export declare function writeRawPage(dataDirPath: string, url: string, pageContent: string): Promise<void>;
|
|
16
|
+
export declare function writeCrawlState(dataDirPath: string, state: CrawlState): Promise<void>;
|
|
17
|
+
export declare function readCrawlState(dataDirPath: string, baseUrl: string): Promise<CrawlState | null>;
|
|
18
|
+
export declare function rebuildCrawlStateFromRecords(dataDirPath: string, baseUrl: string): Promise<CrawlState | null>;
|
|
19
|
+
export declare function readPageRecords(dataDirPath: string): AsyncGenerator<TumblrPage>;
|
|
20
|
+
export declare function mergeArchivedPosts(existing: TumblrPost, incoming: TumblrPost): TumblrPost;
|
|
21
|
+
export declare function resolveDelayMs(robots: TumblrRobots | null, defaultDelayMs: number): number;
|
|
22
|
+
export declare function discoverUrlsToFollow(page: TumblrPage, followedKinds: TumblrPageKind[], newPostCount: number, baseUrl: string): string[];
|
|
23
|
+
export {};
|