crawlforge-extractors 1.4.1 → 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` ·
@@ -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` is registered here but reddit.com blocks plain fetchers; the
216
- 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".
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.4.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
@@ -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: 'Scrape a Reddit thread for title, subreddit, score, comment count, author, and top-level comments.',
669
- targetPattern: /reddit\.com\/r\/[^/]+\/comments\//i,
670
- 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
+
671
676
  return {
672
- title: attr($, 'meta[property="og:title"]', 'content') || text($, 'h1'),
673
- subreddit: text($, 'a[href*="/r/"][class*="subreddit"]') || (($('title').text().match(/r\/([^•]+)/) || [])[1] || '').trim(),
674
- score: text($, '[data-score]') || attr($, '[itemprop="upvoteCount"]', 'content'),
675
- author: text($, 'a[href*="/user/"]'),
676
- posted: attr($, 'time[datetime]', 'datetime'),
677
- body: text($, 'div[data-click-id="text"] p') || attr($, 'meta[property="og:description"]', 'content'),
678
- url: attr($, 'meta[property="og:url"]', 'content'),
679
- 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.'
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
  /**