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 +4 -3
- package/package.json +1 -1
- package/registry/teamtailor.json +17 -0
- package/src/adapters/index.js +2 -0
- package/src/adapters/teamtailor.js +116 -0
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 (
|
|
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
|
@@ -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
|
@@ -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 (`&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
|
+
}
|