job-pro 0.7.1 → 0.7.2
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/cambricon.js +379 -39
- package/dist/horizonrobotics.js +43 -92
- package/dist/index.js +1 -1
- package/dist/sensetime.js +47 -183
- package/dist/wecruit.js +385 -0
- package/package.json +3 -2
package/dist/cambricon.js
CHANGED
|
@@ -1,64 +1,404 @@
|
|
|
1
|
-
// 寒武纪 (Cambricon) —
|
|
2
|
-
//
|
|
3
|
-
// STATUS: stub-only. Cambricon's careers domains do not resolve over public
|
|
4
|
-
// DNS, and no third-party ATS tenant (Feishu, Moka, Greenhouse, Lever) is
|
|
5
|
-
// provisioned for the company. Recruiting runs through internal channels.
|
|
1
|
+
// 寒武纪 (Cambricon) careers adapter — Moka SSR + AES-128-CBC pagination.
|
|
6
2
|
//
|
|
7
3
|
// ============================================================
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// https://hr.cambricon.com — 000 (no public DNS / unreachable)
|
|
11
|
-
// https://careers.cambricon.com — 000 (no public DNS / unreachable)
|
|
12
|
-
// https://campus.cambricon.com — 000 (no public DNS / unreachable)
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16)
|
|
13
5
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// Lever: cambricon — HTTP 404 (no posting)
|
|
17
|
-
// Moka: app.mokahr.com/social-recruitment/cambricon → 302 (unprovisioned)
|
|
6
|
+
// www.cambricon.com (the corporate site) embeds links to Moka tenant URLs
|
|
7
|
+
// in its 加入我们 / careers section. Extracted slugs:
|
|
18
8
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
9
|
+
// /campus-recruitment/cambricon/44201 ← campus + intern (main entry)
|
|
10
|
+
// /recommendation-recruitment/cambricon/42452 (referral channel, overlaps)
|
|
11
|
+
// /recommendation-recruitment/cambricon/46261 (referral channel, overlaps)
|
|
22
12
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
13
|
+
// No /social-recruitment/cambricon/<siteId> URL is published — Cambricon
|
|
14
|
+
// only opens 校招 / 实习 publicly through Moka. The campus SSR HTML embeds
|
|
15
|
+
// `<input id="init-data" value="{...}">` containing the full first page of
|
|
16
|
+
// jobs + aesIv for subsequent AES-CBC paginated calls. Same pattern as
|
|
17
|
+
// `cli/src/megvii.ts`; the heavy lifting (htmlDecode, parseInitData,
|
|
18
|
+
// fetchPortalHtml two-fetch cookie dance, decryptMokaEnvelope) is
|
|
19
|
+
// duplicated here for now — a shared `moka.ts` factory is worth refactoring
|
|
20
|
+
// to once we have 6+ Moka tenants (currently megvii/deepseek/galaxyuniversal/
|
|
21
|
+
// stepfun/moonshot/+cambricon = 6 → schedule for next pass).
|
|
26
22
|
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
import { createDecipheriv } from "node:crypto";
|
|
24
|
+
export { checkResume, extractResumeSignals, scoreOverlap };
|
|
25
|
+
const SOURCE = "app.mokahr.com/cambricon";
|
|
26
|
+
const ORG_SLUG = "cambricon";
|
|
27
|
+
const CAMPUS_SITE_ID = 44201;
|
|
28
|
+
const CAMPUS_URL = `https://app.mokahr.com/campus-recruitment/${ORG_SLUG}/${CAMPUS_SITE_ID}`;
|
|
29
|
+
const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
|
|
30
|
+
const DEFAULT_HEADERS = {
|
|
31
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
32
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
33
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
34
|
+
};
|
|
35
|
+
// ---- helpers (duplicated from megvii.ts — slated for moka.ts refactor) ----
|
|
36
|
+
function htmlDecode(s) {
|
|
37
|
+
return s
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/&/g, "&")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">")
|
|
42
|
+
.replace(/'/g, "'")
|
|
43
|
+
.replace(/'/g, "'");
|
|
44
|
+
}
|
|
45
|
+
function parseInitData(html) {
|
|
46
|
+
const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
|
|
47
|
+
if (!m)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(htmlDecode(m[1]));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function fetchPortalHtml(url) {
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
63
|
+
}
|
|
64
|
+
const cookies = [];
|
|
65
|
+
const headersAny = response.headers;
|
|
66
|
+
if (typeof headersAny.getSetCookie === "function") {
|
|
67
|
+
for (const v of headersAny.getSetCookie.call(response.headers) ?? []) {
|
|
68
|
+
const c = v.split(";")[0];
|
|
69
|
+
if (c)
|
|
70
|
+
cookies.push(c);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (cookies.length === 0) {
|
|
74
|
+
const raw = response.headers.get("set-cookie");
|
|
75
|
+
if (raw)
|
|
76
|
+
cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
|
|
77
|
+
}
|
|
78
|
+
const cookieHeader = cookies.join("; ");
|
|
79
|
+
let r2;
|
|
80
|
+
try {
|
|
81
|
+
r2 = await fetch(url, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
|
|
84
|
+
redirect: "follow",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
89
|
+
}
|
|
90
|
+
if (!r2.ok)
|
|
91
|
+
return { ok: false, status: r2.status, message: `HTTP ${r2.status}` };
|
|
92
|
+
const html = await r2.text();
|
|
93
|
+
return { ok: true, html, cookieHeader, status: r2.status, message: "ok" };
|
|
94
|
+
}
|
|
95
|
+
function decryptMokaEnvelope(envelope, aesIv) {
|
|
96
|
+
if (!envelope.data || !envelope.necromancer)
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const key = Buffer.from(envelope.necromancer, "utf8");
|
|
100
|
+
const iv = Buffer.from(aesIv, "utf8");
|
|
101
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
102
|
+
const plain = Buffer.concat([
|
|
103
|
+
decipher.update(Buffer.from(envelope.data, "base64")),
|
|
104
|
+
decipher.final(),
|
|
105
|
+
]);
|
|
106
|
+
return JSON.parse(plain.toString("utf8"));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function fetchEncryptedPage(pageNum, pageSize, aesIv, cookieHeader) {
|
|
113
|
+
const url = `${API_ENDPOINT}?orgId=${encodeURIComponent(ORG_SLUG)}`;
|
|
114
|
+
const body = {
|
|
115
|
+
orgId: ORG_SLUG,
|
|
116
|
+
siteId: String(CAMPUS_SITE_ID),
|
|
117
|
+
pageNum,
|
|
118
|
+
pageSize,
|
|
119
|
+
needStat: true,
|
|
120
|
+
};
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await fetch(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
...DEFAULT_HEADERS,
|
|
127
|
+
Accept: "application/json,*/*",
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
Origin: "https://app.mokahr.com",
|
|
130
|
+
Referer: CAMPUS_URL,
|
|
131
|
+
Cookie: cookieHeader,
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok)
|
|
140
|
+
return { ok: false, message: `HTTP ${response.status}` };
|
|
141
|
+
let envelope;
|
|
142
|
+
try {
|
|
143
|
+
envelope = await response.json();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { ok: false, message: "bad JSON from upstream" };
|
|
147
|
+
}
|
|
148
|
+
const decoded = decryptMokaEnvelope(envelope, aesIv);
|
|
149
|
+
if (!decoded || decoded.code !== 0 || !decoded.data) {
|
|
150
|
+
return { ok: false, message: decoded?.msg || envelope?.msg || "decrypt or upstream error" };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
jobs: decoded.data.jobs ?? [],
|
|
155
|
+
total: decoded.data.jobStats?.total ?? 0,
|
|
156
|
+
message: "ok",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function buildCityMap(groups) {
|
|
160
|
+
const out = {};
|
|
161
|
+
if (!groups)
|
|
162
|
+
return out;
|
|
163
|
+
for (const g of groups) {
|
|
164
|
+
if (typeof g.cityId === "number" && g.label)
|
|
165
|
+
out[g.cityId] = g.label;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function workCitiesFor(job, cityMap) {
|
|
170
|
+
const cities = (job.locations ?? [])
|
|
171
|
+
.map((l) => {
|
|
172
|
+
if (typeof l.cityId === "number" && cityMap[l.cityId])
|
|
173
|
+
return cityMap[l.cityId];
|
|
174
|
+
return l.country || "";
|
|
175
|
+
})
|
|
176
|
+
.filter((s) => s.length > 0);
|
|
177
|
+
const uniq = [];
|
|
178
|
+
for (const c of cities)
|
|
179
|
+
if (!uniq.includes(c))
|
|
180
|
+
uniq.push(c);
|
|
181
|
+
return uniq.join(" / ");
|
|
182
|
+
}
|
|
183
|
+
function commitmentFor(job) {
|
|
184
|
+
if (typeof job.commitment === "string" && job.commitment.length > 0)
|
|
185
|
+
return job.commitment;
|
|
186
|
+
if (job.hireMode === 1)
|
|
187
|
+
return "全职";
|
|
188
|
+
if (job.hireMode === 2)
|
|
189
|
+
return "实习";
|
|
190
|
+
return "";
|
|
34
191
|
}
|
|
35
|
-
|
|
36
|
-
return {
|
|
192
|
+
function summarize(job, cityMap) {
|
|
193
|
+
return {
|
|
194
|
+
post_id: String(job.id),
|
|
195
|
+
title: job.title ?? "",
|
|
196
|
+
project: job.zhineng?.name ?? "",
|
|
197
|
+
recruit_label: commitmentFor(job),
|
|
198
|
+
bgs: job.department?.name ?? "",
|
|
199
|
+
work_cities: workCitiesFor(job, cityMap),
|
|
200
|
+
apply_url: `${CAMPUS_URL}#/jobs/${encodeURIComponent(job.id)}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function matchesKeyword(job, kw) {
|
|
204
|
+
if (!kw)
|
|
205
|
+
return true;
|
|
206
|
+
const lc = kw.toLowerCase();
|
|
207
|
+
return ((job.title ?? "").toLowerCase().includes(lc) ||
|
|
208
|
+
(job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
|
|
209
|
+
(job.department?.name ?? "").toLowerCase().includes(lc));
|
|
210
|
+
}
|
|
211
|
+
// ---- searchPositions ----
|
|
212
|
+
export async function searchPositions(opts = {}) {
|
|
213
|
+
const pageSize = opts.pageSize ?? 20;
|
|
214
|
+
const page = opts.page ?? 1;
|
|
215
|
+
const keyword = opts.keyword ?? "";
|
|
216
|
+
const portal = await fetchPortalHtml(CAMPUS_URL);
|
|
217
|
+
if (!portal.ok || !portal.html) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
source: SOURCE,
|
|
221
|
+
message: portal.message,
|
|
222
|
+
query: { keyword, page, pageSize },
|
|
223
|
+
positions: [],
|
|
224
|
+
total: 0,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const init = parseInitData(portal.html);
|
|
228
|
+
if (!init || !init.jobs || !init.jobStats) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
source: SOURCE,
|
|
232
|
+
message: "Moka init-data missing jobs/jobStats",
|
|
233
|
+
query: { keyword, page, pageSize },
|
|
234
|
+
positions: [],
|
|
235
|
+
total: 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
239
|
+
let jobs = init.jobs;
|
|
240
|
+
const total = init.jobStats.total ?? jobs.length;
|
|
241
|
+
if (page > 1 && init.aesIv && portal.cookieHeader) {
|
|
242
|
+
const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader);
|
|
243
|
+
if (!more.ok || !more.jobs) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
source: SOURCE,
|
|
247
|
+
message: `pagination failed: ${more.message}`,
|
|
248
|
+
query: { keyword, page, pageSize },
|
|
249
|
+
positions: [],
|
|
250
|
+
total,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
jobs = more.jobs;
|
|
254
|
+
}
|
|
255
|
+
const filtered = jobs.filter((j) => matchesKeyword(j, keyword));
|
|
256
|
+
const sliced = filtered.slice(0, pageSize);
|
|
257
|
+
const positions = sliced.map((j) => summarize(j, cityMap));
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
source: SOURCE,
|
|
261
|
+
query: { keyword, page, pageSize },
|
|
262
|
+
page,
|
|
263
|
+
page_size: pageSize,
|
|
264
|
+
total,
|
|
265
|
+
positions,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// ---- fetchAllPositions ----
|
|
269
|
+
export async function fetchAllPositions(opts = {}) {
|
|
270
|
+
const pageSize = opts.pageSize ?? 20;
|
|
271
|
+
const maxPages = Math.max(1, opts.maxPages ?? 50);
|
|
272
|
+
const keyword = opts.keyword ?? "";
|
|
273
|
+
const portal = await fetchPortalHtml(CAMPUS_URL);
|
|
274
|
+
if (!portal.ok || !portal.html) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
source: SOURCE,
|
|
278
|
+
message: portal.message,
|
|
279
|
+
total: 0,
|
|
280
|
+
fetched: 0,
|
|
281
|
+
positions: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const init = parseInitData(portal.html);
|
|
285
|
+
if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
source: SOURCE,
|
|
289
|
+
message: "Moka init-data missing required fields",
|
|
290
|
+
total: 0,
|
|
291
|
+
fetched: 0,
|
|
292
|
+
positions: [],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const cityMap = buildCityMap(init.jobsGroupedByLocation);
|
|
296
|
+
const total = init.jobStats.total ?? 0;
|
|
297
|
+
const collected = [...init.jobs];
|
|
298
|
+
let page = 2;
|
|
299
|
+
while (collected.length < total && page <= maxPages) {
|
|
300
|
+
const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader ?? "");
|
|
301
|
+
if (!more.ok || !more.jobs || more.jobs.length === 0)
|
|
302
|
+
break;
|
|
303
|
+
collected.push(...more.jobs);
|
|
304
|
+
page += 1;
|
|
305
|
+
}
|
|
306
|
+
const filtered = collected.filter((j) => matchesKeyword(j, keyword));
|
|
307
|
+
return {
|
|
308
|
+
ok: true,
|
|
309
|
+
source: SOURCE,
|
|
310
|
+
total,
|
|
311
|
+
fetched: filtered.length,
|
|
312
|
+
positions: filtered.map((j) => summarize(j, cityMap)),
|
|
313
|
+
};
|
|
37
314
|
}
|
|
315
|
+
// ---- fetchPositionDetail ----
|
|
38
316
|
export async function fetchPositionDetail(postId) {
|
|
39
|
-
return {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
source: SOURCE,
|
|
320
|
+
message: "Moka detail endpoint requires the same encrypted-session flow; not implemented. " +
|
|
321
|
+
"Use the apply_url deeplink for the full JD.",
|
|
322
|
+
post_id: postId,
|
|
323
|
+
apply_url: `${CAMPUS_URL}#/jobs/${encodeURIComponent(postId)}`,
|
|
324
|
+
};
|
|
40
325
|
}
|
|
326
|
+
// ---- fetchDictionaries ----
|
|
41
327
|
export async function fetchDictionaries() {
|
|
42
|
-
|
|
328
|
+
const portal = await fetchPortalHtml(CAMPUS_URL);
|
|
329
|
+
if (!portal.ok || !portal.html) {
|
|
330
|
+
return { ok: false, source: SOURCE, message: portal.message };
|
|
331
|
+
}
|
|
332
|
+
const init = parseInitData(portal.html);
|
|
333
|
+
if (!init) {
|
|
334
|
+
return { ok: false, source: SOURCE, message: "Moka init-data missing" };
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
source: SOURCE,
|
|
339
|
+
locations: init.jobsGroupedByLocation ?? [],
|
|
340
|
+
moka_org: { slug: ORG_SLUG, id: CAMPUS_SITE_ID, url: CAMPUS_URL },
|
|
341
|
+
};
|
|
43
342
|
}
|
|
343
|
+
// ---- notices (no public endpoint) ----
|
|
344
|
+
const NOTICES_STUB_MSG = "Cambricon (寒武纪): no public notices endpoint on Moka tenant";
|
|
44
345
|
export async function listNotices() {
|
|
45
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
source: SOURCE,
|
|
349
|
+
message: NOTICES_STUB_MSG,
|
|
350
|
+
notices: [],
|
|
351
|
+
};
|
|
46
352
|
}
|
|
47
353
|
export async function getNotice(noticeId) {
|
|
48
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
source: SOURCE,
|
|
357
|
+
message: NOTICES_STUB_MSG,
|
|
358
|
+
notice_id: noticeId,
|
|
359
|
+
};
|
|
49
360
|
}
|
|
50
361
|
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
51
|
-
return {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
source: SOURCE,
|
|
365
|
+
question,
|
|
366
|
+
message: NOTICES_STUB_MSG,
|
|
367
|
+
matches: [],
|
|
368
|
+
};
|
|
52
369
|
}
|
|
53
|
-
|
|
370
|
+
// ---- matchResume ----
|
|
371
|
+
export async function matchResume(text, opts = {}) {
|
|
54
372
|
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
373
|
+
const candidates = Math.max(20, opts.candidates ?? 100);
|
|
374
|
+
const search = await fetchAllPositions({
|
|
375
|
+
pageSize: 20,
|
|
376
|
+
maxPages: Math.ceil(candidates / 15),
|
|
377
|
+
});
|
|
378
|
+
if (!search.ok) {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
source: SOURCE,
|
|
382
|
+
extracted_terms: terms,
|
|
383
|
+
city_preferences: cities,
|
|
384
|
+
matches: [],
|
|
385
|
+
message: search.message,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const topN = Math.max(1, opts.topN ?? 10);
|
|
389
|
+
const scored = search.positions
|
|
390
|
+
.map((p) => ({
|
|
391
|
+
p,
|
|
392
|
+
score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
|
|
393
|
+
}))
|
|
394
|
+
.sort((a, b) => b.score - a.score)
|
|
395
|
+
.slice(0, topN)
|
|
396
|
+
.map((x) => x.p);
|
|
55
397
|
return {
|
|
56
|
-
ok:
|
|
398
|
+
ok: true,
|
|
57
399
|
source: SOURCE,
|
|
58
400
|
extracted_terms: terms,
|
|
59
401
|
city_preferences: cities,
|
|
60
|
-
matches:
|
|
61
|
-
message: STUB_MESSAGE,
|
|
402
|
+
matches: scored,
|
|
62
403
|
};
|
|
63
404
|
}
|
|
64
|
-
export { extractResumeSignals, scoreOverlap };
|
package/dist/horizonrobotics.js
CHANGED
|
@@ -1,94 +1,45 @@
|
|
|
1
|
-
// 地平线 (Horizon Robotics)
|
|
2
|
-
//
|
|
3
|
-
// STATUS: stub-only.
|
|
1
|
+
// 地平线 (Horizon Robotics) careers adapter for `job-pro`.
|
|
4
2
|
//
|
|
5
3
|
// ============================================================
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"Session cookies are only minted by enterprise SSO (phone OTP / WeChat OAuth). " +
|
|
49
|
-
"No unauthenticated public API available.";
|
|
50
|
-
export async function searchPositions(_opts = {}) {
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
source: SOURCE,
|
|
54
|
-
message: STUB_MESSAGE,
|
|
55
|
-
query: {},
|
|
56
|
-
positions: [],
|
|
57
|
-
apply_url: SOCIAL_URL,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
export async function fetchAllPositions(_opts = {}) {
|
|
61
|
-
return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
|
|
62
|
-
}
|
|
63
|
-
export async function fetchPositionDetail(postId) {
|
|
64
|
-
return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
|
|
65
|
-
}
|
|
66
|
-
export async function fetchDictionaries() {
|
|
67
|
-
return {
|
|
68
|
-
ok: false,
|
|
69
|
-
source: SOURCE,
|
|
70
|
-
message: STUB_MESSAGE,
|
|
71
|
-
portals: { social: SOCIAL_URL, campus: CAMPUS_URL },
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
export async function listNotices() {
|
|
75
|
-
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
|
|
76
|
-
}
|
|
77
|
-
export async function getNotice(noticeId) {
|
|
78
|
-
return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
|
|
79
|
-
}
|
|
80
|
-
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
81
|
-
return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
|
|
82
|
-
}
|
|
83
|
-
export async function matchResume(text, _opts = {}) {
|
|
84
|
-
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
85
|
-
return {
|
|
86
|
-
ok: false,
|
|
87
|
-
source: SOURCE,
|
|
88
|
-
extracted_terms: terms,
|
|
89
|
-
city_preferences: cities,
|
|
90
|
-
matches: [],
|
|
91
|
-
message: STUB_MESSAGE,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
export { extractResumeSignals, scoreOverlap };
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
|
|
5
|
+
//
|
|
6
|
+
// Horizon's careers run on `wecruit.hotjob.cn`, the same Beisen Wecruit
|
|
7
|
+
// stack as SenseTime (see cli/src/sensetime.ts). The `/{SU…}/pb/<channel>.html`
|
|
8
|
+
// SPA path returns nginx 405 on any anonymous POST. The real XHR is fired
|
|
9
|
+
// at the sibling `/wecruit/positionInfo/listPosition/{SU…}` route.
|
|
10
|
+
//
|
|
11
|
+
// Channels (probed 2026-05-16):
|
|
12
|
+
// * school — `SU6409ef49bef57c635fd390a6` (校园招聘 / 实习生) ~84 positions
|
|
13
|
+
// * social — `SU64819a4f2f9d2433ba8b043a` (社会招聘) ~216 positions
|
|
14
|
+
//
|
|
15
|
+
// Anonymous, no token, no cookie. See cli/src/wecruit.ts for the shared
|
|
16
|
+
// factory: POST to `/wecruit/positionInfo/listPosition/{channelId}` with
|
|
17
|
+
// `application/x-www-form-urlencoded` body containing
|
|
18
|
+
// `isFrompb=true&recruitType=<1|2>&pageSize=N¤tPage=N`. Response is
|
|
19
|
+
// `{ data:{ pageForm:{ totalPage, pageData[…] } }, state:"200" }`.
|
|
20
|
+
import { createAdapter } from "./wecruit.js";
|
|
21
|
+
const adapter = createAdapter({
|
|
22
|
+
host: "wecruit.hotjob.cn",
|
|
23
|
+
label: "Horizon Robotics",
|
|
24
|
+
channels: [
|
|
25
|
+
{
|
|
26
|
+
channelId: "SU6409ef49bef57c635fd390a6",
|
|
27
|
+
recruitType: "campus",
|
|
28
|
+
pagePath: "school",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
channelId: "SU64819a4f2f9d2433ba8b043a",
|
|
32
|
+
recruitType: "social",
|
|
33
|
+
pagePath: "social",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
export const searchPositions = adapter.searchPositions;
|
|
38
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
39
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
40
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
41
|
+
export const listNotices = adapter.listNotices;
|
|
42
|
+
export const getNotice = adapter.getNotice;
|
|
43
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
44
|
+
export const matchResume = adapter.matchResume;
|
|
45
|
+
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.
|
|
54
|
+
const VERSION = "0.7.2";
|
|
55
55
|
const HELP = `
|
|
56
56
|
job-pro — query Chinese big-tech campus recruiting from your terminal
|
|
57
57
|
(job.ha7ch.com)
|
package/dist/sensetime.js
CHANGED
|
@@ -1,186 +1,50 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 商汤 (SenseTime) careers adapter for `job-pro`.
|
|
2
2
|
//
|
|
3
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// positonNum: N } }
|
|
40
|
-
//
|
|
41
|
-
// POST /positionInfo/listSearchTerm/{channelId}
|
|
42
|
-
// Returns filter taxonomies (work cities, departments, job types)
|
|
43
|
-
//
|
|
44
|
-
// POST /positionInfo/listPositionDetail/{channelId}
|
|
45
|
-
// Payload: { postId: str, recruitType: N }
|
|
46
|
-
// Returns full JD for a single posting
|
|
47
|
-
//
|
|
48
|
-
// POST /positionInfo/UnassignedPostDetail/{channelId}
|
|
49
|
-
// Returns detail for positions with unassigned departments
|
|
50
|
-
//
|
|
51
|
-
// GET /suite/post/search/condition/{channelId}
|
|
52
|
-
// Returns search filter configuration
|
|
53
|
-
//
|
|
54
|
-
// Constructed API base:
|
|
55
|
-
// https://hr.sensetime.com/{channelId}/pb/{apiPath}/{channelId}
|
|
56
|
-
// (the Nginx proxy at /SU.../pb/ maps sub-paths to the backend)
|
|
57
|
-
//
|
|
58
|
-
// ============================================================
|
|
59
|
-
// WHY THIS IS A STUB (unauthenticated access is impossible):
|
|
60
|
-
//
|
|
61
|
-
// Every POST request to the above paths returns HTTP 405 Method Not Allowed,
|
|
62
|
-
// regardless of Origin, Referer, Content-Type, or User-Agent headers.
|
|
63
|
-
// GET requests return the SPA HTML shell (client-side routing catch-all).
|
|
64
|
-
//
|
|
65
|
-
// The Nginx WAF at hr.sensetime.com blocks all unauthenticated POST requests.
|
|
66
|
-
// The API requires a valid session cookie / JWT obtained via:
|
|
67
|
-
// POST /login/ or POST /ssoLogin
|
|
68
|
-
// These are enterprise SSO flows (phone OTP, WeChat OAuth, or SAML enterprise SSO)
|
|
69
|
-
// that cannot be automated without a real account.
|
|
70
|
-
//
|
|
71
|
-
// This is fundamentally different from ByteDance/Tencent/Feishu portals, which
|
|
72
|
-
// allow anonymous POST to their search endpoints without any session cookie.
|
|
73
|
-
//
|
|
74
|
-
// Recommendation: Monitor for:
|
|
75
|
-
// (a) A future public campus API at campus.sensetime.com
|
|
76
|
-
// (b) A Feishu Recruiting migration (SenseTime does use Feishu internally)
|
|
77
|
-
// (c) Third-party job boards (牛客, 实习僧) that scrape SenseTime listings
|
|
78
|
-
//
|
|
79
|
-
// ============================================================
|
|
80
|
-
// STUB CONTRACT: All functions return ok:false with STUB_MESSAGE.
|
|
81
|
-
// checkResume is re-exported from tencent.ts (works offline on resume text).
|
|
82
|
-
// When/if SenseTime opens a public API, rewrite this file — the export shape
|
|
83
|
-
// is already locked in by the PositionSummary interface below.
|
|
84
|
-
import { extractResumeSignals, checkResume } from "./tencent.js";
|
|
85
|
-
export { checkResume };
|
|
86
|
-
const SOURCE = "hr.sensetime.com";
|
|
87
|
-
const CAMPUS_URL = "https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/school.html";
|
|
88
|
-
const STUB_MESSAGE = "SenseTime (商汤): no public job API — hr.sensetime.com POSTs are blocked by WAF (HTTP 405) " +
|
|
89
|
-
"without a valid session cookie; campus.sensetime.com and careers.sensetime.com are " +
|
|
90
|
-
"geo-blocked (SSL failure). The HRIS platform (PB/PushB, channel SU6710d7c21c240e54e1f82a1b) " +
|
|
91
|
-
"requires enterprise SSO (phone OTP / WeChat OAuth). " +
|
|
92
|
-
"Documented in cli/src/sensetime.ts header.";
|
|
93
|
-
// ---- searchPositions ----
|
|
94
|
-
export async function searchPositions(_opts = {}) {
|
|
95
|
-
return {
|
|
96
|
-
ok: false,
|
|
97
|
-
source: SOURCE,
|
|
98
|
-
message: STUB_MESSAGE,
|
|
99
|
-
// Expose the discovered endpoint so callers can see what we would have hit
|
|
100
|
-
endpoint: `POST https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/positionInfo/listPosition/SU6710d7c21c240e54e1f82a1b`,
|
|
101
|
-
query: {
|
|
102
|
-
isFrompb: true,
|
|
103
|
-
recruitType: _opts.recruitType ?? 1,
|
|
104
|
-
pageSize: _opts.pageSize ?? 20,
|
|
105
|
-
currentPage: _opts.page ?? 1,
|
|
106
|
-
...(_opts.keyword ? { postKey: _opts.keyword } : {}),
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
|
|
5
|
+
//
|
|
6
|
+
// hr.sensetime.com hosts a Beisen Wecruit (北森招聘云) tenant. The published
|
|
7
|
+
// SPA bundles at `/SU…/pb/<channel>.html` ALWAYS return nginx 405 on
|
|
8
|
+
// anonymous POST, regardless of headers; that path is GET-only at the LB.
|
|
9
|
+
//
|
|
10
|
+
// The SPA's real XHR target (uncovered by intercepting page traffic in a
|
|
11
|
+
// headless Chrome instance) is on a sibling `/wecruit/...` prefix:
|
|
12
|
+
//
|
|
13
|
+
// POST https://hr.sensetime.com/wecruit/positionInfo/listPosition/<SU…>
|
|
14
|
+
// ?iSaJAx=isAjax&request_locale=zh_CN&t=<unix-ms>
|
|
15
|
+
//
|
|
16
|
+
// Content-Type: application/x-www-form-urlencoded (NOT JSON)
|
|
17
|
+
// Body: isFrompb=true&recruitType=2&pageSize=15¤tPage=1
|
|
18
|
+
//
|
|
19
|
+
// Anonymous, no token, no cookie, no captcha. Probed 2026-05-16: the
|
|
20
|
+
// social channel `SU60fa3bdabef57c1023fc1cbc` returns ~89 pages × 12 ≈
|
|
21
|
+
// 1068 active social-hire positions across SenseTime and its subsidiaries.
|
|
22
|
+
//
|
|
23
|
+
// hr.sensetime.com root redirects to the social channel (302); the campus
|
|
24
|
+
// SU referenced in earlier reconnaissance notes (`SU6710d7c21c240e54e1f82a1b`)
|
|
25
|
+
// has been reassigned to a different tenant ("安徽新华发行集团" appears in
|
|
26
|
+
// its responses), so we only wire the social channel. If SenseTime
|
|
27
|
+
// rebroadcasts a campus channel later, add it to the `channels` array.
|
|
28
|
+
//
|
|
29
|
+
// See cli/src/wecruit.ts for the shared factory.
|
|
30
|
+
import { createAdapter } from "./wecruit.js";
|
|
31
|
+
const adapter = createAdapter({
|
|
32
|
+
host: "hr.sensetime.com",
|
|
33
|
+
label: "SenseTime",
|
|
34
|
+
channels: [
|
|
35
|
+
{
|
|
36
|
+
channelId: "SU60fa3bdabef57c1023fc1cbc",
|
|
37
|
+
recruitType: "social",
|
|
38
|
+
pagePath: "social",
|
|
107
39
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
export
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
fetched: 0,
|
|
120
|
-
positions: [],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
// ---- fetchPositionDetail ----
|
|
124
|
-
export async function fetchPositionDetail(postId) {
|
|
125
|
-
return {
|
|
126
|
-
ok: false,
|
|
127
|
-
source: SOURCE,
|
|
128
|
-
message: STUB_MESSAGE,
|
|
129
|
-
post_id: postId,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// ---- fetchDictionaries ----
|
|
133
|
-
//
|
|
134
|
-
// When accessible, POST /positionInfo/listSearchTerm/{channelId} returns:
|
|
135
|
-
// { state: "200", data: { projectList, provinceList, orgList, postTypeList, salaryList } }
|
|
136
|
-
export async function fetchDictionaries() {
|
|
137
|
-
return {
|
|
138
|
-
ok: false,
|
|
139
|
-
source: SOURCE,
|
|
140
|
-
message: STUB_MESSAGE,
|
|
141
|
-
note: "When API becomes accessible: POST /positionInfo/listSearchTerm/{channelId}",
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
// ---- notices (no public endpoint) ----
|
|
145
|
-
export async function listNotices() {
|
|
146
|
-
return {
|
|
147
|
-
ok: false,
|
|
148
|
-
source: SOURCE,
|
|
149
|
-
message: "SenseTime: no public notices endpoint",
|
|
150
|
-
notices: [],
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
export async function getNotice(noticeId) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
source: SOURCE,
|
|
157
|
-
message: "SenseTime: no public notices endpoint",
|
|
158
|
-
notice_id: noticeId,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
export async function findNoticesByQuestion(question, _opts = {}) {
|
|
162
|
-
return {
|
|
163
|
-
ok: false,
|
|
164
|
-
source: SOURCE,
|
|
165
|
-
question,
|
|
166
|
-
message: "SenseTime: no public notices endpoint",
|
|
167
|
-
matches: [],
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
// ---- matchResume ----
|
|
171
|
-
//
|
|
172
|
-
// Because the position search API is inaccessible, we cannot retrieve live listings
|
|
173
|
-
// to score against the resume. Return ok:false with the extracted signals so the
|
|
174
|
-
// caller can display what terms were parsed (useful for debugging the resume text).
|
|
175
|
-
export async function matchResume(text, _opts = {}) {
|
|
176
|
-
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
source: SOURCE,
|
|
180
|
-
extracted_terms: terms,
|
|
181
|
-
city_preferences: cities,
|
|
182
|
-
matches: [],
|
|
183
|
-
message: STUB_MESSAGE,
|
|
184
|
-
apply_url: CAMPUS_URL,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
export const searchPositions = adapter.searchPositions;
|
|
43
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
44
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
45
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
46
|
+
export const listNotices = adapter.listNotices;
|
|
47
|
+
export const getNotice = adapter.getNotice;
|
|
48
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
49
|
+
export const matchResume = adapter.matchResume;
|
|
50
|
+
export const checkResume = adapter.checkResume;
|
package/dist/wecruit.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// Generic Beisen Wecruit (北森 招聘云) adapter factory.
|
|
2
|
+
//
|
|
3
|
+
// Beisen Wecruit is one of two Beisen recruitment products we hit:
|
|
4
|
+
// * Beisen iTalent — hosted on `<tenant>.zhiye.com` (covered by vivo.ts /
|
|
5
|
+
// iflytek.ts / oppo.ts; envelope { Code, Data, Count }).
|
|
6
|
+
// * Beisen Wecruit — multi-tenant on `wecruit.hotjob.cn` and customer-owned
|
|
7
|
+
// hosts like `hr.sensetime.com`, `careers.<co>.com`.
|
|
8
|
+
// This module.
|
|
9
|
+
//
|
|
10
|
+
// Wecruit's distinguishing path is `/wecruit/...` at the host root. The
|
|
11
|
+
// public SPA bundles at `/{SU…}/pb/<channel>.html` are red herrings —
|
|
12
|
+
// every POST to that prefix returns nginx `405 Not Allowed`. The actual
|
|
13
|
+
// XHR the SPA fires is:
|
|
14
|
+
//
|
|
15
|
+
// POST https://<host>/wecruit/positionInfo/listPosition/{SU…}
|
|
16
|
+
// ?iSaJAx=isAjax&request_locale=zh_CN&t=<unix-ms>
|
|
17
|
+
//
|
|
18
|
+
// Content-Type: application/x-www-form-urlencoded
|
|
19
|
+
// Body: isFrompb=true&recruitType=<1|2>&pageSize=15¤tPage=1
|
|
20
|
+
//
|
|
21
|
+
// (Yes, form-urlencoded — not JSON — even though the response is JSON.)
|
|
22
|
+
//
|
|
23
|
+
// Response envelope:
|
|
24
|
+
// { data:{ pageForm:{ totalPage, pageSize, pageData:[…], currentPage,
|
|
25
|
+
// dataCount }, positonNum },
|
|
26
|
+
// state:"200", type:"success" }
|
|
27
|
+
//
|
|
28
|
+
// recruitType encoding: 1 = 校园 (campus / 应届 / 实习), 2 = 社招 (experienced).
|
|
29
|
+
// Each tenant has separate `SU…` channel ids per recruit type. See:
|
|
30
|
+
// * `sensetime.ts` — social `SU60fa3bdabef57c1023fc1cbc`
|
|
31
|
+
// * `horizonrobotics.ts` — school `SU6409ef49bef57c635fd390a6`,
|
|
32
|
+
// social `SU64819a4f2f9d2433ba8b043a`
|
|
33
|
+
//
|
|
34
|
+
// Probed 2026-05-16. Apply URL deep-links to the SPA detail route at
|
|
35
|
+
// `/{SU…}/pb/<channel>.html#/postDetail?postId=<postId>`.
|
|
36
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
37
|
+
export { checkResume };
|
|
38
|
+
// ---------- factory ----------
|
|
39
|
+
export function createAdapter(cfg) {
|
|
40
|
+
const SOURCE = cfg.host;
|
|
41
|
+
const SITE_ROOT = `https://${cfg.host}`;
|
|
42
|
+
const detailUrl = (channelId, pagePath, postId) => `${SITE_ROOT}/${encodeURIComponent(channelId)}/pb/${encodeURIComponent(pagePath)}.html#/postDetail?postId=${encodeURIComponent(postId)}`;
|
|
43
|
+
const HEADERS = (channelId, pagePath) => ({
|
|
44
|
+
"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",
|
|
45
|
+
Accept: "application/json, text/plain, */*",
|
|
46
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
47
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
48
|
+
Origin: SITE_ROOT,
|
|
49
|
+
Referer: `${SITE_ROOT}/${channelId}/pb/${pagePath}.html`,
|
|
50
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
51
|
+
});
|
|
52
|
+
function urlEncode(form) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (const [k, v] of Object.entries(form)) {
|
|
55
|
+
if (v === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
58
|
+
}
|
|
59
|
+
return parts.join("&");
|
|
60
|
+
}
|
|
61
|
+
async function postChannel(channel, pageNum, pageSize, keyword) {
|
|
62
|
+
const ts = Date.now();
|
|
63
|
+
const url = `${SITE_ROOT}/wecruit/positionInfo/listPosition/${channel.channelId}?iSaJAx=isAjax&request_locale=zh_CN&t=${ts}`;
|
|
64
|
+
const recruitType = channel.recruitType === "social" ? 2 : 1;
|
|
65
|
+
const form = {
|
|
66
|
+
isFrompb: true,
|
|
67
|
+
recruitType,
|
|
68
|
+
pageSize,
|
|
69
|
+
currentPage: pageNum,
|
|
70
|
+
};
|
|
71
|
+
if (keyword)
|
|
72
|
+
form.postName = keyword;
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(url, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: HEADERS(channel.channelId, channel.pagePath),
|
|
78
|
+
body: urlEncode(form),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return { ok: false, message: `network error: ${err instanceof Error ? err.message : String(err)}` };
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
86
|
+
}
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = (await response.json());
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
|
|
93
|
+
}
|
|
94
|
+
if (payload.state !== "200" || !payload.data) {
|
|
95
|
+
return { ok: false, message: payload.msg ?? `upstream state=${payload.state}` };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, pageForm: payload.data.pageForm, message: "ok" };
|
|
98
|
+
}
|
|
99
|
+
function summarize(item, channel) {
|
|
100
|
+
const id = String(item.postId ?? "");
|
|
101
|
+
const labelFromRecruitType = item.recruitmentType ?? (item.recruitType === 2 ? "社招" : item.recruitType === 1 ? "校园" : "");
|
|
102
|
+
return {
|
|
103
|
+
post_id: id,
|
|
104
|
+
title: (item.postName ?? "").trim(),
|
|
105
|
+
project: (item.postTypeName ?? "").trim(),
|
|
106
|
+
recruit_label: labelFromRecruitType,
|
|
107
|
+
bgs: (item.department ?? item.company ?? "").trim(),
|
|
108
|
+
work_cities: (item.workPlaceStr ?? "").trim(),
|
|
109
|
+
apply_url: id ? detailUrl(channel.channelId, channel.pagePath, id) : `${SITE_ROOT}/${channel.channelId}/pb/${channel.pagePath}.html`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function channelsForType(t) {
|
|
113
|
+
if (!t || t === "all")
|
|
114
|
+
return cfg.channels;
|
|
115
|
+
return cfg.channels.filter((c) => c.recruitType === t);
|
|
116
|
+
}
|
|
117
|
+
async function searchPositions(opts = {}) {
|
|
118
|
+
const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 15));
|
|
119
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
120
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
121
|
+
const channels = channelsForType(opts.recruitType);
|
|
122
|
+
if (!channels.length) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
source: SOURCE,
|
|
126
|
+
message: `no channels match recruitType=${opts.recruitType ?? "all"}`,
|
|
127
|
+
query: opts,
|
|
128
|
+
positions: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// For single-channel adapters this is one call. For multi-channel
|
|
132
|
+
// (campus+social) we round-robin: we ask each channel for the same
|
|
133
|
+
// page index and merge the resulting positions. Total reflects the
|
|
134
|
+
// sum across channels.
|
|
135
|
+
const positions = [];
|
|
136
|
+
let total = 0;
|
|
137
|
+
let lastMsg = "ok";
|
|
138
|
+
let anyOk = false;
|
|
139
|
+
for (const ch of channels) {
|
|
140
|
+
const r = await postChannel(ch, page, pageSize, keyword);
|
|
141
|
+
if (!r.ok || !r.pageForm) {
|
|
142
|
+
lastMsg = r.message;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
anyOk = true;
|
|
146
|
+
total += (r.pageForm.dataCount ?? 0) || (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0);
|
|
147
|
+
for (const p of r.pageForm.pageData ?? [])
|
|
148
|
+
positions.push(summarize(p, ch));
|
|
149
|
+
}
|
|
150
|
+
if (!anyOk) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
source: SOURCE,
|
|
154
|
+
message: lastMsg,
|
|
155
|
+
query: opts,
|
|
156
|
+
positions,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
source: SOURCE,
|
|
162
|
+
query: opts,
|
|
163
|
+
page,
|
|
164
|
+
page_size: pageSize,
|
|
165
|
+
total,
|
|
166
|
+
positions,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function fetchAllPositions(opts = {}) {
|
|
170
|
+
const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 15));
|
|
171
|
+
const maxPages = Math.max(1, opts.maxPages ?? 30);
|
|
172
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
173
|
+
const channels = channelsForType(opts.recruitType);
|
|
174
|
+
const bucket = [];
|
|
175
|
+
let total = 0;
|
|
176
|
+
let lastMsg = "ok";
|
|
177
|
+
let anyOk = false;
|
|
178
|
+
for (const ch of channels) {
|
|
179
|
+
let chTotal;
|
|
180
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
181
|
+
const r = await postChannel(ch, page, pageSize, keyword);
|
|
182
|
+
if (!r.ok || !r.pageForm) {
|
|
183
|
+
lastMsg = r.message;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
anyOk = true;
|
|
187
|
+
if (chTotal === undefined) {
|
|
188
|
+
chTotal = (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0) || (r.pageForm.dataCount ?? 0);
|
|
189
|
+
total += chTotal;
|
|
190
|
+
}
|
|
191
|
+
const data = r.pageForm.pageData ?? [];
|
|
192
|
+
if (!data.length)
|
|
193
|
+
break;
|
|
194
|
+
for (const p of data)
|
|
195
|
+
bucket.push(summarize(p, ch));
|
|
196
|
+
if (page >= (r.pageForm.totalPage ?? 0))
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!anyOk) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
source: SOURCE,
|
|
204
|
+
message: lastMsg,
|
|
205
|
+
total: 0,
|
|
206
|
+
fetched: bucket.length,
|
|
207
|
+
positions: bucket,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
source: SOURCE,
|
|
213
|
+
total,
|
|
214
|
+
fetched: bucket.length,
|
|
215
|
+
positions: bucket,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function fetchPositionDetail(postId) {
|
|
219
|
+
const id = (postId ?? "").trim();
|
|
220
|
+
if (!id)
|
|
221
|
+
return { ok: false, source: SOURCE, message: "post_id is required" };
|
|
222
|
+
// Wecruit's listPosition includes description-light fields only.
|
|
223
|
+
// We scan pages until we find the post.
|
|
224
|
+
const pageSize = 50;
|
|
225
|
+
const maxPages = 20;
|
|
226
|
+
for (const ch of cfg.channels) {
|
|
227
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
228
|
+
const r = await postChannel(ch, page, pageSize, "");
|
|
229
|
+
if (!r.ok || !r.pageForm)
|
|
230
|
+
break;
|
|
231
|
+
const found = (r.pageForm.pageData ?? []).find((p) => p.postId === id);
|
|
232
|
+
if (found) {
|
|
233
|
+
const summary = summarize(found, ch);
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
source: SOURCE,
|
|
237
|
+
post_id: id,
|
|
238
|
+
title: found.postName ?? "",
|
|
239
|
+
project: summary.project,
|
|
240
|
+
recruit_label: summary.recruit_label,
|
|
241
|
+
company: found.company ?? "",
|
|
242
|
+
department: found.department ?? "",
|
|
243
|
+
work_cities: found.workPlaceStr ?? "",
|
|
244
|
+
recruit_num: found.recruitNumStr ?? "",
|
|
245
|
+
page_views: found.pageViews ?? 0,
|
|
246
|
+
publish_date: found.publishDate ?? found.publishFirstDate ?? "",
|
|
247
|
+
end_date: found.endDate ?? "",
|
|
248
|
+
apply_url: summary.apply_url,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (page >= (r.pageForm.totalPage ?? 0))
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
source: SOURCE,
|
|
258
|
+
post_id: id,
|
|
259
|
+
message: `post ${id} not found across ${cfg.channels.length} channel(s)`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// ---------- fetchDictionaries ----------
|
|
263
|
+
// Synthesize from one page per channel (postTypeName, workPlaceStr, etc.).
|
|
264
|
+
let _dictCache = null;
|
|
265
|
+
async function fetchDictionaries() {
|
|
266
|
+
if (_dictCache !== null)
|
|
267
|
+
return _dictCache;
|
|
268
|
+
const types = new Set();
|
|
269
|
+
const cities = new Set();
|
|
270
|
+
const companies = new Set();
|
|
271
|
+
const channelInfo = [];
|
|
272
|
+
let anyOk = false;
|
|
273
|
+
let lastMsg = "ok";
|
|
274
|
+
for (const ch of cfg.channels) {
|
|
275
|
+
const r = await postChannel(ch, 1, 50, "");
|
|
276
|
+
if (!r.ok || !r.pageForm) {
|
|
277
|
+
lastMsg = r.message;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
anyOk = true;
|
|
281
|
+
const total = (r.pageForm.totalPage ?? 0) * (r.pageForm.pageSize ?? 0) || (r.pageForm.dataCount ?? 0);
|
|
282
|
+
channelInfo.push({
|
|
283
|
+
channelId: ch.channelId,
|
|
284
|
+
recruitType: ch.recruitType,
|
|
285
|
+
pagePath: ch.pagePath,
|
|
286
|
+
total,
|
|
287
|
+
});
|
|
288
|
+
for (const p of r.pageForm.pageData ?? []) {
|
|
289
|
+
if (p.postTypeName)
|
|
290
|
+
types.add(p.postTypeName);
|
|
291
|
+
if (p.workPlaceStr)
|
|
292
|
+
cities.add(p.workPlaceStr);
|
|
293
|
+
if (p.company)
|
|
294
|
+
companies.add(p.company);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!anyOk) {
|
|
298
|
+
const r = { ok: false, source: SOURCE, message: lastMsg };
|
|
299
|
+
_dictCache = r;
|
|
300
|
+
return r;
|
|
301
|
+
}
|
|
302
|
+
const result = {
|
|
303
|
+
ok: true,
|
|
304
|
+
source: SOURCE,
|
|
305
|
+
channels: channelInfo,
|
|
306
|
+
post_types: [...types].sort(),
|
|
307
|
+
cities: [...cities].sort(),
|
|
308
|
+
companies: [...companies].sort(),
|
|
309
|
+
};
|
|
310
|
+
_dictCache = result;
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
// ---------- notices (stub) ----------
|
|
314
|
+
const NOTICES_STUB = {
|
|
315
|
+
ok: false,
|
|
316
|
+
source: SOURCE,
|
|
317
|
+
message: `${cfg.label}: Wecruit tenants have no public notices endpoint`,
|
|
318
|
+
};
|
|
319
|
+
async function listNotices() {
|
|
320
|
+
return { ...NOTICES_STUB, notices: [] };
|
|
321
|
+
}
|
|
322
|
+
async function getNotice(noticeId) {
|
|
323
|
+
return { ...NOTICES_STUB, notice_id: noticeId };
|
|
324
|
+
}
|
|
325
|
+
async function findNoticesByQuestion(question, _opts = {}) {
|
|
326
|
+
return { ...NOTICES_STUB, question, matches: [] };
|
|
327
|
+
}
|
|
328
|
+
// ---------- matchResume ----------
|
|
329
|
+
async function matchResume(text, opts = {}) {
|
|
330
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
331
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
332
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
333
|
+
if (!terms.length) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
source: SOURCE,
|
|
337
|
+
message: "could not extract any technical signals from the text",
|
|
338
|
+
preview: (text ?? "").slice(0, 120),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
342
|
+
const list = await searchPositions({ keyword, page: 1, pageSize: 50 });
|
|
343
|
+
if (!list.ok) {
|
|
344
|
+
return { ok: false, source: SOURCE, message: list.message, positions: [] };
|
|
345
|
+
}
|
|
346
|
+
const scored = [];
|
|
347
|
+
for (const p of list.positions) {
|
|
348
|
+
const blob = [p.title, p.project, p.recruit_label, p.work_cities, p.bgs].join(" ");
|
|
349
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
350
|
+
if (score > 0)
|
|
351
|
+
scored.push({ score, position: p, reasons });
|
|
352
|
+
}
|
|
353
|
+
scored.sort((a, b) => b.score - a.score);
|
|
354
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
355
|
+
if (!shortlist.length) {
|
|
356
|
+
shortlist = list.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
|
|
357
|
+
}
|
|
358
|
+
const matches = shortlist.slice(0, topN).map((s) => {
|
|
359
|
+
const mr = s.reasons.length > 0
|
|
360
|
+
? s.reasons.slice(0, 5)
|
|
361
|
+
: ["no specific keyword overlap — surfaced from initial keyword search"];
|
|
362
|
+
return { ...s.position, match_reasons: mr };
|
|
363
|
+
});
|
|
364
|
+
return {
|
|
365
|
+
ok: true,
|
|
366
|
+
source: SOURCE,
|
|
367
|
+
extracted_terms: terms,
|
|
368
|
+
city_preferences: cities,
|
|
369
|
+
matches,
|
|
370
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
371
|
+
"The only authority on selection is HR.",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
searchPositions,
|
|
376
|
+
fetchAllPositions,
|
|
377
|
+
fetchPositionDetail,
|
|
378
|
+
fetchDictionaries,
|
|
379
|
+
listNotices,
|
|
380
|
+
getNotice,
|
|
381
|
+
findNoticesByQuestion,
|
|
382
|
+
matchResume,
|
|
383
|
+
checkResume,
|
|
384
|
+
};
|
|
385
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-pro",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies,
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 43 live: Tencent, ByteDance, Alibaba, Meituan, Xiaohongshu, JD, Kuaishou, Xiaomi, Baidu, NetEase, Didi, Bilibili, PDD, NIO, MiniMax, Huawei, Weibo, miHoYo, Ping An, SenseTime, Trip.com, Unitree, BYD, Li Auto, Moonshot, Zhipu, iQIYI, Megvii, Agibot, DeepSeek, 01.AI, Galaxy Universal, StepFun, Baichuan, XPeng, WeRide, HoYoverse, iFlytek, OPPO, vivo, SF Express, Horizon Robotics, Cambricon. 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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^20",
|
|
35
|
+
"puppeteer-core": "^25.0.2",
|
|
35
36
|
"tsx": "^4",
|
|
36
37
|
"typescript": "^5"
|
|
37
38
|
},
|