crawlforge-extractors 1.5.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
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",
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { load } from 'cheerio';
23
+ import { safeHref } from '../urls.js';
23
24
 
24
25
  // ── The common job shape ─────────────────────────────────────────────────────
25
26
 
@@ -269,7 +270,7 @@ export const ATS_TEMPLATES = [
269
270
  const items = payload.jobs.map(j => job({
270
271
  id: id(j.id),
271
272
  title: str(j.title),
272
- url: str(j.absolute_url),
273
+ url: safeHref(str(j.absolute_url)),
273
274
  location: str(j.location?.name),
274
275
  // departments and offices ship only with content=true, so a summary
275
276
  // record reports null here rather than a department guessed from the
@@ -359,7 +360,7 @@ export const ATS_TEMPLATES = [
359
360
  id: id(p.id),
360
361
  // Lever calls the job title "text".
361
362
  title: str(p.text),
362
- url: str(p.hostedUrl),
363
+ url: safeHref(str(p.hostedUrl)),
363
364
  location: str(categories.location),
364
365
  department: str(categories.department),
365
366
  team: str(categories.team),
@@ -377,7 +378,7 @@ export const ATS_TEMPLATES = [
377
378
  // cities lists all three here.
378
379
  all_locations: (categories.allLocations || []).map(str).filter(Boolean),
379
380
  salary_range: p.salaryRange || null,
380
- apply_url: str(p.applyUrl)
381
+ apply_url: safeHref(str(p.applyUrl))
381
382
  }
382
383
  });
383
384
  });
@@ -432,7 +433,7 @@ export const ATS_TEMPLATES = [
432
433
  const items = payload.jobs.map(j => job({
433
434
  id: id(j.id),
434
435
  title: str(j.title),
435
- url: str(j.jobUrl),
436
+ url: safeHref(str(j.jobUrl)),
436
437
  location: str(j.location),
437
438
  department: str(j.department),
438
439
  team: str(j.team),
@@ -452,7 +453,7 @@ export const ATS_TEMPLATES = [
452
453
  // Names only. The full entries carry a postal address per country,
453
454
  // which is an office directory, not part of a job listing.
454
455
  secondary_locations: (j.secondaryLocations || []).map(l => str(l.location)).filter(Boolean),
455
- apply_url: str(j.applyUrl)
456
+ apply_url: safeHref(str(j.applyUrl))
456
457
  }
457
458
  }));
458
459
 
@@ -523,7 +524,7 @@ export const ATS_TEMPLATES = [
523
524
  // separate numeric id in this payload.
524
525
  id: id(j.shortcode),
525
526
  title: str(j.title),
526
- url: str(j.url),
527
+ url: safeHref(str(j.url)),
527
528
  location: joinLocation(j.city, j.state, j.country),
528
529
  department: str(j.department),
529
530
  // Workable has no team level.
@@ -540,7 +541,7 @@ export const ATS_TEMPLATES = [
540
541
  shortcode: str(j.shortcode),
541
542
  code: str(j.code),
542
543
  function: str(j.function),
543
- apply_url: str(j.application_url)
544
+ apply_url: safeHref(str(j.application_url))
544
545
  }
545
546
  }));
546
547
 
