@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.
Files changed (68) hide show
  1. package/dist/adapter.js +17 -0
  2. package/dist/agibot.js +399 -0
  3. package/dist/alibaba.js +509 -0
  4. package/dist/antgroup.js +397 -0
  5. package/dist/apply.js +1373 -0
  6. package/dist/baichuan.js +49 -0
  7. package/dist/baidu.js +452 -0
  8. package/dist/bilibili.js +455 -0
  9. package/dist/byd.js +412 -0
  10. package/dist/bytedance.js +619 -0
  11. package/dist/cainiao.js +56 -0
  12. package/dist/cambricon.js +33 -0
  13. package/dist/cdp.js +237 -0
  14. package/dist/cicc.js +56 -0
  15. package/dist/coverage.js +60 -0
  16. package/dist/deepseek.js +25 -0
  17. package/dist/didi.js +381 -0
  18. package/dist/feishu.js +577 -0
  19. package/dist/galaxyuniversal.js +24 -0
  20. package/dist/geely.js +35 -0
  21. package/dist/greenhouse.js +432 -0
  22. package/dist/hikvision.js +58 -0
  23. package/dist/horizonrobotics.js +46 -0
  24. package/dist/hoyoverse.js +26 -0
  25. package/dist/huawei.js +537 -0
  26. package/dist/iflytek.js +380 -0
  27. package/dist/index.js +1828 -0
  28. package/dist/iqiyi.js +494 -0
  29. package/dist/jd.js +559 -0
  30. package/dist/kuaishou.js +496 -0
  31. package/dist/lever.js +455 -0
  32. package/dist/liauto.js +393 -0
  33. package/dist/liepin.js +357 -0
  34. package/dist/lilith.js +300 -0
  35. package/dist/megvii.js +27 -0
  36. package/dist/meituan.js +633 -0
  37. package/dist/memory.js +76 -0
  38. package/dist/mihoyo.js +308 -0
  39. package/dist/minimax.js +32 -0
  40. package/dist/moka.js +473 -0
  41. package/dist/moonshot.js +24 -0
  42. package/dist/netease.js +424 -0
  43. package/dist/nio.js +24 -0
  44. package/dist/oppo.js +285 -0
  45. package/dist/pdd.js +614 -0
  46. package/dist/pingan.js +493 -0
  47. package/dist/sensetime.js +51 -0
  48. package/dist/sf.js +310 -0
  49. package/dist/stepfun.js +24 -0
  50. package/dist/tencent.js +770 -0
  51. package/dist/trip.js +396 -0
  52. package/dist/unitree.js +418 -0
  53. package/dist/vivo.js +361 -0
  54. package/dist/webank.js +55 -0
  55. package/dist/wecruit.js +438 -0
  56. package/dist/weibo.js +337 -0
  57. package/dist/weride.js +29 -0
  58. package/dist/xiaohongshu.js +480 -0
  59. package/dist/xiaomi.js +529 -0
  60. package/dist/xpeng.js +34 -0
  61. package/dist/zerooneai.js +42 -0
  62. package/dist/zhipu.js +478 -0
  63. package/extension/README.md +79 -0
  64. package/extension/background.js +177 -0
  65. package/extension/manifest.json +55 -0
  66. package/extension/popup.html +37 -0
  67. package/extension/popup.js +54 -0
  68. package/package.json +61 -0
