crawlforge-extractors 1.4.0 → 1.4.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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.4.0",
3
+ "version": "1.4.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/templates.js CHANGED
@@ -115,6 +115,23 @@ 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
+
118
135
  function htmlToText(html) {
119
136
  if (!html) return null;
120
137
  const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
@@ -668,7 +685,7 @@ export const TEMPLATES = [
668
685
  id: 'hacker-news-front-page',
669
686
  name: 'Hacker News Front Page',
670
687
  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,
688
+ targetPattern: /news\.ycombinator\.com(\/news)?\/?$/i,
672
689
  extract($) {
673
690
  const stories = [];
674
691
  $('tr.athing').each((_, el) => {
@@ -716,29 +733,74 @@ export const TEMPLATES = [
716
733
  {
717
734
  id: 'stackoverflow-question',
718
735
  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
- });
736
+ description:
737
+ 'Read a Stack Overflow question from the Stack Exchange API rather than the rendered page: ' +
738
+ 'title, body, score, views, tags, owner, and the answers with their scores and which one ' +
739
+ 'was accepted. stackoverflow.com answers every non-browser fetch with a Cloudflare 403, so ' +
740
+ 'the page itself yields nothing; the API is keyless (300 requests per day per IP).',
741
+ targetPattern: /stackoverflow\.com\/questions\/\d+/i,
742
+
743
+ /** Point the fetch at the API document for the same question. */
744
+ resolveUrl(url) {
745
+ const match = new URL(url).pathname.match(/\/questions\/(\d+)/);
746
+ if (!match) return url;
747
+ return `https://api.stackexchange.com/2.3/questions/${match[1]}` +
748
+ `?site=stackoverflow&filter=${STACKEXCHANGE_FILTER}`;
749
+ },
750
+
751
+ extractRaw(body, url) {
752
+ let doc;
753
+ try {
754
+ doc = JSON.parse(body);
755
+ } catch {
756
+ throw new Error(
757
+ `Not a Stack Exchange API document: ${url} did not return JSON. ` +
758
+ 'This template reads the Stack Exchange API.'
759
+ );
760
+ }
761
+
762
+ // The API reports its own failures (bad filter, throttled, no such site)
763
+ // as a 400 with error_* fields; a missing question is an empty items list.
764
+ if (doc && doc.error_id) {
765
+ throw new Error(
766
+ `Stack Exchange API error ${doc.error_id} (${doc.error_name || 'unknown'}): ${doc.error_message || 'no message'}.`
767
+ );
768
+ }
769
+ const question = Array.isArray(doc?.items) ? doc.items[0] : null;
770
+ if (!question) {
771
+ throw new Error(`No Stack Overflow question at ${url}: the API returned no items.`);
772
+ }
773
+
774
+ // Accepted answer first, then by score — the order the site shows.
775
+ const answers = (question.answers || [])
776
+ .slice()
777
+ .sort((a, b) => (Number(Boolean(b.is_accepted)) - Number(Boolean(a.is_accepted))) || ((b.score ?? 0) - (a.score ?? 0)));
731
778
 
732
779
  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
780
+ question_id: question.question_id ?? null,
781
+ // Titles and display names come HTML-encoded (&quot;, &#39;).
782
+ title: htmlToText(question.title),
783
+ body: htmlToText(question.body),
784
+ votes: question.score ?? null,
785
+ views: question.view_count ?? null,
786
+ tags: question.tags || [],
787
+ author: htmlToText(question.owner?.display_name),
788
+ author_reputation: question.owner?.reputation ?? null,
789
+ asked: epochToIso(question.creation_date),
790
+ last_activity: epochToIso(question.last_activity_date),
791
+ link: question.link || null,
792
+ answered: Boolean(question.is_answered),
793
+ accepted_answer_id: question.accepted_answer_id ?? null,
794
+ answer_count: question.answer_count ?? answers.length,
795
+ answers: answers.slice(0, 5).map(a => ({
796
+ answer_id: a.answer_id ?? null,
797
+ votes: a.score ?? null,
798
+ accepted: Boolean(a.is_accepted),
799
+ author: htmlToText(a.owner?.display_name),
800
+ posted: epochToIso(a.creation_date),
801
+ body: (htmlToText(a.body) || '').slice(0, 500) || null
802
+ })),
803
+ quota_remaining: doc.quota_remaining ?? null
742
804
  };
743
805
  }
744
806
  },