jd-intel 0.3.1 → 0.4.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
@@ -34,6 +34,17 @@ Done.
34
34
 
35
35
  ---
36
36
 
37
+ ## Why not just scrape?
38
+
39
+ Because scraping breaks where jd-intel doesn't:
40
+
41
+ - **Full JDs when browsing fails.** SPA-rendered boards, slow loads, auth walls, and geo-restrictions block a browser. They don't block a public API call.
42
+ - **Structured data, not HTML soup.** Salary, location type, department, and clean markdown, normalized across every ATS.
43
+ - **No keys, no browser.** Public APIs only. Runs anywhere your AI does.
44
+ - **One schema, every platform.** Greenhouse, Lever, Ashby, SmartRecruiters, TeamTailor, Recruitee return the same shape.
45
+
46
+ ---
47
+
37
48
  ## What you can do with it
38
49
 
39
50
  - Look up open roles at any company directly from your AI, no copy-paste
@@ -197,8 +208,9 @@ No custom parsing per company.
197
208
  | Lever | Shipped | Common at mid-stage companies |
198
209
  | SmartRecruiters | Shipped | Enterprise and mid-market |
199
210
  | TeamTailor | Shipped | European startups and scale-ups |
200
- | BambooHR | Planned | Mid-market companies |
201
- | Workday | Planned | Large enterprises |
211
+ | Recruitee | Shipped | Dutch / EU SMBs and scale-ups |
212
+ | Personio | Planned | German / EU mid-market |
213
+ | Workday | Planned | Large enterprises (scoped scraper) |
202
214
 
203
215
  Adding a new ATS is a single adapter file. See [Contributing](#contributing).
204
216
 
@@ -223,17 +235,17 @@ All filters AND together. Deep dive on patterns and gotchas: [docs/filters.md](d
223
235
 
224
236
  **Shipped**
225
237
  - Library, CLI, and MCP server (three surfaces of one toolkit)
226
- - Greenhouse, Ashby, Lever, SmartRecruiters, TeamTailor adapters
238
+ - Greenhouse, Ashby, Lever, SmartRecruiters, TeamTailor, Recruitee adapters
227
239
  - Title, topic, location, and date filters
228
240
  - Salary extraction from JD text
229
- - Verified company registry (145+ companies)
241
+ - Verified company registry (155+ companies)
230
242
 
231
243
  **Next**
244
+ - Personio adapter (German / EU mid-market)
232
245
  - Anthropic MCP marketplace submission
233
- - BambooHR and Workable adapters
234
246
 
235
247
  **Planned**
236
- - BambooHR and Workable adapters
248
+ - Workable adapter (parked — needs SPA shortcode resolution)
237
249
  - Workday support (scoped scraper — large enterprise universe)
238
250
  - Temporal tracking (when roles open, close, reopen)
239
251
  - Change detection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jd-intel",
3
- "version": "0.3.1",
3
+ "version": "0.4.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": "incentro", "name": "Incentro", "sector": "it consultancy"},
3
+ {"slug": "channable", "name": "Channable", "sector": "ecommerce / feed management"},
4
+ {"slug": "vandebron", "name": "Vandebron", "sector": "energy"},
5
+ {"slug": "effectory", "name": "Effectory", "sector": "hr tech"},
6
+ {"slug": "nmbrs", "name": "Nmbrs", "sector": "hr / payroll"},
7
+ {"slug": "shypple", "name": "Shypple", "sector": "logistics / freight"},
8
+ {"slug": "floryn", "name": "Floryn", "sector": "fintech / financing"},
9
+ {"slug": "bridgefund", "name": "BridgeFund", "sector": "fintech / sme lending"},
10
+ {"slug": "sendcloud", "name": "Sendcloud", "sector": "shipping saas"},
11
+ {"slug": "brenger", "name": "Brenger", "sector": "logistics / delivery"},
12
+ {"slug": "youwe", "name": "Youwe", "sector": "digital commerce agency"}
13
+ ]
@@ -3,6 +3,7 @@ export { fetchLever, hasLever } from './lever.js';
3
3
  export { fetchAshby, hasAshby } from './ashby.js';
4
4
  export { fetchSmartrecruiters, hasSmartrecruiters } from './smartrecruiters.js';
5
5
  export { fetchTeamtailor, hasTeamtailor } from './teamtailor.js';
6
+ export { fetchRecruitee, hasRecruitee } from './recruitee.js';
6
7
 
