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 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.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",
@@ -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
- targetPattern: /teamtailor\.com\/jobs(\.rss)?(\?|$)/i,
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
- parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}.rss`;
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
- if (!$('rss').length || !items.length) {
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}: ${$('rss').length ? 'the feed has no items' : 'the response is not an RSS feed'}. ` +
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: '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
  /**