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 +24 -61
- package/dist/cicc.js +26 -152
- package/dist/hikvision.js +25 -230
- package/dist/index.js +1 -1
- package/dist/liepin.js +357 -0
- package/dist/webank.js +23 -60
- package/package.json +2 -2
package/dist/cainiao.js
CHANGED
|
@@ -1,63 +1,26 @@
|
|
|
1
|
-
// 菜鸟 (Cainiao Network) —
|
|
1
|
+
// 菜鸟 (Cainiao Network) careers adapter — Liepin aggregator fallback.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
1
|
+
// 海康威视 / Hikvision careers adapter — Liepin aggregator fallback.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
17
|
-
// the
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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.
|
|
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) —
|
|
1
|
+
// 微众银行 (WeBank) careers adapter — Liepin aggregator fallback.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
4
|
-
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies,
|
|
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",
|