@ha7ch/job-pro 1.0.93
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/adapter.js +17 -0
- package/dist/agibot.js +399 -0
- package/dist/alibaba.js +509 -0
- package/dist/antgroup.js +397 -0
- package/dist/apply.js +1373 -0
- package/dist/baichuan.js +49 -0
- package/dist/baidu.js +452 -0
- package/dist/bilibili.js +455 -0
- package/dist/byd.js +412 -0
- package/dist/bytedance.js +619 -0
- package/dist/cainiao.js +56 -0
- package/dist/cambricon.js +33 -0
- package/dist/cdp.js +237 -0
- package/dist/cicc.js +56 -0
- package/dist/coverage.js +60 -0
- package/dist/deepseek.js +25 -0
- package/dist/didi.js +381 -0
- package/dist/feishu.js +577 -0
- package/dist/galaxyuniversal.js +24 -0
- package/dist/geely.js +35 -0
- package/dist/greenhouse.js +432 -0
- package/dist/hikvision.js +58 -0
- package/dist/horizonrobotics.js +46 -0
- package/dist/hoyoverse.js +26 -0
- package/dist/huawei.js +537 -0
- package/dist/iflytek.js +380 -0
- package/dist/index.js +1828 -0
- package/dist/iqiyi.js +494 -0
- package/dist/jd.js +559 -0
- package/dist/kuaishou.js +496 -0
- package/dist/lever.js +455 -0
- package/dist/liauto.js +393 -0
- package/dist/liepin.js +357 -0
- package/dist/lilith.js +300 -0
- package/dist/megvii.js +27 -0
- package/dist/meituan.js +633 -0
- package/dist/memory.js +76 -0
- package/dist/mihoyo.js +308 -0
- package/dist/minimax.js +32 -0
- package/dist/moka.js +473 -0
- package/dist/moonshot.js +24 -0
- package/dist/netease.js +424 -0
- package/dist/nio.js +24 -0
- package/dist/oppo.js +285 -0
- package/dist/pdd.js +614 -0
- package/dist/pingan.js +493 -0
- package/dist/sensetime.js +51 -0
- package/dist/sf.js +310 -0
- package/dist/stepfun.js +24 -0
- package/dist/tencent.js +770 -0
- package/dist/trip.js +396 -0
- package/dist/unitree.js +418 -0
- package/dist/vivo.js +361 -0
- package/dist/webank.js +55 -0
- package/dist/wecruit.js +438 -0
- package/dist/weibo.js +337 -0
- package/dist/weride.js +29 -0
- package/dist/xiaohongshu.js +480 -0
- package/dist/xiaomi.js +529 -0
- package/dist/xpeng.js +34 -0
- package/dist/zerooneai.js +42 -0
- package/dist/zhipu.js +478 -0
- package/extension/README.md +79 -0
- package/extension/background.js +177 -0
- package/extension/manifest.json +55 -0
- package/extension/popup.html +37 -0
- package/extension/popup.js +54 -0
- package/package.json +61 -0
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/lilith.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// 莉莉丝游戏 (Lilith Games) careers adapter — Feishu portal_type=6 via CDP.
|
|
2
|
+
//
|
|
3
|
+
// ============================================================
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16)
|
|
5
|
+
//
|
|
6
|
+
// Lilith's careers feed is hosted at `lilithgames.jobs.feishu.cn` (Feishu
|
|
7
|
+
//招聘 / ATSX). It looks like a standard Feishu tenant on the surface, BUT
|
|
8
|
+
// the `/api/v1/search/job/posts` POST is rejected with `HTTP 405` from
|
|
9
|
+
// ByteDance Tengine for any anonymous caller — Lilith's tenant is one of
|
|
10
|
+
// the few that requires the in-browser `_signature` anti-bot token. The
|
|
11
|
+
// signature is computed by `verifycenter` (`lf-cdn-tos.bytescm.com/.../rc-verifycenter`)
|
|
12
|
+
// at runtime and appended to the URL query string + headers; it's
|
|
13
|
+
// session-bound and short-lived.
|
|
14
|
+
//
|
|
15
|
+
// Reverse-engineering verifycenter is non-trivial. We work around it by
|
|
16
|
+
// using `puppeteer-core` to drive the user's real Chrome (see cli/src/cdp.ts):
|
|
17
|
+
// navigate to the careers page, wait for the SPA's own `search/job/posts`
|
|
18
|
+
// XHR, and read the JSON straight off the network response. Same data
|
|
19
|
+
// shape as `cli/src/feishu.ts`, just sourced through a real browser.
|
|
20
|
+
//
|
|
21
|
+
// Probed 2026-05-16: portal_type=6, channel id 7055353811552127239, default
|
|
22
|
+
// limit=10. The career page filters by `location_code_list` query string;
|
|
23
|
+
// we pass through search options the same way.
|
|
24
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
25
|
+
import { withPage } from "./cdp.js";
|
|
26
|
+
export { checkResume };
|
|
27
|
+
const SOURCE = "lilithgames.jobs.feishu.cn";
|
|
28
|
+
const HOST = "https://lilithgames.jobs.feishu.cn";
|
|
29
|
+
const CAREER_PAGE = `${HOST}/career/`;
|
|
30
|
+
const DETAIL_PAGE = (id) => `${HOST}/career/${encodeURIComponent(id)}/detail`;
|
|
31
|
+
function summarize(item) {
|
|
32
|
+
const id = String(item.id ?? "");
|
|
33
|
+
const cityList = item.city_list ?? [];
|
|
34
|
+
const work_cities = cityList.length > 1
|
|
35
|
+
? cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ")
|
|
36
|
+
: cityList[0]?.name ?? item.city_info?.name ?? "";
|
|
37
|
+
const project = item.job_category?.name ?? item.job_function?.name ?? "";
|
|
38
|
+
return {
|
|
39
|
+
post_id: id,
|
|
40
|
+
title: item.title ?? "",
|
|
41
|
+
project,
|
|
42
|
+
recruit_label: item.recruit_type?.name ?? "",
|
|
43
|
+
bgs: "",
|
|
44
|
+
work_cities,
|
|
45
|
+
apply_url: id ? DETAIL_PAGE(id) : CAREER_PAGE,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function STUB_MESSAGE(reason) {
|
|
49
|
+
return ("Lilith Games (莉莉丝): feishu portal_type=6 requires a browser-minted " +
|
|
50
|
+
"`_signature` ByteDance anti-bot token. " +
|
|
51
|
+
`Could not run the browser fallback: ${reason}. ` +
|
|
52
|
+
"Install Google Chrome (or set $JOB_PRO_CHROME=/path/to/chrome) and " +
|
|
53
|
+
"ensure puppeteer-core is installed (it ships with this CLI by default).");
|
|
54
|
+
}
|
|
55
|
+
async function searchViaBrowser(opts) {
|
|
56
|
+
const limit = Math.max(1, Math.min(50, opts.pageSize ?? 10));
|
|
57
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
58
|
+
const cityCode = (opts.cityCode ?? "").trim();
|
|
59
|
+
// The career page URL itself drives the SPA's initial XHR with the
|
|
60
|
+
// matching filters baked in. We construct a URL that yields the desired
|
|
61
|
+
// search response without needing post-load interactions.
|
|
62
|
+
const params = new URLSearchParams({
|
|
63
|
+
keywords: keyword,
|
|
64
|
+
location: cityCode,
|
|
65
|
+
project: "",
|
|
66
|
+
type: "",
|
|
67
|
+
category: "",
|
|
68
|
+
current: String(opts.page ?? 1),
|
|
69
|
+
limit: String(limit),
|
|
70
|
+
functionCategory: "",
|
|
71
|
+
});
|
|
72
|
+
const targetUrl = `${CAREER_PAGE}?${params.toString()}`;
|
|
73
|
+
const r = await withPage(async (page) => {
|
|
74
|
+
// We arm a response waiter BEFORE goto so we don't miss the XHR.
|
|
75
|
+
// The Feishu SPA fires multiple identical XHRs (one for filters, one
|
|
76
|
+
// for the actual search); we filter to the one that includes
|
|
77
|
+
// `search/job/posts` in the URL AND has non-zero content-length.
|
|
78
|
+
const responsePromise = page.waitForResponse((resp) => {
|
|
79
|
+
const u = resp.url();
|
|
80
|
+
return resp.status() === 200 && /\/api\/v1\/search\/job\/posts/.test(u);
|
|
81
|
+
}, { timeout: 25000 });
|
|
82
|
+
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
83
|
+
const resp = await responsePromise;
|
|
84
|
+
return (await resp.json());
|
|
85
|
+
});
|
|
86
|
+
if (!r.ok) {
|
|
87
|
+
return { ok: false, message: STUB_MESSAGE(r.error.message) };
|
|
88
|
+
}
|
|
89
|
+
const env = r.value;
|
|
90
|
+
if (env.code !== 0 || !env.data) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
message: `upstream returned code=${env.code} (${env.message ?? "unknown"})`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const rawJobs = env.data.job_post_list ?? [];
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
result: {
|
|
100
|
+
ok: true,
|
|
101
|
+
total: env.data.count ?? rawJobs.length,
|
|
102
|
+
positions: rawJobs.map(summarize),
|
|
103
|
+
rawJobs,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// ---------- public API ----------
|
|
108
|
+
export async function searchPositions(opts = {}) {
|
|
109
|
+
const r = await searchViaBrowser(opts);
|
|
110
|
+
if (!r.ok) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
source: SOURCE,
|
|
114
|
+
message: r.message,
|
|
115
|
+
query: opts,
|
|
116
|
+
positions: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
source: SOURCE,
|
|
122
|
+
query: opts,
|
|
123
|
+
page: opts.page ?? 1,
|
|
124
|
+
page_size: opts.pageSize ?? 10,
|
|
125
|
+
total: r.result.total,
|
|
126
|
+
positions: r.result.positions,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function fetchAllPositions(opts = {}) {
|
|
130
|
+
const limit = Math.max(1, Math.min(50, opts.pageSize ?? 30));
|
|
131
|
+
const maxPages = Math.max(1, opts.maxPages ?? 20);
|
|
132
|
+
const bucket = [];
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
135
|
+
const r = await searchViaBrowser({ ...opts, page, pageSize: limit });
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
if (bucket.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
source: SOURCE,
|
|
141
|
+
message: r.message,
|
|
142
|
+
total: 0,
|
|
143
|
+
fetched: 0,
|
|
144
|
+
positions: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (page === 1)
|
|
150
|
+
total = r.result.total;
|
|
151
|
+
if (!r.result.positions.length)
|
|
152
|
+
break;
|
|
153
|
+
bucket.push(...r.result.positions);
|
|
154
|
+
if (bucket.length >= total)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
source: SOURCE,
|
|
160
|
+
total,
|
|
161
|
+
fetched: bucket.length,
|
|
162
|
+
positions: bucket,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// fetchPositionDetail: Feishu has no per-id REST endpoint; scan via search.
|
|
166
|
+
export async function fetchPositionDetail(postId) {
|
|
167
|
+
const id = (postId ?? "").trim();
|
|
168
|
+
if (!id)
|
|
169
|
+
return { ok: false, source: SOURCE, message: "post_id is required" };
|
|
170
|
+
const limit = 50;
|
|
171
|
+
const maxPages = 10;
|
|
172
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
173
|
+
const r = await searchViaBrowser({ page, pageSize: limit });
|
|
174
|
+
if (!r.ok)
|
|
175
|
+
return { ok: false, source: SOURCE, post_id: id, message: r.message };
|
|
176
|
+
const found = r.result.rawJobs.find((p) => String(p.id) === id);
|
|
177
|
+
if (found) {
|
|
178
|
+
const summary = summarize(found);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
source: SOURCE,
|
|
182
|
+
post_id: id,
|
|
183
|
+
title: found.title ?? "",
|
|
184
|
+
project: summary.project,
|
|
185
|
+
recruit_label: summary.recruit_label,
|
|
186
|
+
description: found.description ?? "",
|
|
187
|
+
requirements: found.requirement ?? "",
|
|
188
|
+
work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
|
|
189
|
+
apply_url: summary.apply_url,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (r.result.rawJobs.length < limit)
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
source: SOURCE,
|
|
198
|
+
post_id: id,
|
|
199
|
+
message: `post ${id} not found in browser-driven search (scanned up to ${maxPages * limit} posts)`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// fetchDictionaries: synthesize from one page of results.
|
|
203
|
+
let _dictCache = null;
|
|
204
|
+
export async function fetchDictionaries() {
|
|
205
|
+
if (_dictCache !== null)
|
|
206
|
+
return _dictCache;
|
|
207
|
+
const r = await searchViaBrowser({ pageSize: 50 });
|
|
208
|
+
if (!r.ok) {
|
|
209
|
+
const result = { ok: false, source: SOURCE, message: r.message };
|
|
210
|
+
_dictCache = result;
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
const cats = new Set();
|
|
214
|
+
const cities = new Set();
|
|
215
|
+
for (const j of r.result.rawJobs) {
|
|
216
|
+
const name = j.job_category?.name ?? j.job_function?.name;
|
|
217
|
+
if (name)
|
|
218
|
+
cats.add(name);
|
|
219
|
+
for (const c of j.city_list ?? [])
|
|
220
|
+
if (c.name)
|
|
221
|
+
cities.add(c.name);
|
|
222
|
+
if (j.city_info?.name)
|
|
223
|
+
cities.add(j.city_info.name);
|
|
224
|
+
}
|
|
225
|
+
const result = {
|
|
226
|
+
ok: true,
|
|
227
|
+
source: SOURCE,
|
|
228
|
+
total: r.result.total,
|
|
229
|
+
sample_categories: [...cats].sort(),
|
|
230
|
+
sample_cities: [...cities].sort(),
|
|
231
|
+
};
|
|
232
|
+
_dictCache = result;
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
const NOTICES_MSG = "Lilith Games (莉莉丝): no public notices endpoint on Feishu tenant";
|
|
236
|
+
export async function listNotices() {
|
|
237
|
+
return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
|
|
238
|
+
}
|
|
239
|
+
export async function getNotice(noticeId) {
|
|
240
|
+
return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
|
|
241
|
+
}
|
|
242
|
+
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
243
|
+
return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
|
|
244
|
+
}
|
|
245
|
+
export async function matchResume(text, opts = {}) {
|
|
246
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
247
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
248
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
249
|
+
if (!terms.length) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
source: SOURCE,
|
|
253
|
+
message: "could not extract any technical signals from the text",
|
|
254
|
+
preview: (text ?? "").slice(0, 120),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
258
|
+
const r = await searchViaBrowser({ keyword, pageSize: 50 });
|
|
259
|
+
if (!r.ok) {
|
|
260
|
+
return { ok: false, source: SOURCE, message: r.message, positions: [] };
|
|
261
|
+
}
|
|
262
|
+
const scored = [];
|
|
263
|
+
for (const p of r.result.positions) {
|
|
264
|
+
const blob = [p.title, p.project, p.recruit_label, p.work_cities].join(" ");
|
|
265
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
266
|
+
if (score > 0)
|
|
267
|
+
scored.push({ score, position: p, reasons });
|
|
268
|
+
}
|
|
269
|
+
scored.sort((a, b) => b.score - a.score);
|
|
270
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
271
|
+
if (!shortlist.length) {
|
|
272
|
+
shortlist = r.result.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
|
|
273
|
+
}
|
|
274
|
+
const matches = shortlist.slice(0, topN).map((s) => {
|
|
275
|
+
const mr = s.reasons.length > 0
|
|
276
|
+
? s.reasons.slice(0, 5)
|
|
277
|
+
: ["no specific keyword overlap — surfaced from initial keyword search"];
|
|
278
|
+
return { ...s.position, match_reasons: mr };
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
source: SOURCE,
|
|
283
|
+
extracted_terms: terms,
|
|
284
|
+
city_preferences: cities,
|
|
285
|
+
matches,
|
|
286
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
287
|
+
"The only authority on selection is HR.",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export { extractResumeSignals, scoreOverlap };
|
|
291
|
+
// ---------- Phase 2: fetchApplicationSchema ----------
|
|
292
|
+
import { makeFeishuApplyFn } from "./feishu.js";
|
|
293
|
+
export const fetchApplicationSchema = makeFeishuApplyFn({
|
|
294
|
+
host: "lilithgames.jobs.feishu.cn",
|
|
295
|
+
source: "lilithgames.jobs.feishu.cn",
|
|
296
|
+
channel: "career",
|
|
297
|
+
applyUrlPrefix: "https://lilithgames.jobs.feishu.cn/career/position",
|
|
298
|
+
fetchTitle: (id) => fetchPositionDetail(id),
|
|
299
|
+
submitKind: "cdp-real-browser",
|
|
300
|
+
});
|
package/dist/megvii.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// 旷视科技 / Megvii / Face++ careers — Moka SSR + AES-128-CBC.
|
|
2
|
+
//
|
|
3
|
+
// Two portals on the same Moka tenant `megviihr`:
|
|
4
|
+
// campus → https://app.mokahr.com/campus_apply/megviihr/38642
|
|
5
|
+
// social → https://app.mokahr.com/social-recruitment/megviihr/38641
|
|
6
|
+
// Probed 2026-05; ~5 visible positions (Megvii hiring is currently low).
|
|
7
|
+
// See cli/src/moka.ts for the shared factory.
|
|
8
|
+
import { createAdapter } from "./moka.js";
|
|
9
|
+
const adapter = createAdapter({
|
|
10
|
+
orgSlug: "megviihr",
|
|
11
|
+
label: "Megvii",
|
|
12
|
+
channels: [
|
|
13
|
+
{ siteId: 38642, kind: "campus_apply", recruitType: "campus" },
|
|
14
|
+
{ siteId: 38641, kind: "social-recruitment", recruitType: "social" },
|
|
15
|
+
],
|
|
16
|
+
defaultRecruitType: "social",
|
|
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;
|
|
27
|
+
export const fetchApplicationSchema = adapter.fetchApplicationSchema;
|