job-pro 0.4.0 → 0.5.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/feishu.js +446 -0
- package/dist/huawei.js +506 -0
- package/dist/index.js +22 -1
- package/dist/mihoyo.js +100 -0
- package/dist/minimax.js +32 -0
- package/dist/nio.js +24 -0
- package/dist/pdd.js +434 -0
- package/dist/pingan.js +462 -0
- package/dist/weibo.js +135 -0
- package/package.json +2 -2
package/dist/feishu.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// Generic Feishu Recruiting (ATSX) adapter factory.
|
|
2
|
+
//
|
|
3
|
+
// Feishu Recruiting (飞书招聘) is ByteDance's SaaS ATS platform. Multiple companies
|
|
4
|
+
// self-host it at dedicated subdomains:
|
|
5
|
+
//
|
|
6
|
+
// *.jobs.feishu.cn — standard Feishu subdomains (NIO, etc.)
|
|
7
|
+
// *.jobs.f.mioffice.cn — Xiaomi fork (not this adapter)
|
|
8
|
+
// {tenant}.jobs.feishu.cn/{companyId}/ — multi-tenant portals (MiniMax)
|
|
9
|
+
//
|
|
10
|
+
// API surface (identical across all hosts, verified 2026-05):
|
|
11
|
+
// POST https://<host>/api/v1/search/job/posts
|
|
12
|
+
// GET https://<host>/api/v1/config/job/filters/<channel>
|
|
13
|
+
//
|
|
14
|
+
// Portal scoping is controlled by two required headers:
|
|
15
|
+
// portal-channel: the channel slug ("campus", "internship", or company-path like "379481")
|
|
16
|
+
// website-path: same value as portal-channel
|
|
17
|
+
//
|
|
18
|
+
// For NIO (nio.jobs.feishu.cn):
|
|
19
|
+
// host = "nio.jobs.feishu.cn"
|
|
20
|
+
// channel = "campus"
|
|
21
|
+
// apply_url prefix = "https://nio.jobs.feishu.cn/campus/position"
|
|
22
|
+
//
|
|
23
|
+
// For MiniMax (vrfi1sk8a0.jobs.feishu.cn / company path 379481):
|
|
24
|
+
// host = "vrfi1sk8a0.jobs.feishu.cn"
|
|
25
|
+
// channel = "379481" ← company PATH is the portal-channel!
|
|
26
|
+
// apply_url prefix = "https://vrfi1sk8a0.jobs.feishu.cn/379481/position"
|
|
27
|
+
//
|
|
28
|
+
// ---- PositionSummary field mapping (Feishu → canonical) ----
|
|
29
|
+
// post_id ← String(item.id)
|
|
30
|
+
// title ← item.title
|
|
31
|
+
// project ← item.job_category.name (or job_function.name if category null)
|
|
32
|
+
// recruit_label ← item.recruit_type.name
|
|
33
|
+
// bgs ← "" (not exposed in public search)
|
|
34
|
+
// work_cities ← city_list joined " / " (city_info used as fallback)
|
|
35
|
+
// apply_url ← `${applyUrlPrefix}/${id}/detail`
|
|
36
|
+
//
|
|
37
|
+
// ---- Discovery notes (2026-05) ----
|
|
38
|
+
// - "site not exist" (-9000003) → wrong portal-channel header
|
|
39
|
+
// - 400 empty body → tenant subdomain not configured on Feishu backend
|
|
40
|
+
// - NIO: job_category is null; project comes from job_function.name
|
|
41
|
+
// - MiniMax: job_function is null; project comes from job_category.name
|
|
42
|
+
// - Both: city_info is null; city_list always populated
|
|
43
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
44
|
+
export { checkResume };
|
|
45
|
+
// ---------- createAdapter ----------
|
|
46
|
+
export function createAdapter(cfg) {
|
|
47
|
+
const API_ROOT = `https://${cfg.host}/api/v1`;
|
|
48
|
+
const source = cfg.host;
|
|
49
|
+
function makeHeaders() {
|
|
50
|
+
return {
|
|
51
|
+
"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",
|
|
52
|
+
Accept: "application/json, text/plain, */*",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"portal-channel": cfg.channel,
|
|
55
|
+
"portal-platform": "pc",
|
|
56
|
+
"website-path": cfg.channel,
|
|
57
|
+
Referer: `https://${cfg.host}/${cfg.channel}/position`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function call(path, body) {
|
|
61
|
+
const url = `${API_ROOT}${path}`;
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: makeHeaders(),
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
78
|
+
}
|
|
79
|
+
let payload;
|
|
80
|
+
try {
|
|
81
|
+
payload = (await response.json());
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: payload.code === 0,
|
|
88
|
+
data: payload.data,
|
|
89
|
+
message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function summarizePosition(item) {
|
|
93
|
+
const id = String(item.id ?? "");
|
|
94
|
+
const cityList = item.city_list ?? [];
|
|
95
|
+
let work_cities;
|
|
96
|
+
if (cityList.length > 1) {
|
|
97
|
+
work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
work_cities = cityList[0]?.name ?? item.city_info?.name ?? "";
|
|
101
|
+
}
|
|
102
|
+
// NIO: job_category null, job_function has the name.
|
|
103
|
+
// MiniMax: job_function null, job_category has the name.
|
|
104
|
+
const project = item.job_category?.name ??
|
|
105
|
+
item.job_function?.name ??
|
|
106
|
+
"";
|
|
107
|
+
return {
|
|
108
|
+
post_id: id,
|
|
109
|
+
title: item.title ?? "",
|
|
110
|
+
project,
|
|
111
|
+
recruit_label: item.recruit_type?.name ?? "",
|
|
112
|
+
bgs: "",
|
|
113
|
+
work_cities,
|
|
114
|
+
apply_url: id ? `${cfg.applyUrlPrefix}/${encodeURIComponent(id)}/detail` : `https://${cfg.host}/${cfg.channel}/position`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const asStringList = (v) => {
|
|
118
|
+
if (v === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
121
|
+
return arr.map(String);
|
|
122
|
+
};
|
|
123
|
+
// ---------- searchPositions ----------
|
|
124
|
+
async function searchPositions(opts = {}) {
|
|
125
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
|
|
126
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
127
|
+
const offset = (page - 1) * pageSize;
|
|
128
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
129
|
+
const payload = {
|
|
130
|
+
keyword,
|
|
131
|
+
limit: pageSize,
|
|
132
|
+
offset,
|
|
133
|
+
portal_type: 3,
|
|
134
|
+
portal_entrance: 1,
|
|
135
|
+
language: "zh",
|
|
136
|
+
};
|
|
137
|
+
const recruitmentIdList = asStringList(opts.recruitmentIdList);
|
|
138
|
+
if (recruitmentIdList !== undefined && recruitmentIdList.length > 0) {
|
|
139
|
+
payload.recruitment_id_list = recruitmentIdList;
|
|
140
|
+
}
|
|
141
|
+
const jobCategoryIdList = asStringList(opts.jobCategoryIdList);
|
|
142
|
+
if (jobCategoryIdList?.length) {
|
|
143
|
+
payload.job_category_id_list = jobCategoryIdList;
|
|
144
|
+
}
|
|
145
|
+
const cityIdList = asStringList(opts.cityIdList);
|
|
146
|
+
if (cityIdList?.length) {
|
|
147
|
+
payload.location_code_list = cityIdList;
|
|
148
|
+
}
|
|
149
|
+
const subjectIdList = asStringList(opts.subjectIdList);
|
|
150
|
+
if (subjectIdList?.length) {
|
|
151
|
+
payload.subject_id_list = subjectIdList;
|
|
152
|
+
}
|
|
153
|
+
const response = await call("/search/job/posts", payload);
|
|
154
|
+
if (!response.ok || !response.data) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
message: response.message,
|
|
158
|
+
source,
|
|
159
|
+
query: payload,
|
|
160
|
+
positions: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const rows = response.data.job_post_list ?? [];
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
source,
|
|
167
|
+
query: payload,
|
|
168
|
+
page,
|
|
169
|
+
page_size: pageSize,
|
|
170
|
+
total: response.data.count ?? rows.length,
|
|
171
|
+
positions: rows.map(summarizePosition),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// ---------- fetchAllPositions ----------
|
|
175
|
+
async function fetchAllPositions(opts = {}) {
|
|
176
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
|
|
177
|
+
const maxPages = Math.max(1, opts.maxPages ?? 5);
|
|
178
|
+
const bucket = [];
|
|
179
|
+
let total;
|
|
180
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
181
|
+
const result = await searchPositions({ ...opts, page, pageSize });
|
|
182
|
+
if (!result.ok) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
message: result.message,
|
|
186
|
+
source,
|
|
187
|
+
fetched: bucket.length,
|
|
188
|
+
positions: bucket,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (total === undefined)
|
|
192
|
+
total = result.total;
|
|
193
|
+
if (!result.positions.length)
|
|
194
|
+
break;
|
|
195
|
+
bucket.push(...result.positions);
|
|
196
|
+
if (total !== undefined && bucket.length >= total)
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
source,
|
|
202
|
+
total: total ?? bucket.length,
|
|
203
|
+
fetched: bucket.length,
|
|
204
|
+
positions: bucket,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// ---------- fetchPositionDetail ----------
|
|
208
|
+
// Feishu has no public per-post detail REST endpoint.
|
|
209
|
+
// Paginate search and filter by id.
|
|
210
|
+
async function fetchPositionDetail(postId) {
|
|
211
|
+
const id = (postId ?? "").trim();
|
|
212
|
+
if (!id)
|
|
213
|
+
return { ok: false, source, message: "post_id is required" };
|
|
214
|
+
const pageSize = 100;
|
|
215
|
+
const maxPages = 5;
|
|
216
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
217
|
+
const offset = (page - 1) * pageSize;
|
|
218
|
+
const payload = {
|
|
219
|
+
keyword: "",
|
|
220
|
+
limit: pageSize,
|
|
221
|
+
offset,
|
|
222
|
+
portal_type: 3,
|
|
223
|
+
portal_entrance: 1,
|
|
224
|
+
language: "zh",
|
|
225
|
+
};
|
|
226
|
+
const response = await call("/search/job/posts", payload);
|
|
227
|
+
if (!response.ok || !response.data)
|
|
228
|
+
break;
|
|
229
|
+
const posts = response.data.job_post_list ?? [];
|
|
230
|
+
const found = posts.find((p) => String(p.id) === id);
|
|
231
|
+
if (found) {
|
|
232
|
+
const summary = summarizePosition(found);
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
source,
|
|
236
|
+
post_id: id,
|
|
237
|
+
title: found.title ?? "",
|
|
238
|
+
direction: found.sub_title ?? "",
|
|
239
|
+
description: found.description ?? "",
|
|
240
|
+
requirements: found.requirement ?? "",
|
|
241
|
+
work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
|
|
242
|
+
apply_url: summary.apply_url,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (posts.length < pageSize)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
source,
|
|
251
|
+
post_id: id,
|
|
252
|
+
message: `post ${id} not found in public search results (searched up to ${maxPages * 100} posts)`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ---------- fetchDictionaries ----------
|
|
256
|
+
let _filterCache = null;
|
|
257
|
+
async function fetchDictionaries() {
|
|
258
|
+
if (_filterCache !== null)
|
|
259
|
+
return _filterCache;
|
|
260
|
+
const url = `${API_ROOT}/config/job/filters/${cfg.channel}`;
|
|
261
|
+
let response;
|
|
262
|
+
try {
|
|
263
|
+
response = await fetch(url, { headers: makeHeaders() });
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
const r = {
|
|
267
|
+
ok: false,
|
|
268
|
+
source,
|
|
269
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
270
|
+
};
|
|
271
|
+
_filterCache = r;
|
|
272
|
+
return r;
|
|
273
|
+
}
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const r = { ok: false, source, message: `HTTP ${response.status}` };
|
|
276
|
+
_filterCache = r;
|
|
277
|
+
return r;
|
|
278
|
+
}
|
|
279
|
+
let payload;
|
|
280
|
+
try {
|
|
281
|
+
payload = await response.json();
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
const r = {
|
|
285
|
+
ok: false,
|
|
286
|
+
source,
|
|
287
|
+
message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
288
|
+
};
|
|
289
|
+
_filterCache = r;
|
|
290
|
+
return r;
|
|
291
|
+
}
|
|
292
|
+
if (payload.code !== 0 || !payload.data) {
|
|
293
|
+
const r = {
|
|
294
|
+
ok: false,
|
|
295
|
+
source,
|
|
296
|
+
message: payload.message ?? "upstream error",
|
|
297
|
+
};
|
|
298
|
+
_filterCache = r;
|
|
299
|
+
return r;
|
|
300
|
+
}
|
|
301
|
+
const d = payload.data;
|
|
302
|
+
const jobCategories = (d.job_type_list ?? []).map((cat) => ({
|
|
303
|
+
id: cat.id ?? "",
|
|
304
|
+
name: cat.name ?? "",
|
|
305
|
+
en_name: cat.en_name ?? "",
|
|
306
|
+
depth: cat.depth ?? 1,
|
|
307
|
+
parent_id: cat.parent?.id ?? null,
|
|
308
|
+
}));
|
|
309
|
+
const cities = (d.city_list ?? []).map((c) => ({
|
|
310
|
+
code: c.code ?? "",
|
|
311
|
+
name: c.name ?? "",
|
|
312
|
+
en_name: c.en_name ?? "",
|
|
313
|
+
}));
|
|
314
|
+
const subjects = (d.job_subject_list ?? []).map((s) => ({
|
|
315
|
+
id: s.id ?? "",
|
|
316
|
+
name: s.name?.zh_cn ?? s.name?.i18n ?? "",
|
|
317
|
+
group: s.subject_group_info?.name ?? "",
|
|
318
|
+
}));
|
|
319
|
+
const recruitmentTypes = [
|
|
320
|
+
{ id: "201", name: "正式" },
|
|
321
|
+
{ id: "202", name: "实习" },
|
|
322
|
+
];
|
|
323
|
+
const result = {
|
|
324
|
+
ok: true,
|
|
325
|
+
source,
|
|
326
|
+
jobCategories,
|
|
327
|
+
cities,
|
|
328
|
+
subjects,
|
|
329
|
+
recruitmentTypes,
|
|
330
|
+
};
|
|
331
|
+
_filterCache = result;
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
// ---------- stub notices ----------
|
|
335
|
+
const NOTICES_STUB = {
|
|
336
|
+
ok: false,
|
|
337
|
+
source,
|
|
338
|
+
message: `${cfg.label}: no public notices endpoint`,
|
|
339
|
+
};
|
|
340
|
+
async function listNotices() {
|
|
341
|
+
return NOTICES_STUB;
|
|
342
|
+
}
|
|
343
|
+
async function getNotice(_id) {
|
|
344
|
+
return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
|
|
345
|
+
}
|
|
346
|
+
async function findNoticesByQuestion(_question, _opts = {}) {
|
|
347
|
+
return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
|
|
348
|
+
}
|
|
349
|
+
// ---------- matchResume ----------
|
|
350
|
+
async function matchResume(text, opts = {}) {
|
|
351
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
352
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
353
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
354
|
+
if (!terms.length) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
source,
|
|
358
|
+
message: "could not extract any technical signals from the text",
|
|
359
|
+
preview: (text ?? "").slice(0, 120),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
363
|
+
const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
|
|
364
|
+
if (!list.ok) {
|
|
365
|
+
return { ok: false, source, message: list.message, positions: [] };
|
|
366
|
+
}
|
|
367
|
+
const payload = {
|
|
368
|
+
keyword,
|
|
369
|
+
limit: 100,
|
|
370
|
+
offset: 0,
|
|
371
|
+
portal_type: 3,
|
|
372
|
+
portal_entrance: 1,
|
|
373
|
+
language: "zh",
|
|
374
|
+
};
|
|
375
|
+
const raw = await call("/search/job/posts", payload);
|
|
376
|
+
const rawPosts = raw.ok ? (raw.data?.job_post_list ?? []) : [];
|
|
377
|
+
const rawById = new Map();
|
|
378
|
+
for (const p of rawPosts) {
|
|
379
|
+
rawById.set(String(p.id ?? ""), p);
|
|
380
|
+
}
|
|
381
|
+
const scored = [];
|
|
382
|
+
for (const p of list.positions) {
|
|
383
|
+
const rp = rawById.get(p.post_id);
|
|
384
|
+
const blob = [
|
|
385
|
+
p.title,
|
|
386
|
+
p.project,
|
|
387
|
+
p.recruit_label,
|
|
388
|
+
p.work_cities,
|
|
389
|
+
rp?.description ?? "",
|
|
390
|
+
rp?.requirement ?? "",
|
|
391
|
+
].join(" ");
|
|
392
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
393
|
+
if (score > 0) {
|
|
394
|
+
scored.push({
|
|
395
|
+
score,
|
|
396
|
+
position: p,
|
|
397
|
+
reasons,
|
|
398
|
+
description: rp?.description,
|
|
399
|
+
requirements: rp?.requirement,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
scored.sort((a, b) => b.score - a.score);
|
|
404
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
405
|
+
if (!shortlist.length) {
|
|
406
|
+
shortlist = list.positions.slice(0, candidates).map((position) => ({
|
|
407
|
+
score: 0,
|
|
408
|
+
position,
|
|
409
|
+
reasons: [],
|
|
410
|
+
description: rawById.get(position.post_id)?.description,
|
|
411
|
+
requirements: rawById.get(position.post_id)?.requirement,
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
const matches = shortlist.slice(0, topN).map((s) => {
|
|
415
|
+
const mr = s.reasons.length > 0
|
|
416
|
+
? s.reasons.slice(0, 5)
|
|
417
|
+
: ["no specific keyword overlap — surfaced from initial keyword search"];
|
|
418
|
+
return {
|
|
419
|
+
...s.position,
|
|
420
|
+
description: s.description,
|
|
421
|
+
requirements: s.requirements,
|
|
422
|
+
match_reasons: mr,
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
source,
|
|
428
|
+
extracted_terms: terms,
|
|
429
|
+
city_preferences: cities,
|
|
430
|
+
matches,
|
|
431
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
432
|
+
"The only authority on selection is HR.",
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
searchPositions,
|
|
437
|
+
fetchAllPositions,
|
|
438
|
+
fetchPositionDetail,
|
|
439
|
+
fetchDictionaries,
|
|
440
|
+
listNotices,
|
|
441
|
+
getNotice,
|
|
442
|
+
findNoticesByQuestion,
|
|
443
|
+
matchResume,
|
|
444
|
+
checkResume,
|
|
445
|
+
};
|
|
446
|
+
}
|