job-pro 0.6.0 → 0.7.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/dist/agibot.js +390 -0
- package/dist/baichuan.js +133 -0
- package/dist/cainiao.js +63 -0
- package/dist/cambricon.js +64 -0
- package/dist/cicc.js +152 -0
- package/dist/deepseek.js +39 -0
- package/dist/galaxyuniversal.js +37 -0
- package/dist/geely.js +62 -0
- package/dist/greenhouse.js +370 -0
- package/dist/hikvision.js +185 -0
- package/dist/horizonrobotics.js +66 -0
- package/dist/hoyoverse.js +25 -0
- package/dist/iflytek.js +97 -0
- package/dist/index.js +79 -1
- package/dist/iqiyi.js +485 -0
- package/dist/lever.js +375 -0
- package/dist/liauto.js +362 -0
- package/dist/lilith.js +175 -0
- package/dist/megvii.js +219 -0
- package/dist/moonshot.js +63 -0
- package/dist/oppo.js +94 -0
- package/dist/sf.js +65 -0
- package/dist/stepfun.js +154 -0
- package/dist/vivo.js +70 -0
- package/dist/webank.js +62 -0
- package/dist/weride.js +28 -0
- package/dist/xpeng.js +33 -0
- package/dist/zerooneai.js +38 -0
- package/dist/zhipu.js +469 -0
- package/package.json +2 -2
package/dist/cicc.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Thin client for CICC / 中金公司 (China International Capital Corporation) campus recruiting.
|
|
2
|
+
//
|
|
3
|
+
// ============================================================
|
|
4
|
+
// API DISCOVERY (probed 2026-05-14)
|
|
5
|
+
//
|
|
6
|
+
// Five potential portals were investigated:
|
|
7
|
+
//
|
|
8
|
+
// 1. careers.cicc.com (CICC dedicated careers subdomain)
|
|
9
|
+
// DNS resolves to 198.18.1.66 (IANA RFC 2544 benchmarking range — unreachable
|
|
10
|
+
// from public internet; SSL handshake fails at TCP level).
|
|
11
|
+
// This hostname is dead from outside the CICC intranet.
|
|
12
|
+
//
|
|
13
|
+
// 2. hr.cicc.com (HR portal)
|
|
14
|
+
// DNS resolves to 198.18.1.212 — same IANA-reserved range.
|
|
15
|
+
// Unreachable: SSL handshake fails.
|
|
16
|
+
//
|
|
17
|
+
// 3. www.cicc.com/career (main site career section)
|
|
18
|
+
// Returns HTTP 521 (Cloudflare "Web server is down"). Cloudflare can reach
|
|
19
|
+
// the origin but the origin is not responding. No API endpoints discoverable.
|
|
20
|
+
//
|
|
21
|
+
// 4. app.mokahr.com — Moka ATS, orgId 28961 (reconnaissance-flagged candidate)
|
|
22
|
+
// The Moka platform (app.mokahr.com) is reachable (resolves to Aliyun WAF
|
|
23
|
+
// 47.93.92.61 via authoritative DNS). However:
|
|
24
|
+
// - /campus-recruitment/cicc/28961 → HTTP 302 self-redirect (infinite loop)
|
|
25
|
+
// - /campus-recruitment/cicc-career/28961 → HTTP 200 HTML, but init-data
|
|
26
|
+
// contains {"message":"您访问的页面不存在"} — org page does not exist
|
|
27
|
+
// - /social-recruitment/cicc-career/28961 → same "page not found" response
|
|
28
|
+
// - All /api/campus/jobs?organizationId=28961 variants → {"code":-1,"message":"您访问的页面不存在"}
|
|
29
|
+
// Conclusion: orgId 28961 is either wrong or the CICC Moka tenant is fully
|
|
30
|
+
// auth-gated behind enterprise SSO. No public JSON feed is accessible.
|
|
31
|
+
//
|
|
32
|
+
// 5. Alternative slugs tried on Moka: "cicc", "cicc-career", "zhongjin", numeric
|
|
33
|
+
// variants of orgId (28960–28965) — all return either the same "not found"
|
|
34
|
+
// response or an infinite self-redirect.
|
|
35
|
+
//
|
|
36
|
+
// VERDICT: No public unauthenticated API exists for CICC campus recruiting as of
|
|
37
|
+
// 2026-05-14. All externally-facing hostnames resolve to IANA-reserved IPs (intranet)
|
|
38
|
+
// or return infrastructure errors (521). The Moka tenant, if it exists, is fully
|
|
39
|
+
// auth-gated with no discoverable public endpoints.
|
|
40
|
+
//
|
|
41
|
+
// The canonical path for candidates is the CICC official career portal:
|
|
42
|
+
// https://www.cicc.com/career (may load once the origin is healthy)
|
|
43
|
+
// Or the known Moka URL patterns (require an active employee/candidate session):
|
|
44
|
+
// https://app.mokahr.com/campus-recruitment/cicc-career/28961
|
|
45
|
+
//
|
|
46
|
+
// ============================================================
|
|
47
|
+
// PositionSummary field mapping (canonical keys, matches all other adapters)
|
|
48
|
+
// post_id — string job identifier
|
|
49
|
+
// title — position title
|
|
50
|
+
// project — job category / department (e.g. "投行" / "研究" / "固收")
|
|
51
|
+
// recruit_label — recruit type label (e.g. "校园招聘" / "实习")
|
|
52
|
+
// bgs — business group / division (not exposed without auth, always "")
|
|
53
|
+
// work_cities — work location string
|
|
54
|
+
// apply_url — deep link to the job posting
|
|
55
|
+
// ============================================================
|
|
56
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
57
|
+
export { checkResume };
|
|
58
|
+
const SOURCE = "www.cicc.com";
|
|
59
|
+
const CAMPUS_PAGE = "https://www.cicc.com/career";
|
|
60
|
+
// Moka tenant URL kept for reference — requires auth
|
|
61
|
+
const MOKA_PAGE = "https://app.mokahr.com/campus-recruitment/cicc-career/28961";
|
|
62
|
+
// ---------- stub message ----------
|
|
63
|
+
const STUB_MSG = "CICC (中金公司) campus recruiting has no publicly accessible API as of 2026-05-14. " +
|
|
64
|
+
"careers.cicc.com and hr.cicc.com resolve to IANA-reserved IPs (198.18.x.x, " +
|
|
65
|
+
"unreachable outside the CICC intranet; SSL handshake fails). " +
|
|
66
|
+
"www.cicc.com returns HTTP 521 (Cloudflare origin-down). " +
|
|
67
|
+
"The Moka ATS tenant (orgId 28961) is fully auth-gated: all public API paths " +
|
|
68
|
+
'return {"code":-1,"message":"您访问的页面不存在"}. ' +
|
|
69
|
+
`Apply directly at ${CAMPUS_PAGE} or via the Moka portal (login required): ${MOKA_PAGE}`;
|
|
70
|
+
// ---------- searchPositions ----------
|
|
71
|
+
export async function searchPositions(_opts = {}) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
source: SOURCE,
|
|
75
|
+
message: STUB_MSG,
|
|
76
|
+
apply_url: CAMPUS_PAGE,
|
|
77
|
+
moka_url: MOKA_PAGE,
|
|
78
|
+
positions: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ---------- fetchAllPositions ----------
|
|
82
|
+
export async function fetchAllPositions(_opts = {}) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
source: SOURCE,
|
|
86
|
+
message: STUB_MSG,
|
|
87
|
+
apply_url: CAMPUS_PAGE,
|
|
88
|
+
moka_url: MOKA_PAGE,
|
|
89
|
+
fetched: 0,
|
|
90
|
+
positions: [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ---------- fetchPositionDetail ----------
|
|
94
|
+
export async function fetchPositionDetail(_postId) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
source: SOURCE,
|
|
98
|
+
message: STUB_MSG,
|
|
99
|
+
apply_url: CAMPUS_PAGE,
|
|
100
|
+
moka_url: MOKA_PAGE,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// ---------- fetchDictionaries ----------
|
|
104
|
+
export async function fetchDictionaries() {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
source: SOURCE,
|
|
108
|
+
message: STUB_MSG,
|
|
109
|
+
apply_url: CAMPUS_PAGE,
|
|
110
|
+
moka_url: MOKA_PAGE,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ---------- notices (no public endpoint) ----------
|
|
114
|
+
export async function listNotices() {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
source: SOURCE,
|
|
118
|
+
message: "CICC: no public notices endpoint",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function getNotice(_id) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
source: SOURCE,
|
|
125
|
+
message: "CICC: no public notices endpoint",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function findNoticesByQuestion(_question, _opts = {}) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
source: SOURCE,
|
|
132
|
+
message: "CICC: no public notices endpoint",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ---------- matchResume ----------
|
|
136
|
+
// Resume matching cannot fetch live position data without auth.
|
|
137
|
+
// We extract signals from the resume and direct the user to the CICC career portal.
|
|
138
|
+
export async function matchResume(text, opts = {}) {
|
|
139
|
+
void opts;
|
|
140
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
source: SOURCE,
|
|
144
|
+
message: STUB_MSG,
|
|
145
|
+
apply_url: CAMPUS_PAGE,
|
|
146
|
+
moka_url: MOKA_PAGE,
|
|
147
|
+
extracted_terms: terms,
|
|
148
|
+
city_preferences: cities,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Export helpers so callers that import from this module can use them.
|
|
152
|
+
export { extractResumeSignals, scoreOverlap };
|
package/dist/deepseek.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// DeepSeek (深度求索) — stub adapter for `job-pro`.
|
|
2
|
+
//
|
|
3
|
+
// STATUS: stub-only. DeepSeek is part of High-Flyer (幻方量化) and lists
|
|
4
|
+
// careers via the parent company on Moka social-recruitment. Probe results:
|
|
5
|
+
// www.deepseek.com/careers → 200 HTML, no inline job data / API path
|
|
6
|
+
// careers.deepseek.com → DNS resolves but TLS rejects from non-CN IPs
|
|
7
|
+
// app.mokahr.com/social-recruitment/high-flyer/140576/ → Moka SPA, auth-gated
|
|
8
|
+
// Moka public anonymous API is gated (confirmed; see Moka probe in repo
|
|
9
|
+
// history). When DeepSeek opens a public JSON endpoint we rewrite in one pass.
|
|
10
|
+
import { extractResumeSignals, checkResume } from "./tencent.js";
|
|
11
|
+
export { checkResume };
|
|
12
|
+
const SOURCE = "www.deepseek.com";
|
|
13
|
+
const STUB_MESSAGE = "DeepSeek: no public job API — careers route through Moka social-recruitment " +
|
|
14
|
+
"(high-flyer/140576), which requires session auth. corporate careers page is HTML only.";
|
|
15
|
+
export async function searchPositions(_opts = {}) {
|
|
16
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
|
|
17
|
+
}
|
|
18
|
+
export async function fetchAllPositions(_opts = {}) {
|
|
19
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
|
|
20
|
+
}
|
|
21
|
+
export async function fetchPositionDetail(postId) {
|
|
22
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
|
|
23
|
+
}
|
|
24
|
+
export async function fetchDictionaries() {
|
|
25
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE };
|
|
26
|
+
}
|
|
27
|
+
export async function listNotices() {
|
|
28
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
|
|
29
|
+
}
|
|
30
|
+
export async function getNotice(noticeId) {
|
|
31
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
|
|
32
|
+
}
|
|
33
|
+
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
34
|
+
return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
|
|
35
|
+
}
|
|
36
|
+
export async function matchResume(text, _opts = {}) {
|
|
37
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
38
|
+
return { ok: false, source: SOURCE, extracted_terms: terms, city_preferences: cities, matches: [], message: STUB_MESSAGE };
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 银河通用 / Galaxy Universal (Galbot — embodied AI robotics) — stub for `job-pro`.
|
|
2
|
+
//
|
|
3
|
+
// STATUS: stub-only. Galaxy Universal lists careers via Moka social-recruitment
|
|
4
|
+
// (orgId 165930, slug yinhetongyong), which is auth-gated for anonymous access.
|
|
5
|
+
// Probe results:
|
|
6
|
+
// www.galbot.com/careers, galaxyuniversal.com/careers → no public API discoverable
|
|
7
|
+
// app.mokahr.com/social-recruitment/yinhetongyong/165930 → Moka SPA, session auth required
|
|
8
|
+
import { extractResumeSignals, checkResume } from "./tencent.js";
|
|
9
|
+
export { checkResume };
|
|
10
|
+
const SOURCE = "galbot.com";
|
|
11
|
+
const STUB_MESSAGE = "Galaxy Universal / 银河通用: no public job API — Moka social-recruitment portal " +
|
|
12
|
+
"(yinhetongyong/165930) requires session auth (verified Moka anon path is gated).";
|
|
13
|
+
export async function searchPositions(_opts = {}) {
|
|
14
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
|
|
15
|
+
}
|
|
16
|
+
export async function fetchAllPositions(_opts = {}) {
|
|
17
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
|
|
18
|
+
}
|
|
19
|
+
export async function fetchPositionDetail(postId) {
|
|
20
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
|
|
21
|
+
}
|
|
22
|
+
export async function fetchDictionaries() {
|
|
23
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE };
|
|
24
|
+
}
|
|
25
|
+
export async function listNotices() {
|
|
26
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
|
|
27
|
+
}
|
|
28
|
+
export async function getNotice(noticeId) {
|
|
29
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
|
|
30
|
+
}
|
|
31
|
+
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
32
|
+
return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
|
|
33
|
+
}
|
|
34
|
+
export async function matchResume(text, _opts = {}) {
|
|
35
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
36
|
+
return { ok: false, source: SOURCE, extracted_terms: terms, city_preferences: cities, matches: [], message: STUB_MESSAGE };
|
|
37
|
+
}
|
package/dist/geely.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 吉利汽车 (Geely Auto) — stub adapter for `job-pro`.
|
|
2
|
+
//
|
|
3
|
+
// STATUS: stub-only. The careers domains do not resolve over public DNS,
|
|
4
|
+
// and the third-party ATS slugs (Greenhouse, Lever, Feishu, Moka) all return
|
|
5
|
+
// 404 or are unprovisioned. Public-facing recruiting appears to run only
|
|
6
|
+
// through WeChat / official-account channels.
|
|
7
|
+
//
|
|
8
|
+
// ============================================================
|
|
9
|
+
// RECONNAISSANCE RESULTS (probed 2026-05):
|
|
10
|
+
//
|
|
11
|
+
// https://career.geely.com — 000 (no public DNS / unreachable)
|
|
12
|
+
// https://join.geely.com — 000 (no public DNS)
|
|
13
|
+
// https://hr.geely.com — 000 (no public DNS)
|
|
14
|
+
//
|
|
15
|
+
// Feishu ATSX: geely.jobs.feishu.cn — HTTP 400 (no portal)
|
|
16
|
+
// zeekr.jobs.feishu.cn — HTTP 400 (no portal)
|
|
17
|
+
// Greenhouse: geely / zeekr — HTTP 404 (no board)
|
|
18
|
+
// Lever: geely / zeekr — HTTP 404 (no posting)
|
|
19
|
+
// Moka: app.mokahr.com/social-recruitment/geely → 302 (slug unprovisioned)
|
|
20
|
+
//
|
|
21
|
+
// Conclusion: Geely's recruiting flow is gated behind WeChat / official-account
|
|
22
|
+
// channels and a non-public corporate ATS. No public unauthenticated API
|
|
23
|
+
// available. Visit Geely's official WeChat 吉利汽车招聘 for postings.
|
|
24
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
25
|
+
export { checkResume };
|
|
26
|
+
const SOURCE = "geely.com";
|
|
27
|
+
const STUB_MESSAGE = "Geely (吉利汽车): careers subdomains (career / join / hr.geely.com) fail to resolve over public DNS, " +
|
|
28
|
+
"and no Greenhouse / Lever / Feishu / Moka tenant is provisioned. Recruiting runs through WeChat " +
|
|
29
|
+
"official-account channels. No unauthenticated public API available.";
|
|
30
|
+
export async function searchPositions(_opts = {}) {
|
|
31
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
|
|
32
|
+
}
|
|
33
|
+
export async function fetchAllPositions(_opts = {}) {
|
|
34
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
|
|
35
|
+
}
|
|
36
|
+
export async function fetchPositionDetail(postId) {
|
|
37
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
|
|
38
|
+
}
|
|
39
|
+
export async function fetchDictionaries() {
|
|
40
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE };
|
|
41
|
+
}
|
|
42
|
+
export async function listNotices() {
|
|
43
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
|
|
44
|
+
}
|
|
45
|
+
export async function getNotice(noticeId) {
|
|
46
|
+
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
|
|
47
|
+
}
|
|
48
|
+
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
49
|
+
return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
|
|
50
|
+
}
|
|
51
|
+
export async function matchResume(text, _opts = {}) {
|
|
52
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
source: SOURCE,
|
|
56
|
+
extracted_terms: terms,
|
|
57
|
+
city_preferences: cities,
|
|
58
|
+
matches: [],
|
|
59
|
+
message: STUB_MESSAGE,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export { extractResumeSignals, scoreOverlap };
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Generic Greenhouse Boards adapter factory.
|
|
2
|
+
//
|
|
3
|
+
// Greenhouse (boards-api.greenhouse.io) is a widely-used SaaS ATS. Multiple
|
|
4
|
+
// Chinese companies (or their international arms) self-host their public job
|
|
5
|
+
// board on a `<slug>` namespace there. The unauthenticated REST surface is
|
|
6
|
+
// stable across tenants:
|
|
7
|
+
//
|
|
8
|
+
// GET https://boards-api.greenhouse.io/v1/boards/<slug>/jobs
|
|
9
|
+
// → { jobs: [...], meta: { total: <int> } }
|
|
10
|
+
//
|
|
11
|
+
// GET https://boards-api.greenhouse.io/v1/boards/<slug>/jobs/<id>?content=true
|
|
12
|
+
// → full job object including the rendered description HTML
|
|
13
|
+
//
|
|
14
|
+
// GET https://boards-api.greenhouse.io/v1/boards/<slug>/departments
|
|
15
|
+
// → { departments: [{ id, name, child_ids[], parent_id }] }
|
|
16
|
+
//
|
|
17
|
+
// GET https://boards-api.greenhouse.io/v1/boards/<slug>/offices
|
|
18
|
+
// → { offices: [{ id, name, location, child_ids[], parent_id }] }
|
|
19
|
+
//
|
|
20
|
+
// All endpoints are GET-only, return JSON, and require no auth headers.
|
|
21
|
+
//
|
|
22
|
+
// ---- PositionSummary field mapping (Greenhouse → canonical) ----
|
|
23
|
+
// post_id ← String(job.id)
|
|
24
|
+
// title ← job.title
|
|
25
|
+
// project ← job.departments[0]?.name (or "")
|
|
26
|
+
// recruit_label ← job.metadata where name matches "Employment Type" (else "")
|
|
27
|
+
// bgs ← "" (Greenhouse has no BG dimension)
|
|
28
|
+
// work_cities ← job.location.name
|
|
29
|
+
// apply_url ← job.absolute_url
|
|
30
|
+
//
|
|
31
|
+
// ---- Discovery notes ----
|
|
32
|
+
// * Greenhouse returns the full job list in a single call — no pagination is
|
|
33
|
+
// required for ATS sizes seen so far (<2000 jobs).
|
|
34
|
+
// * The `meta.total` field is always present.
|
|
35
|
+
// * `content=true` on the detail endpoint returns description as escaped HTML.
|
|
36
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
37
|
+
export { checkResume };
|
|
38
|
+
// ---------- createAdapter ----------
|
|
39
|
+
export function createAdapter(cfg) {
|
|
40
|
+
const API_ROOT = `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(cfg.slug)}`;
|
|
41
|
+
const SOURCE = `boards-api.greenhouse.io/${cfg.slug}`;
|
|
42
|
+
const BOARD_URL = `https://job-boards.greenhouse.io/${encodeURIComponent(cfg.slug)}`;
|
|
43
|
+
const HEADERS = {
|
|
44
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
};
|
|
47
|
+
function summarize(job) {
|
|
48
|
+
const id = String(job.id ?? "");
|
|
49
|
+
const dept = job.departments?.[0]?.name ?? "";
|
|
50
|
+
const employmentType = (job.metadata ?? []).find((m) => (m.name ?? "").toLowerCase() === "employment type")?.value;
|
|
51
|
+
const recruit_label = typeof employmentType === "string" ? employmentType : "";
|
|
52
|
+
return {
|
|
53
|
+
post_id: id,
|
|
54
|
+
title: job.title ?? "",
|
|
55
|
+
project: dept,
|
|
56
|
+
recruit_label,
|
|
57
|
+
bgs: "",
|
|
58
|
+
work_cities: job.location?.name ?? "",
|
|
59
|
+
apply_url: job.absolute_url ?? `${BOARD_URL}/jobs/${id}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let _allCache = null;
|
|
63
|
+
async function fetchAllRaw() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (_allCache && now - _allCache.fetchedAt < 5 * 60 * 1000) {
|
|
66
|
+
return _allCache.ok ? { ok: true, jobs: _allCache.jobs } : { ok: false, message: _allCache.message };
|
|
67
|
+
}
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await fetch(`${API_ROOT}/jobs?content=false`, { headers: HEADERS });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const msg = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
74
|
+
_allCache = { ok: false, message: msg, fetchedAt: now };
|
|
75
|
+
return { ok: false, message: msg };
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const msg = `HTTP ${response.status}: ${response.statusText}`;
|
|
79
|
+
_allCache = { ok: false, message: msg, fetchedAt: now };
|
|
80
|
+
return { ok: false, message: msg };
|
|
81
|
+
}
|
|
82
|
+
let payload;
|
|
83
|
+
try {
|
|
84
|
+
payload = (await response.json());
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const msg = `bad JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
88
|
+
_allCache = { ok: false, message: msg, fetchedAt: now };
|
|
89
|
+
return { ok: false, message: msg };
|
|
90
|
+
}
|
|
91
|
+
const jobs = payload.jobs ?? [];
|
|
92
|
+
_allCache = { ok: true, jobs, fetchedAt: now };
|
|
93
|
+
return { ok: true, jobs };
|
|
94
|
+
}
|
|
95
|
+
function applyFilters(jobs, opts) {
|
|
96
|
+
const kw = (opts.keyword ?? "").trim().toLowerCase();
|
|
97
|
+
const deptFilters = (opts.departments ?? []).map((s) => String(s).toLowerCase());
|
|
98
|
+
const cityFilters = (opts.cities ?? []).map((s) => String(s).toLowerCase());
|
|
99
|
+
return jobs.filter((job) => {
|
|
100
|
+
if (kw) {
|
|
101
|
+
const blob = [
|
|
102
|
+
job.title ?? "",
|
|
103
|
+
job.location?.name ?? "",
|
|
104
|
+
(job.departments ?? []).map((d) => d.name).join(" "),
|
|
105
|
+
]
|
|
106
|
+
.join(" ")
|
|
107
|
+
.toLowerCase();
|
|
108
|
+
if (!blob.includes(kw))
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (deptFilters.length) {
|
|
112
|
+
const blob = (job.departments ?? [])
|
|
113
|
+
.map((d) => (d.name ?? "").toLowerCase())
|
|
114
|
+
.join(" ");
|
|
115
|
+
if (!deptFilters.some((d) => blob.includes(d)))
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (cityFilters.length) {
|
|
119
|
+
const blob = (job.location?.name ?? "").toLowerCase();
|
|
120
|
+
if (!cityFilters.some((c) => blob.includes(c)))
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function searchPositions(opts = {}) {
|
|
127
|
+
const pageSize = Math.max(1, Math.min(200, opts.pageSize ?? 20));
|
|
128
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
129
|
+
const pool = await fetchAllRaw();
|
|
130
|
+
if (!pool.ok) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
message: pool.message,
|
|
134
|
+
source: SOURCE,
|
|
135
|
+
apply_url: BOARD_URL,
|
|
136
|
+
positions: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const filtered = applyFilters(pool.jobs, opts);
|
|
140
|
+
const offset = (page - 1) * pageSize;
|
|
141
|
+
const paginated = filtered.slice(offset, offset + pageSize);
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
source: SOURCE,
|
|
145
|
+
query: opts,
|
|
146
|
+
page,
|
|
147
|
+
page_size: pageSize,
|
|
148
|
+
total: filtered.length,
|
|
149
|
+
positions: paginated.map(summarize),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function fetchAllPositions(opts = {}) {
|
|
153
|
+
const pool = await fetchAllRaw();
|
|
154
|
+
if (!pool.ok) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
message: pool.message,
|
|
158
|
+
source: SOURCE,
|
|
159
|
+
apply_url: BOARD_URL,
|
|
160
|
+
fetched: 0,
|
|
161
|
+
positions: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const filtered = applyFilters(pool.jobs, opts);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
source: SOURCE,
|
|
168
|
+
total: filtered.length,
|
|
169
|
+
fetched: filtered.length,
|
|
170
|
+
positions: filtered.map(summarize),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function fetchPositionDetail(postId) {
|
|
174
|
+
const id = (postId ?? "").trim();
|
|
175
|
+
if (!id) {
|
|
176
|
+
return { ok: false, source: SOURCE, message: "post_id is required" };
|
|
177
|
+
}
|
|
178
|
+
let response;
|
|
179
|
+
try {
|
|
180
|
+
response = await fetch(`${API_ROOT}/jobs/${encodeURIComponent(id)}?content=true`, { headers: HEADERS });
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
source: SOURCE,
|
|
186
|
+
post_id: id,
|
|
187
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
source: SOURCE,
|
|
194
|
+
post_id: id,
|
|
195
|
+
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
let job;
|
|
199
|
+
try {
|
|
200
|
+
job = (await response.json());
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
source: SOURCE,
|
|
206
|
+
post_id: id,
|
|
207
|
+
message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const summary = summarize(job);
|
|
211
|
+
const html = job.content ?? "";
|
|
212
|
+
// Crude HTML-to-text: decode common entities, strip tags.
|
|
213
|
+
const description = html
|
|
214
|
+
.replace(/<[^>]+>/g, " ")
|
|
215
|
+
.replace(/ /g, " ")
|
|
216
|
+
.replace(/&/g, "&")
|
|
217
|
+
.replace(/</g, "<")
|
|
218
|
+
.replace(/>/g, ">")
|
|
219
|
+
.replace(/"/g, '"')
|
|
220
|
+
.replace(/'/g, "'")
|
|
221
|
+
.replace(/\s+/g, " ")
|
|
222
|
+
.trim();
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
source: SOURCE,
|
|
226
|
+
post_id: id,
|
|
227
|
+
title: job.title ?? "",
|
|
228
|
+
project: summary.project,
|
|
229
|
+
recruit_label: summary.recruit_label,
|
|
230
|
+
requisition_id: job.requisition_id ?? "",
|
|
231
|
+
first_published: job.first_published ?? "",
|
|
232
|
+
updated_at: job.updated_at ?? "",
|
|
233
|
+
description,
|
|
234
|
+
work_cities: job.location?.name ?? "",
|
|
235
|
+
apply_url: summary.apply_url,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// ---------- fetchDictionaries ----------
|
|
239
|
+
let _dictCache = null;
|
|
240
|
+
async function fetchDictionaries() {
|
|
241
|
+
if (_dictCache !== null)
|
|
242
|
+
return _dictCache;
|
|
243
|
+
try {
|
|
244
|
+
const [deptRes, offRes] = await Promise.all([
|
|
245
|
+
fetch(`${API_ROOT}/departments`, { headers: HEADERS }),
|
|
246
|
+
fetch(`${API_ROOT}/offices`, { headers: HEADERS }),
|
|
247
|
+
]);
|
|
248
|
+
if (!deptRes.ok && !offRes.ok) {
|
|
249
|
+
const r = {
|
|
250
|
+
ok: false,
|
|
251
|
+
source: SOURCE,
|
|
252
|
+
message: `HTTP ${deptRes.status}/${offRes.status}`,
|
|
253
|
+
};
|
|
254
|
+
_dictCache = r;
|
|
255
|
+
return r;
|
|
256
|
+
}
|
|
257
|
+
const deptJson = deptRes.ok
|
|
258
|
+
? (await deptRes.json())
|
|
259
|
+
: { departments: [] };
|
|
260
|
+
const offJson = offRes.ok
|
|
261
|
+
? (await offRes.json())
|
|
262
|
+
: { offices: [] };
|
|
263
|
+
const result = {
|
|
264
|
+
ok: true,
|
|
265
|
+
source: SOURCE,
|
|
266
|
+
departments: (deptJson.departments ?? []).map((d) => ({
|
|
267
|
+
id: d.id ?? 0,
|
|
268
|
+
name: d.name ?? "",
|
|
269
|
+
parent_id: d.parent_id ?? null,
|
|
270
|
+
})),
|
|
271
|
+
offices: (offJson.offices ?? []).map((o) => ({
|
|
272
|
+
id: o.id ?? 0,
|
|
273
|
+
name: o.name ?? "",
|
|
274
|
+
location: o.location ?? "",
|
|
275
|
+
parent_id: o.parent_id ?? null,
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
_dictCache = result;
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
const r = {
|
|
283
|
+
ok: false,
|
|
284
|
+
source: SOURCE,
|
|
285
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
+
};
|
|
287
|
+
_dictCache = r;
|
|
288
|
+
return r;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ---------- notices (stub) ----------
|
|
292
|
+
const NOTICES_STUB = {
|
|
293
|
+
ok: false,
|
|
294
|
+
source: SOURCE,
|
|
295
|
+
message: `${cfg.label}: Greenhouse boards have no announcements endpoint`,
|
|
296
|
+
};
|
|
297
|
+
async function listNotices() {
|
|
298
|
+
return NOTICES_STUB;
|
|
299
|
+
}
|
|
300
|
+
async function getNotice(_id) {
|
|
301
|
+
return NOTICES_STUB;
|
|
302
|
+
}
|
|
303
|
+
async function findNoticesByQuestion(_question, _opts = {}) {
|
|
304
|
+
return NOTICES_STUB;
|
|
305
|
+
}
|
|
306
|
+
// ---------- matchResume ----------
|
|
307
|
+
async function matchResume(text, opts = {}) {
|
|
308
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
309
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
310
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
311
|
+
if (!terms.length) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
source: SOURCE,
|
|
315
|
+
message: "could not extract any technical signals from the text",
|
|
316
|
+
preview: (text ?? "").slice(0, 120),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const pool = await fetchAllRaw();
|
|
320
|
+
if (!pool.ok) {
|
|
321
|
+
return { ok: false, source: SOURCE, message: pool.message, positions: [] };
|
|
322
|
+
}
|
|
323
|
+
const scored = [];
|
|
324
|
+
for (const job of pool.jobs) {
|
|
325
|
+
const blob = [
|
|
326
|
+
job.title ?? "",
|
|
327
|
+
job.location?.name ?? "",
|
|
328
|
+
(job.departments ?? []).map((d) => d.name).join(" "),
|
|
329
|
+
].join(" ");
|
|
330
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
331
|
+
if (score > 0)
|
|
332
|
+
scored.push({ score, raw: job, reasons });
|
|
333
|
+
}
|
|
334
|
+
scored.sort((a, b) => b.score - a.score);
|
|
335
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
336
|
+
if (!shortlist.length) {
|
|
337
|
+
shortlist = pool.jobs.slice(0, candidates).map((raw) => ({
|
|
338
|
+
score: 0,
|
|
339
|
+
raw,
|
|
340
|
+
reasons: [],
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
const matches = shortlist.slice(0, topN).map((s) => {
|
|
344
|
+
const mr = s.reasons.length > 0
|
|
345
|
+
? s.reasons.slice(0, 5)
|
|
346
|
+
: ["no specific keyword overlap — surfaced from full board listing"];
|
|
347
|
+
return { ...summarize(s.raw), match_reasons: mr };
|
|
348
|
+
});
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
source: SOURCE,
|
|
352
|
+
extracted_terms: terms,
|
|
353
|
+
city_preferences: cities,
|
|
354
|
+
matches,
|
|
355
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
356
|
+
"The only authority on selection is HR.",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
searchPositions,
|
|
361
|
+
fetchAllPositions,
|
|
362
|
+
fetchPositionDetail,
|
|
363
|
+
fetchDictionaries,
|
|
364
|
+
listNotices,
|
|
365
|
+
getNotice,
|
|
366
|
+
findNoticesByQuestion,
|
|
367
|
+
matchResume,
|
|
368
|
+
checkResume,
|
|
369
|
+
};
|
|
370
|
+
}
|