jd-intel 0.2.0 → 0.3.0

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 CHANGED
@@ -196,6 +196,7 @@ No custom parsing per company.
196
196
  | Ashby | Shipped | Growing fast with startups |
197
197
  | Lever | Shipped | Common at mid-stage companies |
198
198
  | SmartRecruiters | Shipped | Enterprise and mid-market |
199
+ | TeamTailor | Shipped | European startups and scale-ups |
199
200
  | BambooHR | Planned | Mid-market companies |
200
201
  | Workday | Planned | Large enterprises |
201
202
 
@@ -222,14 +223,14 @@ All filters AND together. Deep dive on patterns and gotchas: [docs/filters.md](d
222
223
 
223
224
  **Shipped**
224
225
  - Library, CLI, and MCP server (three surfaces of one toolkit)
225
- - Greenhouse, Ashby, Lever, SmartRecruiters adapters
226
+ - Greenhouse, Ashby, Lever, SmartRecruiters, TeamTailor adapters
226
227
  - Title, topic, location, and date filters
227
228
  - Salary extraction from JD text
228
- - Verified company registry (100+ companies)
229
+ - Verified company registry (145+ companies)
229
230
 
230
231
  **Next**
231
- - TeamTailor adapter (European startup coverage)
232
232
  - Anthropic MCP marketplace submission
233
+ - BambooHR and Workable adapters
233
234
 
234
235
  **Planned**
235
236
  - BambooHR and Workable adapters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jd-intel",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fetch and normalize job descriptions across every major ATS (Greenhouse, Lever, Ashby) — for your AI assistant, no copy-paste.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,17 @@
1
+ [
2
+ {"slug": "polestar", "name": "Polestar", "sector": "automotive / ev"},
3
+ {"slug": "quinyx", "name": "Quinyx", "sector": "workforce management"},
4
+ {"slug": "qliro", "name": "Qliro", "sector": "payments"},
5
+ {"slug": "tibber", "name": "Tibber", "sector": "energy tech"},
6
+ {"slug": "funnel", "name": "Funnel", "sector": "marketing data"},
7
+ {"slug": "anyfin", "name": "Anyfin", "sector": "fintech"},
8
+ {"slug": "hedvig", "name": "Hedvig", "sector": "insurtech"},
9
+ {"slug": "juni", "name": "Juni", "sector": "fintech"},
10
+ {"slug": "bambuser", "name": "Bambuser", "sector": "live video commerce"},
11
+ {"slug": "karma", "name": "Karma", "sector": "sustainability"},
12
+ {"slug": "mate", "name": "Mate", "sector": "e-mobility"},
13
+ {"slug": "billogram", "name": "Billogram", "sector": "fintech / billing"},
14
+ {"slug": "detectify", "name": "Detectify", "sector": "security"},
15
+ {"slug": "storytel", "name": "Storytel", "sector": "audiobooks"},
16
+ {"slug": "oneflow", "name": "Oneflow", "sector": "contract management"}
17
+ ]
@@ -2,12 +2,14 @@ export { fetchGreenhouse, hasGreenhouse } from './greenhouse.js';
2
2
  export { fetchLever, hasLever } from './lever.js';
3
3
  export { fetchAshby, hasAshby } from './ashby.js';
4
4
  export { fetchSmartrecruiters, hasSmartrecruiters } from './smartrecruiters.js';
5
+ export { fetchTeamtailor, hasTeamtailor } from './teamtailor.js';
5
6
 
6
7
  export const ADAPTERS = {
7
8
  greenhouse: { fetch: (...args) => import('./greenhouse.js').then(m => m.fetchGreenhouse(...args)), has: (...args) => import('./greenhouse.js').then(m => m.hasGreenhouse(...args)) },
8
9
  lever: { fetch: (...args) => import('./lever.js').then(m => m.fetchLever(...args)), has: (...args) => import('./lever.js').then(m => m.hasLever(...args)) },
9
10
  ashby: { fetch: (...args) => import('./ashby.js').then(m => m.fetchAshby(...args)), has: (...args) => import('./ashby.js').then(m => m.hasAshby(...args)) },
10
11
  smartrecruiters: { fetch: (...args) => import('./smartrecruiters.js').then(m => m.fetchSmartrecruiters(...args)), has: (...args) => import('./smartrecruiters.js').then(m => m.hasSmartrecruiters(...args)) },
12
+ teamtailor: { fetch: (...args) => import('./teamtailor.js').then(m => m.fetchTeamtailor(...args)), has: (...args) => import('./teamtailor.js').then(m => m.hasTeamtailor(...args)) },
11
13
  };
12
14
 
13
15
  export const ATS_NAMES = Object.keys(ADAPTERS);
@@ -0,0 +1,116 @@
1
+ import { normalize, stripHtml } from '../normalizer.js';
2
+
3
+ /**
4
+ * Fetch jobs from a TeamTailor career site via its public RSS feed.
5
+ *
6
+ * Why RSS, not the official API:
7
+ * TeamTailor's REST API (api.teamtailor.com/v1/jobs) requires a
8
+ * per-company API key — 401 without it. Unusable for a public
9
+ * registry tool that probes arbitrary companies. The public,
10
+ * unauthenticated path is the career site's jobs.rss feed, which
11
+ * carries the full HTML job description (jd-intel's whole point).
12
+ *
13
+ * Slug maps to `{slug}.teamtailor.com`. The /jobs.rss path serves
14
+ * directly on that subdomain even when the site root 301-redirects
15
+ * to a custom domain (e.g. jobs.tibber.com).
16
+ *
17
+ * RSS quirk: descriptions are HTML-entity-encoded inside the XML
18
+ * (`<p>...`). We decode that outer layer to real HTML, then
19
+ * hand it to stripHtml() which strips tags and resolves the inner
20
+ * entities. Decode order matters — `&` resolves LAST so that
21
+ * double-encoded sequences (`&`) collapse correctly.
22
+ *
23
+ * @param {string} slug - TeamTailor career-site slug (e.g., 'tibber')
24
+ * @returns {Promise<Array>} Normalized job objects
25
+ */
26
+ export async function fetchTeamtailor(slug) {
27
+ const url = `https://${slug}.teamtailor.com/jobs.rss`;
28
+ const resp = await fetch(url, { redirect: 'follow' });
29
+
30
+ if (!resp.ok) {
31
+ if (resp.status === 404) return []; // No TeamTailor site for this slug
32
+ throw new Error(`TeamTailor RSS error for ${slug}: ${resp.status}`);
33
+ }
34
+
35
+ const xml = await resp.text();
36
+
37
+ const company = (
38
+ xml.match(/<channel>[\s\S]*?<title>([\s\S]*?)<\/title>/)?.[1] || slug
39
+ ).trim();
40
+
41
+ const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map(m => m[1]);
42
+
43
+ return items.map(item => {
44
+ const pick = (tag) => {
45
+ const m = item.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`));
46
+ return m ? m[1].trim() : '';
47
+ };
48
+
49
+ const title = decodeEntities(pick('title'));
50
+ const link = pick('link');
51
+ const guid = pick('guid');
52
+ const pubDateRaw = pick('pubDate');
53
+ const department = decodeEntities(pick('tt:department'));
54
+ const city = decodeEntities(pick('tt:city'));
55
+ const country = decodeEntities(pick('tt:country'));
56
+ const remoteStatus = decodeEntities(pick('remoteStatus'));
57
+
58
+ let location = [city, country].filter(Boolean).join(', ');
59
+ if (/remote/i.test(remoteStatus)) {
60
+ location = location ? `Remote - ${location}` : 'Remote';
61
+ }
62
+
63
+ let postedAt = null;
64
+ if (pubDateRaw) {
65
+ const d = new Date(pubDateRaw);
66
+ if (!Number.isNaN(d.getTime())) postedAt = d.toISOString();
67
+ }
68
+
69
+ return normalize({
70
+ companySlug: slug,
71
+ company,
72
+ title,
73
+ department,
74
+ location,
75
+ description: stripHtml(decodeEntities(pick('description'))),
76
+ url: link,
77
+ postedAt,
78
+ salary: null, // No structured salary; normalizer parses from text
79
+ metadata: {
80
+ teamtailorId: guid,
81
+ remoteStatus,
82
+ },
83
+ }, 'teamtailor');
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Decode the RSS entity/CDATA layer to real HTML.
89
+ * `&amp;` is intentionally resolved LAST.
90
+ */
91
+ function decodeEntities(s) {
92
+ if (!s) return '';
93
+ return s
94
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
95
+ .replace(/&lt;/g, '<')
96
+ .replace(/&gt;/g, '>')
97
+ .replace(/&quot;/g, '"')
98
+ .replace(/&#39;/g, "'")
99
+ .replace(/&apos;/g, "'")
100
+ .replace(/&amp;/g, '&');
101
+ }
102
+
103
+ /**
104
+ * Check if a company has a TeamTailor career site.
105
+ */
106
+ export async function hasTeamtailor(slug) {
107
+ try {
108
+ const resp = await fetch(`https://${slug}.teamtailor.com/jobs.rss`, {
109
+ method: 'HEAD',
110
+ redirect: 'follow',
111
+ });
112
+ return resp.ok;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }