crawlforge-extractors 1.1.0 → 1.2.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/templates.js +103 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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
@@ -104,6 +104,33 @@ function tidy(value) {
104
104
  * "by Jonathan Haidt (Author) Format: Hardcover" on a book. Each states the
105
105
  * same fact wrapped in different chrome.
106
106
  */
107
+ function npmLicense(value) {
108
+ if (!value) return null;
109
+ if (typeof value === 'string') return value;
110
+ // Very old packages use { type, url } or an array of them.
111
+ if (Array.isArray(value)) return value.map(npmLicense).filter(Boolean).join(', ') || null;
112
+ return value.type || null;
113
+ }
114
+
115
+ function npmRepositoryUrl(repository) {
116
+ const raw = typeof repository === 'string' ? repository : repository?.url;
117
+ if (!raw) return null;
118
+ // Registry URLs come as git+ssh://git@host/o/r.git, git+https://…, git://…
119
+ // or a bare "owner/repo" shorthand. Normalise to a browsable https URL.
120
+ let url = raw.replace(/^git\+/, '').replace(/\.git$/, '');
121
+ if (/^[\w.-]+\/[\w.-]+$/.test(url)) return `https://github.com/${url}`;
122
+ url = url.replace(/^git@([^:]+):/, 'https://$1/');
123
+ url = url.replace(/^(?:git|ssh):\/\/(?:git@)?/, 'https://');
124
+ url = url.replace(/^https:\/\/git@/, 'https://');
125
+ return url;
126
+ }
127
+
128
+ function npmBugsUrl(bugs) {
129
+ if (!bugs) return null;
130
+ if (typeof bugs === 'string') return bugs;
131
+ return bugs.url || bugs.email || null;
132
+ }
133
+
107
134
  function amazonByline($) {
108
135
  const contributor = tidy($('#bylineInfo .contributorNameID').first().text());
109
136
  const raw = tidy($('#bylineInfo').first().text());
@@ -145,6 +172,22 @@ function fullSizeImage(src) {
145
172
  return src.replace(/\._[^/]*_\.(jpe?g|png|gif)$/i, ".$1");
146
173
  }
147
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
+
148
191
  // ── Template definitions ─────────────────────────────────────────────────────
149
192
 
150
193
  export const TEMPLATES = [
@@ -333,7 +376,8 @@ export const TEMPLATES = [
333
376
  title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
334
377
  channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
335
378
  channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
336
- views: attr($, 'meta[itemprop="interactionCount"]', 'content'),
379
+ views: youtubeInteractionCount($, 'WatchAction'),
380
+ likes: youtubeInteractionCount($, 'LikeAction'),
337
381
  published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
338
382
  description: attr($, 'meta[property="og:description"]', 'content'),
339
383
  thumbnail: attr($, 'meta[property="og:image"]', 'content'),
@@ -466,25 +510,66 @@ export const TEMPLATES = [
466
510
  {
467
511
  id: 'npm-package',
468
512
  name: 'npm Package',
469
- description: 'Scrape an npm package page for name, version, description, weekly downloads, license, and dependencies.',
470
- targetPattern: /npmjs\.com\/package\//i,
471
- extract($) {
472
- const scripts = [];
473
- $('script[type="application/ld+json"]').each((_, el) => {
474
- try { scripts.push(JSON.parse($(el).html())); } catch {}
475
- });
476
- const ld = scripts[0] || {};
513
+ description:
514
+ 'Read a package from the npm registry API rather than the rendered npmjs.com page: exact ' +
515
+ 'latest version, license, repository, homepage, maintainers, dependencies and any ' +
516
+ 'deprecation notice. npmjs.com blocks plain HTTP fetches, and its markup carries no stable ' +
517
+ 'hooks, so the page itself yields almost nothing. Weekly download counts are not included — ' +
518
+ 'they live on a separate api.npmjs.org endpoint.',
519
+ targetPattern: /npmjs\.com\/package\/|registry\.npmjs\.org\//i,
520
+
521
+ /** Point the fetch at the registry document for the same package. */
522
+ resolveUrl(url) {
523
+ const parsed = new URL(url);
524
+ if (parsed.hostname === 'registry.npmjs.org') return url;
525
+ // /package/<name>, /package/@scope/<name>, either optionally followed by
526
+ // /v/<version> or /access etc.
527
+ const match = parsed.pathname.match(/\/package\/((?:@[^/]+\/)?[^/]+)/i);
528
+ if (!match) return url;
529
+ return `https://registry.npmjs.org/${match[1]}`;
530
+ },
531
+
532
+ extractRaw(body, url) {
533
+ let doc;
534
+ try {
535
+ doc = JSON.parse(body);
536
+ } catch {
537
+ throw new Error(
538
+ `Not an npm registry document: ${url} did not return JSON. ` +
539
+ 'This template reads the npm registry API.'
540
+ );
541
+ }
542
+
543
+ // The registry answers an unknown package with {"error":"Not found"}.
544
+ if (!doc || typeof doc.name !== 'string') {
545
+ const reason = typeof doc?.error === 'string' ? doc.error : 'no package document';
546
+ throw new Error(`No npm package at ${url}: ${reason}.`);
547
+ }
548
+
549
+ const latest = doc['dist-tags']?.latest || null;
550
+ const release = (latest && doc.versions?.[latest]) || {};
551
+ const deps = release.dependencies || {};
477
552
 
478
553
  return {
479
- name: text($, 'h1') || ld.name,
480
- version: text($, 'h3[data-testid="package-version-number"]') || text($, '[class*="version"]'),
481
- description: attr($, 'meta[name="description"]', 'content') || text($, 'p[class*="description"]'),
482
- license: text($, 'span[class*="license"]') || text($, '[data-cy="license"]') || ld.license,
483
- weekly_downloads: text($, 'span[class*="weekly-downloads"]') || text($, '[data-cy="downloads"]'),
484
- install_command: `npm install ${ld.name || text($, 'h1') || ''}`.trim(),
485
- homepage: attr($, 'a[href][class*="homepage"]', 'href'),
486
- repository: attr($, 'a[href*="github.com"]', 'href'),
487
- maintainers: list($, 'a[href*="/~"]')
554
+ name: doc.name,
555
+ version: latest,
556
+ description: release.description || doc.description || null,
557
+ license: npmLicense(release.license ?? doc.license),
558
+ homepage: release.homepage || doc.homepage || null,
559
+ repository: npmRepositoryUrl(release.repository || doc.repository),
560
+ bugs: npmBugsUrl(release.bugs || doc.bugs),
561
+ keywords: release.keywords || doc.keywords || [],
562
+ maintainers: (doc.maintainers || [])
563
+ .map(m => (typeof m === 'string' ? m : m?.name))
564
+ .filter(Boolean),
565
+ dependencies: deps,
566
+ dependency_count: Object.keys(deps).length,
567
+ // A string when the publisher deprecated it, false otherwise — npm
568
+ // keeps serving deprecated packages, so this is the only signal.
569
+ deprecated: typeof release.deprecated === 'string' ? release.deprecated : false,
570
+ published: (latest && doc.time?.[latest]) || null,
571
+ last_modified: doc.time?.modified || null,
572
+ install_command: `npm install ${doc.name}`
488
573
  };
489
574
  }
490
575
  }