crawlforge-extractors 1.2.1 → 1.2.3

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.1",
3
+ "version": "1.2.3",
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
@@ -188,6 +188,60 @@ function youtubeInteractionCount($, action) {
188
188
  return count ? Number.parseInt(count, 10) : null;
189
189
  }
190
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
+ * og:description is never just the About text. GitHub appends "Contribute to
225
+ * owner/repo development by creating an account on GitHub." to it, and that
226
+ * sentence is the *whole* value on a repo with no description at all. Strip
227
+ * it; whatever remains is the description, and nothing remaining means the
228
+ * repo has none.
229
+ */
230
+ function githubOgDescription($) {
231
+ const og = attr($, 'meta[property="og:description"]', 'content') || '';
232
+ return tidy(og.replace(/\s*Contribute to \S+ development by creating an account on GitHub\.?/, ''));
233
+ }
234
+
235
+ /**
236
+ * Repo tab counters stamp the exact number in title= ("5,102") and an
237
+ * abbreviated text ("5.1k"); a counter with nothing to show stamps
238
+ * title="Not available", so the title is only trusted when it has a digit.
239
+ */
240
+ function githubCounter($, sel) {
241
+ const exact = attr($, sel, 'title');
242
+ return /\d/.test(exact || '') ? exact : text($, sel);
243
+ }
244
+
191
245
  // ── Template definitions ─────────────────────────────────────────────────────
192
246
 
193
247
  export const TEMPLATES = [
@@ -346,22 +400,36 @@ export const TEMPLATES = [
346
400
  description: 'Scrape a GitHub repository page for stars, forks, description, language, topics, and README summary.',
347
401
  targetPattern: /github\.com\/[^/]+\/[^/]+\/?$/i,
348
402
  extract($) {
403
+ const about = githubSidebarAbout($);
349
404
  return {
350
405
  name: text($, 'strong[itemprop="name"] a') || text($, '.repository-content h1'),
351
- description: attr($, 'meta[property="og:description"]', 'content') || text($, 'p.f4.my-3'),
406
+ // Like homepage: when the sidebar payload is present it is
407
+ // authoritative, so a repo that set no description reports null
408
+ // rather than GitHub's "Contribute to ..." boilerplate.
409
+ description: about
410
+ ? tidy(about.description)
411
+ : tidy(text($, 'p.f4.my-3')) || githubOgDescription($),
352
412
  stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
353
413
  forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
354
414
  // React (logged-out) layout has no watchers aria-label; the count is
355
- // the <strong> right after the single octicon-eye. Language is a
356
- // client-side skeleton on that layout — unrecoverable from static
357
- // HTML, so it stays null there (itemprop still works on classic).
415
+ // the <strong> right after the single octicon-eye.
358
416
  watchers: text($, '.octicon-eye + strong') || text($, '[aria-label*="watchers"]'),
417
+ // Language and the last-push date appear nowhere in the logged-out
418
+ // page (verified 2026-08-26 across five User-Agents, embedded JSON
419
+ // included): the client fetches them after load from header-gated
420
+ // JSON endpoints (/_sidebar, /latest-commit). The selectors below
421
+ // only fire on older server-rendered layouts; otherwise these two
422
+ // stay null rather than guessing.
359
423
  language: text($, 'span[itemprop="programmingLanguage"]') || text($, '.d-inline-flex[class*="language"]'),
360
424
  topics: list($, 'a.topic-tag, a[href^="/topics/"]'),
361
- license: text($, 'a[href*="blob/"][href*="LICENSE"]') || text($, '.octicon-law ~ span'),
425
+ // Never the repo's LICENSE *file* link — its text is the filename,
426
+ // not the licence name.
427
+ license: githubLicense(about?.repo?.license) || text($, '.octicon-law ~ span'),
362
428
  last_updated: attr($, 'relative-time', 'datetime'),
363
- homepage: attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
364
- open_issues: text($, '.Counter[aria-label*="issue"]')
429
+ // When the sidebar payload is present it is authoritative: an empty
430
+ // website means "no homepage", not "go scrape some external link".
431
+ homepage: about ? about.website || null : attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
432
+ open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
365
433
  };
366
434
  }
367
435
  },