@@ -0,0 +1,619 @@
1
+ // Thin client for ByteDance's public campus-recruiting API at jobs.bytedance.com.
2
+ //
3
+ // All endpoints are unauthenticated; the server enforces portal-channel /
4
+ // portal-platform / website-path headers to discourage cross-site embedding.
5
+ //
6
+ // ============================================================
7
+ // Endpoint inventory (probed 2026-05, JS bundle 5635.93c0c8db.js):
8
+ //
9
+ // POST https://jobs.bytedance.com/api/v1/search/job/posts
10
+ // Payload: { keyword, limit, offset, portal_type:3, portal_entrance:1, language:"zh",
11
+ // recruitment_id_list, job_category_id_list, location_code_list,
12
+ // subject_id_list, tag_id_list, storefront_id_list, job_function_id_list }
13
+ // Response: { code:0, data:{ job_post_list:[...], count:<int> }, message:"ok" }
14
+ //
15
+ // GET https://jobs.bytedance.com/api/v1/config/job/filters/{any_id}
16
+ // Returns the full filter taxonomy: job_type_list (=job categories, 2-level),
17
+ // city_list, job_subject_list. The {id} param is ignored — same data every time.
18
+ // Verified: /config/job/filters/campus returns code:0.
19
+ // Note: recruitment_type_list, job_type_count_map, city_count_map are null in the
20
+ // public campus response (counts must be fetched via search).
21
+ //
22
+ // ============================================================
23
+ // Filter semantics (from JS bundle S={1:"1",2:"201",3:"202,301"} mapping):
24
+ // URL ?type=2 → recruitment_id_list:["201"] → 正式 (campus / new-grad) ~2057 posts
25
+ // URL ?type=3 → recruitment_id_list:["202"] → 实习 (intern) ~5767 posts
26
+ // URL ?type=3 → recruitment_id_list:["202","301"]→ 实习+other (S map), same ~5767
27
+ // No filter → all listings ~7824 posts
28
+ // ID 301 alone returns 0 (no active posts).
29
+ //
30
+ // The campus page (jobs.bytedance.com/campus/position) defaults to the 校园招聘 tab (type=2,
31
+ // 正式/new-grad only). Without recruitment_id_list the API returns all 7824 listings
32
+ // (campus + intern combined), which does NOT match the default tab view.
33
+ // The correct default filter is recruitment_id_list:["201"].
34
+ //
35
+ // ============================================================
36
+ // Full filter taxonomy (from GET /api/v1/config/job/filters/campus, probed 2026-05):
37
+ //
38
+ // DIMENSION 1 — job_category_id_list (职位类别, 2-level hierarchy)
39
+ // Parent "研发/R&D" id:6704215862603155720
40
+ // 算法/Algorithm id:6704215956018694411
41
+ // 后端/Backend id:6704215862557018372
42
+ // 客户端/Client id:6704215957146962184
43
+ // 前端/Frontend id:6704215886108035339
44
+ // 测试/Testing id:6704215897130666254
45
+ // 大数据/Big data id:6704215888985327886
46
+ // 机器学习/Machine learning id:6704219534724696331
47
+ // 安全/Security id:6704216109274368264
48
+ // 硬件/Hardware id:6938376045242353957
49
+ // 基础架构/Infrastructure id:6704215958816295181
50
+ // 多媒体/Multimedia id:6704215963966900491
51
+ // 计算机视觉/Computer vision id:6704216296701036811
52
+ // 运维/DevOps id:6704217321877014787
53
+ // 数据挖掘/Data mining id:6704216635923761412
54
+ // 自然语言处理/NLP id:6704219452277262596
55
+ // Parent "运营/Operations" id:6704215882479962371
56
+ // 产品运营/Product ops id:6704216057269192973
57
+ // 商业运营/Commerce ops id:6704215882438019342
58
+ // 用户运营/User ops id:6704215955154667787
59
+ // 项目管理/Project Mgmt id:6863074795655792910
60
+ // 内容运营/Content ops id:6704215961064442123
61
+ // 游戏运营/Game Operations id:6850051246221429006
62
+ // 销售运营/Sales ops id:6704216853931100430
63
+ // 审核/Content auditing id:6704215908782442766
64
+ // 编辑/Editor id:6704217437631416580
65
+ // Parent "产品/Product" id:6704215864629004552
66
+ // 产品经理/Product manager id:6704215864591255820
67
+ // 数据分析/Data analysis id:6704216224387041544
68
+ // 商业产品(广告) id:6704215924712409352
69
+ // Parent "职能/支持" id:6704215913488451847
70
+ // 人力/HR id:6704216386916321540
71
+ // 战略/Strategy id:6704216232129726734
72
+ // 财务/Finance id:6704216480889702664
73
+ // IT支持/IT support id:6704217005358057732
74
+ // 法务/Legal id:6704215913454897421
75
+ // 行政设施/Facilities id:6704216727414114564
76
+ // 内审/Internal Approval id:6850051245856524558
77
+ // Parent "设计/Design" id:6709824272514156812
78
+ // 游戏美术/Game Art id:6850051246036879630
79
+ // 用户研究/User Research id:6709824272996501772
80
+ // 交互设计/Interaction design id:6704216925762750724
81
+ // UI id:6704216194292910348
82
+ // 视觉设计/Visual Design id:6709824272627403020
83
+ // 多媒体设计/Multi-media Design id:6709824273332046088
84
+ // Parent "销售/Sales" id:6709824272505768200
85
+ // 销售/Sales id:6704215938645887239
86
+ // 销售支持/Sales support id:6704215966085024003
87
+ // Parent "市场/Marketing" id:6704215901438216462
88
+ // 营销策划/Marketing planning id:6704216021651163395
89
+ // 广告投放/Advertising id:6704215901392079117
90
+ // 媒介公关/Media relations id:6704217388763580683
91
+ // PR id:6704216386178124040
92
+ // 品牌/Branding id:6704216430973290760
93
+ // 商务拓展BD/Business dev id:6704216950135851275
94
+ // Parent "游戏策划/Game Design" id:6850051244971526414
95
+ // 游戏数值策划/Game Statistics id:6850051245315459342
96
+ // 游戏音频策划/Game Audio id:6850051245680363783
97
+ //
98
+ // DIMENSION 2 — location_code_list (工作地点, city codes)
99
+ // CT_11=北京 CT_125=上海 CT_128=深圳 CT_52=杭州 CT_45=广州 CT_22=成都
100
+ // CT_192=珠海 CT_155=西安 CT_154=武汉 CT_107=南京 CT_190=重庆 CT_163=新加坡
101
+ // CT_188=郑州 CT_66=济南 CT_143=天津 CT_119=青岛 CT_129=沈阳 CT_199=苏州
102
+ // CT_20=长沙 CT_158=厦门 CT_159=中国香港 (+ ~30 more in full list)
103
+ //
104
+ // DIMENSION 3 — recruitment_id_list (招聘类型)
105
+ // "201" = 正式 (campus / new-grad)
106
+ // "202" = 实习 (intern)
107
+ // "301" = (reserved / currently 0 posts)
108
+ //
109
+ // DIMENSION 4 — subject_id_list (项目, special programs — 顶尖/elite tracks)
110
+ // GROUP "实习":
111
+ // 7624086888207862069 = 前沿技术领域人才实习招聘 (~122 posts) ← elite frontier tech intern
112
+ // 7621018569480046853 = Seed大模型人才实习招聘 (~80 posts) ← elite LLM intern
113
+ // 7194661644654577981 = 日常实习 (~2468 posts)
114
+ // 7194661126919358757 = ByteIntern (~3097 posts)
115
+ // GROUP "正式" (en: "Soaring Star Talent Program"):
116
+ // 7624064258157889845 = 2027届前沿技术领域人才校招 (~127 posts) ← elite frontier tech
117
+ // 7621018151002507573 = 2027届Seed大模型人才校招 (~91 posts) ← elite LLM new-grad
118
+ // 7525009396952582407 = 2026届校园招聘 (~1839 posts)
119
+ //
120
+ // To query the 顶尖实习 (top/elite intern) track, use:
121
+ // subject_id_list: ["7624086888207862069"] ← 前沿技术领域人才实习招聘
122
+ // subject_id_list: ["7621018569480046853"] ← Seed大模型人才实习招聘
123
+ // (These are ByteDance's equivalent of Tencent's 顶尖实习 — elite research intern programs)
124
+ //
125
+ // ============================================================
126
+ // Category count breakdown (probed 2026-05, no recruitment filter = all 7824):
127
+ // 研发/R&D: ~4624 运营/Ops: ~1482 产品/Product: ~1096
128
+ // 职能/Corp Func: ~244 设计/Design: ~188 销售/Sales: ~96
129
+ // 市场/Marketing: ~60
130
+ //
131
+ // City count breakdown (no recruitment filter):
132
+ // 北京: ~3429 上海: ~2356 深圳: ~893 杭州: ~790 广州: ~111
133
+ // 成都: ~102 武汉: ~13 南京: ~10 新加坡: ~6 天津: ~2
134
+ //
135
+ // ============================================================
136
+ // Endpoints that do NOT exist publicly (all return 404):
137
+ // POST /api/v1/search/job/post_categories
138
+ // POST /api/v1/search/job/recruitment_types
139
+ // POST /api/v1/search/job/cities
140
+ // POST /api/v1/dict/job_category
141
+ // POST /api/v1/search/job/filters
142
+ // (Any POST variant of the filters path)
143
+ // The notices system has no public endpoints.
144
+ //
145
+ // ============================================================
146
+ // ---- PositionSummary field mapping (ByteDance → canonical) ----
147
+ // post_id ← item.id (stringified)
148
+ // title ← item.title
149
+ // project ← item.job_category.name (closest equiv to Tencent's projectName)
150
+ // recruit_label ← item.recruit_type.name (e.g. "日常实习" / "暑期实习" / "正式")
151
+ // bgs ← "" (ByteDance does not expose BG/事业群 in public search)
152
+ // work_cities ← item.city_info.name + city_list joined with " / " for multi-city posts
153
+ // apply_url ← https://jobs.bytedance.com/campus/position/${id}/detail
154
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
155
+ export { extractResumeSignals, scoreOverlap, checkResume };
156
+ const API_ROOT = "https://jobs.bytedance.com/api/v1";
157
+ const CAMPUS_PAGE = "https://jobs.bytedance.com/campus/position";
158
+ const DETAIL_PAGE = (id) => `https://jobs.bytedance.com/campus/position/${encodeURIComponent(id)}/detail`;
159
+ const DEFAULT_HEADERS = {
160
+ "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",
161
+ Accept: "application/json, text/plain, */*",
162
+ "Content-Type": "application/json",
163
+ "portal-channel": "campus",
164
+ "portal-platform": "pc",
165
+ "website-path": "campus",
166
+ Referer: CAMPUS_PAGE,
167
+ };
168
+ async function call(path, body) {
169
+ const url = `${API_ROOT}${path}`;
170
+ let response;
171
+ try {
172
+ response = await fetch(url, {
173
+ method: "POST",
174
+ headers: DEFAULT_HEADERS,
175
+ body: JSON.stringify(body),
176
+ });
177
+ }
178
+ catch (err) {
179
+ return {
180
+ ok: false,
181
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
182
+ };
183
+ }
184
+ if (!response.ok) {
185
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
186
+ }
187
+ let payload;
188
+ try {
189
+ payload = (await response.json());
190
+ }
191
+ catch (err) {
192
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
193
+ }
194
+ return {
195
+ ok: payload.code === 0,
196
+ data: payload.data,
197
+ message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
198
+ };
199
+ }
200
+ function summarizePosition(item) {
201
+ const id = String(item.id ?? "");
202
+ // Build work_cities: prefer city_list for multi-city; fall back to city_info
203
+ const cityList = item.city_list ?? [];
204
+ let work_cities;
205
+ if (cityList.length > 1) {
206
+ work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
207
+ }
208
+ else {
209
+ work_cities = item.city_info?.name ?? (cityList[0]?.name ?? "");
210
+ }
211
+ return {
212
+ post_id: id,
213
+ title: item.title ?? "",
214
+ project: item.job_category?.name ?? "",
215
+ recruit_label: item.recruit_type?.name ?? "",
216
+ bgs: "",
217
+ work_cities,
218
+ apply_url: id ? DETAIL_PAGE(id) : CAMPUS_PAGE,
219
+ };
220
+ }
221
+ // ---------- searchPositions ----------
222
+ export async function searchPositions(opts = {}) {
223
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
224
+ const page = Math.max(1, opts.page ?? 1);
225
+ const offset = (page - 1) * pageSize;
226
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
227
+ // Build optional filter arrays — undefined means "omit the key" (API returns
228
+ // all for that dim). The ByteDance server is strict: it expects every entry
229
+ // in *_id_list to be a string ("201", "CT_11", "7621018569480046853") and
230
+ // 400s when a number sneaks through, which happens easily when the CLI flag
231
+ // looks numeric. Stringify everything coming in.
232
+ const asStringList = (v) => {
233
+ if (v === undefined)
234
+ return undefined;
235
+ const arr = Array.isArray(v) ? v : [v];
236
+ return arr.map(String);
237
+ };
238
+ const recruitmentIdList = asStringList(opts.recruitmentIdList) ?? ["201"];
239
+ const payload = {
240
+ keyword,
241
+ limit: pageSize,
242
+ offset,
243
+ portal_type: 3,
244
+ portal_entrance: 1,
245
+ language: "zh",
246
+ // "201" = 正式 (campus / new-grad) — matches the default 校园招聘 tab on the website.
247
+ // Without this filter the API returns ~7824 (campus + intern combined).
248
+ recruitment_id_list: recruitmentIdList,
249
+ };
250
+ // Inject optional filters only when caller explicitly provides them
251
+ const jobCategoryIdList = asStringList(opts.jobCategoryIdList);
252
+ if (jobCategoryIdList?.length) {
253
+ payload.job_category_id_list = jobCategoryIdList;
254
+ }
255
+ const cityIdList = asStringList(opts.cityIdList);
256
+ if (cityIdList?.length) {
257
+ payload.location_code_list = cityIdList;
258
+ }
259
+ const subjectIdList = asStringList(opts.subjectIdList);
260
+ if (subjectIdList?.length) {
261
+ payload.subject_id_list = subjectIdList;
262
+ }
263
+ const response = await call("/search/job/posts", payload);
264
+ if (!response.ok || !response.data) {
265
+ return {
266
+ ok: false,
267
+ message: response.message,
268
+ source: "jobs.bytedance.com",
269
+ query: payload,
270
+ positions: [],
271
+ };
272
+ }
273
+ const rows = response.data.job_post_list ?? [];
274
+ return {
275
+ ok: true,
276
+ source: "jobs.bytedance.com",
277
+ query: payload,
278
+ page,
279
+ page_size: pageSize,
280
+ total: response.data.count ?? rows.length,
281
+ positions: rows.map(summarizePosition),
282
+ };
283
+ }
284
+ // ---------- fetchAllPositions ----------
285
+ export async function fetchAllPositions(opts = {}) {
286
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
287
+ const maxPages = Math.max(1, opts.maxPages ?? 5); // cap at 5 pages (500 posts)
288
+ const bucket = [];
289
+ let total;
290
+ for (let page = 1; page <= maxPages; page++) {
291
+ const result = await searchPositions({
292
+ ...opts,
293
+ page,
294
+ pageSize,
295
+ });
296
+ if (!result.ok) {
297
+ return {
298
+ ok: false,
299
+ message: result.message,
300
+ source: "jobs.bytedance.com",
301
+ fetched: bucket.length,
302
+ positions: bucket,
303
+ };
304
+ }
305
+ if (total === undefined)
306
+ total = result.total;
307
+ if (!result.positions.length)
308
+ break;
309
+ bucket.push(...result.positions);
310
+ if (total !== undefined && bucket.length >= total)
311
+ break;
312
+ }
313
+ return {
314
+ ok: true,
315
+ source: "jobs.bytedance.com",
316
+ total: total ?? bucket.length,
317
+ fetched: bucket.length,
318
+ positions: bucket,
319
+ };
320
+ }
321
+ // ---------- fetchPositionDetail ----------
322
+ // ByteDance has no public per-post detail endpoint.
323
+ // We paginate the search at offset 0,100,200,... (up to 5 pages of 100)
324
+ // and filter by id to reconstruct a detail-like object.
325
+ export async function fetchPositionDetail(postId) {
326
+ const id = (postId ?? "").trim();
327
+ if (!id)
328
+ return { ok: false, source: "jobs.bytedance.com", message: "post_id is required" };
329
+ const pageSize = 100;
330
+ const maxPages = 5;
331
+ for (let page = 1; page <= maxPages; page++) {
332
+ const offset = (page - 1) * pageSize;
333
+ const payload = {
334
+ keyword: "",
335
+ limit: pageSize,
336
+ offset,
337
+ portal_type: 3,
338
+ portal_entrance: 1,
339
+ language: "zh",
340
+ recruitment_id_list: ["201"],
341
+ };
342
+ const response = await call("/search/job/posts", payload);
343
+ if (!response.ok || !response.data)
344
+ break;
345
+ const posts = response.data.job_post_list ?? [];
346
+ const found = posts.find((p) => String(p.id) === id);
347
+ if (found) {
348
+ const summary = summarizePosition(found);
349
+ return {
350
+ ok: true,
351
+ source: "jobs.bytedance.com",
352
+ post_id: id,
353
+ title: found.title ?? "",
354
+ direction: found.sub_title ?? "",
355
+ description: found.description ?? "",
356
+ requirements: found.requirement ?? "",
357
+ work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
358
+ recruit_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
359
+ apply_url: summary.apply_url,
360
+ };
361
+ }
362
+ // If this page returned fewer than pageSize, no more pages exist
363
+ if (posts.length < pageSize)
364
+ break;
365
+ }
366
+ return {
367
+ ok: false,
368
+ source: "jobs.bytedance.com",
369
+ post_id: id,
370
+ message: `post ${id} not found in public search results (searched up to ${maxPages * pageSize} posts)`,
371
+ };
372
+ }
373
+ // ---------- fetchDictionaries ----------
374
+ // GET /api/v1/config/job/filters/campus returns the full filter taxonomy.
375
+ // The {id} path segment is ignored by the server — all values return the same data.
376
+ // We cache the result in-process so repeated calls (e.g. autocomplete + search) don't
377
+ // double-fetch. Cache is valid for the lifetime of the Node process.
378
+ let _filterCache = null;
379
+ export async function fetchDictionaries() {
380
+ if (_filterCache !== null)
381
+ return _filterCache;
382
+ const url = `${API_ROOT}/config/job/filters/campus`;
383
+ let response;
384
+ try {
385
+ response = await fetch(url, { headers: DEFAULT_HEADERS });
386
+ }
387
+ catch (err) {
388
+ const r = {
389
+ ok: false,
390
+ source: "jobs.bytedance.com",
391
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
392
+ };
393
+ _filterCache = r;
394
+ return r;
395
+ }
396
+ if (!response.ok) {
397
+ const r = {
398
+ ok: false,
399
+ source: "jobs.bytedance.com",
400
+ message: `HTTP ${response.status}`,
401
+ };
402
+ _filterCache = r;
403
+ return r;
404
+ }
405
+ let payload;
406
+ try {
407
+ payload = await response.json();
408
+ }
409
+ catch (err) {
410
+ const r = {
411
+ ok: false,
412
+ source: "jobs.bytedance.com",
413
+ message: `bad JSON: ${err instanceof Error ? err.message : String(err)}`,
414
+ };
415
+ _filterCache = r;
416
+ return r;
417
+ }
418
+ if (payload.code !== 0 || !payload.data) {
419
+ const r = {
420
+ ok: false,
421
+ source: "jobs.bytedance.com",
422
+ message: payload.message ?? "upstream error",
423
+ };
424
+ _filterCache = r;
425
+ return r;
426
+ }
427
+ const d = payload.data;
428
+ // Normalise job_type_list (職位類別) into flat + hierarchical views
429
+ const jobCategories = (d.job_type_list ?? []).map((cat) => ({
430
+ id: cat.id ?? "",
431
+ name: cat.name ?? "",
432
+ en_name: cat.en_name ?? "",
433
+ depth: cat.depth ?? 1,
434
+ parent_id: cat.parent?.id ?? null,
435
+ children: (cat.children ?? []).map((c) => ({
436
+ id: c.id ?? "",
437
+ name: c.name ?? "",
438
+ en_name: c.en_name ?? "",
439
+ })),
440
+ }));
441
+ // Normalise city_list
442
+ const cities = (d.city_list ?? []).map((c) => ({
443
+ code: c.code ?? "",
444
+ name: c.name ?? "",
445
+ en_name: c.en_name ?? "",
446
+ }));
447
+ // Normalise job_subject_list (项目 / special programs)
448
+ const subjects = (d.job_subject_list ?? []).map((s) => ({
449
+ id: s.id ?? "",
450
+ name: s.name?.zh_cn ?? s.name?.i18n ?? "",
451
+ group: s.subject_group_info?.name ?? "",
452
+ group_en: s.subject_group_info?.en_name ?? s.subject_group_info?.i18n_name ?? "",
453
+ }));
454
+ // recruitment_type_list is null in the public response; expose as static known values
455
+ const recruitmentTypes = [
456
+ { id: "201", name: "正式", note: "campus / new-grad (~2057 posts)" },
457
+ { id: "202", name: "实习", note: "intern (~5767 posts)" },
458
+ { id: "301", name: "其他", note: "reserved, currently 0 active posts" },
459
+ ];
460
+ const result = {
461
+ ok: true,
462
+ source: "jobs.bytedance.com",
463
+ jobCategories,
464
+ cities,
465
+ subjects,
466
+ recruitmentTypes,
467
+ };
468
+ _filterCache = result;
469
+ return result;
470
+ }
471
+ // ---------- stub notices ----------
472
+ const STUB_NOTICES = {
473
+ ok: false,
474
+ source: "jobs.bytedance.com",
475
+ message: "ByteDance: no public notices endpoint",
476
+ };
477
+ export async function listNotices() {
478
+ return STUB_NOTICES;
479
+ }
480
+ export async function getNotice(_id) {
481
+ return {
482
+ ok: false,
483
+ source: "jobs.bytedance.com",
484
+ message: "ByteDance: no public notices endpoint",
485
+ };
486
+ }
487
+ export async function findNoticesByQuestion(_question, _opts = {}) {
488
+ return {
489
+ ok: false,
490
+ source: "jobs.bytedance.com",
491
+ message: "ByteDance: no public notices endpoint",
492
+ };
493
+ }
494
+ // ---------- matchResume ----------
495
+ // Mirror tencent's algorithm:
496
+ // 1. Extract signals from resume text.
497
+ // 2. Search with top-3 terms as keyword (description is already in search results).
498
+ // 3. Score each post against title + description + requirement + city + recruit_type blob.
499
+ // 4. Return top N matches with reasons.
500
+ export async function matchResume(text, opts = {}) {
501
+ const topN = Math.max(1, opts.topN ?? 5);
502
+ const candidates = Math.max(topN, opts.candidates ?? 20);
503
+ const { terms, cities } = extractResumeSignals(text ?? "");
504
+ if (!terms.length) {
505
+ return {
506
+ ok: false,
507
+ source: "jobs.bytedance.com",
508
+ message: "could not extract any technical signals from the text",
509
+ preview: (text ?? "").slice(0, 120),
510
+ };
511
+ }
512
+ const keyword = terms.slice(0, 3).join(" ");
513
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
514
+ if (!list.ok) {
515
+ return { ok: false, source: "jobs.bytedance.com", message: list.message, positions: [] };
516
+ }
517
+ // Re-fetch raw posts to access description + requirement fields
518
+ const payload = {
519
+ keyword,
520
+ limit: 100,
521
+ offset: 0,
522
+ portal_type: 3,
523
+ portal_entrance: 1,
524
+ language: "zh",
525
+ recruitment_id_list: ["201"],
526
+ };
527
+ const raw = await call("/search/job/posts", payload);
528
+ const rawPosts = raw.ok ? (raw.data?.job_post_list ?? []) : [];
529
+ // Build a lookup from id → raw post for blob scoring
530
+ const rawById = new Map();
531
+ for (const p of rawPosts) {
532
+ rawById.set(String(p.id ?? ""), p);
533
+ }
534
+ const scored = [];
535
+ for (const p of list.positions) {
536
+ const rp = rawById.get(p.post_id);
537
+ const blob = [
538
+ p.title,
539
+ p.project,
540
+ p.recruit_label,
541
+ p.work_cities,
542
+ rp?.description ?? "",
543
+ rp?.requirement ?? "",
544
+ ].join(" ");
545
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
546
+ if (score > 0) {
547
+ scored.push({
548
+ score,
549
+ position: p,
550
+ reasons,
551
+ description: rp?.description,
552
+ requirements: rp?.requirement,
553
+ });
554
+ }
555
+ }
556
+ scored.sort((a, b) => b.score - a.score);
557
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
558
+ if (!shortlist.length) {
559
+ // Fall back: return first N positions with score 0
560
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
561
+ score: 0,
562
+ position,
563
+ reasons: [],
564
+ description: rawById.get(position.post_id)?.description,
565
+ requirements: rawById.get(position.post_id)?.requirement,
566
+ }));
567
+ }
568
+ const matches = shortlist.slice(0, topN).map((s) => {
569
+ const mr = s.reasons.length > 0
570
+ ? s.reasons.slice(0, 5)
571
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
572
+ return {
573
+ ...s.position,
574
+ description: s.description,
575
+ requirements: s.requirements,
576
+ match_reasons: mr,
577
+ };
578
+ });
579
+ return {
580
+ ok: true,
581
+ source: "jobs.bytedance.com",
582
+ extracted_terms: terms,
583
+ city_preferences: cities,
584
+ matches,
585
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
586
+ "The only authority on selection is HR.",
587
+ };
588
+ }
589
+ import { buildBespokeApplySchema as _buildBespokeApplySchema_bytedance } from "./apply.js";
590
+ export async function fetchApplicationSchema(postId) {
591
+ const id = (postId ?? "").trim();
592
+ if (!id)
593
+ return { ok: false, source: "jobs.bytedance.com", message: "post_id is required" };
594
+ let title = "";
595
+ let applyUrl = "https://jobs.bytedance.com";
596
+ try {
597
+ const detail = (await fetchPositionDetail(id));
598
+ if (detail?.ok === false) {
599
+ return { ok: false, source: "jobs.bytedance.com", message: detail.message ?? "post not found" };
600
+ }
601
+ title = detail?.title ?? "";
602
+ if (detail?.apply_url)
603
+ applyUrl = detail.apply_url;
604
+ }
605
+ catch { }
606
+ return {
607
+ ok: true,
608
+ schema: _buildBespokeApplySchema_bytedance({
609
+ source: "jobs.bytedance.com",
610
+ postId: id,
611
+ jobTitle: title,
612
+ applyUrl,
613
+ submitEndpoint: "https://jobs.bytedance.com/api/v1/user/applications",
614
+ submitKind: "feishu-3-step",
615
+ endpointVerified: true,
616
+ submitNotes: "ByteDance — POST /api/v1/user/applications. jobs.bytedance.com is an atsx-throne (Feishu) tenant, so it uses Feishu's 3-step apply flow: POST /api/v1/attachment/upload/tokens → PUT presigned URL → POST /api/v1/user/applications with { post_id, attachment_id, applicant_info }. Endpoint anon-probed → HTTP 405 (same route as Feishu adapters; verified in 1.0.62). CAPTCHA verification required for first-time applicants; session cookies via extension.",
617
+ }),
618
+ };
619
+ }
@@ -0,0 +1,56 @@
1
+ // 菜鸟 (Cainiao Network) careers adapter — Liepin aggregator fallback.
2
+ //
3
+ // Cainiao's own careers subdomains (campus / recruit / job.cainiao.com)
4
+ // resolve only on Alibaba-Group-internal DNS. Public-facing positions
5
+ // don't surface through the parent Alibaba feed either
6
+ // (`job-pro alibaba search 菜鸟` → total=0). We surface real
7
+ // currently-open Cainiao positions by querying Liepin
8
+ // (api-c.liepin.com) filtered by compName="菜鸟网络". See
9
+ // `cli/src/liepin.ts` for the shared factory.
10
+ //
11
+ // Source: api-c.liepin.com (`source` field on responses) — clearly NOT
12
+ // the same as Cainiao's own portal.
13
+ import { createAdapter } from "./liepin.js";
14
+ const adapter = createAdapter({
15
+ companyName: "菜鸟网络",
16
+ label: "Cainiao / 菜鸟",
17
+ });
18
+ export const searchPositions = adapter.searchPositions;
19
+ export const fetchAllPositions = adapter.fetchAllPositions;
20
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
21
+ export const fetchDictionaries = adapter.fetchDictionaries;
22
+ export const listNotices = adapter.listNotices;
23
+ export const getNotice = adapter.getNotice;
24
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
25
+ export const matchResume = adapter.matchResume;
26
+ export const checkResume = adapter.checkResume;
27
+ import { buildBespokeApplySchema as _buildBespokeApplySchema_cainiao } from "./apply.js";
28
+ export async function fetchApplicationSchema(postId) {
29
+ const id = (postId ?? "").trim();
30
+ if (!id)
31
+ return { ok: false, source: "cainiao.com (via api-c.liepin.com)", message: "post_id is required" };
32
+ let title = "";
33
+ let applyUrl = "https://cainiao.com";
34
+ try {
35
+ const detail = (await fetchPositionDetail(id));
36
+ if (detail?.ok === false) {
37
+ return { ok: false, source: "cainiao.com (via api-c.liepin.com)", message: detail.message ?? "post not found" };
38
+ }
39
+ title = detail?.title ?? "";
40
+ if (detail?.apply_url)
41
+ applyUrl = detail.apply_url;
42
+ }
43
+ catch { }
44
+ return {
45
+ ok: true,
46
+ schema: _buildBespokeApplySchema_cainiao({
47
+ source: "cainiao.com (via api-c.liepin.com)",
48
+ postId: id,
49
+ jobTitle: title,
50
+ applyUrl,
51
+ submitEndpoint: undefined,
52
+ submitKind: "external",
53
+ submitNotes: "Cainiao (Liepin-backed) — submission is recruiter-IM-mediated through Liepin. Open the apply_url to start the chat.",
54
+ }),
55
+ };
56
+ }