@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,124 @@
|
|
|
1
|
+
const MONTH_INDEXES_BY_FRENCH_NAME = new Map([
|
|
2
|
+
['janvier', 0],
|
|
3
|
+
['février', 1],
|
|
4
|
+
['mars', 2],
|
|
5
|
+
['avril', 3],
|
|
6
|
+
['mai', 4],
|
|
7
|
+
['juin', 5],
|
|
8
|
+
['juillet', 6],
|
|
9
|
+
['août', 7],
|
|
10
|
+
['septembre', 8],
|
|
11
|
+
['octobre', 9],
|
|
12
|
+
['novembre', 10],
|
|
13
|
+
['décembre', 11]
|
|
14
|
+
]);
|
|
15
|
+
// `Jeu. janvier 31, 2019 @ 5:39 pm` — the weekday is dropped, it states nothing the rest
|
|
16
|
+
// does not.
|
|
17
|
+
const DATE_LABEL_PATTERN = /^\p{L}+\.?\s+(\p{L}+)\s+(\d{1,2}),\s*(\d{4})\s*@\s*(\d{1,2}):(\d{2})\s*(am|pm)$/iv;
|
|
18
|
+
const POST_TYPE_CLASS_PREFIX = 'type-';
|
|
19
|
+
// Where a source can sit: the embed, or a link in the body. Everything else a post holds
|
|
20
|
+
// is the theme's own — share buttons, the permalink, the short link.
|
|
21
|
+
const SOURCE_AREA_SELECTOR = '.media, .copy';
|
|
22
|
+
/**
|
|
23
|
+
* Reads the theme `delabienbonnemusique.tumblr.com` has been frozen on since 2019.
|
|
24
|
+
*
|
|
25
|
+
* Every post is one track: a line of text in `.copy`, an embed in `.media` when the video
|
|
26
|
+
* still exists, and sometimes a `.copy.source` link. That line is kept whole and never
|
|
27
|
+
* taken apart — fifteen years of posts were not written to one format, and an artist
|
|
28
|
+
* guessed from a dash is worse than the line itself. Class names are what this reads,
|
|
29
|
+
* which is what a per-blog parser is for; the generic pass is the one that may not rely on
|
|
30
|
+
* them.
|
|
31
|
+
*
|
|
32
|
+
* What the years make fragile is the source: a YouTube or Dailymotion embed that may well
|
|
33
|
+
* be dead by now, a Spotify link, a Grooveshark one on the oldest posts. Every URL found
|
|
34
|
+
* in the embed or the body is kept, in the order it appeared, plus the embed's own title —
|
|
35
|
+
* which on a deleted video is the last trace of what it was.
|
|
36
|
+
*
|
|
37
|
+
* The date is the only one this blog has: the theme writes it in the `title` of its
|
|
38
|
+
* `.date` link, and the sitemap's `lastmod` is a 2023 bulk re-touch that says nothing
|
|
39
|
+
* about publication. It carries no timezone, so it is read as UTC and the label itself is
|
|
40
|
+
* kept in `detail.publishedLabel` for whoever needs to argue about the hour.
|
|
41
|
+
*/
|
|
42
|
+
export const parser = {
|
|
43
|
+
parsePostDom: postElement => {
|
|
44
|
+
const copyElement = postElement.querySelector('.copy:not(.source)');
|
|
45
|
+
const publishedLabel = readDateLabel(postElement);
|
|
46
|
+
const iframe = postElement.querySelector('.media iframe');
|
|
47
|
+
const sourceUrls = readSourceUrls(postElement);
|
|
48
|
+
const detail = {
|
|
49
|
+
postType: readPostType(postElement),
|
|
50
|
+
publishedLabel,
|
|
51
|
+
sourceUrl: sourceUrls[0] ?? null,
|
|
52
|
+
sourceUrls,
|
|
53
|
+
embedTitle: iframe?.getAttribute('title') ?? null
|
|
54
|
+
};
|
|
55
|
+
const parsed = {
|
|
56
|
+
detail,
|
|
57
|
+
title: toTextOrNull(copyElement?.textContent ?? null),
|
|
58
|
+
contentHtml: copyElement?.innerHTML.trim() ?? null,
|
|
59
|
+
publishedOn: toDateOrNull(publishedLabel),
|
|
60
|
+
media: readMedia(iframe)
|
|
61
|
+
};
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
function toTextOrNull(raw) {
|
|
66
|
+
if (raw === null)
|
|
67
|
+
return null;
|
|
68
|
+
const normalized = raw.replace(/\s+/gv, ' ').trim();
|
|
69
|
+
return normalized === '' ? null : normalized;
|
|
70
|
+
}
|
|
71
|
+
function readSourceUrls(postElement) {
|
|
72
|
+
const urls = [];
|
|
73
|
+
for (const area of postElement.querySelectorAll(SOURCE_AREA_SELECTOR)) {
|
|
74
|
+
for (const node of area.querySelectorAll('iframe[src], a[href], embed[src]')) {
|
|
75
|
+
const url = node.getAttribute('src') ?? node.getAttribute('href');
|
|
76
|
+
if (url === null || url === '' || urls.includes(url))
|
|
77
|
+
continue;
|
|
78
|
+
urls.push(url);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return urls;
|
|
82
|
+
}
|
|
83
|
+
function readDateLabel(postElement) {
|
|
84
|
+
return toTextOrNull(postElement.querySelector('.date a')?.getAttribute('title') ?? null);
|
|
85
|
+
}
|
|
86
|
+
function readPostType(postElement) {
|
|
87
|
+
for (const className of Array.from(postElement.classList)) {
|
|
88
|
+
if (className.startsWith(POST_TYPE_CLASS_PREFIX)) {
|
|
89
|
+
return className.slice(POST_TYPE_CLASS_PREFIX.length);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function toDateOrNull(publishedLabel) {
|
|
95
|
+
if (publishedLabel === null)
|
|
96
|
+
return null;
|
|
97
|
+
const parts = DATE_LABEL_PATTERN.exec(publishedLabel);
|
|
98
|
+
if (parts === null)
|
|
99
|
+
return null;
|
|
100
|
+
const [, monthName, day, year, hour, minute, meridiem] = parts;
|
|
101
|
+
const monthIndex = MONTH_INDEXES_BY_FRENCH_NAME.get(monthName?.toLowerCase() ?? '');
|
|
102
|
+
if (monthIndex === undefined)
|
|
103
|
+
return null;
|
|
104
|
+
const hours = Number(hour) % 12 + (meridiem?.toLowerCase() === 'pm' ? 12 : 0);
|
|
105
|
+
return new Date(Date.UTC(Number(year), monthIndex, Number(day), hours, Number(minute)));
|
|
106
|
+
}
|
|
107
|
+
function readMedia(iframe) {
|
|
108
|
+
const url = iframe?.getAttribute('src') ?? null;
|
|
109
|
+
if (url === null)
|
|
110
|
+
return [];
|
|
111
|
+
return [{
|
|
112
|
+
kind: 'iframe',
|
|
113
|
+
url,
|
|
114
|
+
widthPx: toIntegerOrNull(iframe?.getAttribute('width') ?? null),
|
|
115
|
+
heightPx: toIntegerOrNull(iframe?.getAttribute('height') ?? null),
|
|
116
|
+
alternativeText: iframe?.getAttribute('title') ?? null
|
|
117
|
+
}];
|
|
118
|
+
}
|
|
119
|
+
function toIntegerOrNull(raw) {
|
|
120
|
+
if (raw === null)
|
|
121
|
+
return null;
|
|
122
|
+
const parsed = Number.parseInt(raw, 10);
|
|
123
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import { parser } from './index.js';
|
|
4
|
+
// Trimmed from a real block of the blog, keeping every part the parser reads.
|
|
5
|
+
const videoPostBlock = `<article id="post-182451761040" class="post type-video">
|
|
6
|
+
<div class="post-panel">
|
|
7
|
+
<div class="media loading">
|
|
8
|
+
<iframe width="500" height="281" id="youtube_iframe"
|
|
9
|
+
src="https://www.youtube.com/embed/7UWhdL76FKI?feature=oembed"
|
|
10
|
+
title="Murcof x Vanessa Wagner - Gnossienne No. 3 (Album Version)"></iframe>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="copy"><p>Murcof & Vanessa Wagner — Gnossienne No. 3<br></p></div>
|
|
13
|
+
<div class="meta">
|
|
14
|
+
<ul class="meta-list">
|
|
15
|
+
<li class="date">
|
|
16
|
+
<a href="https://delabienbonnemusique.tumblr.com/post/182451761040/x"
|
|
17
|
+
title="Jeu. janvier 31, 2019 @ 5:39 pm"><span class="icon"></span>il y a 7 ans</a>
|
|
18
|
+
</li>
|
|
19
|
+
</ul>
|
|
20
|
+
<div class="share"><div class="short-link">
|
|
21
|
+
<input type="text" readonly value="https://tmblr.co/ZknzVy2fw_E_G">
|
|
22
|
+
</div></div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</article>`;
|
|
26
|
+
const audioPostBlock = `<article id="post-170586407310" class="post type-audio">
|
|
27
|
+
<div class="post-panel">
|
|
28
|
+
<div class="copy"><p>Salvatore Adamo — Tombe la neige</p></div>
|
|
29
|
+
<div class="copy source">
|
|
30
|
+
<p>Source : <a href="https://open.spotify.com/track/6NVx24kOU2B8IZ0HsY2fUz">Spotify</a></p>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="meta"><ul class="meta-list"><li class="date">
|
|
33
|
+
<a href="#" title="Mar. février 6, 2018 @ 10:38 pm">il y a 8 ans</a>
|
|
34
|
+
</li></ul></div>
|
|
35
|
+
</div>
|
|
36
|
+
</article>`;
|
|
37
|
+
function parse(block) {
|
|
38
|
+
const { document } = new JSDOM(`<body>${block}</body>`).window;
|
|
39
|
+
const postElement = document.body.firstElementChild;
|
|
40
|
+
if (postElement === null)
|
|
41
|
+
throw new Error('no-block');
|
|
42
|
+
return parser.parsePostDom(postElement);
|
|
43
|
+
}
|
|
44
|
+
describe('scrapers/@tumblr/blogs/delabienbonnemusique', () => {
|
|
45
|
+
it('keeps the title line whole rather than guessing an artist out of it', () => {
|
|
46
|
+
const { title } = parse(videoPostBlock);
|
|
47
|
+
expect(title).toBe('Murcof & Vanessa Wagner — Gnossienne No. 3');
|
|
48
|
+
});
|
|
49
|
+
it('reads the only publication date the blog states, as UTC', () => {
|
|
50
|
+
const { publishedOn, detail } = parse(videoPostBlock);
|
|
51
|
+
expect(publishedOn?.toISOString()).toBe('2019-01-31T17:39:00.000Z');
|
|
52
|
+
expect(detail.publishedLabel).toBe('Jeu. janvier 31, 2019 @ 5:39 pm');
|
|
53
|
+
});
|
|
54
|
+
it('reads a morning hour without pushing it into the afternoon', () => {
|
|
55
|
+
const { publishedOn } = parse(videoPostBlock.replace('5:39 pm', '11:42 am'));
|
|
56
|
+
expect(publishedOn?.toISOString()).toBe('2019-01-31T11:42:00.000Z');
|
|
57
|
+
});
|
|
58
|
+
it('reads midnight and noon the way the theme means them', () => {
|
|
59
|
+
expect(parse(videoPostBlock.replace('5:39 pm', '12:30 am')).publishedOn?.toISOString())
|
|
60
|
+
.toBe('2019-01-31T00:30:00.000Z');
|
|
61
|
+
expect(parse(videoPostBlock.replace('5:39 pm', '12:30 pm')).publishedOn?.toISOString())
|
|
62
|
+
.toBe('2019-01-31T12:30:00.000Z');
|
|
63
|
+
});
|
|
64
|
+
it('keeps the post body, the embed, and the embed title a dead video leaves behind', () => {
|
|
65
|
+
const { contentHtml, media, detail } = parse(videoPostBlock);
|
|
66
|
+
expect(contentHtml).toBe('<p>Murcof & Vanessa Wagner — Gnossienne No. 3<br></p>');
|
|
67
|
+
expect(media).toEqual([{
|
|
68
|
+
kind: 'iframe',
|
|
69
|
+
url: 'https://www.youtube.com/embed/7UWhdL76FKI?feature=oembed',
|
|
70
|
+
widthPx: 500,
|
|
71
|
+
heightPx: 281,
|
|
72
|
+
alternativeText: 'Murcof x Vanessa Wagner - Gnossienne No. 3 (Album Version)'
|
|
73
|
+
}]);
|
|
74
|
+
expect(detail.postType).toBe('video');
|
|
75
|
+
expect(detail.embedTitle).toBe('Murcof x Vanessa Wagner - Gnossienne No. 3 (Album Version)');
|
|
76
|
+
});
|
|
77
|
+
it('takes the embed as the source of a video post', () => {
|
|
78
|
+
const { detail } = parse(videoPostBlock);
|
|
79
|
+
expect(detail.sourceUrl).toBe('https://www.youtube.com/embed/7UWhdL76FKI?feature=oembed');
|
|
80
|
+
expect(detail.sourceUrls).toEqual([
|
|
81
|
+
'https://www.youtube.com/embed/7UWhdL76FKI?feature=oembed'
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
it('never takes a share button or a permalink for a source', () => {
|
|
85
|
+
const { detail } = parse(videoPostBlock);
|
|
86
|
+
expect(JSON.stringify(detail.sourceUrls)).not.toContain('tmblr.co');
|
|
87
|
+
expect(JSON.stringify(detail.sourceUrls)).not.toContain('/post/182451761040');
|
|
88
|
+
});
|
|
89
|
+
it('reads the source link of a post that has one, and never reads it as the body', () => {
|
|
90
|
+
const { detail, contentHtml, media } = parse(audioPostBlock);
|
|
91
|
+
expect(detail.sourceUrl).toBe('https://open.spotify.com/track/6NVx24kOU2B8IZ0HsY2fUz');
|
|
92
|
+
expect(contentHtml).toBe('<p>Salvatore Adamo — Tombe la neige</p>');
|
|
93
|
+
expect(media).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
it('states what it could not find rather than inventing it', () => {
|
|
96
|
+
const { detail, publishedOn, title } = parse('<article class="post"><div></div></article>');
|
|
97
|
+
expect(title).toBeNull();
|
|
98
|
+
expect(publishedOn).toBeNull();
|
|
99
|
+
expect(detail).toEqual({
|
|
100
|
+
postType: null,
|
|
101
|
+
publishedLabel: null,
|
|
102
|
+
sourceUrl: null,
|
|
103
|
+
sourceUrls: [],
|
|
104
|
+
embedTitle: null
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TumblrBlogParser } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Loads the parser registered for a blog, importing its module on first use.
|
|
4
|
+
*
|
|
5
|
+
* @param blogName - The blog identifier, as `TumblrBlog.name` states it.
|
|
6
|
+
* @returns That blog's parser, or `null` when none is registered.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadBlogParser(blogName: string): Promise<TumblrBlogParser | null>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every blog this package carries a parser for, by the name its host states.
|
|
3
|
+
*
|
|
4
|
+
* Each entry is a dynamic import rather than a static one: a consumer reading one blog
|
|
5
|
+
* has no use for the parsers of the others, and a static import would put all of them in
|
|
6
|
+
* their bundle.
|
|
7
|
+
*/
|
|
8
|
+
const loadersByBlogName = {
|
|
9
|
+
delabienbonnemusique: async () => await import('./delabienbonnemusique/index.js')
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Loads the parser registered for a blog, importing its module on first use.
|
|
13
|
+
*
|
|
14
|
+
* @param blogName - The blog identifier, as `TumblrBlog.name` states it.
|
|
15
|
+
* @returns That blog's parser, or `null` when none is registered.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadBlogParser(blogName) {
|
|
18
|
+
const load = loadersByBlogName[blogName];
|
|
19
|
+
if (load === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
const { parser } = await load();
|
|
22
|
+
return parser;
|
|
23
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TumblrBlogParserLoader, TumblrPage, TumblrRobots, TumblrSitemap } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fetches one page and decodes it as UTF-8, which is what Tumblr serves.
|
|
4
|
+
*
|
|
5
|
+
* @param url - Absolute URL of the page to fetch.
|
|
6
|
+
* @returns The page's raw content.
|
|
7
|
+
* @throws If the response status is not a success, as `fetch-failed-<status>`, so a
|
|
8
|
+
* caller can tell a missing page from an unparseable one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fetchPage(url: string): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Extracts everything a single Tumblr page reveals about its blog and its posts.
|
|
13
|
+
*
|
|
14
|
+
* Reads only the markup Tumblr itself injects — canonical and icon links, Open Graph and
|
|
15
|
+
* `article:*` metas, and the `/post/<id>` permalink pattern — so it holds across themes
|
|
16
|
+
* rather than against one. Theme markup is never interpreted, only kept: each post
|
|
17
|
+
* carries its block's raw HTML in `rawHtml`, which is what a blog's own parser is handed
|
|
18
|
+
* to extract the fields no theme-agnostic rule can reach.
|
|
19
|
+
*
|
|
20
|
+
* Everything that locates the page is derived from `pageUrl`, never from the canonical
|
|
21
|
+
* link: a modern theme points its canonical at `www.tumblr.com/<blog>`, which names a
|
|
22
|
+
* different host than the one being read and is identical on every listing page.
|
|
23
|
+
*
|
|
24
|
+
* @param pageContent - The page's raw HTML.
|
|
25
|
+
* @param pageUrl - The URL the content was fetched from.
|
|
26
|
+
* @returns The page's blog, posts and pagination, each field `null` or empty where the
|
|
27
|
+
* page stated nothing.
|
|
28
|
+
* @throws If the content declares neither a canonical URL nor an Open Graph site name,
|
|
29
|
+
* which is taken to mean it is not a Tumblr page.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parsePage(pageContent: string, pageUrl: string): TumblrPage;
|
|
32
|
+
/**
|
|
33
|
+
* Fills each post's `detail` with what the blog's own parser reads out of its block.
|
|
34
|
+
*
|
|
35
|
+
* The generic pass stops at the markup: it locates a post's block and keeps it, but a
|
|
36
|
+
* title, a date or a track listing sit wherever a theme decided, which no cross-theme rule
|
|
37
|
+
* reaches. This is where a blog's own parser gets its turn, and it is loaded by blog name
|
|
38
|
+
* on first use — a consumer reading one blog never imports the parsers of the others.
|
|
39
|
+
*
|
|
40
|
+
* A page whose blog has no registered parser comes back untouched, which is the common
|
|
41
|
+
* case.
|
|
42
|
+
*
|
|
43
|
+
* @param page - A page as `parsePage` returned it.
|
|
44
|
+
* @param loadParser - Where to look a parser up, for a consumer registering their own.
|
|
45
|
+
* Defaults to the parsers this package carries.
|
|
46
|
+
* @returns The page, with `detail` filled on every post whose block could be read, and
|
|
47
|
+
* the standard fields the parser completed.
|
|
48
|
+
* @throws Whatever the blog's parser throws — a parser that breaks on a post is a bug in
|
|
49
|
+
* that parser, and a crawl reports the page rather than storing a post that looks complete.
|
|
50
|
+
*/
|
|
51
|
+
export declare function fillPostDetails(page: TumblrPage, loadParser?: TumblrBlogParserLoader): Promise<TumblrPage>;
|
|
52
|
+
/**
|
|
53
|
+
* Reads a `sitemap.xml` document, whether it indexes other sitemaps or lists locations.
|
|
54
|
+
*
|
|
55
|
+
* A blog's sitemap enumerates every post without any theme involvement, which makes it
|
|
56
|
+
* the cheapest and most reliable way to discover what a blog holds.
|
|
57
|
+
*
|
|
58
|
+
* @param sitemapContent - The sitemap's raw XML.
|
|
59
|
+
* @returns The sitemaps it points at, or the locations it lists with the date each was
|
|
60
|
+
* last modified.
|
|
61
|
+
* @throws If the document's root is neither `sitemapindex` nor `urlset`, as
|
|
62
|
+
* `not-a-sitemap`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function parseSitemap(sitemapContent: string): TumblrSitemap;
|
|
65
|
+
/**
|
|
66
|
+
* Reads a `robots.txt`, keeping the `Sitemap:` directives and the rules stated for every
|
|
67
|
+
* crawler.
|
|
68
|
+
*
|
|
69
|
+
* @param robotsContent - The file's raw text.
|
|
70
|
+
* @returns The sitemaps it advertises and the `User-agent: *` rules, each list empty when
|
|
71
|
+
* the file states none.
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseRobots(robotsContent: string): TumblrRobots;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
import { loadBlogParser } from './blogs/index.js';
|
|
3
|
+
import { extractBlog, extractCanonicalUrl, extractPageKind, extractPagination, extractPostFromPermalinkPage, extractPostsFromListingPage, readMetaProperty, readSitemapChildUrls, readSitemapLocations, toIntegerOrNull, toOrigin } from './utils.js';
|
|
4
|
+
/**
|
|
5
|
+
* Fetches one page and decodes it as UTF-8, which is what Tumblr serves.
|
|
6
|
+
*
|
|
7
|
+
* @param url - Absolute URL of the page to fetch.
|
|
8
|
+
* @returns The page's raw content.
|
|
9
|
+
* @throws If the response status is not a success, as `fetch-failed-<status>`, so a
|
|
10
|
+
* caller can tell a missing page from an unparseable one.
|
|
11
|
+
*/
|
|
12
|
+
export async function fetchPage(url) {
|
|
13
|
+
const response = await fetch(url);
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`fetch-failed-${response.status}`);
|
|
16
|
+
const content = await response.text();
|
|
17
|
+
return content;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Extracts everything a single Tumblr page reveals about its blog and its posts.
|
|
21
|
+
*
|
|
22
|
+
* Reads only the markup Tumblr itself injects — canonical and icon links, Open Graph and
|
|
23
|
+
* `article:*` metas, and the `/post/<id>` permalink pattern — so it holds across themes
|
|
24
|
+
* rather than against one. Theme markup is never interpreted, only kept: each post
|
|
25
|
+
* carries its block's raw HTML in `rawHtml`, which is what a blog's own parser is handed
|
|
26
|
+
* to extract the fields no theme-agnostic rule can reach.
|
|
27
|
+
*
|
|
28
|
+
* Everything that locates the page is derived from `pageUrl`, never from the canonical
|
|
29
|
+
* link: a modern theme points its canonical at `www.tumblr.com/<blog>`, which names a
|
|
30
|
+
* different host than the one being read and is identical on every listing page.
|
|
31
|
+
*
|
|
32
|
+
* @param pageContent - The page's raw HTML.
|
|
33
|
+
* @param pageUrl - The URL the content was fetched from.
|
|
34
|
+
* @returns The page's blog, posts and pagination, each field `null` or empty where the
|
|
35
|
+
* page stated nothing.
|
|
36
|
+
* @throws If the content declares neither a canonical URL nor an Open Graph site name,
|
|
37
|
+
* which is taken to mean it is not a Tumblr page.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePage(pageContent, pageUrl) {
|
|
40
|
+
const collectedOn = new Date();
|
|
41
|
+
const { document } = new JSDOM(pageContent).window;
|
|
42
|
+
const canonicalUrl = extractCanonicalUrl(document);
|
|
43
|
+
if (canonicalUrl === null && readMetaProperty(document, 'og:site_name') === null) {
|
|
44
|
+
throw new Error('not-a-tumblr-page');
|
|
45
|
+
}
|
|
46
|
+
const baseUrl = toOrigin(pageUrl);
|
|
47
|
+
const kind = extractPageKind(pageUrl);
|
|
48
|
+
return {
|
|
49
|
+
kind,
|
|
50
|
+
url: pageUrl,
|
|
51
|
+
canonicalUrl,
|
|
52
|
+
blog: extractBlog(document, pageUrl, canonicalUrl, kind),
|
|
53
|
+
posts: kind === 'permalink'
|
|
54
|
+
? [extractPostFromPermalinkPage(document, pageUrl)]
|
|
55
|
+
: extractPostsFromListingPage(document, baseUrl),
|
|
56
|
+
pagination: extractPagination(document, pageUrl, baseUrl),
|
|
57
|
+
collectedOn
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fills each post's `detail` with what the blog's own parser reads out of its block.
|
|
62
|
+
*
|
|
63
|
+
* The generic pass stops at the markup: it locates a post's block and keeps it, but a
|
|
64
|
+
* title, a date or a track listing sit wherever a theme decided, which no cross-theme rule
|
|
65
|
+
* reaches. This is where a blog's own parser gets its turn, and it is loaded by blog name
|
|
66
|
+
* on first use — a consumer reading one blog never imports the parsers of the others.
|
|
67
|
+
*
|
|
68
|
+
* A page whose blog has no registered parser comes back untouched, which is the common
|
|
69
|
+
* case.
|
|
70
|
+
*
|
|
71
|
+
* @param page - A page as `parsePage` returned it.
|
|
72
|
+
* @param loadParser - Where to look a parser up, for a consumer registering their own.
|
|
73
|
+
* Defaults to the parsers this package carries.
|
|
74
|
+
* @returns The page, with `detail` filled on every post whose block could be read, and
|
|
75
|
+
* the standard fields the parser completed.
|
|
76
|
+
* @throws Whatever the blog's parser throws — a parser that breaks on a post is a bug in
|
|
77
|
+
* that parser, and a crawl reports the page rather than storing a post that looks complete.
|
|
78
|
+
*/
|
|
79
|
+
export async function fillPostDetails(page, loadParser = loadBlogParser) {
|
|
80
|
+
const blogName = page.blog.name;
|
|
81
|
+
if (blogName === null)
|
|
82
|
+
return page;
|
|
83
|
+
const parser = await loadParser(blogName);
|
|
84
|
+
if (parser === null)
|
|
85
|
+
return page;
|
|
86
|
+
return {
|
|
87
|
+
...page,
|
|
88
|
+
posts: page.posts.map(post => {
|
|
89
|
+
const parsed = parsePostBlock(parser, post);
|
|
90
|
+
return parsed === null ? post : applyParsedPost(post, parsed);
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function parsePostBlock(parser, post) {
|
|
95
|
+
if (post.rawHtml === null)
|
|
96
|
+
return null;
|
|
97
|
+
const { document } = new JSDOM(`<body>${post.rawHtml}</body>`).window;
|
|
98
|
+
const postElement = document.body.firstElementChild;
|
|
99
|
+
if (postElement === null)
|
|
100
|
+
return null;
|
|
101
|
+
return parser.parsePostDom(postElement);
|
|
102
|
+
}
|
|
103
|
+
function applyParsedPost(post, parsed) {
|
|
104
|
+
const { tags = [], media = [] } = parsed;
|
|
105
|
+
return {
|
|
106
|
+
...post,
|
|
107
|
+
detail: parsed.detail,
|
|
108
|
+
title: parsed.title ?? post.title,
|
|
109
|
+
contentHtml: parsed.contentHtml ?? post.contentHtml,
|
|
110
|
+
publishedOn: parsed.publishedOn ?? post.publishedOn,
|
|
111
|
+
tags: tags.length > 0 ? tags : post.tags,
|
|
112
|
+
media: media.length > 0 ? media : post.media
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reads a `sitemap.xml` document, whether it indexes other sitemaps or lists locations.
|
|
117
|
+
*
|
|
118
|
+
* A blog's sitemap enumerates every post without any theme involvement, which makes it
|
|
119
|
+
* the cheapest and most reliable way to discover what a blog holds.
|
|
120
|
+
*
|
|
121
|
+
* @param sitemapContent - The sitemap's raw XML.
|
|
122
|
+
* @returns The sitemaps it points at, or the locations it lists with the date each was
|
|
123
|
+
* last modified.
|
|
124
|
+
* @throws If the document's root is neither `sitemapindex` nor `urlset`, as
|
|
125
|
+
* `not-a-sitemap`.
|
|
126
|
+
*/
|
|
127
|
+
export function parseSitemap(sitemapContent) {
|
|
128
|
+
const { document } = new JSDOM(sitemapContent, { contentType: 'text/xml' }).window;
|
|
129
|
+
const rootName = document.documentElement.localName;
|
|
130
|
+
if (rootName !== 'sitemapindex' && rootName !== 'urlset')
|
|
131
|
+
throw new Error('not-a-sitemap');
|
|
132
|
+
return {
|
|
133
|
+
kind: rootName === 'sitemapindex' ? 'index' : 'urlset',
|
|
134
|
+
childSitemapUrls: readSitemapChildUrls(document),
|
|
135
|
+
locations: readSitemapLocations(document)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Reads a `robots.txt`, keeping the `Sitemap:` directives and the rules stated for every
|
|
140
|
+
* crawler.
|
|
141
|
+
*
|
|
142
|
+
* @param robotsContent - The file's raw text.
|
|
143
|
+
* @returns The sitemaps it advertises and the `User-agent: *` rules, each list empty when
|
|
144
|
+
* the file states none.
|
|
145
|
+
*/
|
|
146
|
+
export function parseRobots(robotsContent) {
|
|
147
|
+
const sitemapUrls = [];
|
|
148
|
+
const disallowedPaths = [];
|
|
149
|
+
const allowedPaths = [];
|
|
150
|
+
let crawlDelaySeconds = null;
|
|
151
|
+
let insideEveryCrawlerGroup = false;
|
|
152
|
+
for (const rawLine of robotsContent.split(/\r?\n/v)) {
|
|
153
|
+
const line = rawLine.replace(/#.*$/v, '').trim();
|
|
154
|
+
const separatorIndex = line.indexOf(':');
|
|
155
|
+
if (separatorIndex === -1)
|
|
156
|
+
continue;
|
|
157
|
+
const directive = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
158
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
159
|
+
if (value === '')
|
|
160
|
+
continue;
|
|
161
|
+
if (directive === 'sitemap')
|
|
162
|
+
sitemapUrls.push(value);
|
|
163
|
+
else if (directive === 'user-agent')
|
|
164
|
+
insideEveryCrawlerGroup = value === '*';
|
|
165
|
+
else if (!insideEveryCrawlerGroup)
|
|
166
|
+
continue;
|
|
167
|
+
else if (directive === 'disallow')
|
|
168
|
+
disallowedPaths.push(value);
|
|
169
|
+
else if (directive === 'allow')
|
|
170
|
+
allowedPaths.push(value);
|
|
171
|
+
else if (directive === 'crawl-delay')
|
|
172
|
+
crawlDelaySeconds = toIntegerOrNull(value);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
sitemapUrls,
|
|
176
|
+
disallowedPaths,
|
|
177
|
+
allowedPaths,
|
|
178
|
+
crawlDelaySeconds
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|