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 CHANGED
@@ -168,9 +168,9 @@ calls `eval`.
168
168
  ## Templates
169
169
 
170
170
  **Pages and products.** `shopify-product` · `shopify-collection` ·
171
- `amazon-product` · `linkedin-profile` · `github-repo` · `youtube-video` ·
172
- `tweet` · `reddit-thread` · `hacker-news-front-page` · `producthunt-launch` ·
173
- `stackoverflow-question` · `npm-package`
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` is registered here but reddit.com blocks plain fetchers; the
209
- REST API steers those callers to its `reddit_search` tool instead.
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.4.0",
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: 'Scrape a Reddit thread for title, subreddit, score, comment count, author, and top-level comments.',
652
- targetPattern: /reddit\.com\/r\/[^/]+\/comments\//i,
653
- extract($) {
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
- title: attr($, 'meta[property="og:title"]', 'content') || text($, 'h1'),
656
- subreddit: text($, 'a[href*="/r/"][class*="subreddit"]') || (($('title').text().match(/r\/([^•]+)/) || [])[1] || '').trim(),
657
- score: text($, '[data-score]') || attr($, '[itemprop="upvoteCount"]', 'content'),
658
- author: text($, 'a[href*="/user/"]'),
659
- posted: attr($, 'time[datetime]', 'datetime'),
660
- body: text($, 'div[data-click-id="text"] p') || attr($, 'meta[property="og:description"]', 'content'),
661
- url: attr($, 'meta[property="og:url"]', 'content'),
662
- flair: text($, '[class*="flair"]')
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)?$/i,
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: 'Scrape a Stack Overflow question for title, body, votes, tags, answers, and accepted answer.',
720
- targetPattern: /stackoverflow\.com\/questions\//i,
721
- extract($) {
722
- const answers = [];
723
- $('.answer').each((_, el) => {
724
- const $a = $(el);
725
- answers.push({
726
- votes: $a.find('[itemprop="upvoteCount"]').attr('content') || $a.find('.js-vote-count').text().trim(),
727
- accepted: $a.hasClass('accepted-answer'),
728
- body: $a.find('.s-prose').first().text().trim().slice(0, 500)
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
- title: text($, '#question-header h1'),
734
- body: text($, '.question .s-prose'),
735
- votes: text($, '.question .js-vote-count') || attr($, '.question [itemprop="upvoteCount"]', 'content'),
736
- views: text($, '.js-view-count') || attr($, 'meta[name="twitter:data1"]', 'content'),
737
- tags: list($, '.post-tag'),
738
- author: text($, '.question .user-details a'),
739
- asked: attr($, '.question time', 'datetime'),
740
- answers: answers.slice(0, 5),
741
- answered: $('div.accepted-answer').length > 0
796
+ question_id: question.question_id ?? null,
797
+ // Titles and display names come HTML-encoded (&quot;, &#39;).
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
  /**