crawlforge-extractors 1.2.0 → 1.2.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/templates.js +75 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, and structural fingerprinting. One implementation, so the two surfaces cannot drift apart.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/templates.js CHANGED
@@ -172,6 +172,64 @@ function fullSizeImage(src) {
172
172
  return src.replace(/\._[^/]*_\.(jpe?g|png|gif)$/i, ".$1");
173
173
  }
174
174
 
175
+ // ── YouTube helpers ──────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Views and likes are both userInteractionCount; only the sibling
179
+ * interactionType tells them apart, and YouTube emits the LikeAction counter
180
+ * first — so reading the attribute directly returns likes where views are
181
+ * meant. There is no interactionCount attribute on the page at all.
182
+ */
183
+ function youtubeInteractionCount($, action) {
184
+ const counter = $('[itemprop="interactionStatistic"]')
185
+ .filter((_, el) => ($(el).find('[itemprop="interactionType"]').attr('content') || '').split('/').pop() === action)
186
+ .first();
187
+ const count = counter.find('[itemprop="userInteractionCount"]').attr('content');
188
+ return count ? Number.parseInt(count, 10) : null;
189
+ }
190
+
191
+ // ── GitHub helpers ───────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * The logged-out repo page is GitHub's React code view: the About sidebar
195
+ * (homepage link, license, topics, counts) is not rendered as HTML — it ships
196
+ * as JSON inside <script data-target="react-app.embeddedData">, and the DOM
197
+ * around it is skeleton placeholders. Parse that payload; the CSS selectors
198
+ * stay as fallbacks for older server-rendered layouts.
199
+ */
200
+ function githubSidebarAbout($) {
201
+ for (const el of $('script[data-target="react-app.embeddedData"]').toArray()) {
202
+ try {
203
+ const about = JSON.parse($(el).text())?.payload?.sidebarAbout;
204
+ if (about) return about;
205
+ } catch {
206
+ // a different embedded payload — keep looking
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * The payload's license is { spdxId: "MIT", name: "MIT License" }. The SPDX
214
+ * id is the short name callers want, but a repo with a non-standard licence
215
+ * ships spdxId "NOASSERTION", where the display name is all there is.
216
+ */
217
+ function githubLicense(license) {
218
+ if (!license) return null;
219
+ const spdx = license.spdxId;
220
+ return (spdx && spdx !== 'NOASSERTION' ? spdx : license.name) || null;
221
+ }
222
+
223
+ /**
224
+ * Repo tab counters stamp the exact number in title= ("5,102") and an
225
+ * abbreviated text ("5.1k"); a counter with nothing to show stamps
226
+ * title="Not available", so the title is only trusted when it has a digit.
227
+ */
228
+ function githubCounter($, sel) {
229
+ const exact = attr($, sel, 'title');
230
+ return /\d/.test(exact || '') ? exact : text($, sel);
231
+ }
232
+
175
233
  // ── Template definitions ─────────────────────────────────────────────────────
176
234
 
177
235
  export const TEMPLATES = [
@@ -330,22 +388,31 @@ export const TEMPLATES = [
330
388
  description: 'Scrape a GitHub repository page for stars, forks, description, language, topics, and README summary.',
331
389
  targetPattern: /github\.com\/[^/]+\/[^/]+\/?$/i,
332
390
  extract($) {
391
+ const about = githubSidebarAbout($);
333
392
  return {
334
393
  name: text($, 'strong[itemprop="name"] a') || text($, '.repository-content h1'),
335
394
  description: attr($, 'meta[property="og:description"]', 'content') || text($, 'p.f4.my-3'),
336
395
  stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
337
396
  forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
338
397
  // React (logged-out) layout has no watchers aria-label; the count is
339
- // the <strong> right after the single octicon-eye. Language is a
340
- // client-side skeleton on that layout — unrecoverable from static
341
- // HTML, so it stays null there (itemprop still works on classic).
398
+ // the <strong> right after the single octicon-eye.
342
399
  watchers: text($, '.octicon-eye + strong') || text($, '[aria-label*="watchers"]'),
400
+ // Language and the last-push date appear nowhere in the logged-out
401
+ // page (verified 2026-08-26 across five User-Agents, embedded JSON
402
+ // included): the client fetches them after load from header-gated
403
+ // JSON endpoints (/_sidebar, /latest-commit). The selectors below
404
+ // only fire on older server-rendered layouts; otherwise these two
405
+ // stay null rather than guessing.
343
406
  language: text($, 'span[itemprop="programmingLanguage"]') || text($, '.d-inline-flex[class*="language"]'),
344
407
  topics: list($, 'a.topic-tag, a[href^="/topics/"]'),
345
- license: text($, 'a[href*="blob/"][href*="LICENSE"]') || text($, '.octicon-law ~ span'),
408
+ // Never the repo's LICENSE *file* link — its text is the filename,
409
+ // not the licence name.
410
+ license: githubLicense(about?.repo?.license) || text($, '.octicon-law ~ span'),
346
411
  last_updated: attr($, 'relative-time', 'datetime'),
347
- homepage: attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
348
- open_issues: text($, '.Counter[aria-label*="issue"]')
412
+ // When the sidebar payload is present it is authoritative: an empty
413
+ // website means "no homepage", not "go scrape some external link".
414
+ homepage: about ? about.website || null : attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
415
+ open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
349
416
  };
350
417
  }
351
418
  },
@@ -360,7 +427,8 @@ export const TEMPLATES = [
360
427
  title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
361
428
  channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
362
429
  channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
363
- views: attr($, 'meta[itemprop="interactionCount"]', 'content'),
430
+ views: youtubeInteractionCount($, 'WatchAction'),
431
+ likes: youtubeInteractionCount($, 'LikeAction'),
364
432
  published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
365
433
  description: attr($, 'meta[property="og:description"]', 'content'),
366
434
  thumbnail: attr($, 'meta[property="og:image"]', 'content'),