@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,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,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 &amp; 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&nbsp;: <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 &amp; 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 {};