job-pro 0.7.5 → 0.8.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/cainiao.js CHANGED
@@ -1,63 +1,26 @@
1
- // 菜鸟 (Cainiao Network) — stub adapter for `job-pro`.
1
+ // 菜鸟 (Cainiao Network) careers adapter Liepin aggregator fallback.
2
2
  //
3
- // STATUS: stub-only. Both campus and social recruiting are routed through
4
- // Alibaba's unified careers infrastructure, which is hosted on subdomains
5
- // that fail to resolve over public DNS (likely group-network-only A records).
3
+ // Cainiao's own careers subdomains (campus / recruit / job.cainiao.com)
4
+ // resolve only on Alibaba-Group-internal DNS. Public-facing positions
5
+ // don't surface through the parent Alibaba feed either
6
+ // (`job-pro alibaba search 菜鸟` → total=0). We surface real
7
+ // currently-open Cainiao positions by querying Liepin
8
+ // (api-c.liepin.com) filtered by compName="菜鸟网络". See
9
+ // `cli/src/liepin.ts` for the shared factory.
6
10
  //
7
- // ============================================================
8
- // RECONNAISSANCE RESULTS (probed 2026-05):
9
- //
10
- // https://campus.cainiao.com — 000 (no public DNS / unreachable)
11
- // https://recruit.cainiao.com — 000 (no public DNS / unreachable)
12
- // https://job.cainiao.com — 000 (no public DNS / unreachable)
13
- //
14
- // The corporate careers blurb on www.cainiao.com links out to
15
- // "campus-talent.alibaba.com" (already covered by the `alibaba` adapter),
16
- // suggesting cainiao postings are merged into the Alibaba Group careers feed
17
- // when they go public. The dedicated Cainiao SPA is internal-only.
18
- //
19
- // Feishu ATSX: cainiao.jobs.feishu.cn HTTP 400 (no portal configured)
20
- // Greenhouse / Lever / Moka: no `cainiao` slug found on any of them.
21
- //
22
- // Conclusion: no unauthenticated public API outside of the Alibaba Group
23
- // feed. Use `job-pro alibaba search "菜鸟"` to surface group-listed roles, or
24
- // visit https://www.cainiao.com/ for direct contact info.
25
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
26
- export { checkResume };
27
- const SOURCE = "cainiao.com";
28
- const STUB_MESSAGE = "Cainiao (菜鸟): dedicated careers subdomains (campus / recruit / job.cainiao.com) fail to resolve " +
29
- "over public DNS. Public-facing roles are surfaced through the Alibaba Group careers feed " +
30
- "(use `job-pro alibaba search \"菜鸟\"`). No standalone unauthenticated public API.";
31
- export async function searchPositions(_opts = {}) {
32
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
33
- }
34
- export async function fetchAllPositions(_opts = {}) {
35
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
36
- }
37
- export async function fetchPositionDetail(postId) {
38
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
39
- }
40
- export async function fetchDictionaries() {
41
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
42
- }
43
- export async function listNotices() {
44
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
45
- }
46
- export async function getNotice(noticeId) {
47
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
48
- }
49
- export async function findNoticesByQuestion(question, _opts = {}) {
50
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
51
- }
52
- export async function matchResume(text, _opts = {}) {
53
- const { terms, cities } = extractResumeSignals(text ?? "");
54
- return {
55
- ok: false,
56
- source: SOURCE,
57
- extracted_terms: terms,
58
- city_preferences: cities,
59
- matches: [],
60
- message: STUB_MESSAGE,
61
- };
62
- }
63
- export { extractResumeSignals, scoreOverlap };
11
+ // Source: api-c.liepin.com (`source` field on responses) — clearly NOT
12
+ // the same as Cainiao's own portal.
13
+ import { createAdapter } from "./liepin.js";
14
+ const adapter = createAdapter({
15
+ companyName: "菜鸟网络",
16
+ label: "Cainiao / 菜鸟",
17
+ });
18
+ export const searchPositions = adapter.searchPositions;
19
+ export const fetchAllPositions = adapter.fetchAllPositions;
20
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
21
+ export const fetchDictionaries = adapter.fetchDictionaries;
22
+ export const listNotices = adapter.listNotices;
23
+ export const getNotice = adapter.getNotice;
24
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
25
+ export const matchResume = adapter.matchResume;
26
+ export const checkResume = adapter.checkResume;
package/dist/cicc.js CHANGED
@@ -1,152 +1,26 @@
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 };
1
+ // 中金 / CICC careers adapter Liepin aggregator fallback.
2
+ //
3
+ // CICC's official careers portal (careers.cicc.com / cicc.com.cn) is
4
+ // Cloudflare-gated with HTTP 521 for non-CN IPs, returns 404 for crawlers,
5
+ // and has no third-party ATS (Moka / Beisen / Greenhouse) tenant. We
6
+ // surface real currently-open CICC positions by querying Liepin
7
+ // (api-c.liepin.com) filtered by compName="中金公司". See
8
+ // `cli/src/liepin.ts` for the shared factory.
9
+ //
10
+ // Source: api-c.liepin.com (`source` field on responses) clearly NOT
11
+ // the same as CICC's own portal. Callers can filter on this attribution
12
+ // if they only want first-party feeds.
13
+ import { createAdapter } from "./liepin.js";
14
+ const adapter = createAdapter({
15
+ companyName: "中金公司",
16
+ label: "CICC / 中金",
17
+ });
18
+ export const searchPositions = adapter.searchPositions;
19
+ export const fetchAllPositions = adapter.fetchAllPositions;
20
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
21
+ export const fetchDictionaries = adapter.fetchDictionaries;
22
+ export const listNotices = adapter.listNotices;
23
+ export const getNotice = adapter.getNotice;
24
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
25
+ export const matchResume = adapter.matchResume;
26
+ export const checkResume = adapter.checkResume;
package/dist/hikvision.js CHANGED
@@ -1,233 +1,28 @@
1
- // 海康威视 / Hikvision careers adapter for `job-pro`.
1
+ // 海康威视 / Hikvision careers adapter Liepin aggregator fallback.
2
2
  //