@@ -602,7 +603,7 @@ export const ATS_TEMPLATES = [
602
603
  const items = payload.offers.map(o => job({
603
604
  id: id(o.id),
604
605
  title: str(o.title),
605
- url: str(o.careers_url),
606
+ url: safeHref(str(o.careers_url)),
606
607
  location: str(o.location),
607
608
  department: str(o.department),
608
609
  team: null,
@@ -623,7 +624,7 @@ export const ATS_TEMPLATES = [
623
624
  slug: str(o.slug),
624
625
  salary: o.salary || null,
625
626
  tags: (o.tags || []).map(str).filter(Boolean),
626
- apply_url: str(o.careers_apply_url)
627
+ apply_url: safeHref(str(o.careers_apply_url))
627
628
  }
628
629
  }));
629
630
 
@@ -652,7 +653,10 @@ export const ATS_TEMPLATES = [
652
653
  'Read a company\'s published Teamtailor jobs from the careers site\'s documented RSS feed ' +
653
654
  'rather than the rendered page: title, department, locations, remote status and plain-text ' +
654
655
  'description for every open role. The feed returns the first 100 jobs unless per_page says otherwise.',
655
- targetPattern: /teamtailor\.com\/jobs(\.rss)?(\?|$)/i,
656
+ // The careers-site root counts as a target: it is what a user pastes, and
657
+ // resolveUrl turns it into <host>/jobs.rss. A deeper path (/jobs/internal/,
658
+ // which robots disallows anyway) still does not match.
659
+ targetPattern: /teamtailor\.com\/?(?:jobs(\.rss)?\/?)?(\?|#|$)/i,
656
660
 
657
661
  /** `company` is the subdomain in https://<company>.teamtailor.com. */
658
662
  listUrl(params = {}) {
@@ -671,7 +675,13 @@ export const ATS_TEMPLATES = [
671
675
  resolveUrl(url) {
672
676
  const parsed = new URL(url);
673
677
  if (parsed.pathname.endsWith('.rss')) return url;
674
- parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}.rss`;
678
+ // The feed is the JOBS page with ".rss" appended, so a careers-site root
679
+ // has to gain the jobs path first. Stripping the trailing slash off "/"
680
+ // leaves "", which built "<host>/.rss" — a URL Teamtailor answers with
681
+ // 403 on every tenant tested. The bare root is what a user actually
682
+ // pastes, so it has to resolve to the documented "<host>/jobs.rss".
683
+ const path = parsed.pathname.replace(/\/+$/, '');
684
+ parsed.pathname = `${path || '/jobs'}.rss`;
675
685
  return parsed.toString();
676
686
  },
677
687
 
@@ -681,9 +691,14 @@ export const ATS_TEMPLATES = [
681
691
  const $ = load(body, { xmlMode: true });
682
692
  const items = $('channel > item');
683
693
 
684
- if (!$('rss').length || !items.length) {
694
+ // Only a response that is not a feed is an error. A valid feed with no
695
+ // <item> is a company with nothing open right now — normative.teamtailor.com
696
+ // serves exactly that — and every sibling ATS connector reports an empty
697
+ // board as count: 0 rather than throwing. Conflating the two turned
698
+ // "nobody is hiring" into "the tool is broken".
699
+ if (!$('rss').length) {
685
700
  throw new Error(
686
- `No Teamtailor job feed at ${url}: ${$('rss').length ? 'the feed has no items' : 'the response is not an RSS feed'}. ` +
701
+ `No Teamtailor job feed at ${url}: the response is not an RSS feed. ` +
687
702
  'The feed is the careers site jobs page with ".rss" appended, e.g. ' +
688
703
  'https://<company>.teamtailor.com/jobs.rss.'
689
704
  );
@@ -701,7 +716,7 @@ export const ATS_TEMPLATES = [
701
716
  // is the one their support pages call the "Job ID".
702
717
  id: id(field('guid')),
703
718
  title: field('title'),
704
- url: field('link'),
719
+ url: safeHref(field('link')),
705
720
  location: locations.length ? locations.join(', ') : null,
706
721
  department: field('tt\\:department'),
707
722
  team: null,
@@ -147,8 +147,14 @@ function parseFlightRows(stream) {
147
147
  const textRow = payload.match(/^T([0-9a-f]+),/i);
148
148
  if (textRow) {
149
149
  const blobStart = payloadStart + textRow[0].length;
150
- const text = Buffer.from(stream.slice(blobStart), 'utf8')
151
- .subarray(0, parseInt(textRow[1], 16))
150
+ const byteLen = parseInt(textRow[1], 16);
151
+ // Decode only up to byteLen bytes. Slicing the whole remaining stream on
152
+ // every text row makes this O(N) per row -> O(N^2) for an
153
+ // attacker-controlled stream of many small text rows. byteLen bytes span
154
+ // at most byteLen characters, so bounding the slice to that many chars
155
+ // keeps the work linear and yields the identical decoded blob.
156
+ const text = Buffer.from(stream.slice(blobStart, blobStart + byteLen), 'utf8')
157
+ .subarray(0, byteLen)
152
158
  .toString('utf8');
153
159
  rows[id] = text;
154
160
  cursor = blobStart + text.length;
package/src/templates.js CHANGED
@@ -45,6 +45,7 @@ import { load } from 'cheerio';
45
45
  // have made one file the whole package.
46
46
  import { ATS_TEMPLATES } from './connectors/ats.js';
47
47
  import { GOV_TEMPLATES } from './connectors/gov.js';
48
+ import { safeHref } from './urls.js';
48
49
 
49
50
  // ── Helpers ──────────────────────────────────────────────────────────────────
50
51
 
@@ -608,7 +609,7 @@ export const TEMPLATES = [
608
609
  last_updated: attr($, 'relative-time', 'datetime'),
609
610
  // When the sidebar payload is present it is authoritative: an empty
610
611
  // website means "no homepage", not "go scrape some external link".
611
- homepage: about ? about.website || null : attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
612
+ homepage: safeHref(about ? about.website : attr($, 'a[href][rel="noopener noreferrer"]', 'href')),
612
613
  open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
613
614
  };
614
615
  }
@@ -623,7 +624,7 @@ export const TEMPLATES = [
623
624
  return {
624
625
  title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
625
626
  channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
626
- channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
627
+ channel_url: safeHref(attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href')),
627
628
  views: youtubeInteractionCount($, 'WatchAction'),
628
629
  likes: youtubeInteractionCount($, 'LikeAction'),
629
630
  published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
@@ -687,7 +688,7 @@ export const TEMPLATES = [
687
688
  body: post.selftext || null,
688
689
  // A link post carries its external URL here; a self post carries its
689
690
  // own permalink, which `url` already reports.
690
- link_url: post.is_self ? null : (post.url || null),
691
+ link_url: post.is_self ? null : safeHref(post.url),
691
692
  url: post.permalink ? `https://www.reddit.com${post.permalink}` : null,
692
693
  flair: post.link_flair_text ?? null,
693
694
  over_18: Boolean(post.over_18),
@@ -713,7 +714,7 @@ export const TEMPLATES = [
713
714
  stories.push({
714
715
  id: $row.attr('id'),
715
716
  title: $titleLink.text().trim(),
716
- url: $titleLink.attr('href'),
717
+ url: safeHref($titleLink.attr('href')),
717
718
  site: $row.find('.sitebit a').text().trim() || null,
718
719
  score: $score.text().replace(' points', '').trim() || null,
719
720
  author: $subtext.find('.hnuser').text().trim() || null,
@@ -738,10 +739,10 @@ export const TEMPLATES = [
738
739
  name: attr($, 'meta[property="og:title"]', 'content'),
739
740
  tagline: attr($, 'meta[property="og:description"]', 'content'),
740
741
  image: attr($, 'meta[property="og:image"]', 'content'),
741
- url: attr($, 'meta[property="og:url"]', 'content'),
742
+ url: safeHref(attr($, 'meta[property="og:url"]', 'content')),
742
743
  votes: text($, '[data-test="vote-button"] span') || text($, 'button[data-vote-button]'),
743
744
  topics: list($, 'a[href*="/topics/"]'),
744
- website: attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href')
745
+ website: safeHref(attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href'))
745
746
  };
746
747
  }
747
748
  },
@@ -869,8 +870,8 @@ export const TEMPLATES = [
869
870
  version: latest,
870
871
  description: release.description || doc.description || null,
871
872
  license: npmLicense(release.license ?? doc.license),
872
- homepage: release.homepage || doc.homepage || null,
873
- repository: npmRepositoryUrl(release.repository || doc.repository),
873
+ homepage: safeHref(release.homepage || doc.homepage),
874
+ repository: safeHref(npmRepositoryUrl(release.repository || doc.repository)),
874
875
  bugs: npmBugsUrl(release.bugs || doc.bugs),
875
876
  keywords: release.keywords || doc.keywords || [],
876
877
  maintainers: (doc.maintainers || [])
package/src/urls.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * URL scheme filtering for values that come from scraped page content or a
3
+ * third-party payload — both untrusted.
4
+ *
5
+ * Such a URL flows into two places that will act on it: a React `href` (React
6
+ * does not sanitise href attributes) and an LLM client that may follow links.
7
+ * A `javascript:` or `data:` value there becomes a live XSS vector or an
8
+ * actionable prompt-injection URL. safeHref keeps only what is safe to hand on.
9
+ */
10
+
11
+ // ASCII control characters (codepoints 0-31 and 127). A browser ignores these
12
+ // inside a URL, so they can disguise a scheme, e.g. "java\tscript:alert(1)".
13
+ // Built from char codes so the source stays plain ASCII (no literal controls).
14
+ const CONTROL_CHARS = (() => {
15
+ let chars = '';
16
+ for (let c = 0; c <= 31; c++) chars += String.fromCharCode(c);
17
+ chars += String.fromCharCode(127);
18
+ return new RegExp('[' + chars + ']', 'g');
19
+ })();
20
+
21
+ /**
22
+ * Return the URL unchanged when it is an http(s) absolute URL or a scheme-less
23
+ * value (a relative path or a protocol-relative `//host` URL — neither carries a
24
+ * scheme to abuse), and null for everything else, including `javascript:`,
25
+ * `data:`, `vbscript:`, `file:`, `mailto:` and `tel:`.
26
+ *
27
+ * Control characters are stripped before the scheme is read, so a value like
28
+ * `java\nscript:alert(1)` — which a browser would execute after ignoring the
29
+ * newline — cannot masquerade as scheme-less and slip through.
30
+ *
31
+ * @param {unknown} url
32
+ * @returns {string|null}
33
+ */
34
+ export function safeHref(url) {
35
+ if (typeof url !== 'string') return null;
36
+ const cleaned = url.replace(CONTROL_CHARS, '').trim();
37
+ if (!cleaned) return null;
38
+ // A leading scheme is a letter followed by letters/digits/+/-/. and a colon.
39
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return cleaned; // relative / protocol-relative
40
+ return /^https?:\/\//i.test(cleaned) ? cleaned : null; // absolute: http(s) only
41
+ }