@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
|
@@ -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
|
+
}
|
package/dist/cainiao.js
ADDED
|
@@ -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
|
+
}
|