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/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 { fetchDictionaries, searchPositions, fetchPositionDetail, fetchAllPositions, listNotices, getNotice, findNoticesByQuestion, matchResume, checkResume, } from "./tencent.js";
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.1.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 (v0.1)
16
- tencent join.qq.com (Tencent / 腾讯)
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 tencent)
19
- search <kw> search openings (kw is free text, <=30 chars)
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 (BG, city, family…)
23
- notices list official announcements
24
- notice <id> show one announcement's full content
25
- flow <question> answer a question using best-matching notices
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 tencent memory event applied "腾讯后台 1200791473415778304"
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
- function emit(result, compact) {
65
- const text = compact
66
- ? JSON.stringify(result)
67
- : JSON.stringify(result, null, 2);
68
- console.log(text);
69
- const ok = typeof result === "object" && result !== null && "ok" in result
70
- ? Boolean(result.ok)
71
- : true;
72
- process.exit(ok ? 0 : 1);
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
- async function runTencent(rawArgs) {
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("expected a verb. Try `job-pro help`.");
194
+ die(`expected a verb. Try \`job-pro help\`.`);
98
195
  const { args, compact } = popCompactFlag(rest);
99
196
  if (verb === "search") {
100
- let { args: a, value: page } = popFlagValue(args, "--page");
101
- let { args: a2, value: pageSize } = popFlagValue(a, "--page-size");
102
- const keyword = a2.join(" ").trim();
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
- page: page ? Number(page) : undefined,
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("usage: job-pro tencent detail <post_id>");
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
- let { args: a, value: maxPages } = popFlagValue(args, "--max-pages");
117
- let { args: a2, value: pageSize } = popFlagValue(a, "--page-size");
118
- const keyword = a2.join(" ").trim();
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
- maxPages: maxPages ? Number(maxPages) : undefined,
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("usage: job-pro tencent notice <id>");
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("usage: job-pro tencent flow <question> [--question-time YYYY-MM-DD] [--top-k N]");
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("usage: job-pro tencent memory <list|get|set|event|clear>");
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("usage: job-pro tencent memory get <key>");
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
- if (cmd === "tencent") {
198
- await runTencent(args.slice(1));
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 in v0.1: tencent. Try \`job-pro help\`.`);
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);