jd-intel 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/package.json +46 -0
- package/registry/ashby.json +16 -0
- package/registry/greenhouse.json +47 -0
- package/registry/lever.json +10 -0
- package/src/adapters/ashby.js +137 -0
- package/src/adapters/greenhouse.js +54 -0
- package/src/adapters/index.js +11 -0
- package/src/adapters/lever.js +77 -0
- package/src/cli.js +143 -0
- package/src/filters.js +83 -0
- package/src/index.js +102 -0
- package/src/normalizer.js +94 -0
- package/src/registry.js +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# jd-intel
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://nodejs.org)
|
|
5
|
+
[](https://www.npmjs.com/package/jd-intel)
|
|
6
|
+
|
|
7
|
+
> **Stop pasting job descriptions into AI assistants. Let your AI fetch them directly.**
|
|
8
|
+
|
|
9
|
+
Full text. Clean structure. Across every major ATS. No copy-paste. No context loss.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
Your AI assistant already knows a lot about you. Your resume is in its memory. Your target roles, your past projects, your background. Ready to help the moment you feed it a job description.
|
|
16
|
+
|
|
17
|
+
So you copy-paste.
|
|
18
|
+
|
|
19
|
+
A JD from Stripe. Another from Mercury. Six more from your target list. Half have broken HTML. Salary info dies in translation. Links get stripped. And for every role, the dance starts over.
|
|
20
|
+
|
|
21
|
+
You could wait for the job boards to ship their own MCPs. They'll get there eventually. On their timeline. Filtered through their priorities, not yours. Tied to their query abstractions.
|
|
22
|
+
|
|
23
|
+
jd-intel skips that wait. Raw JDs, fetched directly by your AI, on your terms. One level below the curated layer.
|
|
24
|
+
|
|
25
|
+
> "Claude, pull the senior PM role at Stripe and draft a cover letter based on my resume."
|
|
26
|
+
|
|
27
|
+
Done.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## What you can do with it
|
|
32
|
+
|
|
33
|
+
- Draft cover letters without pasting anything
|
|
34
|
+
- Tailor your resume across ten roles in one conversation
|
|
35
|
+
- Rank openings by fit with your background
|
|
36
|
+
- Scan a whole sector: "Pull PM roles at fintech companies posted this week"
|
|
37
|
+
- Research teams by reading their JDs in bulk
|
|
38
|
+
|
|
39
|
+
The toolkit fetches. Your AI thinks.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
### For Claude Desktop, Cursor, Windsurf users
|
|
46
|
+
|
|
47
|
+
Add to your MCP config file:
|
|
48
|
+
|
|
49
|
+
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
50
|
+
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"jd-intel": {
|
|
56
|
+
"command": "npx",
|
|
57
|
+
"args": ["-y", "jd-intel-mcp"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Restart your AI client. The tools appear automatically. Ask your AI to fetch any role.
|
|
64
|
+
|
|
65
|
+
**One-command install (avoids hand-editing the config):**
|
|
66
|
+
```bash
|
|
67
|
+
npx jd-intel-mcp install
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### For developers (CLI and library)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm install jd-intel
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or run without installing:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx jd-intel fetch stripe --title-filter "product manager"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or import as a library:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { fetchJobs, registry } from 'jd-intel';
|
|
86
|
+
|
|
87
|
+
const jobs = await fetchJobs({
|
|
88
|
+
company: 'ramp',
|
|
89
|
+
titleFilter: 'engineer',
|
|
90
|
+
postedWithinDays: 14,
|
|
91
|
+
limit: 50,
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Node.js 18+. No API keys. No configuration.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## MCP tools
|
|
100
|
+
|
|
101
|
+
| Tool | Purpose |
|
|
102
|
+
|------|---------|
|
|
103
|
+
| `fetch_jobs` | Get open roles at a company with filters for role type, topic, location, and recency |
|
|
104
|
+
| `search_registry` | Find companies by name or sector |
|
|
105
|
+
| `detect_ats` | Identify which ATS platform a company uses |
|
|
106
|
+
|
|
107
|
+
Plus one Resource: `registry://jd-intel/all`. Full company registry, grouped by ATS. Fetched lazily for broad catalog surveys.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## What you get back
|
|
112
|
+
|
|
113
|
+
Every job normalizes to one schema, across every platform:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"id": "a1b2c3d4e5f6",
|
|
118
|
+
"company": "Stripe",
|
|
119
|
+
"title": "Senior Product Manager, Integrations",
|
|
120
|
+
"department": "Product",
|
|
121
|
+
"location": "San Francisco, CA",
|
|
122
|
+
"locationType": "hybrid",
|
|
123
|
+
"salary": { "min": 180000, "max": 260000, "currency": "USD" },
|
|
124
|
+
"description": "Lead strategy for Stripe's integration ecosystem...",
|
|
125
|
+
"url": "https://boards.greenhouse.io/stripe/jobs/12345",
|
|
126
|
+
"postedAt": "2026-04-10T14:30:00Z"
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
No custom parsing per company.
|
|
131
|
+
|
|
132
|
+
### Data model
|
|
133
|
+
|
|
134
|
+
| Field | Description |
|
|
135
|
+
|-------|-------------|
|
|
136
|
+
| `title` | Full job title |
|
|
137
|
+
| `company` | Normalized company name |
|
|
138
|
+
| `department` | Team or department (when provided) |
|
|
139
|
+
| `location` | City, state, country, or remote |
|
|
140
|
+
| `locationType` | `remote`, `hybrid`, or `onsite` |
|
|
141
|
+
| `salary` | Min-max range with currency (when available) |
|
|
142
|
+
| `description` | Full JD in clean markdown |
|
|
143
|
+
| `url` | Direct link to the posting |
|
|
144
|
+
| `postedAt` | Publication date (when provided) |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Platforms supported
|
|
149
|
+
|
|
150
|
+
| Platform | Status | Typical use |
|
|
151
|
+
|----------|--------|-------------|
|
|
152
|
+
| Greenhouse | Shipped | Most widely used ATS in tech |
|
|
153
|
+
| Ashby | Shipped | Growing fast with startups |
|
|
154
|
+
| Lever | Shipped | Common at mid-stage companies |
|
|
155
|
+
| BambooHR | Planned | Mid-market companies |
|
|
156
|
+
| Workday | Planned | Large enterprises |
|
|
157
|
+
|
|
158
|
+
Adding a new ATS is a single adapter file. See [Contributing](#contributing).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Filters (quick reference)
|
|
163
|
+
|
|
164
|
+
| Flag | What it matches | Use for |
|
|
165
|
+
|------|-----------------|---------|
|
|
166
|
+
| `--title-filter` | Title only | Role identity (PM, engineer, designer) |
|
|
167
|
+
| `--filter` | Title + department + description | Topic or scope (integrations, growth) |
|
|
168
|
+
| `--posted-within-days` | Recent postings | Recency cuts |
|
|
169
|
+
| `--location-include` | Location contains any keyword | Region targeting |
|
|
170
|
+
| `--location-exclude` | Location contains no keyword | Drop geographic noise |
|
|
171
|
+
| `--limit` | First N results | Cap output size |
|
|
172
|
+
|
|
173
|
+
All filters AND together. Deep dive on patterns and gotchas: [docs/filters.md](docs/filters.md).
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Roadmap
|
|
178
|
+
|
|
179
|
+
**Shipped**
|
|
180
|
+
- Library, CLI, and MCP server (three surfaces of one toolkit)
|
|
181
|
+
- Greenhouse, Ashby, Lever adapters
|
|
182
|
+
- Title, topic, location, and date filters
|
|
183
|
+
- Salary extraction from JD text
|
|
184
|
+
- Verified company registry (66 companies)
|
|
185
|
+
|
|
186
|
+
**Next**
|
|
187
|
+
- Anthropic MCP marketplace submission
|
|
188
|
+
- Setup guide with screenshots (non-technical walkthrough)
|
|
189
|
+
- Remote MCP transport (for Claude.ai Custom Connectors)
|
|
190
|
+
|
|
191
|
+
**Planned**
|
|
192
|
+
- BambooHR and Workday adapters
|
|
193
|
+
- Temporal tracking (when roles open, close, reopen)
|
|
194
|
+
- Change detection
|
|
195
|
+
- Resume-aware fit scoring
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Contributing
|
|
200
|
+
|
|
201
|
+
**Add a company to the registry:** submit a PR to the appropriate file in `registry/`.
|
|
202
|
+
|
|
203
|
+
**Add an ATS adapter:** new file in `src/adapters/`. One adapter, one file. Follow the pattern of the existing three.
|
|
204
|
+
|
|
205
|
+
**Request a company:** [open an issue](https://github.com/prPMDev/jd-intel/issues/new). Tell me who's missing.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Built by
|
|
210
|
+
|
|
211
|
+
**[Prashant R](https://prashantrana.xyz)**. PM who builds. I write about what actually happens at the layer below the AI hype.
|
|
212
|
+
|
|
213
|
+
- Portfolio and writing: [prashantrana.xyz](https://prashantrana.xyz)
|
|
214
|
+
- [LinkedIn](https://www.linkedin.com/in/prashant-rana)
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jd-intel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fetch and normalize job descriptions across every major ATS (Greenhouse, Lever, Ashby) — for your AI assistant, no copy-paste.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"jd-intel": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"registry/",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.js",
|
|
17
|
+
"fetch": "node src/cli.js fetch",
|
|
18
|
+
"search": "node src/cli.js search"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"jobs",
|
|
22
|
+
"job-description",
|
|
23
|
+
"ats",
|
|
24
|
+
"greenhouse",
|
|
25
|
+
"lever",
|
|
26
|
+
"ashby",
|
|
27
|
+
"mcp",
|
|
28
|
+
"ai",
|
|
29
|
+
"claude",
|
|
30
|
+
"hiring",
|
|
31
|
+
"careers"
|
|
32
|
+
],
|
|
33
|
+
"author": "Prashant R",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/prPMDev/jd-intel.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/prPMDev/jd-intel/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/prPMDev/jd-intel#readme",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"slug": "notion", "name": "Notion", "sector": "productivity"},
|
|
3
|
+
{"slug": "linear", "name": "Linear", "sector": "developer tools"},
|
|
4
|
+
{"slug": "ramp", "name": "Ramp", "sector": "fintech"},
|
|
5
|
+
{"slug": "resend", "name": "Resend", "sector": "developer tools"},
|
|
6
|
+
{"slug": "clerk", "name": "Clerk", "sector": "developer tools"},
|
|
7
|
+
{"slug": "railway", "name": "Railway", "sector": "developer tools"},
|
|
8
|
+
{"slug": "drata", "name": "Drata", "sector": "security"},
|
|
9
|
+
{"slug": "watershed", "name": "Watershed", "sector": "climate tech"},
|
|
10
|
+
{"slug": "persona", "name": "Persona", "sector": "identity"},
|
|
11
|
+
{"slug": "stytch", "name": "Stytch", "sector": "identity"},
|
|
12
|
+
{"slug": "shiftkey", "name": "ShiftKey", "sector": "healthcare staffing"},
|
|
13
|
+
{"slug": "gorgias", "name": "Gorgias", "sector": "customer support"},
|
|
14
|
+
{"slug": "zapier", "name": "Zapier", "sector": "integration platform"},
|
|
15
|
+
{"slug": "clickup", "name": "ClickUp", "sector": "productivity"}
|
|
16
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"slug": "stripe", "name": "Stripe", "sector": "fintech"},
|
|
3
|
+
{"slug": "airbnb", "name": "Airbnb", "sector": "travel"},
|
|
4
|
+
{"slug": "twitch", "name": "Twitch", "sector": "media"},
|
|
5
|
+
{"slug": "pinterest", "name": "Pinterest", "sector": "social"},
|
|
6
|
+
{"slug": "cloudflare", "name": "Cloudflare", "sector": "infrastructure"},
|
|
7
|
+
{"slug": "databricks", "name": "Databricks", "sector": "data"},
|
|
8
|
+
{"slug": "figma", "name": "Figma", "sector": "design tools"},
|
|
9
|
+
{"slug": "airtable", "name": "Airtable", "sector": "productivity"},
|
|
10
|
+
{"slug": "gusto", "name": "Gusto", "sector": "hr tech"},
|
|
11
|
+
{"slug": "brex", "name": "Brex", "sector": "fintech"},
|
|
12
|
+
{"slug": "vercel", "name": "Vercel", "sector": "developer tools"},
|
|
13
|
+
{"slug": "cockroachlabs", "name": "Cockroach Labs", "sector": "database"},
|
|
14
|
+
{"slug": "datadog", "name": "Datadog", "sector": "observability"},
|
|
15
|
+
{"slug": "elastic", "name": "Elastic", "sector": "search"},
|
|
16
|
+
{"slug": "reddit", "name": "Reddit", "sector": "social"},
|
|
17
|
+
{"slug": "squarespace", "name": "Squarespace", "sector": "web platform"},
|
|
18
|
+
{"slug": "toast", "name": "Toast", "sector": "restaurant tech"},
|
|
19
|
+
{"slug": "roku", "name": "Roku", "sector": "media"},
|
|
20
|
+
{"slug": "webflow", "name": "Webflow", "sector": "web platform"},
|
|
21
|
+
{"slug": "mercury", "name": "Mercury", "sector": "fintech"},
|
|
22
|
+
{"slug": "amplitude", "name": "Amplitude", "sector": "analytics"},
|
|
23
|
+
{"slug": "mixpanel", "name": "Mixpanel", "sector": "analytics"},
|
|
24
|
+
{"slug": "expel", "name": "Expel", "sector": "security"},
|
|
25
|
+
{"slug": "calendly", "name": "Calendly", "sector": "productivity"},
|
|
26
|
+
{"slug": "lattice", "name": "Lattice", "sector": "hr tech"},
|
|
27
|
+
{"slug": "samsara", "name": "Samsara", "sector": "iot"},
|
|
28
|
+
{"slug": "twilio", "name": "Twilio", "sector": "communications"},
|
|
29
|
+
{"slug": "pagerduty", "name": "PagerDuty", "sector": "devops"},
|
|
30
|
+
{"slug": "contentful", "name": "Contentful", "sector": "cms"},
|
|
31
|
+
{"slug": "sendbird", "name": "Sendbird", "sector": "communications"},
|
|
32
|
+
{"slug": "workato", "name": "Workato", "sector": "integration platform"},
|
|
33
|
+
{"slug": "merge", "name": "Merge", "sector": "integration platform"},
|
|
34
|
+
{"slug": "pandadoc", "name": "PandaDoc", "sector": "sales tech"},
|
|
35
|
+
{"slug": "fivetran", "name": "Fivetran", "sector": "data integration"},
|
|
36
|
+
{"slug": "securityscorecard", "name": "SecurityScorecard", "sector": "security"},
|
|
37
|
+
{"slug": "invoca", "name": "Invoca", "sector": "marketing tech"},
|
|
38
|
+
{"slug": "intercom", "name": "Intercom", "sector": "customer support"},
|
|
39
|
+
{"slug": "dialpad", "name": "Dialpad", "sector": "communications"},
|
|
40
|
+
{"slug": "salesloft", "name": "Salesloft", "sector": "sales tech"},
|
|
41
|
+
{"slug": "klaviyo", "name": "Klaviyo", "sector": "marketing tech"},
|
|
42
|
+
{"slug": "cleo", "name": "Cleo", "sector": "fintech"},
|
|
43
|
+
{"slug": "braze", "name": "Braze", "sector": "marketing tech"},
|
|
44
|
+
{"slug": "appsflyer", "name": "AppsFlyer", "sector": "marketing tech"},
|
|
45
|
+
{"slug": "attentive", "name": "Attentive", "sector": "marketing tech"},
|
|
46
|
+
{"slug": "iterable", "name": "Iterable", "sector": "marketing tech"}
|
|
47
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"slug": "olo", "name": "Olo", "sector": "restaurant tech"},
|
|
3
|
+
{"slug": "outreach", "name": "Outreach", "sector": "sales tech"},
|
|
4
|
+
{"slug": "whoop", "name": "WHOOP", "sector": "wearables"},
|
|
5
|
+
{"slug": "plaid", "name": "Plaid", "sector": "fintech"},
|
|
6
|
+
{"slug": "clari", "name": "Clari", "sector": "sales tech"},
|
|
7
|
+
{"slug": "netflix", "name": "Netflix", "sector": "media"},
|
|
8
|
+
{"slug": "lever", "name": "Lever", "sector": "recruiting tech"},
|
|
9
|
+
{"slug": "postman", "name": "Postman", "sector": "developer tools"}
|
|
10
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { normalize } from '../normalizer.js';
|
|
2
|
+
|
|
3
|
+
const API_URL = 'https://jobs.ashbyhq.com/api/non-user-graphql';
|
|
4
|
+
const BOARD_URL = 'https://api.ashbyhq.com/posting-api/job-board';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetch all jobs from an Ashby job board.
|
|
8
|
+
* Public API, no auth required.
|
|
9
|
+
* Docs: https://developers.ashbyhq.com/docs/public-job-posting-api
|
|
10
|
+
*
|
|
11
|
+
* @param {string} slug - Company slug (e.g., 'notion', 'linear')
|
|
12
|
+
* @returns {Promise<Array>} Normalized job objects
|
|
13
|
+
*/
|
|
14
|
+
export async function fetchAshby(slug) {
|
|
15
|
+
// Try the REST API first (simpler, includes compensation)
|
|
16
|
+
try {
|
|
17
|
+
const restJobs = await fetchAshbyRest(slug);
|
|
18
|
+
if (restJobs.length > 0) return restJobs;
|
|
19
|
+
} catch { /* fall through to GraphQL */ }
|
|
20
|
+
|
|
21
|
+
// Fallback: GraphQL API
|
|
22
|
+
return fetchAshbyGraphQL(slug);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchAshbyRest(slug) {
|
|
26
|
+
const url = `${BOARD_URL}/${slug}?includeCompensation=true`;
|
|
27
|
+
const resp = await fetch(url);
|
|
28
|
+
|
|
29
|
+
if (!resp.ok) {
|
|
30
|
+
if (resp.status === 404) return [];
|
|
31
|
+
throw new Error(`Ashby REST API error for ${slug}: ${resp.status}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const data = await resp.json();
|
|
35
|
+
const jobs = data.jobs || [];
|
|
36
|
+
|
|
37
|
+
return jobs.map(job => {
|
|
38
|
+
const salary = parseAshbyCompensation(job.compensation);
|
|
39
|
+
|
|
40
|
+
return normalize({
|
|
41
|
+
companySlug: slug,
|
|
42
|
+
company: data.organizationName || slug,
|
|
43
|
+
title: job.title || '',
|
|
44
|
+
department: job.departmentName || '',
|
|
45
|
+
location: job.location || '',
|
|
46
|
+
description: job.descriptionHtml || job.descriptionPlain || '',
|
|
47
|
+
url: `https://jobs.ashbyhq.com/${slug}/${job.id}`,
|
|
48
|
+
postedAt: job.publishedAt || null,
|
|
49
|
+
salary,
|
|
50
|
+
metadata: {
|
|
51
|
+
ashbyId: job.id,
|
|
52
|
+
employmentType: job.employmentType || '',
|
|
53
|
+
isRemote: job.isRemote || false,
|
|
54
|
+
team: job.teamName || '',
|
|
55
|
+
},
|
|
56
|
+
}, 'ashby');
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchAshbyGraphQL(slug) {
|
|
61
|
+
const query = `{
|
|
62
|
+
jobBoard {
|
|
63
|
+
title
|
|
64
|
+
jobPostings {
|
|
65
|
+
id
|
|
66
|
+
title
|
|
67
|
+
locationName
|
|
68
|
+
employmentType
|
|
69
|
+
descriptionHtml
|
|
70
|
+
publishedDate
|
|
71
|
+
compensationTierSummary
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}`;
|
|
75
|
+
|
|
76
|
+
const resp = await fetch(API_URL, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
operationName: 'ApiJobBoardWithTeams',
|
|
81
|
+
variables: { organizationHostedJobsPageName: slug },
|
|
82
|
+
query,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!resp.ok) return [];
|
|
87
|
+
|
|
88
|
+
const data = await resp.json();
|
|
89
|
+
const board = data.data?.jobBoard;
|
|
90
|
+
if (!board) return [];
|
|
91
|
+
|
|
92
|
+
const postings = board.jobPostings || [];
|
|
93
|
+
|
|
94
|
+
return postings.map(job => normalize({
|
|
95
|
+
companySlug: slug,
|
|
96
|
+
company: board.title || slug,
|
|
97
|
+
title: job.title || '',
|
|
98
|
+
department: '',
|
|
99
|
+
location: job.locationName || '',
|
|
100
|
+
description: job.descriptionHtml || '',
|
|
101
|
+
url: `https://jobs.ashbyhq.com/${slug}/${job.id}`,
|
|
102
|
+
postedAt: job.publishedDate || null,
|
|
103
|
+
salary: null,
|
|
104
|
+
metadata: {
|
|
105
|
+
ashbyId: job.id,
|
|
106
|
+
employmentType: job.employmentType || '',
|
|
107
|
+
compensationSummary: job.compensationTierSummary || '',
|
|
108
|
+
},
|
|
109
|
+
}, 'ashby'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseAshbyCompensation(comp) {
|
|
113
|
+
if (!comp) return null;
|
|
114
|
+
// Ashby compensation can be a string or structured object
|
|
115
|
+
if (typeof comp === 'string') {
|
|
116
|
+
const match = comp.match(/\$?([\d,]+)\s*[-–]\s*\$?([\d,]+)/);
|
|
117
|
+
if (!match) return null;
|
|
118
|
+
return {
|
|
119
|
+
min: parseInt(match[1].replace(/,/g, '')),
|
|
120
|
+
max: parseInt(match[2].replace(/,/g, '')),
|
|
121
|
+
currency: 'USD',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (comp.min && comp.max) {
|
|
125
|
+
return { min: comp.min, max: comp.max, currency: comp.currency || 'USD' };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function hasAshby(slug) {
|
|
131
|
+
try {
|
|
132
|
+
const resp = await fetch(`${BOARD_URL}/${slug}`, { method: 'HEAD' });
|
|
133
|
+
return resp.ok;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { normalize, stripHtml } from '../normalizer.js';
|
|
2
|
+
|
|
3
|
+
const BASE_URL = 'https://boards-api.greenhouse.io/v1/boards';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch all jobs from a Greenhouse job board.
|
|
7
|
+
* Public API, no auth required.
|
|
8
|
+
* Docs: https://developers.greenhouse.io/job-board.html
|
|
9
|
+
*
|
|
10
|
+
* @param {string} slug - Company slug (e.g., 'stripe', 'notion')
|
|
11
|
+
* @returns {Promise<Array>} Normalized job objects
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchGreenhouse(slug) {
|
|
14
|
+
const url = `${BASE_URL}/${slug}/jobs?content=true`;
|
|
15
|
+
const resp = await fetch(url);
|
|
16
|
+
|
|
17
|
+
if (!resp.ok) {
|
|
18
|
+
if (resp.status === 404) return []; // Company not found or no jobs
|
|
19
|
+
throw new Error(`Greenhouse API error for ${slug}: ${resp.status}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const data = await resp.json();
|
|
23
|
+
const jobs = data.jobs || [];
|
|
24
|
+
|
|
25
|
+
return jobs.map(job => normalize({
|
|
26
|
+
companySlug: slug,
|
|
27
|
+
company: data.name || slug,
|
|
28
|
+
title: job.title || '',
|
|
29
|
+
department: job.departments?.[0]?.name || '',
|
|
30
|
+
location: job.location?.name || '',
|
|
31
|
+
description: stripHtml(job.content || ''),
|
|
32
|
+
url: job.absolute_url || '',
|
|
33
|
+
postedAt: job.updated_at || null,
|
|
34
|
+
salary: null, // Greenhouse doesn't expose salary in public API
|
|
35
|
+
metadata: {
|
|
36
|
+
greenhouseId: job.id,
|
|
37
|
+
internal_job_id: job.internal_job_id,
|
|
38
|
+
departments: job.departments?.map(d => d.name) || [],
|
|
39
|
+
offices: job.offices?.map(o => o.name) || [],
|
|
40
|
+
},
|
|
41
|
+
}, 'greenhouse'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a company has a Greenhouse board.
|
|
46
|
+
*/
|
|
47
|
+
export async function hasGreenhouse(slug) {
|
|
48
|
+
try {
|
|
49
|
+
const resp = await fetch(`${BASE_URL}/${slug}`, { method: 'HEAD' });
|
|
50
|
+
return resp.ok;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { fetchGreenhouse, hasGreenhouse } from './greenhouse.js';
|
|
2
|
+
export { fetchLever, hasLever } from './lever.js';
|
|
3
|
+
export { fetchAshby, hasAshby } from './ashby.js';
|
|
4
|
+
|
|
5
|
+
export const ADAPTERS = {
|
|
6
|
+
greenhouse: { fetch: (...args) => import('./greenhouse.js').then(m => m.fetchGreenhouse(...args)), has: (...args) => import('./greenhouse.js').then(m => m.hasGreenhouse(...args)) },
|
|
7
|
+
lever: { fetch: (...args) => import('./lever.js').then(m => m.fetchLever(...args)), has: (...args) => import('./lever.js').then(m => m.hasLever(...args)) },
|
|
8
|
+
ashby: { fetch: (...args) => import('./ashby.js').then(m => m.fetchAshby(...args)), has: (...args) => import('./ashby.js').then(m => m.hasAshby(...args)) },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ATS_NAMES = Object.keys(ADAPTERS);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { normalize, stripHtml } from '../normalizer.js';
|
|
2
|
+
|
|
3
|
+
const BASE_URL = 'https://api.lever.co/v0/postings';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch all jobs from a Lever job board.
|
|
7
|
+
* Public API, no auth required.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} slug - Company slug (e.g., 'stripe', 'figma')
|
|
10
|
+
* @returns {Promise<Array>} Normalized job objects
|
|
11
|
+
*/
|
|
12
|
+
export async function fetchLever(slug) {
|
|
13
|
+
const url = `${BASE_URL}/${slug}?mode=json`;
|
|
14
|
+
const resp = await fetch(url);
|
|
15
|
+
|
|
16
|
+
if (!resp.ok) {
|
|
17
|
+
if (resp.status === 404) return [];
|
|
18
|
+
throw new Error(`Lever API error for ${slug}: ${resp.status}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const jobs = await resp.json();
|
|
22
|
+
if (!Array.isArray(jobs)) return [];
|
|
23
|
+
|
|
24
|
+
return jobs.map(job => {
|
|
25
|
+
const salary = parseLeverSalary(job.categories?.commitment, job.text);
|
|
26
|
+
|
|
27
|
+
return normalize({
|
|
28
|
+
companySlug: slug,
|
|
29
|
+
// Lever's API doesn't return the company name at the board or job level,
|
|
30
|
+
// so the slug is the honest fallback. `categories.team` is the team within
|
|
31
|
+
// the company ("Payments Platform"), not the company itself.
|
|
32
|
+
company: titleCaseSlug(slug),
|
|
33
|
+
title: job.text || '',
|
|
34
|
+
department: job.categories?.department || job.categories?.team || '',
|
|
35
|
+
location: job.categories?.location || '',
|
|
36
|
+
description: stripHtml(job.descriptionPlain || job.description || ''),
|
|
37
|
+
url: job.hostedUrl || '',
|
|
38
|
+
postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
|
|
39
|
+
salary,
|
|
40
|
+
metadata: {
|
|
41
|
+
leverId: job.id,
|
|
42
|
+
team: job.categories?.team || '',
|
|
43
|
+
commitment: job.categories?.commitment || '', // Full-time, Part-time, etc.
|
|
44
|
+
workplaceType: job.workplaceType || '',
|
|
45
|
+
},
|
|
46
|
+
}, 'lever');
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function titleCaseSlug(slug) {
|
|
51
|
+
if (!slug) return '';
|
|
52
|
+
// "cockroachlabs" → "Cockroachlabs", "netflix" → "Netflix"
|
|
53
|
+
// Best-effort display name; users should prefer companySlug for exact matching.
|
|
54
|
+
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseLeverSalary(commitment, title) {
|
|
58
|
+
// Lever doesn't have a salary field, but sometimes it's in the title
|
|
59
|
+
const match = (title || '').match(/\$[\d,]+\s*[-–]\s*\$[\d,]+/);
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
const nums = match[0].match(/[\d,]+/g);
|
|
62
|
+
if (!nums || nums.length < 2) return null;
|
|
63
|
+
return {
|
|
64
|
+
min: parseInt(nums[0].replace(/,/g, '')),
|
|
65
|
+
max: parseInt(nums[1].replace(/,/g, '')),
|
|
66
|
+
currency: 'USD',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function hasLever(slug) {
|
|
71
|
+
try {
|
|
72
|
+
const resp = await fetch(`${BASE_URL}/${slug}?mode=json`, { method: 'HEAD' });
|
|
73
|
+
return resp.ok;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* jd-intel CLI
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* jd-intel fetch <company> [--ats greenhouse|lever|ashby] [--filter keyword|pattern]
|
|
8
|
+
* jd-intel detect <company>
|
|
9
|
+
* jd-intel registry search <query>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { fetchJobs } from './index.js';
|
|
13
|
+
import { detectAts, searchRegistry } from './registry.js';
|
|
14
|
+
|
|
15
|
+
const [,, command, ...args] = process.argv;
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
switch (command) {
|
|
19
|
+
case 'fetch': {
|
|
20
|
+
const company = args[0];
|
|
21
|
+
if (!company) { console.error('Usage: jd-intel fetch <company> [--ats greenhouse|lever|ashby]'); process.exit(1); }
|
|
22
|
+
const getArg = (flag) => {
|
|
23
|
+
const idx = args.indexOf(flag);
|
|
24
|
+
return idx >= 0 ? args[idx + 1] : undefined;
|
|
25
|
+
};
|
|
26
|
+
const ats = getArg('--ats');
|
|
27
|
+
const titleFilter = getArg('--title-filter');
|
|
28
|
+
const filter = getArg('--filter');
|
|
29
|
+
const postedWithinRaw = getArg('--posted-within-days');
|
|
30
|
+
const postedWithinDays = postedWithinRaw !== undefined ? Number(postedWithinRaw) : undefined;
|
|
31
|
+
const locIncludeRaw = getArg('--location-include');
|
|
32
|
+
const locationIncludes = locIncludeRaw ? locIncludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
33
|
+
const locExcludeRaw = getArg('--location-exclude');
|
|
34
|
+
const locationExcludes = locExcludeRaw ? locExcludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
35
|
+
const limitRaw = getArg('--limit');
|
|
36
|
+
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
|
|
37
|
+
|
|
38
|
+
const parts = [];
|
|
39
|
+
if (titleFilter) parts.push(`title: ${titleFilter}`);
|
|
40
|
+
if (filter) parts.push(`topic: ${filter}`);
|
|
41
|
+
if (postedWithinDays !== undefined) parts.push(`within ${postedWithinDays}d`);
|
|
42
|
+
if (locationIncludes) parts.push(`loc+: ${locationIncludes.join('|')}`);
|
|
43
|
+
if (locationExcludes) parts.push(`loc-: ${locationExcludes.join('|')}`);
|
|
44
|
+
const suffix = parts.length ? ` [${parts.join(', ')}]` : '';
|
|
45
|
+
|
|
46
|
+
console.log(`Fetching jobs from ${company}${ats ? ` (${ats})` : ' (auto-detect)'}${suffix}...`);
|
|
47
|
+
const jobs = await fetchJobs({
|
|
48
|
+
company, ats, titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit,
|
|
49
|
+
});
|
|
50
|
+
console.log(`Found ${jobs.length} jobs\n`);
|
|
51
|
+
|
|
52
|
+
for (const job of jobs.slice(0, 20)) {
|
|
53
|
+
const salary = job.salary ? ` | $${job.salary.min?.toLocaleString()}-$${job.salary.max?.toLocaleString()}` : '';
|
|
54
|
+
const loc = job.location ? ` | ${job.location}` : '';
|
|
55
|
+
const dept = job.department ? ` [${job.department}]` : '';
|
|
56
|
+
console.log(` ${job.title}${dept}${loc}${salary}`);
|
|
57
|
+
console.log(` ${job.url}`);
|
|
58
|
+
if (job.description) {
|
|
59
|
+
const preview = job.description.substring(0, 120).replace(/\n/g, ' ');
|
|
60
|
+
console.log(` ${preview}...`);
|
|
61
|
+
}
|
|
62
|
+
console.log();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (jobs.length > 20) {
|
|
66
|
+
console.log(` ... and ${jobs.length - 20} more. Use --json for full output.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (args.includes('--json')) {
|
|
70
|
+
console.log(JSON.stringify(jobs, null, 2));
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case 'detect': {
|
|
76
|
+
const company = args[0];
|
|
77
|
+
if (!company) { console.error('Usage: jd-intel detect <company>'); process.exit(1); }
|
|
78
|
+
console.log(`Detecting ATS for ${company}...`);
|
|
79
|
+
const results = await detectAts(company);
|
|
80
|
+
if (results.length === 0) {
|
|
81
|
+
console.log('No ATS board found for this company.');
|
|
82
|
+
} else {
|
|
83
|
+
for (const r of results) {
|
|
84
|
+
console.log(` Found: ${r.ats} (slug: ${r.slug})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'registry': {
|
|
91
|
+
const subcommand = args[0];
|
|
92
|
+
if (subcommand === 'search') {
|
|
93
|
+
const query = args.slice(1).join(' ');
|
|
94
|
+
if (!query) { console.error('Usage: jd-intel registry search <query>'); process.exit(1); }
|
|
95
|
+
const results = await searchRegistry(query);
|
|
96
|
+
console.log(`Found ${results.length} companies matching "${query}":\n`);
|
|
97
|
+
for (const r of results) {
|
|
98
|
+
console.log(` ${r.name || r.slug} (${r.ats})${r.sector ? ` — ${r.sector}` : ''}`);
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
console.error('Usage: jd-intel registry search <query>');
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
default:
|
|
107
|
+
console.log(`jd-intel — JD intelligence toolkit for your AI assistant.
|
|
108
|
+
|
|
109
|
+
Usage:
|
|
110
|
+
jd-intel fetch <company> [options]
|
|
111
|
+
jd-intel detect <company>
|
|
112
|
+
jd-intel registry search <query>
|
|
113
|
+
|
|
114
|
+
Fetch options:
|
|
115
|
+
--ats greenhouse|lever|ashby Skip auto-detect
|
|
116
|
+
--title-filter pattern Regex matched against TITLE only (role identity)
|
|
117
|
+
--filter pattern Regex matched across title, department, description (topic/scope)
|
|
118
|
+
--posted-within-days N Only jobs posted in the last N days
|
|
119
|
+
--location-include "A,B,C" Keep jobs whose location contains any of these
|
|
120
|
+
--location-exclude "A,B,C" Drop jobs whose location contains any of these
|
|
121
|
+
--limit N Cap results (default 100)
|
|
122
|
+
--json Output full JSON
|
|
123
|
+
|
|
124
|
+
Filter guidance:
|
|
125
|
+
Use --title-filter for "what KIND of role" (PM, engineer, designer).
|
|
126
|
+
Use --filter for "what it's ABOUT" (integrations, growth, payments).
|
|
127
|
+
Both AND together. Avoid --filter "product manager" — description
|
|
128
|
+
mentions of PMs in other roles' JDs create false positives.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
jd-intel fetch stripe
|
|
132
|
+
jd-intel fetch stripe --title-filter "product manager" --filter "growth|platform"
|
|
133
|
+
jd-intel fetch ramp --location-include "United States,US,Remote - US" --location-exclude "London,Dublin"
|
|
134
|
+
jd-intel fetch notion --ats ashby --title-filter engineer --posted-within-days 14
|
|
135
|
+
jd-intel detect figma
|
|
136
|
+
jd-intel registry search fintech`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch(err => {
|
|
141
|
+
console.error('Error:', err.message);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
package/src/filters.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply filters to a list of normalized jobs.
|
|
3
|
+
*
|
|
4
|
+
* Facts go here (deterministic field matches). Interpretations stay with the
|
|
5
|
+
* caller — this module does substring matching on structured fields, nothing
|
|
6
|
+
* semantic.
|
|
7
|
+
*/
|
|
8
|
+
export function applyFilters(jobs, options = {}) {
|
|
9
|
+
const {
|
|
10
|
+
titleFilter,
|
|
11
|
+
filter,
|
|
12
|
+
postedWithinDays,
|
|
13
|
+
locationIncludes,
|
|
14
|
+
locationExcludes,
|
|
15
|
+
limit = 100,
|
|
16
|
+
} = options;
|
|
17
|
+
|
|
18
|
+
let result = jobs;
|
|
19
|
+
|
|
20
|
+
if (titleFilter) {
|
|
21
|
+
const pattern = new RegExp(titleFilter, 'i');
|
|
22
|
+
result = result.filter(j => pattern.test(j.title || ''));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (filter) {
|
|
26
|
+
const pattern = new RegExp(filter, 'i');
|
|
27
|
+
result = result.filter(j =>
|
|
28
|
+
pattern.test(j.title || '') ||
|
|
29
|
+
pattern.test(j.department || '') ||
|
|
30
|
+
pattern.test(j.description || '')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof postedWithinDays === 'number') {
|
|
35
|
+
const cutoff = Date.now() - postedWithinDays * 86400000;
|
|
36
|
+
result = result.filter(j => {
|
|
37
|
+
if (!j.postedAt) return false;
|
|
38
|
+
const posted = new Date(j.postedAt).getTime();
|
|
39
|
+
return Number.isFinite(posted) && posted >= cutoff;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(locationIncludes) && locationIncludes.length > 0) {
|
|
44
|
+
const matchers = locationIncludes.map(makeLocationMatcher);
|
|
45
|
+
result = result.filter(j => {
|
|
46
|
+
const loc = (j.location || '').toLowerCase();
|
|
47
|
+
return matchers.some(m => m(loc));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(locationExcludes) && locationExcludes.length > 0) {
|
|
52
|
+
const matchers = locationExcludes.map(makeLocationMatcher);
|
|
53
|
+
result = result.filter(j => {
|
|
54
|
+
const loc = (j.location || '').toLowerCase();
|
|
55
|
+
return !matchers.some(m => m(loc));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof limit === 'number' && result.length > limit) {
|
|
60
|
+
result = result.slice(0, limit);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a matcher for a single location keyword.
|
|
68
|
+
*
|
|
69
|
+
* Short tokens (≤4 chars) use word-boundary matching to prevent substring
|
|
70
|
+
* collisions like "US" matching "Australia", "Brussels", "Belarus", or "UK"
|
|
71
|
+
* matching "Auckland". Longer tokens use substring matching so phrases like
|
|
72
|
+
* "United States" can match "United States of America".
|
|
73
|
+
*/
|
|
74
|
+
function makeLocationMatcher(needle) {
|
|
75
|
+
const lower = (needle || '').toLowerCase().trim();
|
|
76
|
+
if (!lower) return () => false;
|
|
77
|
+
if (lower.length <= 4) {
|
|
78
|
+
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
|
+
const pattern = new RegExp(`\\b${escaped}\\b`);
|
|
80
|
+
return (loc) => pattern.test(loc);
|
|
81
|
+
}
|
|
82
|
+
return (loc) => loc.includes(lower);
|
|
83
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jd-intel — JD intelligence toolkit: fetch, normalize, and search job descriptions across every major ATS.
|
|
3
|
+
*
|
|
4
|
+
* Fetches, normalizes, and enriches job data from public ATS APIs
|
|
5
|
+
* (Greenhouse, Lever, Ashby) into a unified schema.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
|
|
9
|
+
import { loadRegistry, searchRegistry, detectAts, findAtsBySlug } from './registry.js';
|
|
10
|
+
import { applyFilters } from './filters.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fetch jobs from a company's ATS board.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {string} options.company - Company slug or name
|
|
17
|
+
* @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
|
|
18
|
+
* @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
|
|
19
|
+
* @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
|
|
20
|
+
* @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
|
|
21
|
+
* @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
|
|
22
|
+
* @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
|
|
23
|
+
* @param {number} [options.limit=100] - Maximum jobs to return after filtering.
|
|
24
|
+
* @returns {Promise<Array>} Normalized, filtered job objects
|
|
25
|
+
*/
|
|
26
|
+
export async function fetchJobs({
|
|
27
|
+
company,
|
|
28
|
+
ats,
|
|
29
|
+
titleFilter,
|
|
30
|
+
filter,
|
|
31
|
+
postedWithinDays,
|
|
32
|
+
locationIncludes,
|
|
33
|
+
locationExcludes,
|
|
34
|
+
limit = 100,
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!company) throw new Error('Company slug required');
|
|
37
|
+
|
|
38
|
+
// Unified slug normalization: strip all non-alphanumeric (matches detectAts)
|
|
39
|
+
const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
40
|
+
|
|
41
|
+
let jobs;
|
|
42
|
+
if (ats) {
|
|
43
|
+
const adapter = ADAPTERS[ats];
|
|
44
|
+
if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
|
|
45
|
+
jobs = await adapter.fetch(slug);
|
|
46
|
+
} else {
|
|
47
|
+
// Consult registry first — if we know which ATS this company uses,
|
|
48
|
+
// skip probing the others (saves API calls, clearer error semantics).
|
|
49
|
+
const known = await findAtsBySlug(slug);
|
|
50
|
+
if (known) {
|
|
51
|
+
jobs = await ADAPTERS[known].fetch(slug);
|
|
52
|
+
} else {
|
|
53
|
+
// Discovery mode: company not in registry, probe all adapters
|
|
54
|
+
const results = await Promise.allSettled(
|
|
55
|
+
Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug))
|
|
56
|
+
);
|
|
57
|
+
jobs = results
|
|
58
|
+
.filter(r => r.status === 'fulfilled')
|
|
59
|
+
.flatMap(r => r.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Search for companies in the registry.
|
|
68
|
+
*/
|
|
69
|
+
export async function search({ keyword, location, ats } = {}) {
|
|
70
|
+
// For now, search is registry-based. With SQLite store, this becomes a full-text search.
|
|
71
|
+
if (!keyword) throw new Error('Keyword required');
|
|
72
|
+
return searchRegistry(keyword);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect which ATS platform a company uses (probes each adapter).
|
|
77
|
+
*/
|
|
78
|
+
export { detectAts } from './registry.js';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Look up which ATS a slug belongs to in the registry (cached, no network).
|
|
82
|
+
* Returns the ATS name ("greenhouse" | "lever" | "ashby") or null if not in registry.
|
|
83
|
+
*/
|
|
84
|
+
export { findAtsBySlug } from './registry.js';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Registry management.
|
|
88
|
+
*/
|
|
89
|
+
export const registry = {
|
|
90
|
+
load: loadRegistry,
|
|
91
|
+
search: searchRegistry,
|
|
92
|
+
detect: detectAts,
|
|
93
|
+
findAtsBySlug,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Re-export individual adapters for direct use
|
|
97
|
+
export { fetchGreenhouse } from './adapters/greenhouse.js';
|
|
98
|
+
export { fetchLever } from './adapters/lever.js';
|
|
99
|
+
export { fetchAshby } from './adapters/ashby.js';
|
|
100
|
+
|
|
101
|
+
// Re-export filter logic for reuse (e.g., by the MCP server)
|
|
102
|
+
export { applyFilters } from './filters.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a stable ID for a job posting.
|
|
5
|
+
*/
|
|
6
|
+
export function jobId(company, title, ats) {
|
|
7
|
+
const raw = `${company}|${title}|${ats}`.toLowerCase().trim();
|
|
8
|
+
return createHash('md5').update(raw).digest('hex').substring(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a raw ATS job object into the unified schema.
|
|
13
|
+
*/
|
|
14
|
+
export function normalize(raw, ats) {
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
return {
|
|
17
|
+
id: jobId(raw.company || raw.companySlug, raw.title, ats),
|
|
18
|
+
company: raw.company || raw.companySlug || '',
|
|
19
|
+
companySlug: raw.companySlug || '',
|
|
20
|
+
ats,
|
|
21
|
+
title: raw.title || '',
|
|
22
|
+
department: raw.department || '',
|
|
23
|
+
location: raw.location || '',
|
|
24
|
+
locationType: detectLocationType(raw.location || ''),
|
|
25
|
+
salary: raw.salary || extractSalaryFromText(raw.description || ''),
|
|
26
|
+
description: stripHtml(raw.description || ''),
|
|
27
|
+
url: raw.url || '',
|
|
28
|
+
postedAt: raw.postedAt || null,
|
|
29
|
+
firstSeen: now,
|
|
30
|
+
lastSeen: now,
|
|
31
|
+
status: 'open',
|
|
32
|
+
metadata: raw.metadata || {},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detect location type from location string.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Extract salary range from job description text.
|
|
41
|
+
* Matches patterns like: $162,400 - $243,600 or $150K-$200K
|
|
42
|
+
*/
|
|
43
|
+
function extractSalaryFromText(text) {
|
|
44
|
+
if (!text) return null;
|
|
45
|
+
// Match: $162,400 - $243,600 (full numbers)
|
|
46
|
+
const fullMatch = text.match(/\$([\d,]+)\s*[-–]\s*\$([\d,]+)/);
|
|
47
|
+
if (fullMatch) {
|
|
48
|
+
return {
|
|
49
|
+
min: parseInt(fullMatch[1].replace(/,/g, '')),
|
|
50
|
+
max: parseInt(fullMatch[2].replace(/,/g, '')),
|
|
51
|
+
currency: 'USD',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Match: $150K - $200K (shorthand)
|
|
55
|
+
const kMatch = text.match(/\$(\d+)[kK]\s*[-–]\s*\$(\d+)[kK]/);
|
|
56
|
+
if (kMatch) {
|
|
57
|
+
return {
|
|
58
|
+
min: parseInt(kMatch[1]) * 1000,
|
|
59
|
+
max: parseInt(kMatch[2]) * 1000,
|
|
60
|
+
currency: 'USD',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectLocationType(location) {
|
|
67
|
+
const lower = location.toLowerCase();
|
|
68
|
+
if (/remote/i.test(lower)) return 'remote';
|
|
69
|
+
if (/hybrid/i.test(lower)) return 'hybrid';
|
|
70
|
+
if (/on-?site/i.test(lower)) return 'onsite';
|
|
71
|
+
return location ? 'onsite' : 'unknown';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Strip HTML tags and convert to clean text.
|
|
76
|
+
*/
|
|
77
|
+
export function stripHtml(html) {
|
|
78
|
+
if (!html) return '';
|
|
79
|
+
return html
|
|
80
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
81
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
82
|
+
.replace(/<\/li>/gi, '\n')
|
|
83
|
+
.replace(/<li>/gi, '- ')
|
|
84
|
+
.replace(/<\/h[1-6]>/gi, '\n\n')
|
|
85
|
+
.replace(/<h[1-6][^>]*>/gi, '## ')
|
|
86
|
+
.replace(/<[^>]+>/g, '')
|
|
87
|
+
.replace(/&/g, '&')
|
|
88
|
+
.replace(/</g, '<')
|
|
89
|
+
.replace(/>/g, '>')
|
|
90
|
+
.replace(/ /g, ' ')
|
|
91
|
+
.replace(/&#\d+;/g, '')
|
|
92
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
93
|
+
.trim();
|
|
94
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const REGISTRY_DIR = join(__dirname, '..', 'registry');
|
|
7
|
+
|
|
8
|
+
let cache = {};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load company registry for a specific ATS or all ATS platforms.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadRegistry(ats) {
|
|
14
|
+
if (ats && cache[ats]) return cache[ats];
|
|
15
|
+
|
|
16
|
+
if (ats) {
|
|
17
|
+
try {
|
|
18
|
+
const data = await readFile(join(REGISTRY_DIR, `${ats}.json`), 'utf-8');
|
|
19
|
+
cache[ats] = JSON.parse(data);
|
|
20
|
+
return cache[ats];
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Load all
|
|
27
|
+
const all = {};
|
|
28
|
+
for (const platform of ['greenhouse', 'lever', 'ashby']) {
|
|
29
|
+
try {
|
|
30
|
+
const data = await readFile(join(REGISTRY_DIR, `${platform}.json`), 'utf-8');
|
|
31
|
+
all[platform] = JSON.parse(data);
|
|
32
|
+
cache[platform] = all[platform];
|
|
33
|
+
} catch {
|
|
34
|
+
all[platform] = [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Search registry for companies matching a query.
|
|
42
|
+
*/
|
|
43
|
+
export async function searchRegistry(query) {
|
|
44
|
+
const all = await loadRegistry();
|
|
45
|
+
const lower = query.toLowerCase();
|
|
46
|
+
const results = [];
|
|
47
|
+
|
|
48
|
+
for (const [ats, companies] of Object.entries(all)) {
|
|
49
|
+
for (const company of companies) {
|
|
50
|
+
const name = (company.name || company.slug || '').toLowerCase();
|
|
51
|
+
const sector = (company.sector || '').toLowerCase();
|
|
52
|
+
if (name.includes(lower) || sector.includes(lower)) {
|
|
53
|
+
results.push({ ...company, ats });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Look up which ATS a slug belongs to in the registry.
|
|
63
|
+
* Returns the ATS name (e.g., "greenhouse") or null if not in registry.
|
|
64
|
+
*/
|
|
65
|
+
export async function findAtsBySlug(slug) {
|
|
66
|
+
const all = await loadRegistry();
|
|
67
|
+
for (const [ats, companies] of Object.entries(all)) {
|
|
68
|
+
if (companies.some(c => c.slug === slug)) return ats;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Auto-detect which ATS a company uses.
|
|
75
|
+
*/
|
|
76
|
+
export async function detectAts(companyName) {
|
|
77
|
+
const { ADAPTERS } = await import('./adapters/index.js');
|
|
78
|
+
const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
79
|
+
|
|
80
|
+
const results = [];
|
|
81
|
+
const checks = Object.entries(ADAPTERS).map(async ([ats, adapter]) => {
|
|
82
|
+
const found = await adapter.has(slug);
|
|
83
|
+
if (found) results.push({ ats, slug });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await Promise.allSettled(checks);
|
|
87
|
+
return results;
|
|
88
|
+
}
|