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 +7 -0
- package/package.json +1 -1
- package/src/templates.js +84 -22
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.
|
|
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)
|
|
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:
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
780
|
+
question_id: question.question_id ?? null,
|
|
781
|
+
// Titles and display names come HTML-encoded (", ').
|
|
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
|
},
|