crawlforge-extractors 1.14.0 → 1.16.0
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/README.md +6 -1
- package/index.d.ts +19 -1
- package/package.json +1 -1
- package/src/blockedPage.js +14 -1
- package/src/connectors/ats.js +31 -0
- package/src/connectors/gov.js +16 -0
- package/src/pageText.js +5 -3
- package/src/templates.js +105 -8
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ URL is reported in `reason` as a retired handle.
|
|
|
202
202
|
### Recognising a blocked page
|
|
203
203
|
|
|
204
204
|
`documentVerdict` says what a fetched document is: the page, a bot-wall
|
|
205
|
-
interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5), an
|
|
205
|
+
interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5, Fastly), an
|
|
206
206
|
HTTP error page, an empty shell, a short error-titled placeholder, or a
|
|
207
207
|
client-rendered app's own short error fallback under a normal title (quora.com's
|
|
208
208
|
"Something went wrong. Wait a moment and try again."). A wall
|
|
@@ -291,6 +291,11 @@ deduplicated on the URL without fragment or trailing slash; a
|
|
|
291
291
|
|
|
292
292
|
**Government APIs** (`src/connectors/gov.js`). `nhtsa-vin` · `npi-provider`
|
|
293
293
|
|
|
294
|
+
A template that can run from params instead of a URL lists them in `params`
|
|
295
|
+
(`[{ name, required, description }]`, also in `registry.list()`). A template
|
|
296
|
+
can add `warnings` to its result: `reddit-thread` warns when the URL names a
|
|
297
|
+
different subreddit from the post's.
|
|
298
|
+
|
|
294
299
|
`shopify-collection` is a list connector: it reads a store's own
|
|
295
300
|
`/collections/<handle>/products.json` and returns every product in the
|
|
296
301
|
collection with the same authoritative price, compare-at price and stock that
|
package/index.d.ts
CHANGED
|
@@ -62,6 +62,20 @@ export interface ScrapeTemplate {
|
|
|
62
62
|
* host rate limiter's job.
|
|
63
63
|
*/
|
|
64
64
|
crawlDelaySeconds?: number;
|
|
65
|
+
/** What listUrl takes. Declared by every template that defines listUrl. */
|
|
66
|
+
params?: TemplateParam[];
|
|
67
|
+
/**
|
|
68
|
+
* Notes about a request that still produced a correct record (reddit-thread:
|
|
69
|
+
* the URL named the wrong subreddit). run() returns them as `warnings`.
|
|
70
|
+
*/
|
|
71
|
+
warnings?: (data: Record<string, unknown>, url: string) => string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One parameter a list connector's listUrl accepts. */
|
|
75
|
+
export interface TemplateParam {
|
|
76
|
+
name: string;
|
|
77
|
+
required: boolean;
|
|
78
|
+
description: string;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
export interface TemplateSummary {
|
|
@@ -76,6 +90,8 @@ export interface TemplateSummary {
|
|
|
76
90
|
requires_api_key?: true;
|
|
77
91
|
/** Present only when the template sets credentialRef. */
|
|
78
92
|
credential_ref?: string;
|
|
93
|
+
/** Present on every template that can be driven by params. */
|
|
94
|
+
params?: TemplateParam[];
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
export interface TemplateResult {
|
|
@@ -86,6 +102,8 @@ export interface TemplateResult {
|
|
|
86
102
|
fetchedUrl?: string;
|
|
87
103
|
data: Record<string, unknown>;
|
|
88
104
|
extractedAt: string;
|
|
105
|
+
/** Present only when the template's warnings hook reported something. */
|
|
106
|
+
warnings?: string[];
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
export interface TemplateListResult {
|
|
@@ -308,7 +326,7 @@ export default TemplateRegistry;
|
|
|
308
326
|
|
|
309
327
|
/** The bot-defence vendor whose interstitial a document is, and what gave it away. */
|
|
310
328
|
export interface ChallengeVerdict {
|
|
311
|
-
vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel' | 'aws-waf' | 'f5';
|
|
329
|
+
vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel' | 'aws-waf' | 'f5' | 'fastly';
|
|
312
330
|
evidence: string;
|
|
313
331
|
}
|
|
314
332
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/blockedPage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* blockedPage.js — decide whether a fetched document is the page or a wall.
|
|
3
3
|
*
|
|
4
|
-
* Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF and
|
|
4
|
+
* Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel, AWS WAF, F5 and Fastly answer
|
|
5
5
|
* a blocked request with a page of their own, often under HTTP 200: a title, some prose
|
|
6
6
|
* and a challenge script. Reported as a successful scrape, that page hides
|
|
7
7
|
* the block — producthunt.com came back "success:true, title: Just a
|
|
@@ -80,6 +80,19 @@ const CHALLENGES = [
|
|
|
80
80
|
title: /^request rejected$/i,
|
|
81
81
|
markers: /the requested url was rejected\.?\s*please consult with your administrator/i,
|
|
82
82
|
evidence: 'an F5 "Request Rejected" blocking page'
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// Fastly's client challenge: HTTP 200, the title "Client Challenge", a
|
|
86
|
+
// <noscript> asking for JavaScript and a loader for
|
|
87
|
+
// /_fs-ch-<token>/script.js, every asset under that same prefix
|
|
88
|
+
// (lemonde.fr to a plain fetch, 3 KB, `via: 1.1 varnish` and
|
|
89
|
+
// `x-served-by: cache-…`, 2026-10-03). Its only text outside <noscript>
|
|
90
|
+
// is a hidden "A required part of this site couldn't load" notice, which
|
|
91
|
+
// is why it read as a page rather than an empty shell.
|
|
92
|
+
vendor: 'fastly',
|
|
93
|
+
title: /^client challenge$/i,
|
|
94
|
+
markers: /\/_fs-ch-[\w-]+\//,
|
|
95
|
+
evidence: 'a Fastly client challenge script'
|
|
83
96
|
}
|
|
84
97
|
];
|
|
85
98
|
|
package/src/connectors/ats.js
CHANGED
|
@@ -232,6 +232,11 @@ export const ATS_TEMPLATES = [
|
|
|
232
232
|
'payload: Stripe\'s 571-job board is 349 KB without them and 4.2 MB with them.',
|
|
233
233
|
targetPattern: /(boards|job-boards|boards-api)\.greenhouse\.io\//i,
|
|
234
234
|
|
|
235
|
+
params: [
|
|
236
|
+
{ name: 'company', required: true, description: 'Board token, the path segment in https://job-boards.greenhouse.io/<token>.' },
|
|
237
|
+
{ name: 'content', required: false, description: 'true adds each job\'s description (much larger response).' }
|
|
238
|
+
],
|
|
239
|
+
|
|
235
240
|
/**
|
|
236
241
|
* `company` is Greenhouse's board token — the path segment in
|
|
237
242
|
* https://job-boards.greenhouse.io/<token>, not the company's display name.
|
|
@@ -322,6 +327,12 @@ export const ATS_TEMPLATES = [
|
|
|
322
327
|
*/
|
|
323
328
|
crawlDelaySeconds: 1,
|
|
324
329
|
|
|
330
|
+
params: [
|
|
331
|
+
{ name: 'company', required: true, description: 'The path segment in https://jobs.lever.co/<company>.' },
|
|
332
|
+
{ name: 'skip', required: false, description: 'Postings to skip, for paging.' },
|
|
333
|
+
{ name: 'limit', required: false, description: 'Postings to return, for paging.' }
|
|
334
|
+
],
|
|
335
|
+
|
|
325
336
|
/** `company` is the path segment in https://jobs.lever.co/<company>. */
|
|
326
337
|
listUrl(params = {}) {
|
|
327
338
|
const company = requireParam(
|
|
@@ -404,6 +415,11 @@ export const ATS_TEMPLATES = [
|
|
|
404
415
|
'a fraction of that without.',
|
|
405
416
|
targetPattern: /(jobs|api)\.ashbyhq\.com\//i,
|
|
406
417
|
|
|
418
|
+
params: [
|
|
419
|
+
{ name: 'company', required: true, description: 'Jobs page name, the segment in https://jobs.ashbyhq.com/<name>.' },
|
|
420
|
+
{ name: 'descriptions', required: false, description: 'true keeps each job\'s plain-text description.' }
|
|
421
|
+
],
|
|
422
|
+
|
|
407
423
|
/** `company` is Ashby's jobs page name — the segment in https://jobs.ashbyhq.com/<name>. */
|
|
408
424
|
listUrl(params = {}) {
|
|
409
425
|
const company = requireParam(
|
|
@@ -495,6 +511,11 @@ export const ATS_TEMPLATES = [
|
|
|
495
511
|
'documented ?details=true parameter.',
|
|
496
512
|
targetPattern: /(apply\.workable\.com|www\.workable\.com\/api\/accounts|[\w-]+\.workable\.com\/(jobs|spi))/i,
|
|
497
513
|
|
|
514
|
+
params: [
|
|
515
|
+
{ name: 'company', required: true, description: 'Account subdomain, the path segment in https://apply.workable.com/<subdomain>.' },
|
|
516
|
+
{ name: 'details', required: false, description: 'true adds each job\'s description.' }
|
|
517
|
+
],
|
|
518
|
+
|
|
498
519
|
/** `company` is the account subdomain — the first part of the signed-in Workable URL. */
|
|
499
520
|
listUrl(params = {}) {
|
|
500
521
|
const company = requireParam(
|
|
@@ -580,6 +601,10 @@ export const ATS_TEMPLATES = [
|
|
|
580
601
|
'plain-text description for every open role in one request.',
|
|
581
602
|
targetPattern: /\.recruitee\.com\//i,
|
|
582
603
|
|
|
604
|
+
params: [
|
|
605
|
+
{ name: 'company', required: true, description: 'The subdomain in https://<company>.recruitee.com.' }
|
|
606
|
+
],
|
|
607
|
+
|
|
583
608
|
/** `company` is the Recruitee subdomain in https://<company>.recruitee.com. */
|
|
584
609
|
listUrl(params = {}) {
|
|
585
610
|
const company = requireParam(
|
|
@@ -672,6 +697,12 @@ export const ATS_TEMPLATES = [
|
|
|
672
697
|
// which robots disallows anyway) still does not match.
|
|
673
698
|
targetPattern: /teamtailor\.com\/?(?:jobs(\.rss)?\/?)?(\?|#|$)/i,
|
|
674
699
|
|
|
700
|
+
params: [
|
|
701
|
+
{ name: 'company', required: true, description: 'The subdomain in https://<company>.teamtailor.com.' },
|
|
702
|
+
{ name: 'per_page', required: false, description: 'Jobs per feed page (feed default 100).' },
|
|
703
|
+
{ name: 'offset', required: false, description: 'Jobs to skip, for paging.' }
|
|
704
|
+
],
|
|
705
|
+
|
|
675
706
|
/** `company` is the subdomain in https://<company>.teamtailor.com. */
|
|
676
707
|
listUrl(params = {}) {
|
|
677
708
|
const company = requireParam(
|
package/src/connectors/gov.js
CHANGED
|
@@ -199,6 +199,11 @@ export const GOV_TEMPLATES = [
|
|
|
199
199
|
'unknown positions), and vPIC\'s own error codes are reported rather than swallowed.',
|
|
200
200
|
targetPattern: /vpic\.nhtsa\.dot\.gov\/api\/vehicles\/DecodeVin/i,
|
|
201
201
|
|
|
202
|
+
params: [
|
|
203
|
+
{ name: 'vin', required: true, description: 'The VIN to decode; "*" marks an unknown position, as in 5UXWX7C5*BA.' },
|
|
204
|
+
{ name: 'modelYear', required: false, description: 'Model year, which vPIC says improves decode accuracy.' }
|
|
205
|
+
],
|
|
206
|
+
|
|
202
207
|
/**
|
|
203
208
|
* @param {{ vin: string, modelYear?: string|number }} params
|
|
204
209
|
*/
|
|
@@ -342,6 +347,17 @@ export const GOV_TEMPLATES = [
|
|
|
342
347
|
'NPI and joins nothing to it.',
|
|
343
348
|
targetPattern: /npiregistry\.cms\.hhs\.gov\/api/i,
|
|
344
349
|
|
|
350
|
+
// At least one search criterion is required; none of them is on its own.
|
|
351
|
+
params: [
|
|
352
|
+
...NPI_SEARCH_FIELDS.map(name => ({
|
|
353
|
+
name,
|
|
354
|
+
required: false,
|
|
355
|
+
description: 'Registry search criterion, in its own spelling. Pass at least one.'
|
|
356
|
+
})),
|
|
357
|
+
{ name: 'limit', required: false, description: `Records to return, 1-${NPI_MAX_LIMIT} (default ${NPI_DEFAULT_LIMIT}).` },
|
|
358
|
+
{ name: 'skip', required: false, description: `Records to skip, 0-${NPI_MAX_SKIP}.` }
|
|
359
|
+
],
|
|
360
|
+
|
|
345
361
|
/**
|
|
346
362
|
* @param {Record<string, string|number>} params — the registry's own search fields
|
|
347
363
|
*/
|
package/src/pageText.js
CHANGED
|
@@ -22,8 +22,10 @@ const BLOCK_SELECTOR =
|
|
|
22
22
|
const SENTINEL = '\uE000';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Flatten elements to text, one line per block element
|
|
26
|
-
*
|
|
25
|
+
* Flatten elements to text, one line per block element: a block starts and
|
|
26
|
+
* ends a line, so inline text just before one is not welded to it (Readability
|
|
27
|
+
* turns "July 2023<br><br>If you…" into "July 2023<p>If you…</p>", which read
|
|
28
|
+
* "July 2023If you…", R24 3.1). Table cells on a row are joined by a space. Works on a detached clone, so the caller's tree is
|
|
27
29
|
* untouched. Callers remove `<script>`/`<style>` first: their text is read
|
|
28
30
|
* like any other.
|
|
29
31
|
*
|
|
@@ -36,7 +38,7 @@ export function flattenText($, root = $('body')) {
|
|
|
36
38
|
const $root = root.clone();
|
|
37
39
|
$root.find('br').replaceWith(SENTINEL);
|
|
38
40
|
$root.find('td, th').after(' ');
|
|
39
|
-
$root.find(BLOCK_SELECTOR).after(SENTINEL);
|
|
41
|
+
$root.find(BLOCK_SELECTOR).before(SENTINEL).after(SENTINEL);
|
|
40
42
|
return $root
|
|
41
43
|
.toArray()
|
|
42
44
|
.map((el) => $(el).text())
|
package/src/templates.js
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
* Defining extractList is the only signal that a template is a list connector;
|
|
33
33
|
* there is no `kind` field. A list connector may also define
|
|
34
34
|
* resolveUrl/targetPattern, so a caller can pass a URL instead of params.
|
|
35
|
+
* Every template with a listUrl declares what it takes, which list() publishes:
|
|
36
|
+
* params — [{ name, required, description }]
|
|
37
|
+
*
|
|
38
|
+
* One more optional hook reports what the caller should know about a request
|
|
39
|
+
* that still produced a correct record; run() returns it as `warnings`:
|
|
40
|
+
* warnings(data,url) — string[]
|
|
35
41
|
*
|
|
36
42
|
* A connector against a key-based API declares it:
|
|
37
43
|
* requiresApiKey: true
|
|
@@ -519,6 +525,37 @@ function githubCounter($, sel) {
|
|
|
519
525
|
return /\d/.test(exact || '') ? exact : text($, sel);
|
|
520
526
|
}
|
|
521
527
|
|
|
528
|
+
/**
|
|
529
|
+
* A count as an integer, or null. The sidebar payload carries exact numbers
|
|
530
|
+
* (stargazerCount: 32407) and a counter's title= the same number with commas
|
|
531
|
+
* ("32,407"); the counter's visible text is rounded ("32.4k") and reads as
|
|
532
|
+
* null here rather than as a number it is not.
|
|
533
|
+
*/
|
|
534
|
+
function githubCount(value) {
|
|
535
|
+
if (typeof value === 'number') return Number.isInteger(value) ? value : null;
|
|
536
|
+
const digits = typeof value === 'string' ? value.replace(/,/g, '').trim() : '';
|
|
537
|
+
return /^\d+$/.test(digits) ? Number.parseInt(digits, 10) : null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** README excerpts are capped: the first paragraph of some READMEs is an essay. */
|
|
541
|
+
const README_SUMMARY_MAX = 500;
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The README's first paragraph with text in it. The rendered README is in the
|
|
545
|
+
* served page (article.markdown-body); a leading paragraph of badges is all
|
|
546
|
+
* <img> and has no text, so it is skipped.
|
|
547
|
+
*/
|
|
548
|
+
function githubReadmeSummary($) {
|
|
549
|
+
for (const el of $('article.markdown-body p').toArray()) {
|
|
550
|
+
const paragraph = $(el).text().replace(/\s+/g, ' ').trim();
|
|
551
|
+
if (!paragraph) continue;
|
|
552
|
+
return paragraph.length > README_SUMMARY_MAX
|
|
553
|
+
? `${paragraph.slice(0, README_SUMMARY_MAX - 1).trimEnd()}…`
|
|
554
|
+
: paragraph;
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
522
559
|
// ── Template definitions ─────────────────────────────────────────────────────
|
|
523
560
|
|
|
524
561
|
export const TEMPLATES = [
|
|
@@ -601,6 +638,13 @@ export const TEMPLATES = [
|
|
|
601
638
|
return parsed.toString();
|
|
602
639
|
},
|
|
603
640
|
|
|
641
|
+
params: [
|
|
642
|
+
{ name: 'store', required: true, description: 'Storefront domain, e.g. "www.allbirds.com".' },
|
|
643
|
+
{ name: 'collection', required: true, description: 'Collection handle, e.g. "mens" from /collections/mens.' },
|
|
644
|
+
{ name: 'limit', required: false, description: 'Products per page, 1-250 (Shopify default 30).' },
|
|
645
|
+
{ name: 'page', required: false, description: 'Page number, 1 or more.' }
|
|
646
|
+
],
|
|
647
|
+
|
|
604
648
|
/**
|
|
605
649
|
* Build the listing URL from params instead of a URL.
|
|
606
650
|
* @param {{ store: string, collection: string, limit?: number, page?: number }} params
|
|
@@ -749,8 +793,24 @@ export const TEMPLATES = [
|
|
|
749
793
|
{
|
|
750
794
|
id: 'github-repo',
|
|
751
795
|
name: 'GitHub Repository',
|
|
752
|
-
description:
|
|
753
|
-
|
|
796
|
+
description:
|
|
797
|
+
'Scrape a GitHub repository page for description, exact star, fork, watcher and open-issue ' +
|
|
798
|
+
'counts, topics, licence, homepage and a README summary (the README\'s first paragraph). ' +
|
|
799
|
+
'A /tree/<branch>/… link reads the repository\'s overview page. Language and last-push ' +
|
|
800
|
+
'date are null on the page GitHub serves logged out — its browser fetches them after load.',
|
|
801
|
+
targetPattern: /github\.com\/[^/]+\/[^/]+(?:\/tree\/.+)?\/?$/i,
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* A /tree/<branch>/<path> link is a directory listing: no About sidebar,
|
|
805
|
+
* no README of the repository. The repo-level facts this template returns
|
|
806
|
+
* live on the overview page, so that is what gets fetched.
|
|
807
|
+
*/
|
|
808
|
+
resolveUrl(url) {
|
|
809
|
+
const parsed = safeUrl(url);
|
|
810
|
+
const repo = parsed && /^\/([^/]+)\/([^/]+)\/tree\//.exec(parsed.pathname);
|
|
811
|
+
return repo ? `${parsed.origin}/${repo[1]}/${repo[2]}` : url;
|
|
812
|
+
},
|
|
813
|
+
|
|
754
814
|
extract($) {
|
|
755
815
|
const about = githubSidebarAbout($);
|
|
756
816
|
return {
|
|
@@ -761,11 +821,13 @@ export const TEMPLATES = [
|
|
|
761
821
|
description: about
|
|
762
822
|
? tidy(about.description)
|
|
763
823
|
: tidy(text($, 'p.f4.my-3')) || githubOgDescription($),
|
|
764
|
-
|
|
765
|
-
|
|
824
|
+
// Exact integers: the payload's counts first, then the counter's
|
|
825
|
+
// title=. The counter's text ("32.4k") is rounded and never used.
|
|
826
|
+
stars: githubCount(about?.stargazerCount) ?? githubCount(attr($, '#repo-stars-counter-star', 'title')),
|
|
827
|
+
forks: githubCount(about?.forksCount) ?? githubCount(attr($, '#repo-network-counter', 'title')),
|
|
766
828
|
// React (logged-out) layout has no watchers aria-label; the count is
|
|
767
829
|
// the <strong> right after the single octicon-eye.
|
|
768
|
-
watchers: text($, '.octicon-eye + strong')
|
|
830
|
+
watchers: githubCount(about?.watcherCount) ?? githubCount(text($, '.octicon-eye + strong')),
|
|
769
831
|
// Language and the last-push date appear nowhere in the logged-out
|
|
770
832
|
// page (verified 2026-08-26 across five User-Agents, embedded JSON
|
|
771
833
|
// included): the client fetches them after load from header-gated
|
|
@@ -781,7 +843,8 @@ export const TEMPLATES = [
|
|
|
781
843
|
// When the sidebar payload is present it is authoritative: an empty
|
|
782
844
|
// website means "no homepage", not "go scrape some external link".
|
|
783
845
|
homepage: safeHref(about ? about.website : attr($, 'a[href][rel="noopener noreferrer"]', 'href')),
|
|
784
|
-
open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
|
|
846
|
+
open_issues: githubCount(githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')),
|
|
847
|
+
readme_summary: githubReadmeSummary($)
|
|
785
848
|
};
|
|
786
849
|
}
|
|
787
850
|
},
|
|
@@ -790,7 +853,19 @@ export const TEMPLATES = [
|
|
|
790
853
|
id: 'youtube-video',
|
|
791
854
|
name: 'YouTube Video',
|
|
792
855
|
description: 'Scrape a YouTube video page for title, channel, views, likes, publish date, and description.',
|
|
793
|
-
targetPattern: /youtube\.com\/watch
|
|
856
|
+
targetPattern: /youtube\.com\/watch|youtu\.be\/[\w-]+/i,
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* youtu.be/<id> is the share-button link. youtu.be only redirects, so the
|
|
860
|
+
* watch page for the same id is what gets fetched.
|
|
861
|
+
*/
|
|
862
|
+
resolveUrl(url) {
|
|
863
|
+
const parsed = safeUrl(url);
|
|
864
|
+
if (!parsed || !/^(?:www\.)?youtu\.be$/i.test(parsed.hostname)) return url;
|
|
865
|
+
const id = parsed.pathname.split('/').filter(Boolean)[0];
|
|
866
|
+
return id ? `https://www.youtube.com/watch?v=${encodeURIComponent(id)}` : url;
|
|
867
|
+
},
|
|
868
|
+
|
|
794
869
|
extract($) {
|
|
795
870
|
return {
|
|
796
871
|
title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
|
|
@@ -866,6 +941,21 @@ export const TEMPLATES = [
|
|
|
866
941
|
removed: post.removed_by_category ?? post._meta?.removal_type ?? null,
|
|
867
942
|
note: 'Read from the Arctic Shift archive, not reddit.com. Scores and comment counts of content less than ~36h old may read 0/1. For the comment tree call reddit_search with mode:"thread" and this id.'
|
|
868
943
|
};
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Only the post id reaches the archive, so a URL naming the wrong
|
|
948
|
+
* subreddit still returns the right post — ids are unique across Reddit,
|
|
949
|
+
* and reddit.com itself redirects such a URL to the real one. That is
|
|
950
|
+
* worth a warning, not a refusal: the caller gets the post it named.
|
|
951
|
+
*/
|
|
952
|
+
warnings(data, url) {
|
|
953
|
+
const named = /reddit\.com\/r\/([^/?#]+)\/comments\//i.exec(url ?? '')?.[1];
|
|
954
|
+
if (!named || !data.subreddit || named.toLowerCase() === data.subreddit.toLowerCase()) return [];
|
|
955
|
+
return [
|
|
956
|
+
`The URL names r/${named}, but post ${data.id} is in r/${data.subreddit}. Reddit post ids ` +
|
|
957
|
+
'are unique, so this is the post the id names; data.url is its real address.'
|
|
958
|
+
];
|
|
869
959
|
}
|
|
870
960
|
},
|
|
871
961
|
|
|
@@ -1177,6 +1267,8 @@ export class TemplateRegistry {
|
|
|
1177
1267
|
targetPattern: t.targetPattern ? t.targetPattern.toString() : null,
|
|
1178
1268
|
// extractList is the only signal that a template returns N entities.
|
|
1179
1269
|
mode: t.extractList ? 'list' : 'entity',
|
|
1270
|
+
// What listUrl takes, so a caller can drive a connector without a URL.
|
|
1271
|
+
...(t.params ? { params: t.params } : {}),
|
|
1180
1272
|
...(t.requiresApiKey ? { requires_api_key: true } : {}),
|
|
1181
1273
|
...(t.credentialRef ? { credential_ref: t.credentialRef } : {})
|
|
1182
1274
|
}));
|
|
@@ -1254,13 +1346,18 @@ export class TemplateRegistry {
|
|
|
1254
1346
|
);
|
|
1255
1347
|
}
|
|
1256
1348
|
|
|
1349
|
+
// Something about the request worth telling the caller that does not make
|
|
1350
|
+
// the record wrong (reddit-thread: the URL named the wrong subreddit).
|
|
1351
|
+
const warnings = template.warnings ? template.warnings(data, url) : [];
|
|
1352
|
+
|
|
1257
1353
|
return {
|
|
1258
1354
|
template: id,
|
|
1259
1355
|
template_name: template.name,
|
|
1260
1356
|
url,
|
|
1261
1357
|
...(fetchedUrl !== url ? { fetchedUrl } : {}),
|
|
1262
1358
|
data,
|
|
1263
|
-
extractedAt: new Date().toISOString()
|
|
1359
|
+
extractedAt: new Date().toISOString(),
|
|
1360
|
+
...(warnings.length > 0 ? { warnings } : {})
|
|
1264
1361
|
};
|
|
1265
1362
|
}
|
|
1266
1363
|
|