crawlforge-extractors 1.4.1 → 1.5.1
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 +19 -5
- package/index.d.ts +12 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/connectors/ats.js +18 -4
- package/src/templates.js +92 -45
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` ·
|
|
@@ -212,8 +212,22 @@ swallowed, because a partial decode is a real answer. `npi-provider` reads the
|
|
|
212
212
|
CMS NPI Registry — a public professional registry — and passes its records
|
|
213
213
|
through as published. Neither needs a key.
|
|
214
214
|
|
|
215
|
-
`reddit-thread`
|
|
216
|
-
|
|
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".
|
|
217
231
|
|
|
218
232
|
`smartrecruiters-postings` is deliberately **not** shipped: SmartRecruiters
|
|
219
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.1",
|
|
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/connectors/ats.js
CHANGED
|
@@ -652,7 +652,10 @@ export const ATS_TEMPLATES = [
|
|
|
652
652
|
'Read a company\'s published Teamtailor jobs from the careers site\'s documented RSS feed ' +
|
|
653
653
|
'rather than the rendered page: title, department, locations, remote status and plain-text ' +
|
|
654
654
|
'description for every open role. The feed returns the first 100 jobs unless per_page says otherwise.',
|
|
655
|
-
|
|
655
|
+
// The careers-site root counts as a target: it is what a user pastes, and
|
|
656
|
+
// resolveUrl turns it into <host>/jobs.rss. A deeper path (/jobs/internal/,
|
|
657
|
+
// which robots disallows anyway) still does not match.
|
|
658
|
+
targetPattern: /teamtailor\.com\/?(?:jobs(\.rss)?\/?)?(\?|#|$)/i,
|
|
656
659
|
|
|
657
660
|
/** `company` is the subdomain in https://<company>.teamtailor.com. */
|
|
658
661
|
listUrl(params = {}) {
|
|
@@ -671,7 +674,13 @@ export const ATS_TEMPLATES = [
|
|
|
671
674
|
resolveUrl(url) {
|
|
672
675
|
const parsed = new URL(url);
|
|
673
676
|
if (parsed.pathname.endsWith('.rss')) return url;
|
|
674
|
-
|
|
677
|
+
// The feed is the JOBS page with ".rss" appended, so a careers-site root
|
|
678
|
+
// has to gain the jobs path first. Stripping the trailing slash off "/"
|
|
679
|
+
// leaves "", which built "<host>/.rss" — a URL Teamtailor answers with
|
|
680
|
+
// 403 on every tenant tested. The bare root is what a user actually
|
|
681
|
+
// pastes, so it has to resolve to the documented "<host>/jobs.rss".
|
|
682
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
683
|
+
parsed.pathname = `${path || '/jobs'}.rss`;
|
|
675
684
|
return parsed.toString();
|
|
676
685
|
},
|
|
677
686
|
|
|
@@ -681,9 +690,14 @@ export const ATS_TEMPLATES = [
|
|
|
681
690
|
const $ = load(body, { xmlMode: true });
|
|
682
691
|
const items = $('channel > item');
|
|
683
692
|
|
|
684
|
-
|
|
693
|
+
// Only a response that is not a feed is an error. A valid feed with no
|
|
694
|
+
// <item> is a company with nothing open right now — normative.teamtailor.com
|
|
695
|
+
// serves exactly that — and every sibling ATS connector reports an empty
|
|
696
|
+
// board as count: 0 rather than throwing. Conflating the two turned
|
|
697
|
+
// "nobody is hiring" into "the tool is broken".
|
|
698
|
+
if (!$('rss').length) {
|
|
685
699
|
throw new Error(
|
|
686
|
-
`No Teamtailor job feed at ${url}:
|
|
700
|
+
`No Teamtailor job feed at ${url}: the response is not an RSS feed. ` +
|
|
687
701
|
'The feed is the careers site jobs page with ".rss" appended, e.g. ' +
|
|
688
702
|
'https://<company>.teamtailor.com/jobs.rss.'
|
|
689
703
|
);
|
package/src/templates.js
CHANGED
|
@@ -132,6 +132,19 @@ function epochToIso(seconds) {
|
|
|
132
132
|
*/
|
|
133
133
|
const STACKEXCHANGE_FILTER = '!20aKG._8Oscv*6djs8Pgm';
|
|
134
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
|
+
|
|
135
148
|
function htmlToText(html) {
|
|
136
149
|
if (!html) return null;
|
|
137
150
|
const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
|
|
@@ -561,24 +574,6 @@ export const TEMPLATES = [
|
|
|
561
574
|
}
|
|
562
575
|
},
|
|
563
576
|
|
|
564
|
-
{
|
|
565
|
-
id: 'linkedin-profile',
|
|
566
|
-
name: 'LinkedIn Profile',
|
|
567
|
-
description: 'Scrape a LinkedIn public profile for name, headline, location, and about section.',
|
|
568
|
-
targetPattern: /linkedin\.com\/in\//i,
|
|
569
|
-
extract($) {
|
|
570
|
-
return {
|
|
571
|
-
name: text($, 'h1') || text($, '.top-card-layout__title'),
|
|
572
|
-
headline: text($, '.top-card-layout__headline') || text($, 'h2'),
|
|
573
|
-
location: text($, '.top-card-layout__first-subline') || text($, '.profile-info-subheader'),
|
|
574
|
-
about: text($, '.core-section-container__content p') || text($, '.summary'),
|
|
575
|
-
connections: text($, '.top-card__connections'),
|
|
576
|
-
current_company: text($, '.top-card-layout__card-inner-full-width .top-card-link'),
|
|
577
|
-
note: 'LinkedIn requires authentication for full profiles. This template works on public profile pages only.'
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
},
|
|
581
|
-
|
|
582
577
|
{
|
|
583
578
|
id: 'github-repo',
|
|
584
579
|
name: 'GitHub Repository',
|
|
@@ -646,37 +641,58 @@ export const TEMPLATES = [
|
|
|
646
641
|
}
|
|
647
642
|
},
|
|
648
643
|
|
|
649
|
-
{
|
|
650
|
-
id: 'tweet',
|
|
651
|
-
name: 'Tweet / X Post',
|
|
652
|
-
description: 'Scrape a tweet/X post for text, author, timestamp, likes, and retweets from the Open Graph / structured data.',
|
|
653
|
-
targetPattern: /(twitter|x)\.com\/[^/]+\/status\//i,
|
|
654
|
-
extract($) {
|
|
655
|
-
return {
|
|
656
|
-
text: attr($, 'meta[property="og:description"]', 'content'),
|
|
657
|
-
author: attr($, 'meta[property="og:title"]', 'content'),
|
|
658
|
-
url: attr($, 'meta[property="og:url"]', 'content') || attr($, 'link[rel="canonical"]', 'href'),
|
|
659
|
-
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
660
|
-
note: 'X.com requires JavaScript rendering for full tweet data. Structured metadata is returned from static HTML.'
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
},
|
|
664
|
-
|
|
665
644
|
{
|
|
666
645
|
id: 'reddit-thread',
|
|
667
646
|
name: 'Reddit Thread',
|
|
668
|
-
description: '
|
|
669
|
-
targetPattern: /reddit\.com\/r\/[^/]+\/comments
|
|
670
|
-
|
|
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
|
+
|
|
671
676
|
return {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
author:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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.'
|
|
680
696
|
};
|
|
681
697
|
}
|
|
682
698
|
},
|
|
@@ -877,6 +893,37 @@ export const TEMPLATES = [
|
|
|
877
893
|
...GOV_TEMPLATES
|
|
878
894
|
];
|
|
879
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
|
+
|
|
880
927
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
881
928
|
|
|
882
929
|
/**
|