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 +25 -5
- package/package.json +1 -1
- package/registry/smartrecruiters.json +13 -0
- package/registry/teamtailor.json +17 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/smartrecruiters.js +108 -0
- package/src/adapters/teamtailor.js +116 -0
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 (
|
|
229
|
+
- Verified company registry (145+ companies)
|
|
210
230
|
|
|
211
231
|
**Next**
|
|
212
232
|
- Anthropic MCP marketplace submission
|
|
213
|
-
-
|
|
214
|
-
- Remote MCP transport (for Claude.ai Custom Connectors)
|
|
233
|
+
- BambooHR and Workable adapters
|
|
215
234
|
|
|
216
235
|
**Planned**
|
|
217
|
-
- BambooHR and
|
|
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
|
@@ -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
|
+
]
|
package/src/adapters/index.js
CHANGED
|
@@ -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
|
+
* (`<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 (`&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
|
+
* `&` 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(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, "'")
|
|
99
|
+
.replace(/'/g, "'")
|
|
100
|
+
.replace(/&/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
|
+
}
|