3
- // ============================================================
4
- // DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
3
+ // hr.hikvision.com is gated by Tencent EdgeOne which 403s any non-CN IP
4
+ // regardless of cookies. www.hikvision.com.cn has no public DNS A record
5
+ // outside Mainland China. There is no third-party ATS tenant.
5
6
  //
6
- // Hikvision's careers funnel sits behind two stacked barriers:
7
- // 1. `www.hikvision.com.cn` (the canonical CN careers host) has NO public
8
- // DNS A record outside of Mainland China (NXDOMAIN on Google DNS,
9
- // Cloudflare DNS, etc.).
10
- // 2. `www.hikvision.com/cn/about/Talent-recruit/` is served by Tencent
11
- // Cloud EdgeOne. Anonymous GETs from a non-CN egress receive an
12
- // `EO_Bot_Ssid` JS challenge that, even when solved by a real Chrome
13
- // session, leads to a hard `HTTP 403` from the upstream — EdgeOne is
14
- // gating on source IP, not just cookies.
7
+ // Until a CN-egress proxy path lands (set `JOB_PRO_HTTPS_PROXY` and see
8
+ // the historical CDP-driven adapter at `git log cli/src/hikvision.ts`),
9
+ // we surface real currently-open Hikvision positions by querying Liepin
10
+ // (api-c.liepin.com) filtered by compName="海康威视". See
11
+ // `cli/src/liepin.ts` for the shared factory.
15
12
  //
