@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.
Files changed (67) hide show
  1. package/TODO.md +1 -0
  2. package/agnostic/@publisher/index.d.ts +47 -0
  3. package/agnostic/@publisher/index.js +361 -0
  4. package/agnostic/elo/index.d.ts +23 -0
  5. package/agnostic/elo/index.js +28 -0
  6. package/agnostic/elo/index.test.js +67 -0
  7. package/agnostic/index.d.ts +5 -0
  8. package/agnostic/index.js +5 -0
  9. package/agnostic/poetry/generate-rhymes/index.d.ts +1 -0
  10. package/agnostic/poetry/generate-rhymes/index.js +125 -0
  11. package/agnostic/poetry/index.d.ts +1 -0
  12. package/agnostic/poetry/index.js +1 -0
  13. package/agnostic/scrapers/@meteociel/index.js +240 -186
  14. package/agnostic/scrapers/@meteociel/index.test.js +43 -0
  15. package/agnostic/scrapers/@meteociel/locations/index.js +9 -5
  16. package/agnostic/scrapers/@meteociel/types.js +1 -0
  17. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.d.ts +22 -0
  18. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.js +124 -0
  19. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.d.ts +1 -0
  20. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.js +107 -0
  21. package/agnostic/scrapers/@tumblr/blogs/index.d.ts +8 -0
  22. package/agnostic/scrapers/@tumblr/blogs/index.js +23 -0
  23. package/agnostic/scrapers/@tumblr/index.d.ts +73 -0
  24. package/agnostic/scrapers/@tumblr/index.js +180 -0
  25. package/agnostic/scrapers/@tumblr/index.test.d.ts +1 -0
  26. package/agnostic/scrapers/@tumblr/index.test.js +319 -0
  27. package/agnostic/scrapers/@tumblr/types.d.ts +238 -0
  28. package/agnostic/scrapers/@tumblr/types.js +38 -0
  29. package/agnostic/scrapers/@tumblr/utils.d.ts +12 -0
  30. package/agnostic/scrapers/@tumblr/utils.js +407 -0
  31. package/agnostic/scrapers/index.d.ts +2 -0
  32. package/agnostic/scrapers/index.js +2 -0
  33. package/agnostic/weather/index.d.ts +1 -0
  34. package/agnostic/weather/index.js +1 -0
  35. package/agnostic/weather/types.d.ts +2 -2
  36. package/agnostic/weather/types.js +8 -0
  37. package/components/index.d.ts +1 -0
  38. package/components/index.js +1 -0
  39. package/components/public-classnames.d.ts +5 -0
  40. package/components/public-classnames.js +5 -0
  41. package/components/utils/index.d.ts +1 -0
  42. package/components/utils/index.js +12 -0
  43. package/components/utils/types.d.ts +3 -0
  44. package/components/utils/types.js +1 -0
  45. package/index.d.ts +3 -0
  46. package/index.js +3 -0
  47. package/node/headless-browser/index.d.ts +37 -0
  48. package/node/headless-browser/index.js +126 -0
  49. package/node/headless-browser/types.d.ts +47 -0
  50. package/node/headless-browser/types.js +5 -0
  51. package/node/index.js +1 -0
  52. package/node/index.test.d.ts +1 -0
  53. package/node/index.test.js +6 -0
  54. package/node/scrapers/@tumblr/index.d.ts +61 -0
  55. package/node/scrapers/@tumblr/index.js +182 -0
  56. package/node/scrapers/@tumblr/index.test.d.ts +1 -0
  57. package/node/scrapers/@tumblr/index.test.js +185 -0
  58. package/node/scrapers/@tumblr/types.d.ts +91 -0
  59. package/node/scrapers/@tumblr/types.js +1 -0
  60. package/node/scrapers/@tumblr/utils.d.ts +23 -0
  61. package/node/scrapers/@tumblr/utils.js +328 -0
  62. package/node/scrapers/index.d.ts +1 -0
  63. package/node/scrapers/index.js +1 -0
  64. package/package.json +197 -31
  65. package/chunks/chunk-JQLUR5LO.js +0 -12
  66. package/components/Comp/index.js +0 -0
  67. /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
+ };
@@ -0,0 +1,5 @@
1
+ /** What a read returns: the body the server sent, or the DOM once scripts have run. */
2
+ export const headlessReadSources = [
3
+ 'served',
4
+ 'rendered'
5
+ ];
package/node/index.js CHANGED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ describe('fake-test', () => {
3
+ it('passes every time', () => {
4
+ expect(true).toBe(true);
5
+ });
6
+ });
@@ -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 {};