7
8
  export const ADAPTERS = {
8
9
  greenhouse: { fetch: (...args) => import('./greenhouse.js').then(m => m.fetchGreenhouse(...args)), has: (...args) => import('./greenhouse.js').then(m => m.hasGreenhouse(...args)) },
@@ -10,6 +11,7 @@ export const ADAPTERS = {
10
11
  ashby: { fetch: (...args) => import('./ashby.js').then(m => m.fetchAshby(...args)), has: (...args) => import('./ashby.js').then(m => m.hasAshby(...args)) },
11
12
  smartrecruiters: { fetch: (...args) => import('./smartrecruiters.js').then(m => m.fetchSmartrecruiters(...args)), has: (...args) => import('./smartrecruiters.js').then(m => m.hasSmartrecruiters(...args)) },
12
13
  teamtailor: { fetch: (...args) => import('./teamtailor.js').then(m => m.fetchTeamtailor(...args)), has: (...args) => import('./teamtailor.js').then(m => m.hasTeamtailor(...args)) },
14
+ recruitee: { fetch: (...args) => import('./recruitee.js').then(m => m.fetchRecruitee(...args)), has: (...args) => import('./recruitee.js').then(m => m.hasRecruitee(...args)) },
13
15
  };
14
16
 
15
17
  export const ATS_NAMES = Object.keys(ADAPTERS);
@@ -0,0 +1,69 @@
1
+ import { normalize, stripHtml } from '../normalizer.js';
2
+
3
+ /**
4
+ * Fetch jobs from a Recruitee career site.
5
+ * Public API, no auth required.
6
+ * Docs: https://docs.recruitee.com/reference/offers
7
+ *
8
+ * Single GET returns every offer with the full HTML description
9
+ * inline — no N+1 (unlike SmartRecruiters), no XML (unlike
10
+ * TeamTailor/Personio). The simplest adapter shape in the toolkit.
11
+ *
12
+ * @param {string} slug - Recruitee company subdomain (e.g., 'vandebron')
13
+ * @returns {Promise<Array>} Normalized job objects
14
+ */
15
+ export async function fetchRecruitee(slug) {
16
+ const url = `https://${slug}.recruitee.com/api/offers/`;
17
+ const resp = await fetch(url);
18
+
19
+ if (!resp.ok) {
20
+ if (resp.status === 404) return []; // No Recruitee site for this slug
21
+ throw new Error(`Recruitee API error for ${slug}: ${resp.status}`);
22
+ }
23
+
24
+ const data = await resp.json();
25
+ const offers = data.offers || [];
26
+
27
+ return offers.map(offer => {
28
+ const place = [offer.city, offer.country].filter(Boolean).join(', ');
29
+ let location = place;
30
+ if (offer.remote) location = place ? `Remote - ${place}` : 'Remote';
31
+
32
+ let postedAt = null;
33
+ if (offer.created_at) {
34
+ // Recruitee returns "2026-05-13 07:38:11 UTC"; coerce to ISO.
35
+ const iso = offer.created_at.replace(' UTC', 'Z').replace(' ', 'T');
36
+ const d = new Date(iso);
37
+ if (!Number.isNaN(d.getTime())) postedAt = d.toISOString();
38
+ }
39
+
40
+ return normalize({
41
+ companySlug: slug,
42
+ company: offer.company_name || slug,
43
+ title: offer.title || '',
44
+ department: offer.department || '',
45
+ location,
46
+ description: stripHtml(offer.description || ''),
47
+ url: offer.careers_url || offer.careers_apply_url || '',
48
+ postedAt,
49
+ salary: null, // No structured salary; normalizer parses from text
50
+ metadata: {
51
+ recruiteeId: offer.guid || offer.id,
52
+ employmentType: offer.employment_type_code || '',
53
+ category: offer.category_code || '',
54
+ },
55
+ }, 'recruitee');
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Check if a company has a Recruitee career site.
61
+ */
62
+ export async function hasRecruitee(slug) {
63
+ try {
64
+ const resp = await fetch(`https://${slug}.recruitee.com/api/offers/`);
65
+ return resp.ok;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
package/src/normalizer.js CHANGED
@@ -3,8 +3,11 @@ import { createHash } from 'node:crypto';
3
3
  /**
4
4
  * Generate a stable ID for a job posting.
5
5
  */
6
- export function jobId(company, title, ats) {
7
- const raw = `${company}|${title}|${ats}`.toLowerCase().trim();
6
+ export function jobId(company, title, ats, location = '') {
7
+ // Location is part of identity: the same role posted in multiple
8
+ // offices is distinct requisitions with distinct URLs. Omitting it
9
+ // collapsed multi-office postings to one id (see issue #17).
10
+ const raw = `${company}|${title}|${ats}|${location}`.toLowerCase().trim();
8
11
  return createHash('md5').update(raw).digest('hex').substring(0, 12);
9
12
  }
10
13
 
@@ -14,7 +17,7 @@ export function jobId(company, title, ats) {
14
17
  export function normalize(raw, ats) {
15
18
  const now = new Date().toISOString();
16
19
  return {
17
- id: jobId(raw.company || raw.companySlug, raw.title, ats),
20
+ id: jobId(raw.company || raw.companySlug, raw.title, ats, raw.location || ''),
18
21
  company: raw.company || raw.companySlug || '',
19
22
  companySlug: raw.companySlug || '',
20
23
  ats,