16
- // This adapter therefore drives `puppeteer-core` (see cli/src/cdp.ts) but
17
- // the CDP layer needs an egress proxy with a CN exit IP. Users supply one
18
- // via the `JOB_PRO_HTTPS_PROXY` env var (any HTTP/SOCKS5 URL supported by
19
- // Chromium's `--proxy-server` flag). Without it the adapter returns
20
- // `ok:false` with a helpful hint rather than pretending to work.
21
- //
22
- // When the proxy IS set and we successfully load the careers page, we
23
- // extract job listings either from inline JSON (Hikvision's SPA inlines
24
- // the first 20 results into `<script id="__NEXT_DATA__">`) or by
25
- // scanning for visible job-card anchors and pulling title + city out of
26
- // their text content.
27
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
28
- import { withPage } from "./cdp.js";
29
- export { checkResume };
30
- const SOURCE = "hikvision.com";
31
- const CAREER_URL = "https://www.hikvision.com/cn/about/Talent-recruit/";
32
- const SOCIAL_URL = "https://www.hikvision.com/cn/about/social-recruitment/";
33
- const PROXY_HINT = "Hikvision (海康威视) is geo-fenced behind Tencent EdgeOne — anonymous " +
34
- "non-CN IPs receive HTTP 403 from www.hikvision.com careers paths, " +
35
- "and www.hikvision.com.cn has no public DNS record outside Mainland " +
36
- "China. Set `JOB_PRO_HTTPS_PROXY=<cn-proxy-url>` (HTTP or SOCKS5) before " +
37
- "running job-pro to route Chrome's egress through a CN IP; the adapter " +
38
- "will then proceed via puppeteer-core (see cli/src/cdp.ts).";
39
- function summarize(raw, recruitType) {
40
- const id = (raw.href.match(/\/(\d{4,})(?:[\/?#]|$)/)?.[1] ?? raw.title).slice(0, 40);
41
- return {
42
- post_id: id,
43
- title: raw.title,
44
- project: "",
45
- recruit_label: recruitType === "campus" ? "校招" : "社招",
46
- bgs: "",
47
- work_cities: raw.city,
48
- apply_url: raw.href.startsWith("http") ? raw.href : `https://www.hikvision.com${raw.href}`,
49
- };
50
- }
51
- async function scrape(recruitType) {
52
- // Refuse to scrape without an explicit CN-egress proxy. Without one,
53
- // EdgeOne 403s and the SPA never renders; previously the adapter
54
- // accidentally picked up product-navigation anchors (e.g.
55
- // "Explosion-Proof-Positioning-System") because they matched
56
- // `href*='position'`. Cleaner to fail fast.
57
- if (!process.env.JOB_PRO_HTTPS_PROXY) {
58
- return { ok: false, message: PROXY_HINT };
59
- }
60
- const url = recruitType === "campus" ? CAREER_URL : SOCIAL_URL;
61
- const r = await withPage(async (page) => {
62
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
63
- await new Promise((resolve) => setTimeout(resolve, 5000));
64
- const final = await page.evaluate(() => {
65
- const html_size = document.documentElement.outerHTML.length;
66
- // Pick only anchors that live inside a careers-flavoured container
67
- // (heuristic — Hikvision's careers SPA wraps job cards in
68
- // `.recruit-list`, `.job-list`, or has `Talent-recruit` in their
69
- // hrefs PATH SEGMENT, not just substring).
70
- const isJobLink = (a) => {
71
- const href = a.getAttribute("href") ?? "";
72
- // Path-segment match (not substring) — avoids product URLs.
73
- if (!/\/(Talent-?recruit|social-recruit|campus-recruit|recruitment\/jobs|positions?\/[0-9]+)(\/|$|\?)/i.test(href)) {
74
- return false;
75
- }
76
- return true;
77
- };
78
- const anchors = Array.from(document.querySelectorAll("a[href]"));
79
- const raw = [];
80
- for (const a of anchors) {
81
- if (!isJobLink(a))
82
- continue;
83
- const text = (a.textContent ?? "").replace(/\s+/g, " ").trim();
84
- if (text.length < 3 || text.length > 200)
85
- continue;
86
- const href = a.getAttribute("href") ?? "";
87
- const cityMatch = text.match(/(.+?)\s+([一-龥]{2,8}(?:市|省)|[A-Z][a-z]+(?:,\s?[A-Z]{2})?)\s*$/);
88
- const title = cityMatch ? cityMatch[1].trim() : text;
89
- const city = cityMatch ? cityMatch[2] : "";
90
- raw.push({ title, city, href });
91
- }
92
- return { html_size, raw };
93
- });
94
- return final;
95
- });
96
- if (!r.ok) {
97
- return { ok: false, message: `${r.error.message}. ${PROXY_HINT}` };
98
- }
99
- // EdgeOne anti-bot challenge fits in ~7KB; real careers SPA is much bigger.
100
- if (r.value.html_size < 15000 && r.value.raw.length === 0) {
101
- return {
102
- ok: false,
103
- message: `careers page rendered only ${r.value.html_size} bytes — looks like EdgeOne 403/challenge. ${PROXY_HINT}`,
104
- };
105
- }
106
- if (r.value.raw.length === 0) {
107
- return {
108
- ok: false,
109
- message: `careers page rendered but no job links matched the careers-path filter. The DOM structure may have changed; please report at https://github.com/HA7CH/job-pro/issues.`,
110
- };
111
- }
112
- return { ok: true, raw: r.value.raw };
113
- }
114
- export async function searchPositions(opts = {}) {
115
- const rt = opts.recruitType ?? "all";
116
- const types = rt === "all" ? ["campus", "social"] : [rt];
117
- const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
118
- const page = Math.max(1, opts.page ?? 1);
119
- const keyword = (opts.keyword ?? "").trim().toLowerCase();
120
- const positions = [];
121
- let lastMsg = PROXY_HINT;
122
- let anyOk = false;
123
- for (const t of types) {
124
- const r = await scrape(t);
125
- if (!r.ok) {
126
- lastMsg = r.message;
127
- continue;
128
- }
129
- anyOk = true;
130
- for (const raw of r.raw)
131
- positions.push(summarize(raw, t));
132
- }
133
- if (!anyOk) {
134
- return {
135
- ok: false,
136
- source: SOURCE,
137
- message: lastMsg,
138
- query: opts,
139
- positions: [],
140
- };
141
- }
142
- const filtered = keyword
143
- ? positions.filter((p) => p.title.toLowerCase().includes(keyword) || p.work_cities.toLowerCase().includes(keyword))
144
- : positions;
145
- const offset = (page - 1) * pageSize;
146
- return {
147
- ok: true,
148
- source: SOURCE,
149
- query: opts,
150
- page,
151
- page_size: pageSize,
152
- total: filtered.length,
153
- positions: filtered.slice(offset, offset + pageSize),
154
- };
155
- }
156
- export async function fetchAllPositions(opts = {}) {
157
- const all = await searchPositions({ ...opts, page: 1, pageSize: 100 });
158
- if (!all.ok) {
159
- return {
160
- ok: false,
161
- source: SOURCE,
162
- message: all.message,
163
- total: 0,
164
- fetched: 0,
165
- positions: [],
166
- };
167
- }
168
- return {
169
- ok: true,
170
- source: SOURCE,
171
- total: all.total,
172
- fetched: all.positions.length,
173
- positions: all.positions,
174
- };
175
- }
176
- export async function fetchPositionDetail(postId) {
177
- const id = (postId ?? "").trim();
178
- return {
179
- ok: false,
180
- source: SOURCE,
181
- post_id: id,
182
- message: PROXY_HINT,
183
- };
184
- }
185
- export async function fetchDictionaries() {
186
- return { ok: false, source: SOURCE, message: PROXY_HINT };
187
- }
188
- export async function listNotices() {
189
- return { ok: false, source: SOURCE, message: PROXY_HINT, notices: [] };
190
- }
191
- export async function getNotice(noticeId) {
192
- return { ok: false, source: SOURCE, message: PROXY_HINT, notice_id: noticeId };
193
- }
194
- export async function findNoticesByQuestion(question, _opts = {}) {
195
- return {
196
- ok: false,
197
- source: SOURCE,
198
- question,
199
- message: PROXY_HINT,
200
- matches: [],
201
- };
202
- }
203
- export async function matchResume(text, opts = {}) {
204
- const { terms, cities } = extractResumeSignals(text ?? "");
205
- const list = await searchPositions({ pageSize: 50 });
206
- if (!list.ok) {
207
- return {
208
- ok: false,
209
- source: SOURCE,
210
- extracted_terms: terms,
211
- city_preferences: cities,
212
- matches: [],
213
- message: list.message,
214
- };
215
- }
216
- const topN = Math.max(1, opts.topN ?? 5);
217
- const scored = list.positions
218
- .map((p) => ({
219
- p,
220
- score: scoreOverlap(`${p.title} ${p.work_cities}`, terms, cities).score,
221
- }))
222
- .sort((a, b) => b.score - a.score)
223
- .slice(0, topN)
224
- .map((x) => x.p);
225
- return {
226
- ok: true,
227
- source: SOURCE,
228
- extracted_terms: terms,
229
- city_preferences: cities,
230
- matches: scored,
231
- };
232
- }
233
- export { extractResumeSignals, scoreOverlap };
13
+ // Source: api-c.liepin.com (`source` field on responses) — clearly NOT
14
+ // the same as Hikvision's own portal.
15
+ import { createAdapter } from "./liepin.js";
16
+ const adapter = createAdapter({
17
+ companyName: "海康威视",
18
+ label: "Hikvision / 海康威视",
19
+ });
20
+ export const searchPositions = adapter.searchPositions;
21
+ export const fetchAllPositions = adapter.fetchAllPositions;
22
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
23
+ export const fetchDictionaries = adapter.fetchDictionaries;
24
+ export const listNotices = adapter.listNotices;
25
+ export const getNotice = adapter.getNotice;
26
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
27
+ export const matchResume = adapter.matchResume;
28
+ export const checkResume = adapter.checkResume;
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import * as webank from "./webank.js";
51
51
  import * as horizonrobotics from "./horizonrobotics.js";
52
52
  import * as cambricon from "./cambricon.js";
53
53
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
54
- const VERSION = "0.7.5";
54
+ const VERSION = "0.8.0";
55
55
  const HELP = `
56
56
  job-pro — query Chinese big-tech campus recruiting from your terminal
57
57
  (job.ha7ch.com)
package/dist/liepin.js ADDED
@@ -0,0 +1,357 @@
1
+ // Generic 猎聘 (Liepin) aggregator factory for `job-pro`.
2
+ //
3
+ // ============================================================
4
+ // WHY THIS EXISTS
5
+ //
6
+ // Four of the 50 companies (hikvision / cicc / cainiao / webank) have no
7
+ // publicly reachable canonical job feed — see `docs/stub-unblock.md`.
8
+ // Liepin (https://www.liepin.com) is a major Chinese job aggregator
9
+ // whose public `pc-search-job` endpoint surfaces real, currently-open
10
+ // positions for every Chinese employer of consequence. It does NOT
11
+ // require authentication, just a one-time XSRF-TOKEN cookie that the
12
+ // liepin.com home page sets on first request.
13
+ //
14
+ // We use Liepin here as a fallback ONLY for the 4 adapters above. The
15
+ // other 46 adapters continue to talk to their company's own API. Every
16
+ // position surfaced through this factory has `source: "api-c.liepin.com"`
17
+ // in its envelope so consumers can tell it's a third-party feed.
18
+ //
19
+ // ============================================================
20
+ // API DISCOVERY (probed 2026-05-16)
21
+ //
22
+ // 1. GET https://www.liepin.com/ → Set-Cookie: XSRF-TOKEN=<token>
23
+ // 2. POST https://api-c.liepin.com/api/com.liepin.searchfront4c.pc-search-job
24
+ // Content-Type: application/json;charset=UTF-8
25
+ // Origin: https://www.liepin.com
26
+ // X-Client-Type: web
27
+ // X-Xsrf-Token: <token from cookie>
28
+ // X-Fscp-Std-Info: {"client_id": "40108"}
29
+ // X-Fscp-Version: 1.1
30
+ // Body: { data: { mainSearchPcConditionForm: { key:"<co>", city:"410",
31
+ // dq:"410", currentPage:N,
32
+ // pageSize:M, … },
33
+ // passThroughForm: { scene:"init" } } }
34
+ // Response: { flag:1, data:{ data:{ jobCardList:[{ comp, job, recruiter, … }],
35
+ // compCard:{…} } } }
36
+ //
37
+ // `city:"410"` = 全国 (all of China). Per-city codes are documented in
38
+ // Liepin's filter taxonomy; left as future work.
39
+ import { randomUUID } from "node:crypto";
40
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
41
+ export { checkResume, extractResumeSignals, scoreOverlap };
42
+ const HOME = "https://www.liepin.com";
43
+ const SEARCH_URL = "https://api-c.liepin.com/api/com.liepin.searchfront4c.pc-search-job";
44
+ const SOURCE = "api-c.liepin.com";
45
+ const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
46
+ // ---------- shared XSRF-TOKEN cache ----------
47
+ // One token per Node process. Liepin's token is short-lived (~hour) but for a
48
+ // CLI process that finishes in seconds, refreshing on every invocation is
49
+ // fine. We still cache it within the process so multi-call workflows reuse it.
50
+ let _token = null;
51
+ async function getToken() {
52
+ if (_token && Date.now() - _token.fetchedAt < 30 * 60 * 1000) {
53
+ return { ok: true, xsrf: _token.value, cookie: _token.cookieHeader };
54
+ }
55
+ let response;
56
+ try {
57
+ response = await fetch(HOME, {
58
+ method: "GET",
59
+ headers: { "User-Agent": USER_AGENT, "Accept-Language": "zh-CN,zh;q=0.9" },
60
+ });
61
+ }
62
+ catch (err) {
63
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
64
+ }
65
+ // getSetCookie() is the Node-undici-canonical API for multi-Set-Cookie headers.
66
+ const headersAny = response.headers;
67
+ const setCookies = typeof headersAny.getSetCookie === "function"
68
+ ? headersAny.getSetCookie.call(response.headers) ?? []
69
+ : (response.headers.get("set-cookie") ?? "").split(/,(?=[^;]+=)/);
70
+ let xsrf = "";
71
+ const cookieParts = [];
72
+ for (const c of setCookies) {
73
+ const kv = c.split(";")[0].trim();
74
+ cookieParts.push(kv);
75
+ if (kv.startsWith("XSRF-TOKEN="))
76
+ xsrf = kv.slice("XSRF-TOKEN=".length);
77
+ }
78
+ if (!xsrf) {
79
+ return { ok: false, message: "liepin.com did not set an XSRF-TOKEN cookie" };
80
+ }
81
+ _token = { value: xsrf, cookieHeader: cookieParts.join("; "), fetchedAt: Date.now() };
82
+ return { ok: true, xsrf, cookie: _token.cookieHeader };
83
+ }
84
+ // ---------- summarise ----------
85
+ function summarize(card) {
86
+ const comp = card.comp ?? {};
87
+ const job = card.job ?? {};
88
+ return {
89
+ post_id: String(job.jobId ?? ""),
90
+ title: (job.title ?? "").trim(),
91
+ project: "",
92
+ recruit_label: job.jobKind === "1" ? "全职" : job.jobKind === "2" ? "社招" : "",
93
+ bgs: (comp.compIndustry ?? "").trim(),
94
+ work_cities: (job.dq ?? "").trim(),
95
+ apply_url: job.link ?? job.pcOuterLink ?? (job.jobId ? `https://www.liepin.com/job/${encodeURIComponent(String(job.jobId))}.shtml` : HOME),
96
+ };
97
+ }
98
+ // ---------- core: search for a single page ----------
99
+ async function searchOnePage(companyName, keyword, page, pageSize) {
100
+ const tok = await getToken();
101
+ if (!tok.ok)
102
+ return tok;
103
+ const fullKey = [companyName, keyword].filter(Boolean).join(" ").trim();
104
+ const body = {
105
+ data: {
106
+ mainSearchPcConditionForm: {
107
+ city: "410",
108
+ dq: "410",
109
+ pubTime: "",
110
+ currentPage: Math.max(0, page - 1),
111
+ pageSize: Math.max(1, Math.min(40, pageSize)),
112
+ key: fullKey,
113
+ suggestTag: "",
114
+ workYearCode: "",
115
+ compId: "",
116
+ compName: companyName,
117
+ compTag: "",
118
+ industry: "",
119
+ salaryCode: "",
120
+ jobKind: "",
121
+ compScale: "",
122
+ compKind: "",
123
+ compStage: "",
124
+ eduLevel: "",
125
+ salaryLow: "",
126
+ salaryHigh: "",
127
+ },
128
+ passThroughForm: { scene: "init", skId: "", fkId: "", ckId: "", suggest: null },
129
+ },
130
+ };
131
+ let response;
132
+ try {
133
+ response = await fetch(SEARCH_URL, {
134
+ method: "POST",
135
+ headers: {
136
+ "Content-Type": "application/json;charset=UTF-8",
137
+ "User-Agent": USER_AGENT,
138
+ Origin: HOME,
139
+ Referer: `${HOME}/zhaopin/?key=${encodeURIComponent(fullKey)}`,
140
+ Accept: "application/json, text/plain, */*",
141
+ "Accept-Language": "zh-CN,zh;q=0.9",
142
+ "X-Client-Type": "web",
143
+ "X-Requested-With": "XMLHttpRequest",
144
+ "X-Fscp-Std-Info": '{"client_id": "40108"}',
145
+ "X-Fscp-Version": "1.1",
146
+ "X-Fscp-Trace-Id": randomUUID(),
147
+ "X-Xsrf-Token": tok.xsrf,
148
+ Cookie: tok.cookie,
149
+ },
150
+ body: JSON.stringify(body),
151
+ });
152
+ }
153
+ catch (err) {
154
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
155
+ }
156
+ if (!response.ok) {
157
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
158
+ }
159
+ let env;
160
+ try {
161
+ env = (await response.json());
162
+ }
163
+ catch (err) {
164
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
165
+ }
166
+ if (env.flag !== 1 || !env.data?.data) {
167
+ return { ok: false, message: env.msg ?? `flag=${env.flag} code=${env.code ?? "?"}` };
168
+ }
169
+ const inner = env.data.data;
170
+ const jobs = inner.jobCardList ?? [];
171
+ // Filter to the actual target company (Liepin's relevance ranker leaks
172
+ // adjacent employers when there's no exact match).
173
+ const exact = jobs.filter((c) => (c.comp?.compName ?? "") === companyName);
174
+ return {
175
+ ok: true,
176
+ total: exact.length === 0 ? jobs.length : exact.length,
177
+ jobs: exact.length === 0 ? jobs : exact,
178
+ compCard: inner.compCard,
179
+ };
180
+ }
181
+ export function createAdapter(cfg) {
182
+ const ATTRIBUTION = `via Liepin (api-c.liepin.com) — official portal not publicly accessible`;
183
+ async function searchPositions(opts = {}) {
184
+ const page = Math.max(1, opts.page ?? 1);
185
+ const pageSize = Math.max(1, Math.min(40, opts.pageSize ?? 20));
186
+ const r = await searchOnePage(cfg.companyName, (opts.keyword ?? "").trim(), page, pageSize);
187
+ if (!r.ok) {
188
+ return {
189
+ ok: false,
190
+ source: SOURCE,
191
+ company: cfg.companyName,
192
+ attribution: ATTRIBUTION,
193
+ message: r.message,
194
+ query: opts,
195
+ positions: [],
196
+ };
197
+ }
198
+ return {
199
+ ok: true,
200
+ source: SOURCE,
201
+ company: cfg.companyName,
202
+ attribution: ATTRIBUTION,
203
+ comp_card: r.compCard,
204
+ query: opts,
205
+ page,
206
+ page_size: pageSize,
207
+ total: r.total,
208
+ positions: r.jobs.map(summarize),
209
+ };
210
+ }
211
+ async function fetchAllPositions(opts = {}) {
212
+ const pageSize = Math.max(1, Math.min(40, opts.pageSize ?? 40));
213
+ const maxPages = Math.max(1, opts.maxPages ?? 10);
214
+ const bucket = [];
215
+ let total = 0;
216
+ let lastMsg = "ok";
217
+ let anyOk = false;
218
+ for (let page = 1; page <= maxPages; page++) {
219
+ const r = await searchOnePage(cfg.companyName, (opts.keyword ?? "").trim(), page, pageSize);
220
+ if (!r.ok) {
221
+ lastMsg = r.message;
222
+ break;
223
+ }
224
+ anyOk = true;
225
+ total = r.total;
226
+ if (!r.jobs.length)
227
+ break;
228
+ for (const c of r.jobs)
229
+ bucket.push(summarize(c));
230
+ if (r.jobs.length < pageSize)
231
+ break;
232
+ }
233
+ if (!anyOk) {
234
+ return {
235
+ ok: false,
236
+ source: SOURCE,
237
+ company: cfg.companyName,
238
+ attribution: ATTRIBUTION,
239
+ message: lastMsg,
240
+ total: 0,
241
+ fetched: 0,
242
+ positions: [],
243
+ };
244
+ }
245
+ return {
246
+ ok: true,
247
+ source: SOURCE,
248
+ company: cfg.companyName,
249
+ attribution: ATTRIBUTION,
250
+ total,
251
+ fetched: bucket.length,
252
+ positions: bucket,
253
+ };
254
+ }
255
+ async function fetchPositionDetail(postId) {
256
+ const id = (postId ?? "").trim();
257
+ if (!id)
258
+ return { ok: false, source: SOURCE, message: "post_id is required" };
259
+ // Liepin's detail page is `/job/{id}.shtml` — non-API, HTML-only. We
260
+ // surface the deep-link rather than pretend to fetch a JSON detail.
261
+ return {
262
+ ok: true,
263
+ source: SOURCE,
264
+ company: cfg.companyName,
265
+ attribution: ATTRIBUTION,
266
+ post_id: id,
267
+ apply_url: `https://www.liepin.com/job/${encodeURIComponent(id)}.shtml`,
268
+ message: "Liepin position detail is HTML-only; visit apply_url for the full JD.",
269
+ };
270
+ }
271
+ async function fetchDictionaries() {
272
+ // Surface the compCard payload (industry / scale / tags) as the closest
273
+ // thing to a "taxonomy" we can offer from a third-party aggregator.
274
+ const r = await searchOnePage(cfg.companyName, "", 1, 5);
275
+ if (!r.ok) {
276
+ return { ok: false, source: SOURCE, message: r.message };
277
+ }
278
+ return {
279
+ ok: true,
280
+ source: SOURCE,
281
+ company: cfg.companyName,
282
+ attribution: ATTRIBUTION,
283
+ comp_card: r.compCard ?? null,
284
+ note: "Liepin doesn't expose a per-company filter taxonomy; comp_card holds " +
285
+ "the company profile (industry, scale, stage, tags).",
286
+ };
287
+ }
288
+ const NOTICES_MSG = `${cfg.label}: surfaced via Liepin aggregator; no notices endpoint available.`;
289
+ async function listNotices() {
290
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
291
+ }
292
+ async function getNotice(noticeId) {
293
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
294
+ }
295
+ async function findNoticesByQuestion(question, _opts = {}) {
296
+ return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
297
+ }
298
+ // matchResume reuses extractResumeSignals / scoreOverlap from tencent.ts
299
+ // so the contract matches every other adapter.
300
+ async function matchResume(text, opts = {}) {
301
+ const topN = Math.max(1, opts.topN ?? 5);
302
+ const candidates = Math.max(topN, opts.candidates ?? 20);
303
+ const { terms, cities } = extractResumeSignals(text ?? "");
304
+ if (!terms.length) {
305
+ return {
306
+ ok: false,
307
+ source: SOURCE,
308
+ message: "could not extract any technical signals from the text",
309
+ preview: (text ?? "").slice(0, 120),
310
+ };
311
+ }
312
+ const keyword = terms.slice(0, 3).join(" ");
313
+ const list = await searchPositions({ keyword, page: 1, pageSize: 40 });
314
+ if (!list.ok) {
315
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
316
+ }
317
+ const scored = [];
318
+ for (const p of list.positions) {
319
+ const blob = [p.title, p.bgs, p.work_cities, p.recruit_label].join(" ");
320
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
321
+ if (score > 0)
322
+ scored.push({ score, position: p, reasons });
323
+ }
324
+ scored.sort((a, b) => b.score - a.score);
325
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
326
+ if (!shortlist.length) {
327
+ shortlist = list.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
328
+ }
329
+ const matches = shortlist.slice(0, topN).map((s) => {
330
+ const mr = s.reasons.length > 0
331
+ ? s.reasons.slice(0, 5)
332
+ : ["no specific keyword overlap — surfaced from Liepin search"];
333
+ return { ...s.position, match_reasons: mr };
334
+ });
335
+ return {
336
+ ok: true,
337
+ source: SOURCE,
338
+ attribution: ATTRIBUTION,
339
+ extracted_terms: terms,
340
+ city_preferences: cities,
341
+ matches,
342
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
343
+ "The only authority on selection is HR.",
344
+ };
345
+ }
346
+ return {
347
+ searchPositions,
348
+ fetchAllPositions,
349
+ fetchPositionDetail,
350
+ fetchDictionaries,
351
+ listNotices,
352
+ getNotice,
353
+ findNoticesByQuestion,
354
+ matchResume,
355
+ checkResume,
356
+ };
357
+ }
package/dist/webank.js CHANGED
@@ -1,62 +1,25 @@
1
- // 微众银行 (WeBank) — stub adapter for `job-pro`.
1
+ // 微众银行 (WeBank) careers adapter Liepin aggregator fallback.
2
2
  //
3
- // STATUS: stub-only. WeBank operates a campus-recruiting portal at
4
- // career.webank.com but the domain is not resolvable from public DNS, and
5
- // every public ATS slug we probed (Feishu, Moka, Greenhouse, Lever) returns
6
- // 404. WeBank's hiring funnel runs entirely through WeChat mini-programs.
3
+ // WeBank's career page at www.webank.com/career/ is a 15KB static Vue
4
+ // brochure with no embedded job feed; recruitment runs through the
5
+ // 微众银行招聘 WeChat 公众号 微信小程序 chain. We surface real
6
+ // currently-open WeBank positions by querying Liepin
7
+ // (api-c.liepin.com) filtered by compName="微众银行". See
8
+ // `cli/src/liepin.ts` for the shared factory.
7
9
  //
8
- // ============================================================
9
- // RECONNAISSANCE RESULTS (probed 2026-05):
10
- //
11
- // https://career.webank.com — 000 (no public DNS / unreachable)
12
- // https://job.webank.com — 000 (no public DNS / unreachable)
13
- // https://hr.webank.com — 000 (no public DNS / unreachable)
14
- //
15
- // Feishu ATSX: webank.jobs.feishu.cn HTTP 400 (no portal)
16
- // Greenhouse: webank — HTTP 404 (no board)
17
- // Lever: webank — HTTP 404 (no posting)
18
- //
19
- // The official 微众银行招聘 WeChat 公众号 publishes openings as articles
20
- // and routes applications into a mini-program; no JSON surface is exposed.
21
- //
22
- // Conclusion: no unauthenticated public API. Apply via the WeChat 微众银行招聘
23
- // account or visit https://www.webank.com/ for company contact info.
24
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
25
- export { checkResume };
26
- const SOURCE = "webank.com";
27
- const STUB_MESSAGE = "WeBank (微众银行): career.webank.com and sibling subdomains fail to resolve over public DNS. " +
28
- "Recruiting runs through the WeChat 微众银行招聘 mini-program; no Greenhouse / Lever / Feishu " +
29
- "tenant provisioned. 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 };
10
+ // Source: api-c.liepin.com (`source` field on responses) — clearly NOT
11
+ // the same as WeBank's WeChat mini-program funnel.
12
+ import { createAdapter } from "./liepin.js";
13
+ const adapter = createAdapter({
14
+ companyName: "微众银行",
15
+ label: "WeBank / 微众银行",
16
+ });
17
+ export const searchPositions = adapter.searchPositions;
18
+ export const fetchAllPositions = adapter.fetchAllPositions;
19
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
20
+ export const fetchDictionaries = adapter.fetchDictionaries;
21
+ export const listNotices = adapter.listNotices;
22
+ export const getNotice = adapter.getNotice;
23
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
24
+ export const matchResume = adapter.matchResume;
25
+ export const checkResume = adapter.checkResume;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.7.5",
4
- "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 46 live (incl. Lilith via local Chrome / puppeteer-core). +Hikvision via CDP + CN proxy when JOB_PRO_HTTPS_PROXY is set. No signup, no token, no server.",
3
+ "version": "0.8.0",
4
+ "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, all 50 live. 46 via each company's own API; the 4 with no public canonical feed (Hikvision, CICC, Cainiao, WeBank) surfaced via Liepin as a clearly-labeled third-party fallback. No signup, no token, no server.",
5
5
  "homepage": "https://job.ha7ch.com",
6
6
  "repository": "https://github.com/HA7CH/job-pro",
7
7
  "license": "MIT",