@mxfb/tools 1.0.0 → 1.0.3
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,407 @@
|
|
|
1
|
+
const TUMBLR_HOSTNAME_SUFFIX = '.tumblr.com';
|
|
2
|
+
const TUMBLR_CANONICAL_HOSTNAMES = ['www.tumblr.com', 'tumblr.com'];
|
|
3
|
+
const PERMALINK_PATHNAME_PATTERN = /^\/post\/(\d+)(?:\/|$)/v;
|
|
4
|
+
const INDEX_PAGE_PATHNAME_PATTERN = /^\/page\/(\d+)(?:\/|$)/v;
|
|
5
|
+
const ARCHIVE_PATHNAME_PATTERN = /^\/archive(?:\/|$)/v;
|
|
6
|
+
const TAGGED_PATHNAME_PATTERN = /^\/tagged\//v;
|
|
7
|
+
// A post block never reaches across one of these, so they stop the walk up the tree on a
|
|
8
|
+
// permalink page, where no sibling post is there to stop it.
|
|
9
|
+
const PAGE_LANDMARK_SELECTOR = 'nav, header, footer, aside';
|
|
10
|
+
export function toIntegerOrNull(raw) {
|
|
11
|
+
if (raw === null)
|
|
12
|
+
return null;
|
|
13
|
+
const parsed = Number.parseInt(raw, 10);
|
|
14
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
15
|
+
}
|
|
16
|
+
export function readSitemapChildUrls(document) {
|
|
17
|
+
const urls = [];
|
|
18
|
+
for (const entry of Array.from(document.getElementsByTagName('sitemap'))) {
|
|
19
|
+
const url = readFirstChildText(entry, 'loc');
|
|
20
|
+
if (url !== null)
|
|
21
|
+
urls.push(url);
|
|
22
|
+
}
|
|
23
|
+
return urls;
|
|
24
|
+
}
|
|
25
|
+
export function readSitemapLocations(document) {
|
|
26
|
+
const locations = [];
|
|
27
|
+
for (const entry of Array.from(document.getElementsByTagName('url'))) {
|
|
28
|
+
const url = readFirstChildText(entry, 'loc');
|
|
29
|
+
if (url === null)
|
|
30
|
+
continue;
|
|
31
|
+
locations.push({
|
|
32
|
+
url,
|
|
33
|
+
lastModifiedOn: toDateOrNull(readFirstChildText(entry, 'lastmod'))
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return locations;
|
|
37
|
+
}
|
|
38
|
+
export function toOrigin(url) {
|
|
39
|
+
if (url === null)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url).origin;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function readMetaProperty(document, property) {
|
|
49
|
+
return readAttribute(document, `meta[property="${property}"]`, 'content');
|
|
50
|
+
}
|
|
51
|
+
export function extractCanonicalUrl(document) {
|
|
52
|
+
return readLinkHref(document, 'canonical')
|
|
53
|
+
?? readMetaProperty(document, 'og:url');
|
|
54
|
+
}
|
|
55
|
+
export function extractPageKind(pageUrl) {
|
|
56
|
+
const pathname = toPathname(pageUrl);
|
|
57
|
+
if (pathname === null)
|
|
58
|
+
return 'unknown';
|
|
59
|
+
if (PERMALINK_PATHNAME_PATTERN.test(pathname))
|
|
60
|
+
return 'permalink';
|
|
61
|
+
if (ARCHIVE_PATHNAME_PATTERN.test(pathname))
|
|
62
|
+
return 'archive';
|
|
63
|
+
// Kept as a pattern to stay symmetric with the three above.
|
|
64
|
+
/* eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with */
|
|
65
|
+
if (TAGGED_PATHNAME_PATTERN.test(pathname))
|
|
66
|
+
return 'tagged';
|
|
67
|
+
if (INDEX_PAGE_PATHNAME_PATTERN.test(pathname))
|
|
68
|
+
return 'index';
|
|
69
|
+
if (pathname === '/')
|
|
70
|
+
return 'index';
|
|
71
|
+
return 'custom';
|
|
72
|
+
}
|
|
73
|
+
export function extractBlog(document, pageUrl, canonicalUrl, pageKind) {
|
|
74
|
+
const baseUrl = toOrigin(pageUrl);
|
|
75
|
+
const iconHref = readLinkHref(document, 'apple-touch-icon')
|
|
76
|
+
?? readLinkHref(document, 'icon');
|
|
77
|
+
return {
|
|
78
|
+
name: extractBlogName(pageUrl, canonicalUrl),
|
|
79
|
+
title: readMetaProperty(document, 'og:site_name')
|
|
80
|
+
?? toTextOrNull(document.querySelector('title')?.textContent ?? null),
|
|
81
|
+
description: extractBlogDescription(document, pageKind),
|
|
82
|
+
baseUrl,
|
|
83
|
+
avatarUrl: toAbsoluteUrl(iconHref, baseUrl),
|
|
84
|
+
navLinks: extractNavLinks(document, baseUrl)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function extractPostFromPermalinkPage(document, pageUrl) {
|
|
88
|
+
const id = extractPostIdFromUrl(pageUrl);
|
|
89
|
+
const postIdByAnchor = indexPostAnchors(document, toOrigin(pageUrl));
|
|
90
|
+
return {
|
|
91
|
+
...createIdentifiedPost(id, pageUrl),
|
|
92
|
+
type: extractPostType(readMetaProperty(document, 'og:type')),
|
|
93
|
+
publishedOn: toDateOrNull(readMetaProperty(document, 'article:published_time')),
|
|
94
|
+
title: readMetaProperty(document, 'og:title'),
|
|
95
|
+
tags: readEveryMetaProperty(document, 'article:tag'),
|
|
96
|
+
media: extractMedia(document),
|
|
97
|
+
...readRawHtml(findPostElement(document, id, postIdByAnchor), 'permalink')
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function extractPostsFromListingPage(document, baseUrl) {
|
|
101
|
+
const postIdByAnchor = indexPostAnchors(document, baseUrl);
|
|
102
|
+
const postsById = new Map();
|
|
103
|
+
for (const [anchor, id] of postIdByAnchor) {
|
|
104
|
+
if (postsById.has(id))
|
|
105
|
+
continue;
|
|
106
|
+
const url = toAbsoluteUrl(anchor.getAttribute('href'), baseUrl);
|
|
107
|
+
postsById.set(id, {
|
|
108
|
+
...createIdentifiedPost(id, url),
|
|
109
|
+
...readRawHtml(findPostElement(document, id, postIdByAnchor), 'listing')
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return Array.from(postsById.values());
|
|
113
|
+
}
|
|
114
|
+
export function extractPagination(document, pageUrl, baseUrl) {
|
|
115
|
+
const currentPageNumber = extractCurrentPageNumber(pageUrl);
|
|
116
|
+
const pageUrlsByNumber = collectPageUrlsByNumber(document, baseUrl);
|
|
117
|
+
const siblingPageUrl = (offset) => {
|
|
118
|
+
if (currentPageNumber === null)
|
|
119
|
+
return null;
|
|
120
|
+
return pageUrlsByNumber.get(currentPageNumber + offset) ?? null;
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
currentPageNumber,
|
|
124
|
+
nextPageUrl: toAbsoluteUrl(readLinkHref(document, 'next'), baseUrl) ?? siblingPageUrl(1),
|
|
125
|
+
previousPageUrl: toAbsoluteUrl(readLinkHref(document, 'prev'), baseUrl) ?? siblingPageUrl(-1)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function readFirstChildText(element, tagName) {
|
|
129
|
+
return toTextOrNull(element.getElementsByTagName(tagName)[0]?.textContent ?? null);
|
|
130
|
+
}
|
|
131
|
+
function normalizeWhitespace(raw) {
|
|
132
|
+
return raw.replace(/\s+/gv, ' ').trim();
|
|
133
|
+
}
|
|
134
|
+
function toTextOrNull(raw) {
|
|
135
|
+
if (raw === null)
|
|
136
|
+
return null;
|
|
137
|
+
const normalized = normalizeWhitespace(raw);
|
|
138
|
+
return normalized === '' ? null : normalized;
|
|
139
|
+
}
|
|
140
|
+
function toDateOrNull(raw) {
|
|
141
|
+
if (raw === null)
|
|
142
|
+
return null;
|
|
143
|
+
const parsed = new Date(raw);
|
|
144
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
145
|
+
}
|
|
146
|
+
function toAbsoluteUrl(href, baseUrl) {
|
|
147
|
+
if (href === null)
|
|
148
|
+
return null;
|
|
149
|
+
try {
|
|
150
|
+
return new URL(href, baseUrl ?? undefined).href;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function toPathname(url) {
|
|
157
|
+
if (url === null)
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
return new URL(url).pathname;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function sharesOrigin(url, baseUrl) {
|
|
167
|
+
if (baseUrl === null)
|
|
168
|
+
return true;
|
|
169
|
+
return toOrigin(url) === toOrigin(baseUrl);
|
|
170
|
+
}
|
|
171
|
+
function readAttribute(document, selector, attributeName) {
|
|
172
|
+
return toTextOrNull(document.querySelector(selector)?.getAttribute(attributeName) ?? null);
|
|
173
|
+
}
|
|
174
|
+
function readMetaName(document, name) {
|
|
175
|
+
return readAttribute(document, `meta[name="${name}"]`, 'content');
|
|
176
|
+
}
|
|
177
|
+
function readLinkHref(document, relation) {
|
|
178
|
+
return readAttribute(document, `link[rel="${relation}"]`, 'href');
|
|
179
|
+
}
|
|
180
|
+
function readEveryMetaProperty(document, property) {
|
|
181
|
+
return Array
|
|
182
|
+
.from(document.querySelectorAll(`meta[property="${property}"]`))
|
|
183
|
+
.map(element => toTextOrNull(element.getAttribute('content')))
|
|
184
|
+
.filter((content) => content !== null);
|
|
185
|
+
}
|
|
186
|
+
function extractBlogName(pageUrl, canonicalUrl) {
|
|
187
|
+
return readBlogNameFromCanonicalUrl(canonicalUrl)
|
|
188
|
+
?? readBlogNameFromHostname(pageUrl);
|
|
189
|
+
}
|
|
190
|
+
function readBlogNameFromCanonicalUrl(canonicalUrl) {
|
|
191
|
+
if (canonicalUrl === null)
|
|
192
|
+
return null;
|
|
193
|
+
let url;
|
|
194
|
+
try {
|
|
195
|
+
url = new URL(canonicalUrl);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (!TUMBLR_CANONICAL_HOSTNAMES.includes(url.hostname))
|
|
201
|
+
return null;
|
|
202
|
+
const segments = url.pathname.split('/').filter(segment => segment !== '');
|
|
203
|
+
return segments[0] ?? null;
|
|
204
|
+
}
|
|
205
|
+
function readBlogNameFromHostname(pageUrl) {
|
|
206
|
+
let hostname;
|
|
207
|
+
try {
|
|
208
|
+
hostname = new URL(pageUrl).hostname;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (!hostname.endsWith(TUMBLR_HOSTNAME_SUFFIX))
|
|
214
|
+
return null;
|
|
215
|
+
const name = hostname.slice(0, -TUMBLR_HOSTNAME_SUFFIX.length);
|
|
216
|
+
if (name === '' || name === 'www' || name.includes('.'))
|
|
217
|
+
return null;
|
|
218
|
+
return name;
|
|
219
|
+
}
|
|
220
|
+
function extractBlogDescription(document, pageKind) {
|
|
221
|
+
if (pageKind === 'permalink')
|
|
222
|
+
return null;
|
|
223
|
+
return readMetaName(document, 'description')
|
|
224
|
+
?? readMetaProperty(document, 'og:description');
|
|
225
|
+
}
|
|
226
|
+
function extractNavLinks(document, baseUrl) {
|
|
227
|
+
const navLinks = [];
|
|
228
|
+
const seenUrls = new Set();
|
|
229
|
+
for (const anchor of document.querySelectorAll('nav a[href], header a[href]')) {
|
|
230
|
+
const url = toAbsoluteUrl(anchor.getAttribute('href'), baseUrl);
|
|
231
|
+
const label = toTextOrNull(anchor.textContent);
|
|
232
|
+
if (url === null || label === null)
|
|
233
|
+
continue;
|
|
234
|
+
if (!sharesOrigin(url, baseUrl) || seenUrls.has(url))
|
|
235
|
+
continue;
|
|
236
|
+
seenUrls.add(url);
|
|
237
|
+
navLinks.push({ label, url });
|
|
238
|
+
}
|
|
239
|
+
return navLinks;
|
|
240
|
+
}
|
|
241
|
+
function extractPostIdFromUrl(url) {
|
|
242
|
+
const pathname = toPathname(url);
|
|
243
|
+
if (pathname === null)
|
|
244
|
+
return null;
|
|
245
|
+
return PERMALINK_PATHNAME_PATTERN.exec(pathname)?.[1] ?? null;
|
|
246
|
+
}
|
|
247
|
+
function extractPageNumberFromUrl(url) {
|
|
248
|
+
const pathname = toPathname(url);
|
|
249
|
+
if (pathname === null)
|
|
250
|
+
return null;
|
|
251
|
+
return toIntegerOrNull(INDEX_PAGE_PATHNAME_PATTERN.exec(pathname)?.[1] ?? null);
|
|
252
|
+
}
|
|
253
|
+
function extractPostType(openGraphType) {
|
|
254
|
+
if (openGraphType === null)
|
|
255
|
+
return 'unknown';
|
|
256
|
+
if (openGraphType.startsWith('video'))
|
|
257
|
+
return 'video';
|
|
258
|
+
if (openGraphType.startsWith('music'))
|
|
259
|
+
return 'audio';
|
|
260
|
+
return 'unknown';
|
|
261
|
+
}
|
|
262
|
+
function extractMedia(document) {
|
|
263
|
+
const media = [];
|
|
264
|
+
const imageUrl = readMetaProperty(document, 'og:image');
|
|
265
|
+
if (imageUrl !== null) {
|
|
266
|
+
media.push({
|
|
267
|
+
kind: 'image',
|
|
268
|
+
url: imageUrl,
|
|
269
|
+
widthPx: toIntegerOrNull(readMetaProperty(document, 'og:image:width')),
|
|
270
|
+
heightPx: toIntegerOrNull(readMetaProperty(document, 'og:image:height')),
|
|
271
|
+
alternativeText: readMetaProperty(document, 'og:image:alt')
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const videoUrl = readMetaProperty(document, 'og:video');
|
|
275
|
+
if (videoUrl !== null) {
|
|
276
|
+
media.push({
|
|
277
|
+
kind: 'video',
|
|
278
|
+
url: videoUrl,
|
|
279
|
+
widthPx: toIntegerOrNull(readMetaProperty(document, 'og:video:width')),
|
|
280
|
+
heightPx: toIntegerOrNull(readMetaProperty(document, 'og:video:height')),
|
|
281
|
+
alternativeText: null
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const audioUrl = readMetaProperty(document, 'og:audio');
|
|
285
|
+
if (audioUrl !== null) {
|
|
286
|
+
media.push({
|
|
287
|
+
kind: 'audio',
|
|
288
|
+
url: audioUrl,
|
|
289
|
+
widthPx: null,
|
|
290
|
+
heightPx: null,
|
|
291
|
+
alternativeText: null
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return media;
|
|
295
|
+
}
|
|
296
|
+
function indexPostAnchors(document, baseUrl) {
|
|
297
|
+
const postIdByAnchor = new Map();
|
|
298
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
299
|
+
const url = toAbsoluteUrl(anchor.getAttribute('href'), baseUrl);
|
|
300
|
+
if (url === null || !sharesOrigin(url, baseUrl))
|
|
301
|
+
continue;
|
|
302
|
+
const id = extractPostIdFromUrl(url);
|
|
303
|
+
if (id === null)
|
|
304
|
+
continue;
|
|
305
|
+
postIdByAnchor.set(anchor, id);
|
|
306
|
+
}
|
|
307
|
+
return postIdByAnchor;
|
|
308
|
+
}
|
|
309
|
+
function findPostElement(document, postId, postIdByAnchor) {
|
|
310
|
+
if (postId === null)
|
|
311
|
+
return null;
|
|
312
|
+
const blocks = [];
|
|
313
|
+
for (const [anchor, id] of postIdByAnchor) {
|
|
314
|
+
if (id !== postId)
|
|
315
|
+
continue;
|
|
316
|
+
const block = climbToPostBlock(anchor, postId, postIdByAnchor);
|
|
317
|
+
if (block !== null && !blocks.includes(block))
|
|
318
|
+
blocks.push(block);
|
|
319
|
+
}
|
|
320
|
+
if (blocks.length === 0)
|
|
321
|
+
return document.querySelector('article');
|
|
322
|
+
const namedBlocks = blocks.filter(block => namesPost(block, postId));
|
|
323
|
+
return widestElement(namedBlocks.length > 0 ? namedBlocks : blocks);
|
|
324
|
+
}
|
|
325
|
+
function climbToPostBlock(anchor, postId, postIdByAnchor) {
|
|
326
|
+
let candidate = anchor;
|
|
327
|
+
let widestBlock = null;
|
|
328
|
+
let nearestNamedBlock = null;
|
|
329
|
+
while (holdsOnlyThisPost(candidate, postId, postIdByAnchor)) {
|
|
330
|
+
widestBlock = candidate;
|
|
331
|
+
if (nearestNamedBlock === null && namesPost(candidate, postId))
|
|
332
|
+
nearestNamedBlock = candidate;
|
|
333
|
+
const parent = candidate.parentElement;
|
|
334
|
+
if (parent === null || parent.tagName === 'BODY' || parent.tagName === 'HTML')
|
|
335
|
+
break;
|
|
336
|
+
candidate = parent;
|
|
337
|
+
}
|
|
338
|
+
return nearestNamedBlock ?? widestBlock;
|
|
339
|
+
}
|
|
340
|
+
function widestElement(elements) {
|
|
341
|
+
let widest = null;
|
|
342
|
+
for (const element of elements) {
|
|
343
|
+
if (widest === null || element.outerHTML.length > widest.outerHTML.length)
|
|
344
|
+
widest = element;
|
|
345
|
+
}
|
|
346
|
+
return widest;
|
|
347
|
+
}
|
|
348
|
+
function holdsOnlyThisPost(element, postId, postIdByAnchor) {
|
|
349
|
+
if (element.matches(PAGE_LANDMARK_SELECTOR))
|
|
350
|
+
return false;
|
|
351
|
+
if (element.querySelector(PAGE_LANDMARK_SELECTOR) !== null)
|
|
352
|
+
return false;
|
|
353
|
+
for (const anchor of element.querySelectorAll('a[href]')) {
|
|
354
|
+
const id = postIdByAnchor.get(anchor);
|
|
355
|
+
if (id !== undefined && id !== postId)
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
function namesPost(element, postId) {
|
|
361
|
+
return element.tagName === 'ARTICLE' || element.id.includes(postId);
|
|
362
|
+
}
|
|
363
|
+
function readRawHtml(postElement, source) {
|
|
364
|
+
if (postElement === null)
|
|
365
|
+
return { rawHtml: null, rawHtmlSource: null };
|
|
366
|
+
return { rawHtml: postElement.outerHTML, rawHtmlSource: source };
|
|
367
|
+
}
|
|
368
|
+
function createIdentifiedPost(id, url) {
|
|
369
|
+
return {
|
|
370
|
+
id,
|
|
371
|
+
url,
|
|
372
|
+
type: 'unknown',
|
|
373
|
+
publishedOn: null,
|
|
374
|
+
title: null,
|
|
375
|
+
contentHtml: null,
|
|
376
|
+
rawHtml: null,
|
|
377
|
+
rawHtmlSource: null,
|
|
378
|
+
detail: null,
|
|
379
|
+
tags: [],
|
|
380
|
+
media: [],
|
|
381
|
+
notesCount: null,
|
|
382
|
+
reblogOfUrl: null,
|
|
383
|
+
lastModifiedOn: null
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function collectPageUrlsByNumber(document, baseUrl) {
|
|
387
|
+
const pageUrlsByNumber = new Map();
|
|
388
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
389
|
+
const url = toAbsoluteUrl(anchor.getAttribute('href'), baseUrl);
|
|
390
|
+
if (url === null || !sharesOrigin(url, baseUrl))
|
|
391
|
+
continue;
|
|
392
|
+
const pageNumber = extractPageNumberFromUrl(url);
|
|
393
|
+
if (pageNumber === null || pageUrlsByNumber.has(pageNumber))
|
|
394
|
+
continue;
|
|
395
|
+
pageUrlsByNumber.set(pageNumber, url);
|
|
396
|
+
}
|
|
397
|
+
return pageUrlsByNumber;
|
|
398
|
+
}
|
|
399
|
+
function extractCurrentPageNumber(pageUrl) {
|
|
400
|
+
const pathname = toPathname(pageUrl);
|
|
401
|
+
if (pathname === null)
|
|
402
|
+
return null;
|
|
403
|
+
const declaredPageNumber = extractPageNumberFromUrl(pageUrl);
|
|
404
|
+
if (declaredPageNumber !== null)
|
|
405
|
+
return declaredPageNumber;
|
|
406
|
+
return pathname === '/' ? 1 : null;
|
|
407
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -157,7 +157,7 @@ export type WeatherObservation = WeatherDataCore & {
|
|
|
157
157
|
/** Observation source metadata (station, provider, license...). */ source?: ObservationDataSource;
|
|
158
158
|
/** Time when the data was collected or ingested by the system. */ collectedOn?: Date;
|
|
159
159
|
/** Human-readable description or summary of the observation. */ description?: string;
|
|
160
|
-
/** Raw source data as received from the provider. */ rawData?: Record<string,
|
|
160
|
+
/** Raw source data as received from the provider. */ rawData?: Record<string, unknown> | string;
|
|
161
161
|
/** Quality control and processing metadata for the observation. */ observationQuality?: ObservationQualityMetadata;
|
|
162
162
|
};
|
|
163
163
|
};
|
|
@@ -175,7 +175,7 @@ export type WeatherForecast = WeatherDataCore & {
|
|
|
175
175
|
/** Forecast model and source metadata. */ source?: ForecastDataSource;
|
|
176
176
|
/** Time when the data was collected or ingested by the system. */ collectedOn?: Date;
|
|
177
177
|
/** Human-readable description or summary of the observation. */ description?: string;
|
|
178
|
-
/** Raw source data as received from the provider. */ rawData?: Record<string,
|
|
178
|
+
/** Raw source data as received from the provider. */ rawData?: Record<string, unknown> | string;
|
|
179
179
|
/** Associated probabilistic forecast information, if available. */ probabilistic?: ProbabilisticData;
|
|
180
180
|
};
|
|
181
181
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core weather data types for archiving and exchanging meteorological information.
|
|
3
|
+
*
|
|
4
|
+
* The structures are inspired by WMO (World Meteorological Organization) guidance
|
|
5
|
+
* and CF Conventions. Units are encoded explicitly in property names to avoid
|
|
6
|
+
* ambiguity and to make transformations between systems easier and safer.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as utils from './utils/index.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as utils from './utils/index.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function mergeClassNames(...names: Array<string | null | undefined | Array<string | null | undefined>>): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function mergeClassNames(...names) {
|
|
2
|
+
return names.map(name => {
|
|
3
|
+
if (typeof name !== 'string' && !Array.isArray(name))
|
|
4
|
+
return false;
|
|
5
|
+
if (Array.isArray(name))
|
|
6
|
+
return mergeClassNames(...name);
|
|
7
|
+
if (name.trim() === '')
|
|
8
|
+
return false;
|
|
9
|
+
return name.trim();
|
|
10
|
+
}).filter((name) => typeof name === 'string')
|
|
11
|
+
.join(' ');
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.d.ts
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HeadlessBrowserOptions, HeadlessReadOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A Chrome session that reads pages an HTTP client alone cannot reach.
|
|
4
|
+
*
|
|
5
|
+
* Some hosts — every `*.tumblr.com` blog among them — answer any plain request with a 403
|
|
6
|
+
* carrying a JavaScript challenge, and only serve the real page to whoever runs it. One
|
|
7
|
+
* tab is kept per origin: the first read of an origin navigates there and waits for the
|
|
8
|
+
* challenge to clear, and both read modes then reuse that cleared tab, so the cookies it
|
|
9
|
+
* was granted keep applying.
|
|
10
|
+
*
|
|
11
|
+
* The browser starts itself on first use and must be closed by the caller.
|
|
12
|
+
*/
|
|
13
|
+
export declare class HeadlessBrowser {
|
|
14
|
+
private readonly options;
|
|
15
|
+
private readonly pagesByOrigin;
|
|
16
|
+
private browser;
|
|
17
|
+
constructor(options?: HeadlessBrowserOptions);
|
|
18
|
+
/**
|
|
19
|
+
* Reads one URL as text, starting the browser and clearing the origin's challenge when
|
|
20
|
+
* this is the first read of that origin.
|
|
21
|
+
*
|
|
22
|
+
* @param url - Absolute URL to read.
|
|
23
|
+
* @param readOptions - Overrides this session's read defaults for this call.
|
|
24
|
+
* @returns The page's text, or `null` when the URL has no readable origin, or when a
|
|
25
|
+
* `'served'` read met a response that was not a success. A `'rendered'` read returns
|
|
26
|
+
* whatever the tab ended up holding, error pages included, since that is the document
|
|
27
|
+
* the browser was given.
|
|
28
|
+
* @throws If the browser cannot be started, if a navigation times out, or if an
|
|
29
|
+
* interstitial never clears, as `challenge-not-cleared`.
|
|
30
|
+
*/
|
|
31
|
+
readText(url: string, readOptions?: HeadlessReadOptions): Promise<string | null>;
|
|
32
|
+
/** Closes every tab and the browser itself. Reading again starts a new one. */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
private openOrigin;
|
|
35
|
+
private start;
|
|
36
|
+
private waitForChallenge;
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
|
3
|
+
+ ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
|
|
4
|
+
const DEFAULT_LAUNCH_ARGS = [
|
|
5
|
+
'--no-sandbox',
|
|
6
|
+
'--disable-setuid-sandbox',
|
|
7
|
+
// What a challenge actually reads: the automation flag is a one-line giveaway, and
|
|
8
|
+
// Chrome's headless default user agent names itself `HeadlessChrome`.
|
|
9
|
+
'--disable-blink-features=AutomationControlled'
|
|
10
|
+
];
|
|
11
|
+
const DEFAULT_CHALLENGE_MARKERS = ['Checking your browser'];
|
|
12
|
+
const DEFAULT_CHALLENGE_TIMEOUT_MS = 30000;
|
|
13
|
+
const DEFAULT_NAVIGATION_TIMEOUT_MS = 60000;
|
|
14
|
+
const DEFAULT_WAIT_UNTIL = 'networkidle2';
|
|
15
|
+
const CHALLENGE_POLL_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* A Chrome session that reads pages an HTTP client alone cannot reach.
|
|
18
|
+
*
|
|
19
|
+
* Some hosts — every `*.tumblr.com` blog among them — answer any plain request with a 403
|
|
20
|
+
* carrying a JavaScript challenge, and only serve the real page to whoever runs it. One
|
|
21
|
+
* tab is kept per origin: the first read of an origin navigates there and waits for the
|
|
22
|
+
* challenge to clear, and both read modes then reuse that cleared tab, so the cookies it
|
|
23
|
+
* was granted keep applying.
|
|
24
|
+
*
|
|
25
|
+
* The browser starts itself on first use and must be closed by the caller.
|
|
26
|
+
*/
|
|
27
|
+
export class HeadlessBrowser {
|
|
28
|
+
options;
|
|
29
|
+
pagesByOrigin = new Map();
|
|
30
|
+
browser = null;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reads one URL as text, starting the browser and clearing the origin's challenge when
|
|
36
|
+
* this is the first read of that origin.
|
|
37
|
+
*
|
|
38
|
+
* @param url - Absolute URL to read.
|
|
39
|
+
* @param readOptions - Overrides this session's read defaults for this call.
|
|
40
|
+
* @returns The page's text, or `null` when the URL has no readable origin, or when a
|
|
41
|
+
* `'served'` read met a response that was not a success. A `'rendered'` read returns
|
|
42
|
+
* whatever the tab ended up holding, error pages included, since that is the document
|
|
43
|
+
* the browser was given.
|
|
44
|
+
* @throws If the browser cannot be started, if a navigation times out, or if an
|
|
45
|
+
* interstitial never clears, as `challenge-not-cleared`.
|
|
46
|
+
*/
|
|
47
|
+
async readText(url, readOptions = {}) {
|
|
48
|
+
let origin;
|
|
49
|
+
try {
|
|
50
|
+
origin = new URL(url).origin;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const resolved = { ...this.options.read, ...readOptions };
|
|
56
|
+
const page = await this.openOrigin(origin, url, resolved);
|
|
57
|
+
if (resolved.source !== 'rendered')
|
|
58
|
+
return await readServedText(page, url);
|
|
59
|
+
if (page.url() !== url) {
|
|
60
|
+
await page.goto(url, { waitUntil: resolved.waitUntil ?? DEFAULT_WAIT_UNTIL });
|
|
61
|
+
await this.waitForChallenge(page);
|
|
62
|
+
}
|
|
63
|
+
await wait(resolved.settleMs ?? 0);
|
|
64
|
+
return await page.content();
|
|
65
|
+
}
|
|
66
|
+
/** Closes every tab and the browser itself. Reading again starts a new one. */
|
|
67
|
+
async close() {
|
|
68
|
+
const browser = this.browser;
|
|
69
|
+
this.browser = null;
|
|
70
|
+
this.pagesByOrigin.clear();
|
|
71
|
+
await browser?.close();
|
|
72
|
+
}
|
|
73
|
+
async openOrigin(origin, entryUrl, readOptions) {
|
|
74
|
+
const known = this.pagesByOrigin.get(origin);
|
|
75
|
+
if (known !== undefined)
|
|
76
|
+
return known;
|
|
77
|
+
const browser = await this.start();
|
|
78
|
+
const page = await browser.newPage();
|
|
79
|
+
this.pagesByOrigin.set(origin, page);
|
|
80
|
+
page.setDefaultNavigationTimeout(this.options.navigationTimeoutMs ?? DEFAULT_NAVIGATION_TIMEOUT_MS);
|
|
81
|
+
// A challenge answers 403 until it is solved, so a failed navigation is expected here.
|
|
82
|
+
await page
|
|
83
|
+
.goto(entryUrl, { waitUntil: readOptions.waitUntil ?? DEFAULT_WAIT_UNTIL })
|
|
84
|
+
.catch(() => null);
|
|
85
|
+
await this.waitForChallenge(page);
|
|
86
|
+
return page;
|
|
87
|
+
}
|
|
88
|
+
async start() {
|
|
89
|
+
this.browser ??= await puppeteer.launch({
|
|
90
|
+
headless: true,
|
|
91
|
+
args: [
|
|
92
|
+
...this.options.launchArgs ?? DEFAULT_LAUNCH_ARGS,
|
|
93
|
+
`--user-agent=${this.options.userAgent ?? DEFAULT_USER_AGENT}`
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
return this.browser;
|
|
97
|
+
}
|
|
98
|
+
async waitForChallenge(page) {
|
|
99
|
+
const markers = this.options.challengeMarkers ?? DEFAULT_CHALLENGE_MARKERS;
|
|
100
|
+
if (markers.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const deadline = Date.now()
|
|
103
|
+
+ (this.options.challengeTimeoutMs ?? DEFAULT_CHALLENGE_TIMEOUT_MS);
|
|
104
|
+
while (Date.now() < deadline) {
|
|
105
|
+
// Reading mid-navigation throws, which only means the challenge is still working.
|
|
106
|
+
const content = await page.content().catch(() => null);
|
|
107
|
+
if (content !== null && !markers.some(marker => content.includes(marker)))
|
|
108
|
+
return;
|
|
109
|
+
await wait(CHALLENGE_POLL_MS);
|
|
110
|
+
}
|
|
111
|
+
throw new Error('challenge-not-cleared');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function readServedText(page, url) {
|
|
115
|
+
return await page.evaluate(async (target) => {
|
|
116
|
+
const response = await fetch(target, { credentials: 'include' });
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
return null;
|
|
119
|
+
return await response.text();
|
|
120
|
+
}, url);
|
|
121
|
+
}
|
|
122
|
+
async function wait(durationMs) {
|
|
123
|
+
if (durationMs <= 0)
|
|
124
|
+
return;
|
|
125
|
+
await new Promise(resolve => { setTimeout(resolve, durationMs); });
|
|
126
|
+
}
|