job-pro 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/alibaba.js +478 -0
- package/dist/baidu.js +421 -0
- package/dist/bilibili.js +381 -0
- package/dist/bytedance.js +588 -0
- package/dist/didi.js +350 -0
- package/dist/index.js +143 -49
- package/dist/jd.js +528 -0
- package/dist/kuaishou.js +465 -0
- package/dist/meituan.js +602 -0
- package/dist/netease.js +393 -0
- package/dist/tencent.js +146 -13
- package/dist/xiaohongshu.js +449 -0
- package/dist/xiaomi.js +520 -0
- package/package.json +7 -2
package/dist/didi.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Thin client for Didi's public job portal API at talent.didiglobal.com.
|
|
2
|
+
//
|
|
3
|
+
// ============================================================
|
|
4
|
+
// API DISCOVERY NOTES (probed 2026-05):
|
|
5
|
+
//
|
|
6
|
+
// campus.didiglobal.com — Moka white-label campus site (redirects to /campus_apply/didiglobal/96064).
|
|
7
|
+
// All data endpoints return AES-encrypted blobs {"data":"...","necromancer":"..."}.
|
|
8
|
+
// Cannot be decoded without the JS runtime cipher. BLOCKED.
|
|
9
|
+
//
|
|
10
|
+
// talent.didiglobal.com — Didi's self-hosted recruiting portal. Serves all open positions
|
|
11
|
+
// (campus + social hire combined, 1200+ active listings).
|
|
12
|
+
// Public, unauthenticated, no CORS restrictions.
|
|
13
|
+
//
|
|
14
|
+
// talent.didiglobal.com/recruit-portal-service/api/job/front/list — live ✓
|
|
15
|
+
// talent.didiglobal.com/recruit-portal-service/api/job/front/view/{jdId} — live ✓
|
|
16
|
+
// talent.didiglobal.com/recruit-portal-service/api/job/job_locations — live ✓
|
|
17
|
+
// talent.didiglobal.com/recruit-portal-service/api/job/jdpublish/confirm/listJdTypes — live ✓
|
|
18
|
+
//
|
|
19
|
+
// ============================================================
|
|
20
|
+
// Endpoint: GET /recruit-portal-service/api/job/front/list
|
|
21
|
+
// Query params:
|
|
22
|
+
// jobName — keyword filter (URL-encoded, e.g. "算法")
|
|
23
|
+
// workArea — city name filter, e.g. "北京市" (from /api/job/job_locations list)
|
|
24
|
+
// jobType — job category code (integer, see taxonomy below)
|
|
25
|
+
// recruitType — declared but NOT enforced server-side; returns same 1213 regardless of value
|
|
26
|
+
// page — 1-indexed page number
|
|
27
|
+
// size — page size; server ignores values != 16 and always returns 16 items/page
|
|
28
|
+
// Response: { meta:{api,method,code:0,message}, data:{total,items:[...],page,size} }
|
|
29
|
+
//
|
|
30
|
+
// ============================================================
|
|
31
|
+
// Filter taxonomy (verified 2026-05):
|
|
32
|
+
//
|
|
33
|
+
// jobType codes (from GET /api/job/jdpublish/confirm/listJdTypes):
|
|
34
|
+
// 1=技术 (~416) 2=设计 (~20) 3=产品 (~101) 4=数据 (~68)
|
|
35
|
+
// 5=运营 (~382) 6=销售 (~54) 7=客服 9=市场 (~18)
|
|
36
|
+
// 10=人力 (~18) 11=行政 12=财务 13=法务
|
|
37
|
+
// 14=公关 15=战略 16=风控 18=安全 (~47)
|
|
38
|
+
// 19=供应链 20=采购
|
|
39
|
+
//
|
|
40
|
+
// workArea city values (from GET /api/job/job_locations, 52 total):
|
|
41
|
+
// Top cities (2026-05): 北京市 (~838) 深圳市 上海市 杭州市 成都市 广州市
|
|
42
|
+
// Also: 武汉市 天津市 南京市 西安市 重庆市 厦门市 香港岛 九龙
|
|
43
|
+
// International: Mexico City Sao Paulo
|
|
44
|
+
//
|
|
45
|
+
// ============================================================
|
|
46
|
+
// Site URL pattern for campus-tab positions (talent.didiglobal.com):
|
|
47
|
+
// The portal has four tabs: 社会招聘 (social) / 校园招聘 (campus) / 实习生招聘 (intern) / 内推
|
|
48
|
+
// The API returns all listings without tab-level filtering — both campus (JR-prefix jdNo) and
|
|
49
|
+
// social (J-prefix jdNo) positions are included in every response.
|
|
50
|
+
// There is no public API filter to isolate campus-only listings.
|
|
51
|
+
// The campus.didiglobal.com (Moka) site would expose campus-only data but uses client-side AES
|
|
52
|
+
// encryption that cannot be bypassed without executing Moka's JavaScript.
|
|
53
|
+
//
|
|
54
|
+
// ============================================================
|
|
55
|
+
// Page size is always 16 (server-enforced). To fetch more positions use fetchAllPositions()
|
|
56
|
+
// which paginates up to maxPages.
|
|
57
|
+
//
|
|
58
|
+
// ============================================================
|
|
59
|
+
// ---- PositionSummary field mapping (Didi → canonical) ----
|
|
60
|
+
// post_id ← jdId (stringified) or jdNo as fallback
|
|
61
|
+
// title ← jobName (stripped of trailing "(jdNo)" suffix that Didi appends)
|
|
62
|
+
// project ← deptName (closest to Tencent's projectName / BG)
|
|
63
|
+
// recruit_label ← "" (recruitType field exists in /view but not in list response; campus vs social
|
|
64
|
+
// cannot be distinguished from the list API)
|
|
65
|
+
// bgs ← "" (Didi does not expose BG / 事业群 in public search)
|
|
66
|
+
// work_cities ← workArea
|
|
67
|
+
// apply_url ← https://talent.didiglobal.com/campus#/position/{jdId}/detail
|
|
68
|
+
import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
|
|
69
|
+
export { checkResume };
|
|
70
|
+
const API_ROOT = "https://talent.didiglobal.com/recruit-portal-service/api";
|
|
71
|
+
const PORTAL_PAGE = "https://talent.didiglobal.com/";
|
|
72
|
+
const DETAIL_PAGE = (jdId) => `https://talent.didiglobal.com/campus#/position/${encodeURIComponent(jdId)}/detail`;
|
|
73
|
+
const DEFAULT_HEADERS = {
|
|
74
|
+
"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",
|
|
75
|
+
Accept: "application/json, text/plain, */*",
|
|
76
|
+
Referer: PORTAL_PAGE,
|
|
77
|
+
};
|
|
78
|
+
async function call(path, params = {}) {
|
|
79
|
+
// Build query string — omit undefined values
|
|
80
|
+
const qs = Object.entries(params)
|
|
81
|
+
.filter(([, v]) => v !== undefined && v !== "")
|
|
82
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
83
|
+
.join("&");
|
|
84
|
+
const url = `${API_ROOT}${path}${qs ? "?" + qs : ""}`;
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await fetch(url, { headers: DEFAULT_HEADERS });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
message: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
|
|
97
|
+
}
|
|
98
|
+
let payload;
|
|
99
|
+
try {
|
|
100
|
+
payload = (await response.json());
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
104
|
+
}
|
|
105
|
+
const code = payload.meta?.code ?? 0;
|
|
106
|
+
return {
|
|
107
|
+
ok: code === 0,
|
|
108
|
+
data: payload.data,
|
|
109
|
+
message: payload.meta?.message || (code === 0 ? "ok" : "upstream error"),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Strip the "(JR2026XXXXXXX)" suffix that Didi appends to jobName in the list endpoint. */
|
|
113
|
+
function stripJdNoSuffix(jobName, jdNo) {
|
|
114
|
+
if (!jdNo)
|
|
115
|
+
return jobName;
|
|
116
|
+
const suffix = ` (${jdNo})`;
|
|
117
|
+
return jobName.endsWith(suffix) ? jobName.slice(0, -suffix.length) : jobName;
|
|
118
|
+
}
|
|
119
|
+
function summarizePosition(item) {
|
|
120
|
+
const jdId = String(item.jdId ?? "");
|
|
121
|
+
const rawName = item.jobName ?? "";
|
|
122
|
+
const title = stripJdNoSuffix(rawName, item.jdNo);
|
|
123
|
+
return {
|
|
124
|
+
post_id: jdId || (item.jdNo ?? ""),
|
|
125
|
+
title,
|
|
126
|
+
project: item.deptName ?? "",
|
|
127
|
+
recruit_label: "", // not available in list response
|
|
128
|
+
bgs: "",
|
|
129
|
+
work_cities: (item.workArea ?? "").trim(),
|
|
130
|
+
apply_url: jdId ? DETAIL_PAGE(jdId) : PORTAL_PAGE,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// ---------- searchPositions ----------
|
|
134
|
+
export async function searchPositions(opts = {}) {
|
|
135
|
+
const page = Math.max(1, opts.page ?? 1);
|
|
136
|
+
const keyword = (opts.keyword ?? "").trim().slice(0, 60);
|
|
137
|
+
const params = {
|
|
138
|
+
page,
|
|
139
|
+
size: 16, // server enforces 16; pass it explicitly for clarity
|
|
140
|
+
...(keyword ? { jobName: keyword } : {}),
|
|
141
|
+
...(opts.workArea ? { workArea: opts.workArea } : {}),
|
|
142
|
+
...(opts.jobType !== undefined ? { jobType: opts.jobType } : {}),
|
|
143
|
+
};
|
|
144
|
+
const response = await call("/job/front/list", params);
|
|
145
|
+
if (!response.ok || !response.data) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
message: response.message,
|
|
149
|
+
source: "talent.didiglobal.com",
|
|
150
|
+
query: params,
|
|
151
|
+
total: 0,
|
|
152
|
+
positions: [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const rows = response.data.items ?? [];
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
source: "talent.didiglobal.com",
|
|
159
|
+
query: params,
|
|
160
|
+
page,
|
|
161
|
+
page_size: rows.length, // actual count (always ≤ 16)
|
|
162
|
+
total: response.data.total ?? rows.length,
|
|
163
|
+
positions: rows.map(summarizePosition),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// ---------- fetchAllPositions ----------
|
|
167
|
+
export async function fetchAllPositions(opts = {}) {
|
|
168
|
+
const maxPages = Math.max(1, opts.maxPages ?? 10); // default: up to 160 posts
|
|
169
|
+
const bucket = [];
|
|
170
|
+
let total;
|
|
171
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
172
|
+
const result = await searchPositions({ ...opts, page });
|
|
173
|
+
if (!result.ok) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
message: result.message,
|
|
177
|
+
source: "talent.didiglobal.com",
|
|
178
|
+
fetched: bucket.length,
|
|
179
|
+
positions: bucket,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (total === undefined)
|
|
183
|
+
total = result.total;
|
|
184
|
+
if (!result.positions.length)
|
|
185
|
+
break;
|
|
186
|
+
bucket.push(...result.positions);
|
|
187
|
+
if (total !== undefined && bucket.length >= total)
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
source: "talent.didiglobal.com",
|
|
193
|
+
total: total ?? bucket.length,
|
|
194
|
+
fetched: bucket.length,
|
|
195
|
+
positions: bucket,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// ---------- fetchPositionDetail ----------
|
|
199
|
+
export async function fetchPositionDetail(postId) {
|
|
200
|
+
const id = (postId ?? "").trim();
|
|
201
|
+
if (!id)
|
|
202
|
+
return { ok: false, source: "talent.didiglobal.com", message: "post_id is required" };
|
|
203
|
+
const response = await call(`/job/front/view/${encodeURIComponent(id)}`);
|
|
204
|
+
if (!response.ok || !response.data) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
source: "talent.didiglobal.com",
|
|
208
|
+
post_id: id,
|
|
209
|
+
message: response.message || "no detail returned",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const raw = response.data;
|
|
213
|
+
const jdId = String(raw.jdId ?? id);
|
|
214
|
+
const rawName = raw.jobName ?? "";
|
|
215
|
+
const title = stripJdNoSuffix(rawName, raw.jdNo);
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
source: "talent.didiglobal.com",
|
|
219
|
+
post_id: jdId,
|
|
220
|
+
jd_no: raw.jdNo ?? "",
|
|
221
|
+
title,
|
|
222
|
+
project: raw.deptName ?? "",
|
|
223
|
+
recruit_label: raw.recruitType ?? "",
|
|
224
|
+
description: raw.jobDesc ?? "",
|
|
225
|
+
requirements: raw.qualification ?? "",
|
|
226
|
+
work_cities: (raw.workArea ?? "").trim(),
|
|
227
|
+
job_type: raw.jobType ?? "",
|
|
228
|
+
recruit_num: raw.recruitNum ?? null,
|
|
229
|
+
publish_time: raw.publishTime ?? "",
|
|
230
|
+
apply_url: DETAIL_PAGE(jdId),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// ---------- fetchDictionaries ----------
|
|
234
|
+
export async function fetchDictionaries() {
|
|
235
|
+
const [locations, jobTypes] = await Promise.all([
|
|
236
|
+
call("/job/job_locations"),
|
|
237
|
+
call("/job/jdpublish/confirm/listJdTypes"),
|
|
238
|
+
]);
|
|
239
|
+
return {
|
|
240
|
+
ok: locations.ok && jobTypes.ok,
|
|
241
|
+
source: "talent.didiglobal.com",
|
|
242
|
+
cities: locations.data ?? [],
|
|
243
|
+
job_types: (jobTypes.data ?? []).map((jt) => ({ code: jt.code, name: jt.name })),
|
|
244
|
+
note: [
|
|
245
|
+
"cities: pass as workArea filter (exact string match, e.g. '北京市').",
|
|
246
|
+
"job_types: pass as jobType filter (integer code, e.g. 1 for 技术).",
|
|
247
|
+
"recruitType filter is declared but NOT enforced — all values return the full dataset.",
|
|
248
|
+
"Page size is server-fixed at 16 items/page regardless of size param.",
|
|
249
|
+
].join(" "),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// ---------- stub notices ----------
|
|
253
|
+
const STUB_NOTICES = {
|
|
254
|
+
ok: false,
|
|
255
|
+
source: "talent.didiglobal.com",
|
|
256
|
+
message: "Didi: no public notices/announcements endpoint on talent.didiglobal.com",
|
|
257
|
+
};
|
|
258
|
+
export async function listNotices() {
|
|
259
|
+
return STUB_NOTICES;
|
|
260
|
+
}
|
|
261
|
+
export async function getNotice(_id) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
source: "talent.didiglobal.com",
|
|
265
|
+
message: "Didi: no public notices endpoint",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
export async function findNoticesByQuestion(_question, _opts = {}) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
source: "talent.didiglobal.com",
|
|
272
|
+
message: "Didi: no public notices endpoint",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// ---------- matchResume ----------
|
|
276
|
+
export async function matchResume(text, opts = {}) {
|
|
277
|
+
const topN = Math.max(1, opts.topN ?? 5);
|
|
278
|
+
const candidates = Math.max(topN, opts.candidates ?? 20);
|
|
279
|
+
const { terms, cities } = extractResumeSignals(text ?? "");
|
|
280
|
+
if (!terms.length) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
source: "talent.didiglobal.com",
|
|
284
|
+
message: "could not extract any technical signals from the text",
|
|
285
|
+
preview: (text ?? "").slice(0, 120),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const keyword = terms.slice(0, 3).join(" ");
|
|
289
|
+
const list = await searchPositions({ keyword, page: 1 });
|
|
290
|
+
if (!list.ok) {
|
|
291
|
+
return { ok: false, source: "talent.didiglobal.com", message: list.message, positions: [] };
|
|
292
|
+
}
|
|
293
|
+
const scored = [];
|
|
294
|
+
for (const p of list.positions) {
|
|
295
|
+
const blob = [p.title, p.project, p.recruit_label, p.work_cities].join(" ");
|
|
296
|
+
const { score, reasons } = scoreOverlap(blob, terms, cities);
|
|
297
|
+
if (score > 0) {
|
|
298
|
+
scored.push({ score, position: p, reasons });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
scored.sort((a, b) => b.score - a.score);
|
|
302
|
+
let shortlist = scored.slice(0, Math.max(topN, candidates));
|
|
303
|
+
if (!shortlist.length) {
|
|
304
|
+
shortlist = list.positions.slice(0, candidates).map((position) => ({
|
|
305
|
+
score: 0,
|
|
306
|
+
position,
|
|
307
|
+
reasons: [],
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
const enriched = [];
|
|
311
|
+
for (const entry of shortlist.slice(0, candidates)) {
|
|
312
|
+
const detail = await fetchPositionDetail(entry.position.post_id);
|
|
313
|
+
if (detail.ok) {
|
|
314
|
+
const jdBlob = [detail.description, detail.requirements, detail.work_cities].join(" ");
|
|
315
|
+
const { score: extraScore, reasons: extraReasons } = scoreOverlap(jdBlob, terms, cities);
|
|
316
|
+
const combined = [...new Set([...entry.reasons, ...extraReasons])].slice(0, 5);
|
|
317
|
+
enriched.push({
|
|
318
|
+
...entry,
|
|
319
|
+
score: entry.score + extraScore,
|
|
320
|
+
reasons: combined,
|
|
321
|
+
description: detail.description,
|
|
322
|
+
requirements: detail.requirements,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
enriched.push(entry);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
enriched.sort((a, b) => b.score - a.score);
|
|
330
|
+
const matches = enriched.slice(0, topN).map((s) => {
|
|
331
|
+
const mr = s.reasons.length > 0
|
|
332
|
+
? s.reasons.slice(0, 5)
|
|
333
|
+
: ["no specific keyword overlap — surfaced from initial keyword search"];
|
|
334
|
+
return {
|
|
335
|
+
...s.position,
|
|
336
|
+
description: s.description,
|
|
337
|
+
requirements: s.requirements,
|
|
338
|
+
match_reasons: mr,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
source: "talent.didiglobal.com",
|
|
344
|
+
extracted_terms: terms,
|
|
345
|
+
city_preferences: cities,
|
|
346
|
+
matches,
|
|
347
|
+
note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
|
|
348
|
+
"The only authority on selection is HR.",
|
|
349
|
+
};
|
|
350
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import
|
|
3
|
+
import * as tencent from "./tencent.js";
|
|
4
|
+
import * as bytedance from "./bytedance.js";
|
|
5
|
+
import * as alibaba from "./alibaba.js";
|
|
6
|
+
import * as meituan from "./meituan.js";
|
|
7
|
+
import * as xiaohongshu from "./xiaohongshu.js";
|
|
8
|
+
import * as jd from "./jd.js";
|
|
9
|
+
import * as kuaishou from "./kuaishou.js";
|
|
10
|
+
import * as xiaomi from "./xiaomi.js";
|
|
11
|
+
import * as baidu from "./baidu.js";
|
|
12
|
+
import * as netease from "./netease.js";
|
|
13
|
+
import * as didi from "./didi.js";
|
|
14
|
+
import * as bilibili from "./bilibili.js";
|
|
4
15
|
import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
|
|
5
|
-
const VERSION = "0.
|
|
16
|
+
const VERSION = "0.4.0";
|
|
6
17
|
const HELP = `
|
|
7
18
|
job-pro — query Chinese big-tech campus recruiting from your terminal
|
|
8
19
|
(job.ha7ch.com)
|
|
@@ -12,17 +23,28 @@ USAGE
|
|
|
12
23
|
job-pro --version
|
|
13
24
|
job-pro help
|
|
14
25
|
|
|
15
|
-
COMPANIES
|
|
16
|
-
tencent join.qq.com
|
|
26
|
+
COMPANIES
|
|
27
|
+
tencent join.qq.com (Tencent / 腾讯)
|
|
28
|
+
bytedance jobs.bytedance.com (ByteDance / 字节跳动)
|
|
29
|
+
alibaba campus-talent.alibaba.com (Alibaba / 阿里巴巴)
|
|
30
|
+
meituan zhaopin.meituan.com (Meituan / 美团)
|
|
31
|
+
xiaohongshu job.xiaohongshu.com (Xiaohongshu / 小红书)
|
|
32
|
+
jd campus.jd.com (JD / 京东)
|
|
33
|
+
kuaishou campus.kuaishou.cn (Kuaishou / 快手)
|
|
34
|
+
xiaomi xiaomi.jobs.f.mioffice.cn (Xiaomi / 小米 — Feishu ATSX)
|
|
35
|
+
baidu talent.baidu.com (Baidu / 百度)
|
|
36
|
+
netease hr.163.com (NetEase / 网易)
|
|
37
|
+
didi talent.didiglobal.com (Didi / 滴滴 — mixed campus+social)
|
|
38
|
+
bilibili jobs.bilibili.com (Bilibili / 哔哩哔哩)
|
|
17
39
|
|
|
18
|
-
VERBS (for
|
|
19
|
-
search <kw> search openings (
|
|
40
|
+
VERBS (same surface for every company)
|
|
41
|
+
search <kw> search openings (free text)
|
|
20
42
|
detail <post_id> show full JD for one job
|
|
21
43
|
all [<kw>] paginate every job (filter by kw if given)
|
|
22
|
-
dicts dump filter dictionaries (
|
|
23
|
-
notices list official announcements
|
|
24
|
-
notice <id> show one announcement's
|
|
25
|
-
flow <question> answer
|
|
44
|
+
dicts dump filter dictionaries (where supported)
|
|
45
|
+
notices list official announcements (where supported)
|
|
46
|
+
notice <id> show one announcement's content (tencent only)
|
|
47
|
+
flow <question> answer using best-matching notices (tencent only)
|
|
26
48
|
match <resume-text-or--> rank jobs by overlap with resume text
|
|
27
49
|
pass "-" to read resume from stdin
|
|
28
50
|
resume-check <resume-text-or--> structural sanity check on a resume
|
|
@@ -33,12 +55,16 @@ OUTPUT
|
|
|
33
55
|
|
|
34
56
|
EXAMPLES
|
|
35
57
|
job-pro tencent search "后台开发" --page-size 5
|
|
58
|
+
job-pro bytedance search "前端" --page-size 5
|
|
59
|
+
job-pro alibaba search "AI" --page-size 5
|
|
36
60
|
job-pro tencent detail 1200791473415778304
|
|
61
|
+
job-pro bytedance detail 7638940721068099893
|
|
62
|
+
job-pro alibaba detail 199903220038
|
|
37
63
|
job-pro tencent notices
|
|
38
64
|
job-pro tencent flow "腾讯2026实习什么时候开始投递" --question-time 2026-05-13
|
|
39
65
|
cat my-resume.md | job-pro tencent match -
|
|
40
66
|
job-pro tencent memory set "stack=Go,Python" "target_city=深圳"
|
|
41
|
-
job-pro
|
|
67
|
+
job-pro bytedance memory event applied "ByteDance 前端 7638940721068099893"
|
|
42
68
|
|
|
43
69
|
DOCS
|
|
44
70
|
https://job.ha7ch.com
|
|
@@ -61,15 +87,72 @@ function popFlagValue(args, name) {
|
|
|
61
87
|
out.splice(i, 2);
|
|
62
88
|
return { args: out, value };
|
|
63
89
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
// Generic flag harvester: walk the remaining args, pull every `--<flag> <value>`
|
|
91
|
+
// pair into an options bag (kebab-case → camelCase), parse CSVs to arrays and
|
|
92
|
+
// integer-looking values to numbers, and return the positional args left over.
|
|
93
|
+
// This is what lets adapter-specific filters like `--bg-ids 956,29294`,
|
|
94
|
+
// `--cities 北京,上海`, `--recruitment-id-list 201,202`, `--batch-id 100000560002`,
|
|
95
|
+
// `--recruit-type social` flow straight into the adapter's SearchOptions.
|
|
96
|
+
function kebabToCamel(s) {
|
|
97
|
+
return s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
98
|
+
}
|
|
99
|
+
function parseScalar(v) {
|
|
100
|
+
if (v === "true")
|
|
101
|
+
return true;
|
|
102
|
+
if (v === "false")
|
|
103
|
+
return false;
|
|
104
|
+
if (/^-?\d+$/.test(v))
|
|
105
|
+
return Number(v);
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
function parseValue(v) {
|
|
109
|
+
if (v.includes(","))
|
|
110
|
+
return v.split(",").map((p) => parseScalar(p.trim()));
|
|
111
|
+
return parseScalar(v);
|
|
112
|
+
}
|
|
113
|
+
// Adapter SearchOptions whose names look like plurals / id lists must always
|
|
114
|
+
// receive an array, so `--bg-ids 29294` (single value) becomes `[29294]`,
|
|
115
|
+
// not `29294`. Multi-value via CSV (`--bg-ids 29294,956`) already arrays.
|
|
116
|
+
function maybeArrayWrap(key, value) {
|
|
117
|
+
if (Array.isArray(value))
|
|
118
|
+
return value;
|
|
119
|
+
if (/(?:Ids|IdList|List|Codes|Categories|Regions|Cities|Departments)$/.test(key)) {
|
|
120
|
+
return [value];
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
function popAllOpts(args) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const opts = {};
|
|
127
|
+
let i = 0;
|
|
128
|
+
while (i < args.length) {
|
|
129
|
+
const a = args[i];
|
|
130
|
+
if (a.startsWith("--") && a.length > 2) {
|
|
131
|
+
const key = kebabToCamel(a.slice(2));
|
|
132
|
+
const next = args[i + 1];
|
|
133
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
134
|
+
opts[key] = maybeArrayWrap(key, parseValue(next));
|
|
135
|
+
i += 2;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
opts[key] = true;
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out.push(a);
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { args: out, opts };
|
|
148
|
+
}
|
|
149
|
+
function emit(value, compact) {
|
|
150
|
+
if (compact) {
|
|
151
|
+
console.log(JSON.stringify(value));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log(JSON.stringify(value, null, 2));
|
|
155
|
+
}
|
|
73
156
|
}
|
|
74
157
|
function readResumeArg(arg) {
|
|
75
158
|
if (!arg)
|
|
@@ -91,56 +174,66 @@ function readResumeArg(arg) {
|
|
|
91
174
|
return arg;
|
|
92
175
|
}
|
|
93
176
|
}
|
|
94
|
-
|
|
177
|
+
const ADAPTERS = {
|
|
178
|
+
tencent,
|
|
179
|
+
bytedance: bytedance,
|
|
180
|
+
alibaba: alibaba,
|
|
181
|
+
meituan: meituan,
|
|
182
|
+
xiaohongshu: xiaohongshu,
|
|
183
|
+
jd: jd,
|
|
184
|
+
kuaishou: kuaishou,
|
|
185
|
+
xiaomi: xiaomi,
|
|
186
|
+
baidu: baidu,
|
|
187
|
+
netease: netease,
|
|
188
|
+
didi: didi,
|
|
189
|
+
bilibili: bilibili,
|
|
190
|
+
};
|
|
191
|
+
async function runCompany(adapter, company, rawArgs) {
|
|
95
192
|
const [verb, ...rest] = rawArgs;
|
|
96
193
|
if (!verb)
|
|
97
|
-
die(
|
|
194
|
+
die(`expected a verb. Try \`job-pro help\`.`);
|
|
98
195
|
const { args, compact } = popCompactFlag(rest);
|
|
99
196
|
if (verb === "search") {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return emit(await searchPositions({
|
|
197
|
+
const { args: positional, opts } = popAllOpts(args);
|
|
198
|
+
const keyword = positional.join(" ").trim();
|
|
199
|
+
return emit(await adapter.searchPositions({
|
|
104
200
|
keyword,
|
|
105
|
-
|
|
106
|
-
pageSize: pageSize ? Number(pageSize) : undefined,
|
|
201
|
+
...opts,
|
|
107
202
|
}), compact);
|
|
108
203
|
}
|
|
109
204
|
if (verb === "detail") {
|
|
110
205
|
const postId = args[0];
|
|
111
206
|
if (!postId)
|
|
112
|
-
die(
|
|
113
|
-
return emit(await fetchPositionDetail(postId), compact);
|
|
207
|
+
die(`usage: job-pro ${company} detail <post_id>`);
|
|
208
|
+
return emit(await adapter.fetchPositionDetail(postId), compact);
|
|
114
209
|
}
|
|
115
210
|
if (verb === "all") {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return emit(await fetchAllPositions({
|
|
211
|
+
const { args: positional, opts } = popAllOpts(args);
|
|
212
|
+
const keyword = positional.join(" ").trim();
|
|
213
|
+
return emit(await adapter.fetchAllPositions({
|
|
120
214
|
keyword,
|
|
121
|
-
|
|
122
|
-
pageSize: pageSize ? Number(pageSize) : undefined,
|
|
215
|
+
...opts,
|
|
123
216
|
}), compact);
|
|
124
217
|
}
|
|
125
218
|
if (verb === "dicts") {
|
|
126
|
-
return emit(await fetchDictionaries(), compact);
|
|
219
|
+
return emit(await adapter.fetchDictionaries(), compact);
|
|
127
220
|
}
|
|
128
221
|
if (verb === "notices") {
|
|
129
|
-
return emit(await listNotices(), compact);
|
|
222
|
+
return emit(await adapter.listNotices(), compact);
|
|
130
223
|
}
|
|
131
224
|
if (verb === "notice") {
|
|
132
225
|
const id = args[0];
|
|
133
226
|
if (!id)
|
|
134
|
-
die(
|
|
135
|
-
return emit(await getNotice(id), compact);
|
|
227
|
+
die(`usage: job-pro ${company} notice <id>`);
|
|
228
|
+
return emit(await adapter.getNotice(id), compact);
|
|
136
229
|
}
|
|
137
230
|
if (verb === "flow") {
|
|
138
231
|
const { args: a, value: questionTime } = popFlagValue(args, "--question-time");
|
|
139
232
|
const { args: a2, value: topK } = popFlagValue(a, "--top-k");
|
|
140
233
|
const question = a2.join(" ").trim();
|
|
141
234
|
if (!question)
|
|
142
|
-
die(
|
|
143
|
-
return emit(await findNoticesByQuestion(question, {
|
|
235
|
+
die(`usage: job-pro ${company} flow <question> [--question-time YYYY-MM-DD] [--top-k N]`);
|
|
236
|
+
return emit(await adapter.findNoticesByQuestion(question, {
|
|
144
237
|
questionTime,
|
|
145
238
|
topK: topK ? Number(topK) : undefined,
|
|
146
239
|
}), compact);
|
|
@@ -149,25 +242,25 @@ async function runTencent(rawArgs) {
|
|
|
149
242
|
const { args: a, value: topN } = popFlagValue(args, "--top-n");
|
|
150
243
|
const { args: a2, value: candidates } = popFlagValue(a, "--candidates");
|
|
151
244
|
const text = readResumeArg(a2[0]);
|
|
152
|
-
return emit(await matchResume(text, {
|
|
245
|
+
return emit(await adapter.matchResume(text, {
|
|
153
246
|
topN: topN ? Number(topN) : undefined,
|
|
154
247
|
candidates: candidates ? Number(candidates) : undefined,
|
|
155
248
|
}), compact);
|
|
156
249
|
}
|
|
157
250
|
if (verb === "resume-check") {
|
|
158
251
|
const text = readResumeArg(args[0]);
|
|
159
|
-
return emit(checkResume(text), compact);
|
|
252
|
+
return emit(adapter.checkResume(text), compact);
|
|
160
253
|
}
|
|
161
254
|
if (verb === "memory") {
|
|
162
255
|
const [sub, ...subArgs] = args;
|
|
163
256
|
if (!sub)
|
|
164
|
-
die(
|
|
257
|
+
die(`usage: job-pro ${company} memory <list|get|set|event|clear>`);
|
|
165
258
|
if (sub === "list")
|
|
166
259
|
return emit(memoryList(), compact);
|
|
167
260
|
if (sub === "get") {
|
|
168
261
|
const key = subArgs[0];
|
|
169
262
|
if (!key)
|
|
170
|
-
die(
|
|
263
|
+
die(`usage: job-pro ${company} memory get <key>`);
|
|
171
264
|
return emit(memoryGet(key), compact);
|
|
172
265
|
}
|
|
173
266
|
if (sub === "set") {
|
|
@@ -194,11 +287,12 @@ async function main() {
|
|
|
194
287
|
console.log(VERSION);
|
|
195
288
|
return;
|
|
196
289
|
}
|
|
197
|
-
|
|
198
|
-
|
|
290
|
+
const adapter = ADAPTERS[cmd];
|
|
291
|
+
if (adapter) {
|
|
292
|
+
await runCompany(adapter, cmd, args.slice(1));
|
|
199
293
|
return;
|
|
200
294
|
}
|
|
201
|
-
die(`unknown company: ${cmd}. Supported
|
|
295
|
+
die(`unknown company: ${cmd}. Supported: ${Object.keys(ADAPTERS).join(", ")}. Try \`job-pro help\`.`);
|
|
202
296
|
}
|
|
203
297
|
main().catch((err) => {
|
|
204
298
|
console.error("Error:", err instanceof Error ? err.message : err);
|