@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/feishu.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
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
|
+
// ---------- shared apply-schema helper (re-used by bespoke Feishu adapters) ----------
|
|
46
|
+
//
|
|
47
|
+
// xiaomi.ts / zhipu.ts / iqiyi.ts / agibot.ts / lilith.ts each predate the
|
|
48
|
+
// factory and have their own searchPositions implementations. To give them
|
|
49
|
+
// the same Phase-2 behaviour as factory-using adapters (nio / minimax /
|
|
50
|
+
// baichuan / zerooneai), each can call `buildFeishuApplySchema()` from
|
|
51
|
+
// its own fetchApplicationSchema function.
|
|
52
|
+
/**
|
|
53
|
+
* Wire fetchApplicationSchema for a bespoke Feishu adapter that doesn't use
|
|
54
|
+
* createAdapter. The callback `fetchTitle(id)` is the adapter's own
|
|
55
|
+
* fetchPositionDetail (or any function that returns `{ ok, title }`).
|
|
56
|
+
*
|
|
57
|
+
* Usage:
|
|
58
|
+
* export const fetchApplicationSchema = makeFeishuApplyFn({
|
|
59
|
+
* host: HOST, source: SOURCE, channel: CHANNEL,
|
|
60
|
+
* applyUrlPrefix: APPLY_PREFIX,
|
|
61
|
+
* fetchTitle: (id) => fetchPositionDetail(id),
|
|
62
|
+
* submitKind: "feishu-3-step", // override for lilith → "cdp-real-browser"
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
export function makeFeishuApplyFn(opts) {
|
|
66
|
+
return async function fetchApplicationSchema(postId) {
|
|
67
|
+
const id = (postId ?? "").trim();
|
|
68
|
+
if (!id)
|
|
69
|
+
return { ok: false, source: opts.source, message: "post_id is required" };
|
|
70
|
+
let title = "";
|
|
71
|
+
try {
|
|
72
|
+
const detail = (await opts.fetchTitle(id));
|
|
73
|
+
if (detail?.ok === false) {
|
|
74
|
+
return { ok: false, source: opts.source, message: detail.message ?? "post not found" };
|
|
75
|
+
}
|
|
76
|
+
title = detail?.title ?? "";
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// detail call failures aren't fatal for the schema — we can still
|
|
80
|
+
// return what we know.
|
|
81
|
+
}
|
|
82
|
+
const schema = buildFeishuApplySchema({
|
|
83
|
+
host: opts.host,
|
|
84
|
+
source: opts.source,
|
|
85
|
+
channel: opts.channel,
|
|
86
|
+
applyUrlPrefix: opts.applyUrlPrefix,
|
|
87
|
+
postId: id,
|
|
88
|
+
jobTitle: title,
|
|
89
|
+
});
|
|
90
|
+
if (opts.submitKind === "cdp-real-browser") {
|
|
91
|
+
schema.submit_kind = "cdp-real-browser";
|
|
92
|
+
schema.submit_notes =
|
|
93
|
+
"Lilith's Feishu tenant requires a runtime-minted `_signature` token. " +
|
|
94
|
+
"Submission must drive a real browser (puppeteer-core) — staged dry-run " +
|
|
95
|
+
"only for now.";
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, schema };
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function buildFeishuApplySchema(args) {
|
|
101
|
+
const standard = [
|
|
102
|
+
{ label: "Name", required: true, fields: [{ name: "name", type: "input_text" }] },
|
|
103
|
+
{ label: "Email", required: true, fields: [{ name: "email", type: "input_text" }] },
|
|
104
|
+
{ label: "Phone", required: true, fields: [{ name: "phone", type: "input_text" }] },
|
|
105
|
+
{ label: "Resume", required: true, fields: [{ name: "resume", type: "input_file" }] },
|
|
106
|
+
];
|
|
107
|
+
return {
|
|
108
|
+
source: args.source,
|
|
109
|
+
post_id: args.postId,
|
|
110
|
+
job_title: args.jobTitle,
|
|
111
|
+
apply_url: `${args.applyUrlPrefix}/${encodeURIComponent(args.postId)}/detail`,
|
|
112
|
+
submit_endpoint: `https://${args.host}/api/v1/user/applications`,
|
|
113
|
+
submit_method: "POST",
|
|
114
|
+
submit_kind: "feishu-3-step",
|
|
115
|
+
endpoint_verified: true,
|
|
116
|
+
submit_notes: "Feishu apply is a 3-step token flow: POST /api/v1/attachment/upload/tokens → " +
|
|
117
|
+
"PUT presigned URL on lf-package-cn.feishucdn.com → POST /api/v1/attachment/exchange/tokens → " +
|
|
118
|
+
"POST /api/v1/user/applications with { post_id, attachment_id, applicant_info }. " +
|
|
119
|
+
"Endpoint extracted from atsx-throne/hire-fe-prod/saas-career/4026.f23f1edc.js " +
|
|
120
|
+
"(/user/applications path) and anon-probed → HTTP 405 = real REST route in Feishu's " +
|
|
121
|
+
"routing table (method/csrf requirements differ from anon POST). Requires candidate " +
|
|
122
|
+
"session cookies (capture via extension/, drop under ~/.jobpro/<adapter>.session.json).",
|
|
123
|
+
questions: standard,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ---------- createAdapter ----------
|
|
127
|
+
export function createAdapter(cfg) {
|
|
128
|
+
const API_ROOT = `https://${cfg.host}/api/v1`;
|
|
129
|
+
const source = cfg.host;
|
|
130
|
+
function makeHeaders() {
|
|
131
|
+
return {
|
|
132
|
+
"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",
|
|
133
|
+
Accept: "application/json, text/plain, */*",
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"portal-channel": cfg.channel,
|
|
136
|
+
"portal-platform": "pc",
|
|
137
|
+
"website-path": cfg.channel,
|
|
138
|
+
Referer: `https://${cfg.host}/${cfg.channel}/position`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function call(path, body) {
|
|
142
|
+
const url = `${API_ROOT}${path}`;
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetch(url, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: makeHeaders(),
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
159
|
+
}
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = (await response.json());
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: payload.code === 0,
|
|
169
|
+
data: payload.data,
|
|
170
|
+
message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function summarizePosition(item) {
|
|
174
|
+
const id = String(item.id ?? "");
|
|
175
|
+
const cityList = item.city_list ?? [];
|
|
176
|
+
let work_cities;
|
|
177
|
+
if (cityList.length > 1) {
|
|
178
|
+
work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
work_cities = cityList[0]?.name ?? item.city_info?.name ?? "";
|
|
182
|
+
}
|
|
183
|
+
// NIO: job_category null, job_function has the name.
|
|
184
|
+
// MiniMax: job_function null, job_category has the name.
|
|
185
|
+
const project = item.job_category?.name ??
|
|
186
|
+
item.job_function?.name ??
|
|
187
|
+
"";
|
|
188
|
+
return {
|
|
189
|
+
post_id: id,
|
|
190
|
+
title: item.title ?? "",
|
|
191
|
+
project,
|
|
192
|
+
recruit_label: item.recruit_type?.name ?? "",
|
|
193
|
+
bgs: "",
|
|
194
|
+
work_cities,
|
|
195
|
+
apply_url: id ? `${cfg.applyUrlPrefix}/${encodeURIComponent(id)}/detail` : `https://${cfg.host}/${cfg.channel}/position`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const asStringList = (v) => {
|
|
199
|
+
if (v === undefined)
|
|
200
|
+
return undefined;
|
|
201
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
202
|
+
return arr.map(String);
|
|
203
|
+
};
|
|
204
|
+
// ---------- searchPositions ----------
|
|
205
|
+
async function searchPositions(opts = {}) {
|
|
206
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
|
|
207
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
208
|
+
const offset = (page - 1) * pageSize;
|
|
209
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
210
|
+
const payload = {
|
|
211
|
+
keyword,
|
|
212
|
+
limit: pageSize,
|
|
213
|
+
offset,
|
|
214
|
+
portal_type: 3,
|
|
215
|
+
portal_entrance: 1,
|
|
216
|
+
language: "zh",
|
|
217
|
+
};
|
|
218
|
+
const recruitmentIdList = asStringList(opts.recruitmentIdList);
|
|
219
|
+
if (recruitmentIdList !== undefined && recruitmentIdList.length > 0) {
|
|
220
|
+
payload.recruitment_id_list = recruitmentIdList;
|
|
221
|
+
}
|
|
222
|
+
const jobCategoryIdList = asStringList(opts.jobCategoryIdList);
|
|
223
|
+
if (jobCategoryIdList?.length) {
|
|
224
|
+
payload.job_category_id_list = jobCategoryIdList;
|
|
225
|
+
}
|
|
226
|
+
const cityIdList = asStringList(opts.cityIdList);
|
|
227
|
+
if (cityIdList?.length) {
|
|
228
|
+
payload.location_code_list = cityIdList;
|
|
229
|
+
}
|
|
230
|
+
const subjectIdList = asStringList(opts.subjectIdList);
|
|
231
|
+
if (subjectIdList?.length) {
|
|
232
|
+
payload.subject_id_list = subjectIdList;
|
|
233
|
+
}
|
|
234
|
+
const response = await call("/search/job/posts", payload);
|
|
235
|
+
if (!response.ok || !response.data) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
message: response.message,
|
|
239
|
+
source,
|
|
240
|
+
query: payload,
|
|
241
|
+
positions: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const rows = response.data.job_post_list ?? [];
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
source,
|
|
248
|
+
query: payload,
|
|
249
|
+
page,
|
|
250
|
+
page_size: pageSize,
|
|
251
|
+
total: response.data.count ?? rows.length,
|
|
252
|
+
positions: rows.map(summarizePosition),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ---------- fetchAllPositions ----------
|
|
256
|
+
async function fetchAllPositions(opts = {}) {
|
|
257
|
+
const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
|
|
258
|
+
const maxPages = Math.max(1, opts.maxPages ?? 5);
|
|
259
|
+
const bucket = [];
|
|
260
|
+
let total;
|
|
261
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
262
|
+
const result = await searchPositions({ ...opts, page, pageSize });
|
|
263
|
+
if (!result.ok) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
message: result.message,
|
|
267
|
+
source,
|
|
268
|
+
fetched: bucket.length,
|
|
269
|
+
positions: bucket,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (total === undefined)
|
|
273
|
+
total = result.total;
|
|
274
|
+
if (!result.positions.length)
|
|
275
|
+
break;
|
|
276
|
+
bucket.push(...result.positions);
|
|
277
|
+
if (total !== undefined && bucket.length >= total)
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
source,
|
|
283
|
+
total: total ?? bucket.length,
|
|
284
|
+
fetched: bucket.length,
|
|
285
|
+
positions: bucket,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// ---------- fetchPositionDetail ----------
|
|
289
|
+
// Feishu has no public per-post detail REST endpoint.
|
|
290
|
+
// Paginate search and filter by id.
|
|
291
|
+
async function fetchPositionDetail(postId) {
|
|
292
|
+
const id = (postId ?? "").trim();
|
|
293
|
+
if (!id)
|
|
294
|
+
return { ok: false, source, message: "post_id is required" };
|
|
295
|
+
const pageSize = 100;
|
|
296
|
+
const maxPages = 5;
|
|
297
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
298
|
+
const offset = (page - 1) * pageSize;
|
|
299
|
+
const payload = {
|
|
300
|
+
keyword: "",
|
|
301
|
+
limit: pageSize,
|
|
302
|
+
offset,
|
|
303
|
+
portal_type: 3,
|
|
304
|
+
portal_entrance: 1,
|
|
305
|
+
language: "zh",
|
|
306
|
+
};
|
|
307
|
+
const response = await call("/search/job/posts", payload);
|
|
308
|
+
if (!response.ok || !response.data)
|
|
309
|
+
break;
|
|
310
|
+
const posts = response.data.job_post_list ?? [];
|
|
311
|
+
const found = posts.find((p) => String(p.id) === id);
|
|
312
|
+
if (found) {
|
|
313
|
+
const summary = summarizePosition(found);
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
source,
|
|
317
|
+
post_id: id,
|
|
318
|
+
title: found.title ?? "",
|
|
319
|
+
direction: found.sub_title ?? "",
|
|
320
|
+
description: found.description ?? "",
|
|
321
|
+
requirements: found.requirement ?? "",
|
|
322
|
+
work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
|
|
323
|
+
apply_url: summary.apply_url,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (posts.length < pageSize)
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
source,
|
|
332
|
+
post_id: id,
|
|
333
|
+
message: `post ${id} not found in public search results (searched up to ${maxPages * 100} posts)`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// ---------- fetchDictionaries ----------
|
|
337
|
+
let _filterCache = null;
|
|
338
|
+
async function fetchDictionaries() {
|
|
339
|
+
if (_filterCache !== null)
|
|
340
|
+
return _filterCache;
|
|
341
|
+
const url = `${API_ROOT}/config/job/filters/${cfg.channel}`;
|
|
342
|
+
let response;
|
|
343
|
+
try {
|
|
344
|
+
response = await fetch(url, { headers: makeHeaders() });
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
const r = {
|
|
348
|
+
ok: false,
|
|
349
|
+
source,
|
|
350
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
351
|
+
};
|
|
352
|
+
_filterCache = r;
|
|
353
|
+
return r;
|
|
354
|
+
}
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
const r = { ok: false, source, message: `HTTP ${response.status}` };
|
|
357
|
+
_filterCache = r;
|
|
358
|
+
return r;
|
|
359
|
+
}
|
|
360
|
+
let payload;
|
|
361
|
+
try {
|
|
362
|
+
payload = await response.json();
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
const r = {
|
|
366
|
+
ok: false,
|
|
367
|
+
source,
|
|
368
|
+
message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
369
|
+
};
|
|
370
|
+
_filterCache = r;
|
|
371
|
+
return r;
|
|
372
|
+
}
|
|
373
|
+
if (payload.code !== 0 || !payload.data) {
|
|
374
|
+
const r = {
|
|
375
|
+
ok: false,
|
|
376
|
+
source,
|
|
377
|
+
message: payload.message ?? "upstream error",
|
|
378
|
+
};
|
|
379
|
+
_filterCache = r;
|
|
380
|
+
return r;
|
|
381
|
+
}
|
|
382
|
+
const d = payload.data;
|
|
383
|
+
const jobCategories = (d.job_type_list ?? []).map((cat) => ({
|
|
384
|
+
id: cat.id ?? "",
|
|
385
|
+
name: cat.name ?? "",
|
|
386
|
+
en_name: cat.en_name ?? "",
|
|
387
|
+
depth: cat.depth ?? 1,
|
|
388
|
+
parent_id: cat.parent?.id ?? null,
|
|
389
|
+
}));
|
|
390
|
+
const cities = (d.city_list ?? []).map((c) => ({
|
|
391
|
+
code: c.code ?? "",
|
|
392
|
+
name: c.name ?? "",
|
|
393
|
+
en_name: c.en_name ?? "",
|
|
394
|
+
}));
|
|
395
|
+
const subjects = (d.job_subject_list ?? []).map((s) => ({
|
|
396
|
+
id: s.id ?? "",
|
|
397
|
+
name: s.name?.zh_cn ?? s.name?.i18n ?? "",
|
|
398
|
+
group: s.subject_group_info?.name ?? "",
|
|
399
|
+
}));
|
|
400
|
+
const recruitmentTypes = [
|
|
401
|
+
{ id: "201", name: "正式" },
|
|
402
|
+
{ id: "202", name: "实习" },
|
|
403
|
+
];
|
|
404
|
+
const result = {
|
|
405
|
+
ok: true,
|
|
406
|
+
source,
|
|
407
|
+
jobCategories,
|
|
408
|
+
cities,
|
|
409
|
+
subjects,
|
|
410
|
+
recruitmentTypes,
|
|
411
|
+
};
|
|
412
|
+
_filterCache = result;
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
// ---------- stub notices ----------
|
|
416
|
+
const NOTICES_STUB = {
|
|
417
|
+
ok: false,
|
|
418
|
+
source,
|
|
419
|
+
message: `${cfg.label}: no public notices endpoint`,
|
|
420
|
+
};
|
|
421
|
+
async function listNotices() {
|
|
422
|
+
return NOTICES_STUB;
|
|
423
|
+
}
|
|
424
|
+
async function getNotice(_id) {
|
|
425
|
+
return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
|
|
426
|
+
}
|
|
427
|
+
async function findNoticesByQuestion(_question, _opts = {}) {
|
|
428
|
+
return { ok: false, source, message: `${cfg.label}: no public notices endpoint` };
|
|
429
|
+
}
|
|
430
|
+
// ---------- matchResume ----------
|
|
431
|
+
async function matchResume(text, opts = {}) {
|
|
432
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
433
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
434
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
435
|
+
if (!terms.length) {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
source,
|
|
439
|
+
message: "could not extract any technical signals from the text",
|
|
440
|
+
preview: (text ?? "").slice(0, 120),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
444
|
+
const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
|
|
445
|
+
if (!list.ok) {
|
|
446
|
+
return { ok: false, source, message: list.message, positions: [] };
|
|
447
|
+
}
|
|
448
|
+
const payload = {
|
|
449
|
+
keyword,
|
|
450
|
+
limit: 100,
|
|
451
|
+
offset: 0,
|
|
452
|
+
portal_type: 3,
|
|
453
|
+
portal_entrance: 1,
|
|
454
|
+
language: "zh",
|
|
455
|
+
};
|
|
456
|
+
const raw = await call("/search/job/posts", payload);
|
|
457
|
+
const rawPosts = raw.ok ? (raw.data?.job_post_list ?? []) : [];
|
|
458
|
+
const rawById = new Map();
|
|
459
|
+
for (const p of rawPosts) {
|
|
460
|
+
rawById.set(String(p.id ?? ""), p);
|
|
461
|
+
}
|
|
462
|
+
const scored = [];
|
|
463
|
+
for (const p of list.positions) {
|
|
464
|
+
const rp = rawById.get(p.post_id);
|
|
465
|
+
const blob = [
|
|
466
|
+
p.title,
|
|
467
|
+
p.project,
|
|
468
|
+
p.recruit_label,
|
|
469
|
+
p.work_cities,
|
|
470
|
+
rp?.description ?? "",
|
|
471
|
+
rp?.requirement ?? "",
|
|
472
|
+
].join(" ");
|
|
473
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
474
|
+
if (score > 0) {
|
|
475
|
+
scored.push({
|
|
476
|
+
score,
|
|
477
|
+
position: p,
|
|
478
|
+
reasons,
|
|
479
|
+
description: rp?.description,
|
|
480
|
+
requirements: rp?.requirement,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
scored.sort((a, b) => b.score - a.score);
|
|
485
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
486
|
+
if (!shortlist.length) {
|
|
487
|
+
shortlist = list.positions.slice(0, candidates).map((position) => ({
|
|
488
|
+
score: 0,
|
|
489
|
+
position,
|
|
490
|
+
reasons: [],
|
|
491
|
+
description: rawById.get(position.post_id)?.description,
|
|
492
|
+
requirements: rawById.get(position.post_id)?.requirement,
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
const matches = shortlist.slice(0, topN).map((s) => {
|
|
496
|
+
const mr = s.reasons.length > 0
|
|
497
|
+
? s.reasons.slice(0, 5)
|
|
498
|
+
: ["no specific keyword overlap — surfaced from initial keyword search"];
|
|
499
|
+
return {
|
|
500
|
+
...s.position,
|
|
501
|
+
description: s.description,
|
|
502
|
+
requirements: s.requirements,
|
|
503
|
+
match_reasons: mr,
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
ok: true,
|
|
508
|
+
source,
|
|
509
|
+
extracted_terms: terms,
|
|
510
|
+
city_preferences: cities,
|
|
511
|
+
matches,
|
|
512
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
513
|
+
"The only authority on selection is HR.",
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// ---------- fetchApplicationSchema (Phase 2) ----------
|
|
517
|
+
//
|
|
518
|
+
// Feishu's apply funnel is a 3-step token flow, not a single multipart
|
|
519
|
+
// POST. Discovered via JS-bundle inspection of nio.jobs.feishu.cn
|
|
520
|
+
// (lf-package-cn.feishucdn.com/obj/atsx-throne/hire-fe-prod/portal/
|
|
521
|
+
// saas-career/static/js/*.js) — the routes baked into the bundle are:
|
|
522
|
+
//
|
|
523
|
+
// 1. POST {API_ROOT}/attachment/upload/tokens
|
|
524
|
+
// → returns short-lived presigned upload URL + attachment_id
|
|
525
|
+
// 2. PUT <presigned-URL on lf-package-cn.feishucdn.com>
|
|
526
|
+
// → uploads the resume PDF/DOCX bytes directly
|
|
527
|
+
// 3. POST {API_ROOT}/attachment/exchange/tokens
|
|
528
|
+
// → exchanges short-lived id for a permanent attachment_id
|
|
529
|
+
// 4. POST {API_ROOT}/user/delivery/check (pre-flight, optional)
|
|
530
|
+
// 5. POST {API_ROOT}/resume/apply
|
|
531
|
+
// body: { post_id, attachment_id, applicant_info: { name, email,
|
|
532
|
+
// phone, ... }, ... }
|
|
533
|
+
// → returns { code:0, data:{ application_id } } on success
|
|
534
|
+
//
|
|
535
|
+
// The whole flow requires the user to be logged in as a candidate; the
|
|
536
|
+
// session cookie set during login authorizes every call above. Capture
|
|
537
|
+
// via the browser extension (~/.jobpro/<co>.session.json), then a
|
|
538
|
+
// future iteration adds an `executeSubmission` hook that drives the
|
|
539
|
+
// 3-step flow with the captured cookies.
|
|
540
|
+
//
|
|
541
|
+
// For now `fetchApplicationSchema` returns the contact-info schema
|
|
542
|
+
// (sufficient for dry-run staging) plus `submit_kind: "feishu-3-step"`
|
|
543
|
+
// so the dispatcher refuses --really-submit with a useful pointer.
|
|
544
|
+
async function fetchApplicationSchema(postId) {
|
|
545
|
+
const id = (postId ?? "").trim();
|
|
546
|
+
if (!id)
|
|
547
|
+
return { ok: false, source, message: "post_id is required" };
|
|
548
|
+
const detail = await fetchPositionDetail(id);
|
|
549
|
+
const detailAny = detail;
|
|
550
|
+
if (!detailAny.ok) {
|
|
551
|
+
return { ok: false, source, message: detailAny.message ?? "post not found" };
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
schema: buildFeishuApplySchema({
|
|
556
|
+
host: cfg.host,
|
|
557
|
+
source,
|
|
558
|
+
channel: cfg.channel,
|
|
559
|
+
applyUrlPrefix: cfg.applyUrlPrefix,
|
|
560
|
+
postId: id,
|
|
561
|
+
jobTitle: detailAny.title ?? "",
|
|
562
|
+
}),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
searchPositions,
|
|
567
|
+
fetchAllPositions,
|
|
568
|
+
fetchPositionDetail,
|
|
569
|
+
fetchDictionaries,
|
|
570
|
+
listNotices,
|
|
571
|
+
getNotice,
|
|
572
|
+
findNoticesByQuestion,
|
|
573
|
+
matchResume,
|
|
574
|
+
checkResume,
|
|
575
|
+
fetchApplicationSchema,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 银河通用 / Galaxy Universal (Galbot) careers — Moka SSR + AES-128-CBC.
|
|
2
|
+
//
|
|
3
|
+
// Portal: https://app.mokahr.com/social-recruitment/yinhetongyong/165929
|
|
4
|
+
// Probed 2026-05; ~121 social-hire positions.
|
|
5
|
+
// See cli/src/moka.ts for the shared factory.
|
|
6
|
+
import { createAdapter } from "./moka.js";
|
|
7
|
+
const adapter = createAdapter({
|
|
8
|
+
orgSlug: "yinhetongyong",
|
|
9
|
+
label: "Galaxy Universal",
|
|
10
|
+
channels: [
|
|
11
|
+
{ siteId: 165929, kind: "social-recruitment", recruitType: "social" },
|
|
12
|
+
],
|
|
13
|
+
defaultRecruitType: "social",
|
|
14
|
+
});
|
|
15
|
+
export const searchPositions = adapter.searchPositions;
|
|
16
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
17
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
18
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
19
|
+
export const listNotices = adapter.listNotices;
|
|
20
|
+
export const getNotice = adapter.getNotice;
|
|
21
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
22
|
+
export const matchResume = adapter.matchResume;
|
|
23
|
+
export const checkResume = adapter.checkResume;
|
|
24
|
+
export const fetchApplicationSchema = adapter.fetchApplicationSchema;
|
package/dist/geely.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 吉利汽车 (Geely Auto) careers adapter — Moka SSR + AES-128-CBC pagination.
|
|
2
|
+
//
|
|
3
|
+
// ============================================================
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16)
|
|
5
|
+
//
|
|
6
|
+
// `job.geely.com` is a CNAME that 302-redirects to a Moka tenant:
|
|
7
|
+
// https://app.mokahr.com/social-recruitment/geely/96123/
|
|
8
|
+
//
|
|
9
|
+
// (The `198.18.x` IP that `job.geely.com` resolves to is an Alibaba-Cloud
|
|
10
|
+
// front; the actual upstream is `app.mokahr.com`.) The SSR HTML at that
|
|
11
|
+
// URL embeds the standard Moka `<input id="init-data" value="…">` blob
|
|
12
|
+
// containing the first page of jobs + aesIv for AES-128-CBC pagination.
|
|
13
|
+
//
|
|
14
|
+
// Same factory as `cli/src/moka.ts` (used by megvii / cambricon / etc.).
|
|
15
|
+
// Only the social-recruitment channel is published publicly — no
|
|
16
|
+
// campus-recruitment URL is linked from the Geely corporate site.
|
|
17
|
+
import { createAdapter } from "./moka.js";
|
|
18
|
+
const adapter = createAdapter({
|
|
19
|
+
orgSlug: "geely",
|
|
20
|
+
label: "Geely",
|
|
21
|
+
channels: [
|
|
22
|
+
{ siteId: 96123, kind: "social-recruitment", recruitType: "social" },
|
|
23
|
+
],
|
|
24
|
+
defaultRecruitType: "social",
|
|
25
|
+
});
|
|
26
|
+
export const searchPositions = adapter.searchPositions;
|
|
27
|
+
export const fetchAllPositions = adapter.fetchAllPositions;
|
|
28
|
+
export const fetchPositionDetail = adapter.fetchPositionDetail;
|
|
29
|
+
export const fetchDictionaries = adapter.fetchDictionaries;
|
|
30
|
+
export const listNotices = adapter.listNotices;
|
|
31
|
+
export const getNotice = adapter.getNotice;
|
|
32
|
+
export const findNoticesByQuestion = adapter.findNoticesByQuestion;
|
|
33
|
+
export const matchResume = adapter.matchResume;
|
|
34
|
+
export const checkResume = adapter.checkResume;
|
|
35
|
+
export const fetchApplicationSchema = adapter.fetchApplicationSchema;
|