job-pro 0.7.4 → 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/cdp.js CHANGED
@@ -79,6 +79,11 @@ async function launchOnce() {
79
79
  ". Set $JOB_PRO_CHROME=/path/to/chrome to override.",
80
80
  };
81
81
  }
82
+ // Optional egress proxy — useful for geo-fenced upstreams (e.g. hikvision
83
+ // requires a CN-egress to pass its Tencent EdgeOne 403 check). Set
84
+ // `$JOB_PRO_HTTPS_PROXY=http://user:pass@host:port` or `socks5://host:port`.
85
+ const proxy = process.env.JOB_PRO_HTTPS_PROXY?.trim();
86
+ const proxyArg = proxy ? [`--proxy-server=${proxy}`] : [];
82
87
  try {
83
88
  const browser = await pp.mod.launch({
84
89
  executablePath: chrome,
@@ -87,6 +92,7 @@ async function launchOnce() {
87
92
  "--no-sandbox",
88
93
  "--disable-blink-features=AutomationControlled",
89
94
  "--disable-features=IsolateOrigins,site-per-process",
95
+ ...proxyArg,
90
96
  ],
91
97
  });
92
98
  return browser;
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,185 +1,28 @@
1
- // Thin client for 海康威视 / Hikvision campus-recruiting portals.
2
- //
3
- // ============================================================
4
- // RECONNAISSANCE RESULTS (probed 2026-05):
5
- //
6
- // https://hr.hikvision.com/
7
- // https://hr.hikvision.com/zwzx (老职位中心 / legacy position center)
8
- // https://campus.hikvision.com/
9
- // TLS ECONNRESET from non-CN IP (geo-blocked by WAF/CDN)
10
- // DNS resolves to CGNAT 198.18.1.57/58 via local proxy, never reaches origin.
11
- // HTTP port 80 also hangs (socket hang-up). Both domains are inaccessible
12
- // from outside Mainland China. Confirmed with both curl (SSL_ERROR_SYSCALL)
13
- // and Node.js https / undici (ECONNRESET).
14
- //
15
- // https://app.mokahr.com/campus-recruitment/hikvision/58022
16
- // → app.mokahr.com serves a 302 redirect loop until a session cookie is set,
17
- // then loads the SPA shell with init-data: {"message":-1}.
18
- // message:-1 is Moka's "org not found / org not active on public campus portal"
19
- // status. The org slug "hikvision" resolves to orgId 58022 but the public
20
- // campus module is inactive for this tenant.
21
- // All /api/campus/v*/jobs?orgId=58022 and /api/campus/v*/... paths → 404.
22
- //
23
- // https://www.hikvision.com/en/about-us/careers/
24
- // → Reachable (AEM/Adobe Experience Manager marketing page). Links only to
25
- // regional career pages on the global site — no job search API.
26
- //
27
- // ============================================================
28
- // INFRASTRUCTURE NOTES:
29
- //
30
- // Hikvision is a 50,000+ employee Chinese enterprise headquartered in Hangzhou.
31
- // Their recruiting stack is entirely self-hosted behind the corporate CDN/WAF.
32
- // Unlike ByteDance/Tencent/JD (which expose public unauthenticated search APIs),
33
- // Hikvision's hr.hikvision.com portal appears to be:
34
- // • HTTPS only on port 443, WAF blocks TLS handshakes from non-CN egress IPs
35
- // • No HTTP (port 80) fallback — socket hangs immediately
36
- // • Likely Alibaba Cloud WAF or Hikvision's own security gateway
37
- //
38
- // The legacy position center at /zwzx is on the same domain and equally blocked.
39
- //
40
- // Moka ATS (Moka HR, app.mokahr.com) orgId 58022:
41
- // • The campus-recruitment portal returns message:-1 (tenant inactive / not found)
42
- // • Hikvision may have migrated away from Moka or never activated the public campus module
43
- // • No public /api/campus/* endpoint returns job data for this org
44
- //
45
- // ============================================================
46
- // WHY THIS IS A STUB (unauthenticated API access is impossible from non-CN):
47
- //
48
- // Both career portals (hr.hikvision.com and campus.hikvision.com) are behind a
49
- // geo-blocking WAF that resets TLS connections from non-Mainland-China IP ranges.
50
- // Even if a valid API path were known (e.g. from JS bundle analysis), the TLS
51
- // handshake never completes — no HTTP request can be made.
52
- //
53
- // The Moka ATS fallback (orgId 58022) returns org-not-found, providing no data.
54
- //
55
- // POSSIBLE FUTURE UNBLOCKING:
56
- // (a) Access from a Mainland China exit node (VPS/proxy)
57
- // (b) Hikvision activating their Moka public campus module
58
- // (c) Hikvision publishing a CDN-fronted public job API (unlikely given security posture)
59
- // (d) Third-party aggregators: 牛客网, 实习僧, Boss直聘 (separate adapters)
60
- //
61
- // ============================================================
62
- // STUB CONTRACT:
63
- // All functions return ok:false with STUB_MESSAGE.
64
- // checkResume is re-exported from tencent.ts (works offline on resume text).
65
- // PositionSummary matches the canonical shape used by every other adapter.
66
- //
67
- // ============================================================
68
- // ---- PositionSummary field mapping (Hikvision → canonical, for when API becomes accessible) ----
69
- // post_id ← position ID from hr.hikvision.com or Moka publishId
70
- // title ← position name / 职位名称
71
- // project ← job category / 职位类别 (e.g. "软件开发", "算法研究", "嵌入式开发")
72
- // recruit_label ← recruit type / 招聘类型 (e.g. "校招", "实习", "社招")
73
- // bgs ← business line / 事业部 (not exposed in known public payloads → "")
74
- // work_cities ← work location / 工作地点 (e.g. "杭州" / "北京 / 上海")
75
- // apply_url ← https://hr.hikvision.com/zwzx#/job/<id> (inferred from URL pattern)
76
- import { extractResumeSignals, checkResume } from "./tencent.js";
77
- export { checkResume };
78
- const SOURCE = "hr.hikvision.com";
79
- const CAMPUS_URL = "https://hr.hikvision.com/zwzx";
80
- const MOKA_URL = "https://app.mokahr.com/campus-recruitment/hikvision/58022";
81
- const STUB_MESSAGE = "Hikvision (海康威视): no public job API accessible from outside Mainland China. " +
82
- "hr.hikvision.com and campus.hikvision.com are geo-blocked (TLS ECONNRESET, WAF resets " +
83
- "all non-CN connections). Moka ATS orgId 58022 returns message:-1 (org not active on " +
84
- "public campus portal). To access Hikvision jobs, visit hr.hikvision.com directly from " +
85
- "a Mainland China network, or check 牛客网/Boss直聘/实习僧 for aggregated listings. " +
86
- "Documented in cli/src/hikvision.ts header.";
87
- // ---- searchPositions ----
88
- export async function searchPositions(_opts = {}) {
89
- return {
90
- ok: false,
91
- source: SOURCE,
92
- message: STUB_MESSAGE,
93
- // Expose the discovered endpoint candidate so callers can see what we would have hit
94
- endpoint_candidates: [
95
- `GET ${CAMPUS_URL} (geo-blocked from non-CN)`,
96
- `GET https://campus.hikvision.com/ (geo-blocked from non-CN)`,
97
- `GET ${MOKA_URL} (Moka orgId 58022, message:-1 — org inactive)`,
98
- ],
99
- query: {
100
- keyword: _opts.keyword ?? "",
101
- page: _opts.page ?? 1,
102
- pageSize: _opts.pageSize ?? 20,
103
- recruitType: _opts.recruitType ?? "campus",
104
- },
105
- page: _opts.page ?? 1,
106
- page_size: _opts.pageSize ?? 20,
107
- total: 0,
108
- positions: [],
109
- };
110
- }
111
- // ---- fetchAllPositions ----
112
- export async function fetchAllPositions(_opts = {}) {
113
- return {
114
- ok: false,
115
- source: SOURCE,
116
- message: STUB_MESSAGE,
117
- total: 0,
118
- fetched: 0,
119
- positions: [],
120
- };
121
- }
122
- // ---- fetchPositionDetail ----
123
- export async function fetchPositionDetail(postId) {
124
- return {
125
- ok: false,
126
- source: SOURCE,
127
- message: STUB_MESSAGE,
128
- post_id: postId,
129
- };
130
- }
131
- // ---- fetchDictionaries ----
132
- export async function fetchDictionaries() {
133
- return {
134
- ok: false,
135
- source: SOURCE,
136
- message: STUB_MESSAGE,
137
- note: "When hr.hikvision.com becomes accessible from non-CN: " +
138
- "inspect JS bundles at /zwzx for /api/* filter taxonomy endpoints " +
139
- "(job categories, work cities, recruit types).",
140
- };
141
- }
142
- // ---- notices (no public endpoint) ----
143
- export async function listNotices() {
144
- return {
145
- ok: false,
146
- source: SOURCE,
147
- message: "Hikvision: no public notices endpoint",
148
- notices: [],
149
- };
150
- }
151
- export async function getNotice(noticeId) {
152
- return {
153
- ok: false,
154
- source: SOURCE,
155
- message: "Hikvision: no public notices endpoint",
156
- notice_id: noticeId,
157
- };
158
- }
159
- export async function findNoticesByQuestion(question, _opts = {}) {
160
- return {
161
- ok: false,
162
- source: SOURCE,
163
- question,
164
- message: "Hikvision: no public notices endpoint",
165
- matches: [],
166
- };
167
- }
168
- // ---- matchResume ----
169
- //
170
- // Because the position search API is inaccessible, we cannot retrieve live listings
171
- // to score against the resume. Return ok:false with the extracted signals so the
172
- // caller can display what terms were parsed (useful for debugging the resume text).
173
- export async function matchResume(text, _opts = {}) {
174
- const { terms, cities } = extractResumeSignals(text ?? "");
175
- return {
176
- ok: false,
177
- source: SOURCE,
178
- extracted_terms: terms,
179
- city_preferences: cities,
180
- matches: [],
181
- message: STUB_MESSAGE,
182
- apply_url: CAMPUS_URL,
183
- moka_url: MOKA_URL,
184
- };
185
- }
1
+ // 海康威视 / Hikvision careers adapter — Liepin aggregator fallback.
2
+ //
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.
6
+ //
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.
12
+ //
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.4";
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.4",
4
- "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 46 live (incl. Lilith via local Chrome / puppeteer-core). 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",