jd-intel 0.1.1 → 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
@@ -119,6 +119,24 @@ If `npx jd-intel-mcp install` fails, edit the config directly.
119
119
 
120
120
  Restart Claude Desktop.
121
121
 
122
+ ### Updating
123
+
124
+ `npx -y jd-intel-mcp` auto-updates within ~24 hours via npm's cache. To force an update immediately:
125
+
126
+ ```bash
127
+ npx clear-npx-cache
128
+ ```
129
+
130
+ Then quit and reopen Claude Desktop.
131
+
132
+ If you installed the library or CLI directly:
133
+
134
+ ```bash
135
+ npm install jd-intel@latest # force latest
136
+ # or
137
+ npm update jd-intel # respect semver
138
+ ```
139
+
122
140
  ---
123
141
 
124
142
  ## MCP tools
@@ -177,6 +195,8 @@ No custom parsing per company.
177
195
  | Greenhouse | Shipped | Most widely used ATS in tech |
178
196
  | Ashby | Shipped | Growing fast with startups |
179
197
  | Lever | Shipped | Common at mid-stage companies |
198
+ | SmartRecruiters | Shipped | Enterprise and mid-market |
199
+ | TeamTailor | Shipped | European startups and scale-ups |
180
200
  | BambooHR | Planned | Mid-market companies |
181
201
  | Workday | Planned | Large enterprises |
182
202
 
@@ -203,18 +223,18 @@ All filters AND together. Deep dive on patterns and gotchas: [docs/filters.md](d
203
223
 
204
224
  **Shipped**
205
225
  - Library, CLI, and MCP server (three surfaces of one toolkit)
206
- - Greenhouse, Ashby, Lever adapters
226
+ - Greenhouse, Ashby, Lever, SmartRecruiters, TeamTailor adapters
207
227
  - Title, topic, location, and date filters
208
228
  - Salary extraction from JD text
209
- - Verified company registry (100+ companies)
229
+ - Verified company registry (145+ companies)
210
230
 
211
231
  **Next**
212
232
  - Anthropic MCP marketplace submission
213
- - Setup guide with screenshots (non-technical walkthrough)
214
- - Remote MCP transport (for Claude.ai Custom Connectors)
233
+ - BambooHR and Workable adapters
215
234
 
216
235
  **Planned**
217
- - BambooHR and Workday adapters
236
+ - BambooHR and Workable adapters
237
+ - Workday support (scoped scraper — large enterprise universe)
218
238
  - Temporal tracking (when roles open, close, reopen)
219
239
  - Change detection
220
240
  - Resume-aware fit scoring
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jd-intel",
3
- "version": "0.1.1",
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,13 @@
1
+ [
2
+ {"slug": "Visa", "name": "Visa", "sector": "payments"},
3
+ {"slug": "Wise", "name": "Wise", "sector": "fintech"},
4
+ {"slug": "Wabtec", "name": "Wabtec", "sector": "rail / industrial"},
5
+ {"slug": "Sutherland", "name": "Sutherland", "sector": "bpo / cx"},
6
+ {"slug": "AveryDennison", "name": "Avery Dennison", "sector": "materials"},
7
+ {"slug": "PublicStorage", "name": "Public Storage", "sector": "real estate"},
8
+ {"slug": "Sportradar", "name": "Sportradar", "sector": "sports data"},
9
+ {"slug": "Entain", "name": "Entain", "sector": "gaming"},
10
+ {"slug": "Picnic", "name": "Picnic", "sector": "grocery delivery"},
11
+ {"slug": "Hootsuite", "name": "Hootsuite", "sector": "social media management"},
12
+ {"slug": "BusinessWire", "name": "Business Wire", "sector": "pr / newswire"}
13
+ ]
@@ -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
+ ]
@@ -1,11 +1,15 @@
1
1
  export { fetchGreenhouse, hasGreenhouse } from './greenhouse.js';
2
2
  export { fetchLever, hasLever } from './lever.js';
3
3
  export { fetchAshby, hasAshby } from './ashby.js';
4
+ export { fetchSmartrecruiters, hasSmartrecruiters } from './smartrecruiters.js';
5
+ export { fetchTeamtailor, hasTeamtailor } from './teamtailor.js';
4
6
 
5
7
  export const ADAPTERS = {
6
8
  greenhouse: { fetch: (...args) => import('./greenhouse.js').then(m => m.fetchGreenhouse(...args)), has: (...args) => import('./greenhouse.js').then(m => m.hasGreenhouse(...args)) },
7
9
  lever: { fetch: (...args) => import('./lever.js').then(m => m.fetchLever(...args)), has: (...args) => import('./lever.js').then(m => m.hasLever(...args)) },
8
10
  ashby: { fetch: (...args) => import('./ashby.js').then(m => m.fetchAshby(...args)), has: (...args) => import('./ashby.js').then(m => m.hasAshby(...args)) },
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)) },
9
13
  };
