jd-intel 0.2.0 → 0.3.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.
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.1",
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,18 @@
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
+ {"slug": "crunchbase", "name": "Crunchbase", "sector": "company data"}
18
+ ]
@@ -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,134 @@
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
+ // Most sites are {slug}.teamtailor.com, but some sit on a regional
27
+ // segment, e.g. crunchbase.na.teamtailor.com. '' is the base host.
28
+ const TT_REGIONS = ['', 'na', 'eu'];
29
+
30
+ /**
31
+ * Resolve which TeamTailor host actually serves this slug's feed.
32
+ * Returns the first 200 Response, throws on a non-404 error, or
33
+ * returns null if no region has a feed.
34
+ */
35
+ async function resolveFeed(slug, method = 'GET') {
36
+ for (const region of TT_REGIONS) {
37
+ const host = region
38
+ ? `${slug}.${region}.teamtailor.com`
39
+ : `${slug}.teamtailor.com`;
40
+ const resp = await fetch(`https://${host}/jobs.rss`, {
41
+ method,
42
+ redirect: 'follow',
43
+ });
44
+ if (resp.ok) return resp;
45
+ if (resp.status !== 404) {
46
+ throw new Error(`TeamTailor RSS error for ${slug}: ${resp.status}`);
47
+ }
48
+ // 404 on this host — try the next region.
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export async function fetchTeamtailor(slug) {
54
+ const resp = await resolveFeed(slug, 'GET');
55
+ if (!resp) return []; // No TeamTailor site in any known region
56
+
57
+ const xml = await resp.text();
58
+
59
+ const company = (
60
+ xml.match(/<channel>[\s\S]*?<title>([\s\S]*?)<\/title>/)?.[1] || slug
61
+ ).trim();
62
+
63
+ const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map(m => m[1]);
64
+
65
+ return items.map(item => {
66
+ const pick = (tag) => {
67
+ const m = item.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`));
68
+ return m ? m[1].trim() : '';
69
+ };
70
+
71
+ const title = decodeEntities(pick('title'));
72
+ const link = pick('link');
73
+ const guid = pick('guid');
74
+ const pubDateRaw = pick('pubDate');
75
+ const department = decodeEntities(pick('tt:department'));
76
+ const city = decodeEntities(pick('tt:city'));
77
+ const country = decodeEntities(pick('tt:country'));
78
+ const remoteStatus = decodeEntities(pick('remoteStatus'));
79
+
80
+ let location = [city, country].filter(Boolean).join(', ');
81
+ if (/remote/i.test(remoteStatus)) {
82
+ location = location ? `Remote - ${location}` : 'Remote';
83
+ }
84
+
85
+ let postedAt = null;
86
+ if (pubDateRaw) {
87
+ const d = new Date(pubDateRaw);
88
+ if (!Number.isNaN(d.getTime())) postedAt = d.toISOString();
89
+ }
90
+
91
+ return normalize({
92
+ companySlug: slug,
93
+ company,
94
+ title,
95
+ department,
96
+ location,
97
+ description: stripHtml(decodeEntities(pick('description'))),
98
+ url: link,
99
+ postedAt,
100
+ salary: null, // No structured salary; normalizer parses from text
101
+ metadata: {
102
+ teamtailorId: guid,
103
+ remoteStatus,
104
+ },
105
+ }, 'teamtailor');
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Decode the RSS entity/CDATA layer to real HTML.
111
+ * `&amp;` is intentionally resolved LAST.
112
+ */
113
+ function decodeEntities(s) {
114
+ if (!s) return '';
115
+ return s
116
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
117
+ .replace(/&lt;/g, '<')
118
+ .replace(/&gt;/g, '>')
119
+ .replace(/&quot;/g, '"')
120
+ .replace(/&#39;/g, "'")
121
+ .replace(/&apos;/g, "'")
122
+ .replace(/&amp;/g, '&');
123
+ }
124
+
125
+ /**
126
+ * Check if a company has a TeamTailor career site.
127
+ */
128
+ export async function hasTeamtailor(slug) {
129
+ try {
130
+ return (await resolveFeed(slug, 'HEAD')) !== null;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }