crawlforge-extractors 1.4.0 → 1.5.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 +26 -5
- package/index.d.ts +12 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/templates.js +176 -67
package/README.md
CHANGED
|
@@ -168,9 +168,9 @@ calls `eval`.
|
|
|
168
168
|
## Templates
|
|
169
169
|
|
|
170
170
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
|
171
|
-
`amazon-product` · `
|
|
172
|
-
`
|
|
173
|
-
`
|
|
171
|
+
`amazon-product` · `github-repo` · `youtube-video` · `reddit-thread` ·
|
|
172
|
+
`hacker-news-front-page` · `producthunt-launch` · `stackoverflow-question` ·
|
|
173
|
+
`npm-package`
|
|
174
174
|
|
|
175
175
|
**Job boards** (`src/connectors/ats.js`). `greenhouse-jobs` ·
|
|
176
176
|
`lever-postings` · `ashby-jobs` · `workable-jobs` · `recruitee-offers` ·
|
|
@@ -197,6 +197,13 @@ large board past 4 MB. `lever-postings` declares `crawlDelaySeconds: 1`,
|
|
|
197
197
|
which `api.lever.co/robots.txt` asks for and the calling surface's host rate
|
|
198
198
|
limiter is expected to honour.
|
|
199
199
|
|
|
200
|
+
`stackoverflow-question` reads the Stack Exchange API rather than the rendered
|
|
201
|
+
page: stackoverflow.com answers every non-browser fetch (curl, node, and a
|
|
202
|
+
browser User-Agent alike) with a Cloudflare 403, so the old selector extractor
|
|
203
|
+
never saw a document. The API is keyless — 300 requests per day per IP — and
|
|
204
|
+
one request carries the question, its owner and every answer; the template
|
|
205
|
+
returns the accepted answer first, then by score, with bodies as plain text.
|
|
206
|
+
|
|
200
207
|
`nhtsa-vin` decodes a VIN through the NHTSA vPIC API — the ~154 returned
|
|
201
208
|
fields are curated into a named vehicle shape with the API's empty-string
|
|
202
209
|
"not applicable" normalised to `null`, the full set kept under `raw`, and the
|
|
@@ -205,8 +212,22 @@ swallowed, because a partial decode is a real answer. `npi-provider` reads the
|
|
|
205
212
|
CMS NPI Registry — a public professional registry — and passes its records
|
|
206
213
|
through as published. Neither needs a key.
|
|
207
214
|
|
|
208
|
-
`reddit-thread`
|
|
209
|
-
|
|
215
|
+
`reddit-thread` reads the Arctic Shift community archive
|
|
216
|
+
(`arctic-shift.photon-reddit.com/api/posts/ids`) rather than reddit.com, which
|
|
217
|
+
403s every non-browser client and disallows everything in robots.txt. One
|
|
218
|
+
keyless request returns the post record — title, subreddit, author, score,
|
|
219
|
+
upvote ratio, comment count, body, flair, removal state. The comment tree is
|
|
220
|
+
a second endpoint and belongs to the calling surface's `reddit_search` tool,
|
|
221
|
+
which the returned `id` feeds directly.
|
|
222
|
+
|
|
223
|
+
`linkedin-profile` and `tweet` are **retired** (2026-08-30) and live in
|
|
224
|
+
`RETIRED_TEMPLATES` with the reason: LinkedIn's robots.txt disallows every
|
|
225
|
+
path for all agents but its own crawler and profiles sit behind an auth wall;
|
|
226
|
+
X's robots.txt disallows every path for generic agents and its keyless embed
|
|
227
|
+
endpoints (`cdn.syndication.twimg.com`, `publish.x.com/oembed`) are disallowed
|
|
228
|
+
by their own robots.txt. `retiredTemplate(idOrUrl)` answers for either an id or
|
|
229
|
+
a URL they handled, so a surface can return the reason instead of "unknown
|
|
230
|
+
template".
|
|
210
231
|
|
|
211
232
|
`smartrecruiters-postings` is deliberately **not** shipped: SmartRecruiters
|
|
212
233
|
documents the endpoint publicly, but `api.smartrecruiters.com/robots.txt`
|
package/index.d.ts
CHANGED
|
@@ -100,6 +100,18 @@ export interface TemplateListResult {
|
|
|
100
100
|
|
|
101
101
|
export declare const TEMPLATES: ScrapeTemplate[];
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Templates withdrawn because there is no compliant way to reach the data,
|
|
105
|
+
* by id: the URL shape each one handled, and why it is gone.
|
|
106
|
+
*/
|
|
107
|
+
export declare const RETIRED_TEMPLATES: Record<string, { targetPattern: RegExp; reason: string }>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The retired template a caller is reaching for — by id, or by a URL one of
|
|
111
|
+
* them handled — or null.
|
|
112
|
+
*/
|
|
113
|
+
export declare function retiredTemplate(idOrUrl: string): { id: string; reason: string } | null;
|
|
114
|
+
|
|
103
115
|
export declare class TemplateRegistry {
|
|
104
116
|
/** @param templates injectable, so a test can register a fixture template. */
|
|
105
117
|
constructor(templates?: ScrapeTemplate[]);
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* caller has already issued rather than a URL.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
export { TemplateRegistry, TEMPLATES } from './src/templates.js';
|
|
17
|
+
export { TemplateRegistry, TEMPLATES, RETIRED_TEMPLATES, retiredTemplate } from './src/templates.js';
|
|
18
18
|
export { TemplateRegistry as default } from './src/templates.js';
|
|
19
19
|
|
|
20
20
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, and embedded-state extraction. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/templates.js
CHANGED
|
@@ -115,6 +115,36 @@ function normalizeTags(tags) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** body_html is a rendered HTML fragment; callers want the copy, not the markup. */
|
|
118
|
+
/** Stack Exchange timestamps are epoch seconds. */
|
|
119
|
+
function epochToIso(seconds) {
|
|
120
|
+
return typeof seconds === 'number' && Number.isFinite(seconds)
|
|
121
|
+
? new Date(seconds * 1000).toISOString()
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stack Exchange API filter created once via /2.3/filters/create with
|
|
127
|
+
* include=question.body;question.answers;answer.body;question.accepted_answer_id
|
|
128
|
+
* on base=default (filters are permanent and shareable per the API docs).
|
|
129
|
+
* The default base keeps the response wrapper (.items, .quota_remaining) and
|
|
130
|
+
* the standard question/answer fields; the includes add the bodies and the
|
|
131
|
+
* nested answers so one request carries the whole thread.
|
|
132
|
+
*/
|
|
133
|
+
const STACKEXCHANGE_FILTER = '!20aKG._8Oscv*6djs8Pgm';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The community Reddit archive reddit-thread reads. reddit.com answers every
|
|
137
|
+
* non-browser client with a 403 (IP/TLS-reputation based, stealth browsers
|
|
138
|
+
* included) and its robots.txt disallows everything; the archive's robots.txt
|
|
139
|
+
* allows all agents and its /api/posts/ids answers keyless.
|
|
140
|
+
*/
|
|
141
|
+
const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
|
|
142
|
+
|
|
143
|
+
/** The base36 post id in a reddit.com post URL, or null when there is none. */
|
|
144
|
+
function redditPostId(url) {
|
|
145
|
+
return /\/comments\/([a-z0-9]+)/i.exec(url)?.[1] ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
function htmlToText(html) {
|
|
119
149
|
if (!html) return null;
|
|
120
150
|
const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
|
|
@@ -544,24 +574,6 @@ export const TEMPLATES = [
|
|
|
544
574
|
}
|
|
545
575
|
},
|
|
546
576
|
|
|
547
|
-
{
|
|
548
|
-
id: 'linkedin-profile',
|
|
549
|
-
name: 'LinkedIn Profile',
|
|
550
|
-
description: 'Scrape a LinkedIn public profile for name, headline, location, and about section.',
|
|
551
|
-
targetPattern: /linkedin\.com\/in\//i,
|
|
552
|
-
extract($) {
|
|
553
|
-
return {
|
|
554
|
-
name: text($, 'h1') || text($, '.top-card-layout__title'),
|
|
555
|
-
headline: text($, '.top-card-layout__headline') || text($, 'h2'),
|
|
556
|
-
location: text($, '.top-card-layout__first-subline') || text($, '.profile-info-subheader'),
|
|
557
|
-
about: text($, '.core-section-container__content p') || text($, '.summary'),
|
|
558
|
-
connections: text($, '.top-card__connections'),
|
|
559
|
-
current_company: text($, '.top-card-layout__card-inner-full-width .top-card-link'),
|
|
560
|
-
note: 'LinkedIn requires authentication for full profiles. This template works on public profile pages only.'
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
},
|
|
564
|
-
|
|
565
577
|
{
|
|
566
578
|
id: 'github-repo',
|
|
567
579
|
name: 'GitHub Repository',
|
|
@@ -629,37 +641,58 @@ export const TEMPLATES = [
|
|
|
629
641
|
}
|
|
630
642
|
},
|
|
631
643
|
|
|
632
|
-
{
|
|
633
|
-
id: 'tweet',
|
|
634
|
-
name: 'Tweet / X Post',
|
|
635
|
-
description: 'Scrape a tweet/X post for text, author, timestamp, likes, and retweets from the Open Graph / structured data.',
|
|
636
|
-
targetPattern: /(twitter|x)\.com\/[^/]+\/status\//i,
|
|
637
|
-
extract($) {
|
|
638
|
-
return {
|
|
639
|
-
text: attr($, 'meta[property="og:description"]', 'content'),
|
|
640
|
-
author: attr($, 'meta[property="og:title"]', 'content'),
|
|
641
|
-
url: attr($, 'meta[property="og:url"]', 'content') || attr($, 'link[rel="canonical"]', 'href'),
|
|
642
|
-
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
643
|
-
note: 'X.com requires JavaScript rendering for full tweet data. Structured metadata is returned from static HTML.'
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
},
|
|
647
|
-
|
|
648
644
|
{
|
|
649
645
|
id: 'reddit-thread',
|
|
650
646
|
name: 'Reddit Thread',
|
|
651
|
-
description: '
|
|
652
|
-
targetPattern: /reddit\.com\/r\/[^/]+\/comments
|
|
653
|
-
|
|
647
|
+
description: 'Read a Reddit post — title, subreddit, author, score, upvote ratio, comment count, body and flair — from the Arctic Shift archive, since reddit.com blocks plain fetchers. For the comment tree, pass the returned id to the reddit_search tool in thread mode.',
|
|
648
|
+
targetPattern: /reddit\.com\/(?:r\/[^/]+\/)?comments\/[a-z0-9]+/i,
|
|
649
|
+
|
|
650
|
+
resolveUrl(url) {
|
|
651
|
+
const id = redditPostId(url);
|
|
652
|
+
if (!id) return url;
|
|
653
|
+
return `${ARCTIC_SHIFT_BASE}/api/posts/ids?ids=${id}`;
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
extractRaw(body, url) {
|
|
657
|
+
let doc;
|
|
658
|
+
try {
|
|
659
|
+
doc = JSON.parse(body);
|
|
660
|
+
} catch {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Not an Arctic Shift document: ${url} did not return JSON. ` +
|
|
663
|
+
'This template reads the Arctic Shift archive, not reddit.com.'
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
// The archive reports a bad request as {data:null, error:"..."}; a post
|
|
667
|
+
// it has never captured is an empty data list.
|
|
668
|
+
if (doc && doc.error) {
|
|
669
|
+
throw new Error(`Arctic Shift error: ${doc.error}`);
|
|
670
|
+
}
|
|
671
|
+
const post = Array.isArray(doc?.data) ? doc.data[0] : null;
|
|
672
|
+
if (!post) {
|
|
673
|
+
throw new Error(`No Reddit post at ${url}: the Arctic Shift archive has no record of it.`);
|
|
674
|
+
}
|
|
675
|
+
|
|
654
676
|
return {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
author:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
677
|
+
id: post.id ?? null,
|
|
678
|
+
title: post.title ?? null,
|
|
679
|
+
subreddit: post.subreddit ?? null,
|
|
680
|
+
author: post.author ?? null,
|
|
681
|
+
score: typeof post.score === 'number' ? post.score : null,
|
|
682
|
+
upvote_ratio: typeof post.upvote_ratio === 'number' ? post.upvote_ratio : null,
|
|
683
|
+
num_comments: typeof post.num_comments === 'number' ? post.num_comments : null,
|
|
684
|
+
posted: epochToIso(post.created_utc),
|
|
685
|
+
// "[removed]" and "[deleted]" come back as written: that is what the
|
|
686
|
+
// archive holds, and a caller can tell it from an empty post.
|
|
687
|
+
body: post.selftext || null,
|
|
688
|
+
// A link post carries its external URL here; a self post carries its
|
|
689
|
+
// own permalink, which `url` already reports.
|
|
690
|
+
link_url: post.is_self ? null : (post.url || null),
|
|
691
|
+
url: post.permalink ? `https://www.reddit.com${post.permalink}` : null,
|
|
692
|
+
flair: post.link_flair_text ?? null,
|
|
693
|
+
over_18: Boolean(post.over_18),
|
|
694
|
+
removed: post.removed_by_category ?? post._meta?.removal_type ?? null,
|
|
695
|
+
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.'
|
|
663
696
|
};
|
|
664
697
|
}
|
|
665
698
|
},
|
|
@@ -668,7 +701,7 @@ export const TEMPLATES = [
|
|
|
668
701
|
id: 'hacker-news-front-page',
|
|
669
702
|
name: 'Hacker News Front Page',
|
|
670
703
|
description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
|
|
671
|
-
targetPattern: /news\.ycombinator\.com(\/news)
|
|
704
|
+
targetPattern: /news\.ycombinator\.com(\/news)?\/?$/i,
|
|
672
705
|
extract($) {
|
|
673
706
|
const stories = [];
|
|
674
707
|
$('tr.athing').each((_, el) => {
|
|
@@ -716,29 +749,74 @@ export const TEMPLATES = [
|
|
|
716
749
|
{
|
|
717
750
|
id: 'stackoverflow-question',
|
|
718
751
|
name: 'Stack Overflow Question',
|
|
719
|
-
description:
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
752
|
+
description:
|
|
753
|
+
'Read a Stack Overflow question from the Stack Exchange API rather than the rendered page: ' +
|
|
754
|
+
'title, body, score, views, tags, owner, and the answers with their scores and which one ' +
|
|
755
|
+
'was accepted. stackoverflow.com answers every non-browser fetch with a Cloudflare 403, so ' +
|
|
756
|
+
'the page itself yields nothing; the API is keyless (300 requests per day per IP).',
|
|
757
|
+
targetPattern: /stackoverflow\.com\/questions\/\d+/i,
|
|
758
|
+
|
|
759
|
+
/** Point the fetch at the API document for the same question. */
|
|
760
|
+
resolveUrl(url) {
|
|
761
|
+
const match = new URL(url).pathname.match(/\/questions\/(\d+)/);
|
|
762
|
+
if (!match) return url;
|
|
763
|
+
return `https://api.stackexchange.com/2.3/questions/${match[1]}` +
|
|
764
|
+
`?site=stackoverflow&filter=${STACKEXCHANGE_FILTER}`;
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
extractRaw(body, url) {
|
|
768
|
+
let doc;
|
|
769
|
+
try {
|
|
770
|
+
doc = JSON.parse(body);
|
|
771
|
+
} catch {
|
|
772
|
+
throw new Error(
|
|
773
|
+
`Not a Stack Exchange API document: ${url} did not return JSON. ` +
|
|
774
|
+
'This template reads the Stack Exchange API.'
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// The API reports its own failures (bad filter, throttled, no such site)
|
|
779
|
+
// as a 400 with error_* fields; a missing question is an empty items list.
|
|
780
|
+
if (doc && doc.error_id) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Stack Exchange API error ${doc.error_id} (${doc.error_name || 'unknown'}): ${doc.error_message || 'no message'}.`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const question = Array.isArray(doc?.items) ? doc.items[0] : null;
|
|
786
|
+
if (!question) {
|
|
787
|
+
throw new Error(`No Stack Overflow question at ${url}: the API returned no items.`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Accepted answer first, then by score — the order the site shows.
|
|
791
|
+
const answers = (question.answers || [])
|
|
792
|
+
.slice()
|
|
793
|
+
.sort((a, b) => (Number(Boolean(b.is_accepted)) - Number(Boolean(a.is_accepted))) || ((b.score ?? 0) - (a.score ?? 0)));
|
|
731
794
|
|
|
732
795
|
return {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
796
|
+
question_id: question.question_id ?? null,
|
|
797
|
+
// Titles and display names come HTML-encoded (", ').
|
|
798
|
+
title: htmlToText(question.title),
|
|
799
|
+
body: htmlToText(question.body),
|
|
800
|
+
votes: question.score ?? null,
|
|
801
|
+
views: question.view_count ?? null,
|
|
802
|
+
tags: question.tags || [],
|
|
803
|
+
author: htmlToText(question.owner?.display_name),
|
|
804
|
+
author_reputation: question.owner?.reputation ?? null,
|
|
805
|
+
asked: epochToIso(question.creation_date),
|
|
806
|
+
last_activity: epochToIso(question.last_activity_date),
|
|
807
|
+
link: question.link || null,
|
|
808
|
+
answered: Boolean(question.is_answered),
|
|
809
|
+
accepted_answer_id: question.accepted_answer_id ?? null,
|
|
810
|
+
answer_count: question.answer_count ?? answers.length,
|
|
811
|
+
answers: answers.slice(0, 5).map(a => ({
|
|
812
|
+
answer_id: a.answer_id ?? null,
|
|
813
|
+
votes: a.score ?? null,
|
|
814
|
+
accepted: Boolean(a.is_accepted),
|
|
815
|
+
author: htmlToText(a.owner?.display_name),
|
|
816
|
+
posted: epochToIso(a.creation_date),
|
|
817
|
+
body: (htmlToText(a.body) || '').slice(0, 500) || null
|
|
818
|
+
})),
|
|
819
|
+
quota_remaining: doc.quota_remaining ?? null
|
|
742
820
|
};
|
|
743
821
|
}
|
|
744
822
|
},
|
|
@@ -815,6 +893,37 @@ export const TEMPLATES = [
|
|
|
815
893
|
...GOV_TEMPLATES
|
|
816
894
|
];
|
|
817
895
|
|
|
896
|
+
/**
|
|
897
|
+
* Templates withdrawn because there is no compliant way to reach the data.
|
|
898
|
+
* They are kept here by id, so a caller naming one gets the reason rather
|
|
899
|
+
* than "unknown template", and by pattern, so `auto` can say the same for a
|
|
900
|
+
* URL they would have matched. Verified against each site's robots.txt on
|
|
901
|
+
* 2026-08-30.
|
|
902
|
+
*/
|
|
903
|
+
export const RETIRED_TEMPLATES = {
|
|
904
|
+
'linkedin-profile': {
|
|
905
|
+
targetPattern: /linkedin\.com\/in\//i,
|
|
906
|
+
reason: 'linkedin.com/robots.txt disallows every path for all agents except LinkedIn\'s own crawler, and profile pages sit behind an authentication wall, so there is no compliant way to read a profile.'
|
|
907
|
+
},
|
|
908
|
+
tweet: {
|
|
909
|
+
targetPattern: /(twitter|x)\.com\/[^/]+\/status\//i,
|
|
910
|
+
reason: 'x.com/robots.txt disallows every path for generic agents, and the keyless embed endpoints (cdn.syndication.twimg.com, publish.x.com/oembed) are disallowed by their own robots.txt, so a tweet cannot be read without X API credentials.'
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The retired template a caller is reaching for — by id, or by a URL one of
|
|
916
|
+
* them handled — as { id, reason }, or null.
|
|
917
|
+
*/
|
|
918
|
+
export function retiredTemplate(idOrUrl) {
|
|
919
|
+
if (typeof idOrUrl !== 'string') return null;
|
|
920
|
+
if (RETIRED_TEMPLATES[idOrUrl]) return { id: idOrUrl, reason: RETIRED_TEMPLATES[idOrUrl].reason };
|
|
921
|
+
for (const [id, entry] of Object.entries(RETIRED_TEMPLATES)) {
|
|
922
|
+
if (entry.targetPattern.test(idOrUrl)) return { id, reason: entry.reason };
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
|
|
818
927
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
819
928
|
|
|
820
929
|
/**
|