10
14
 
11
15
  export const ATS_NAMES = Object.keys(ADAPTERS);
@@ -0,0 +1,108 @@
1
+ import { normalize, stripHtml } from '../normalizer.js';
2
+
3
+ const BASE_URL = 'https://api.smartrecruiters.com/v1/companies';
4
+ const PAGE_SIZE = 100;
5
+
6
+ /**
7
+ * Fetch all postings from a SmartRecruiters company.
8
+ * Public API, no auth required.
9
+ * Docs: https://developers.smartrecruiters.com/reference/postingsget-1
10
+ *
11
+ * Two-step flow (unavoidable N+1):
12
+ * - The postings LIST endpoint omits the job description entirely.
13
+ * - jd-intel's contract is "full JD text", so we must fetch each
14
+ * posting's DETAIL endpoint to get jobAd.sections.
15
+ * Large enterprise tenants with hundreds of openings will therefore be
16
+ * slow against SmartRecruiters specifically. This is the API's shape,
17
+ * not a bug here.
18
+ *
19
+ * @param {string} slug - SmartRecruiters company identifier (e.g., 'Visa')
20
+ * @returns {Promise<Array>} Normalized job objects
21
+ */
22
+ export async function fetchSmartrecruiters(slug) {
23
+ // 1. Page through the postings list.
24
+ const postings = [];
25
+ let offset = 0;
26
+
27
+ while (true) {
28
+ const listUrl = `${BASE_URL}/${slug}/postings?limit=${PAGE_SIZE}&offset=${offset}`;
29
+ const resp = await fetch(listUrl);
30
+
31
+ if (!resp.ok) {
32
+ if (resp.status === 404) return []; // Company not found
33
+ throw new Error(`SmartRecruiters API error for ${slug}: ${resp.status}`);
34
+ }
35
+
36
+ const data = await resp.json();
37
+ const content = data.content || [];
38
+ postings.push(...content);
39
+
40
+ offset += PAGE_SIZE;
41
+ if (content.length === 0 || offset >= (data.totalFound || 0)) break;
42
+ }
43
+
44
+ // 2. Fetch detail per posting for the description.
45
+ const jobs = await Promise.all(postings.map(async (p) => {
46
+ let sections = {};
47
+ let postingUrl = '';
48
+
49
+ try {
50
+ const detailResp = await fetch(`${BASE_URL}/${slug}/postings/${p.id}`);
51
+ if (detailResp.ok) {
52
+ const detail = await detailResp.json();
53
+ sections = detail.jobAd?.sections || {};
54
+ postingUrl = detail.postingUrl || detail.applyUrl || '';
55
+ }
56
+ } catch {
57
+ // Detail fetch failed: fall back to list-only fields (no description).
58
+ }
59
+
60
+ const description = [
61
+ sections.jobDescription?.text,
62
+ sections.qualifications?.text,
63
+ sections.additionalInformation?.text,
64
+ ].filter(Boolean).join('\n\n');
65
+
66
+ const loc = p.location || {};
67
+ const place = loc.fullLocation
68
+ || [loc.city, loc.region, loc.country].filter(Boolean).join(', ');
69
+ let location = place;
70
+ if (loc.remote) location = `Remote - ${place}`.replace(/ - $/, ' ');
71
+ else if (loc.hybrid) location = `Hybrid - ${place}`.replace(/ - $/, ' ');
72
+
73
+ return normalize({
74
+ companySlug: slug,
75
+ company: p.company?.name || slug,
76
+ title: p.name || '',
77
+ department: p.department?.label || p.function?.label || '',
78
+ location,
79
+ description: stripHtml(description),
80
+ url: postingUrl,
81
+ postedAt: p.releasedDate || null,
82
+ salary: null, // SmartRecruiters has no structured salary; normalizer parses text
83
+ metadata: {
84
+ smartRecruitersId: p.id,
85
+ refNumber: p.refNumber || '',
86
+ function: p.function?.label || '',
87
+ experienceLevel: p.experienceLevel?.label || '',
88
+ typeOfEmployment: p.typeOfEmployment?.label || '',
89
+ },
90
+ }, 'smartrecruiters');
91
+ }));
92
+
93
+ return jobs;
94
+ }
95
+
96
+ /**
97
+ * Check if a company exists on SmartRecruiters.
98
+ * (HEAD isn't reliably supported on the postings endpoint, so use a
99
+ * minimal GET.)
100
+ */
101
+ export async function hasSmartrecruiters(slug) {
102
+ try {
103
+ const resp = await fetch(`${BASE_URL}/${slug}/postings?limit=1`);
104
+ return resp.ok;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
@@ -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
+ * (`&lt;p&gt;...`). 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 — `&amp;` resolves LAST so that
21
+ * double-encoded sequences (`&amp;amp;`) 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
+ }