job-pro 0.7.0 → 0.7.2

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/iflytek.js CHANGED
@@ -1,97 +1,339 @@
1
- // 科大讯飞 (iFlytek) stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. The public careers portal exists but the underlying
4
- // recruitment API is gated behind a 301 redirect chain into Beisen's iTalent
5
- // ATS (italent.cn), which requires a logged-in candidate session before any
6
- // position JSON is returned.
1
+ // 科大讯飞 (iFlytek) careers adapter for `job-pro`.
7
2
  //
8
3
  // ============================================================
9
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // API DISCOVERY (probed 2026-05-16)
10
5
  //
11
- // www.iflytek.com/careers — corporate page only, no listings JSON
12
- // campus.iflytek.com — 301 redirect to Tengine origin which
13
- // issues a second 301 to the italent
14
- // candidate-portal sign-in form (the
15
- // favicon path is /italent.ico, which
16
- // confirms the Beisen / 北森 backend).
17
- // career.iflytek.com — 301 chain into the same iTalent portal
18
- // hr.iflytek.com — 301 chain, gated
6
+ // campus.iflytek.com / career.iflytek.com / hr.iflytek.com all 301-chain into
7
+ // Beisen iTalent's candidate-portal sign-in form (favicon /italent.ico is the
8
+ // dead giveaway for Beisen / 北森). That portal is candidate-session-only.
19
9
  //
20
- // Feishu ATSX tenants probed:
21
- // iflytek.jobs.feishu.cn HTTP 400 empty body (no tenant)
10
+ // The *public* careers site is a sibling Beisen tenant hosted at
11
+ // https://iflytek.zhiye.com/ the same SaaS stack we already use for vivo
12
+ // (see cli/src/vivo.ts). The paginated list endpoint is anonymous: no
13
+ // session cookie, no signed header, no CSRF token. Same response envelope
14
+ // as vivo and other zhiye.com tenants:
22
15
  //
23
- // Moka:
24
- // app.mokahr.com/social-recruitment/iflytek — page shell renders but
25
- // the per-slug job feed returns the Moka SPA error page.
16
+ // POST /api/Jobad/GetJobAdPageList
17
+ // payload: { PageIndex (0-based), PageSize, KeyWords, SpecialType,
18
+ // PortalId: "", DisplayFields: [...], Category?: [...] }
19
+ // headers: standard browser UA + Content-Type=application/json +
20
+ // Referer=https://iflytek.zhiye.com/jobs +
21
+ // x-requested-with=xmlhttprequest + langtype=zh_CN
22
+ // envelope: { Code:200, Data:[RawJobAd[]], Count:<int>, Total:<int> }
26
23
  //
27
- // Conclusion: iFlytek publishes positions through the Beisen iTalent portal,
28
- // whose JSON endpoints require an authenticated candidate session. There is
29
- // no unauthenticated public REST surface as of probe date. When upstream
30
- // exposes one, this adapter can be upgraded to a thin client.
24
+ // Probed 2026-05-16: 744 positions across campus / social / intern channels.
25
+ // Category labels seen: "校园招聘", "员工社招", "员工校招", "实习生".
31
26
  //
27
+ // Endpoint inventory (all anon, all on iflytek.zhiye.com):
28
+ // POST /api/Jobad/GetJobAdPageList → paginated job list
29
+ // POST /api/Jobad/GetJobAdSearchConditions → filter taxonomy
30
+ // GET /api/Jobad/GetSpecialJobAdList → hot/special jobs
31
+ // GET /api/Jobad/SearchAreasTreeConditions → city tree
32
+ // GET /api/Common/GetPortalAIRobot → portal config
32
33
  // ============================================================
33
- // PositionSummary field mapping (canonical):
34
- // post_id, title, project, recruit_label, bgs, work_cities, apply_url
35
34
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
36
35
  export { checkResume };
37
- const SOURCE = "campus.iflytek.com";
38
- const STUB_MESSAGE = "iFlytek (科大讯飞): careers portal is fronted by Beisen iTalent (italent.cn) " +
39
- "which gates all job-list JSON behind an authenticated candidate session. " +
40
- "No unauthenticated public API available. Visit https://campus.iflytek.com/ for the portal.";
41
- export async function searchPositions(_opts = {}) {
36
+ const SOURCE = "iflytek.zhiye.com";
37
+ const API_ROOT = "https://iflytek.zhiye.com";
38
+ const SITE_ROOT = "https://iflytek.zhiye.com/jobs";
39
+ const DETAIL_PAGE = (id) => `https://iflytek.zhiye.com/jobs?jobAdId=${encodeURIComponent(id)}`;
40
+ const DEFAULT_HEADERS = {
41
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
42
+ Accept: "application/json",
43
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
44
+ Referer: SITE_ROOT,
45
+ Origin: API_ROOT,
46
+ "x-requested-with": "xmlhttprequest",
47
+ langtype: "zh_CN",
48
+ };
49
+ async function post(path, body) {
50
+ let response;
51
+ try {
52
+ response = await fetch(`${API_ROOT}${path}`, {
53
+ method: "POST",
54
+ headers: { ...DEFAULT_HEADERS, "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ });
57
+ }
58
+ catch (err) {
59
+ return {
60
+ ok: false,
61
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
62
+ };
63
+ }
64
+ if (!response.ok) {
65
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
66
+ }
67
+ let payload;
68
+ try {
69
+ payload = (await response.json());
70
+ }
71
+ catch (err) {
72
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
73
+ }
42
74
  return {
43
- ok: false,
75
+ ok: payload.Code === 200,
76
+ data: payload.Data,
77
+ count: payload.Count ?? payload.Total,
78
+ message: payload.Message || (payload.Code === 200 ? "ok" : "upstream error"),
79
+ };
80
+ }
81
+ async function get(path) {
82
+ let response;
83
+ try {
84
+ response = await fetch(`${API_ROOT}${path}`, { method: "GET", headers: DEFAULT_HEADERS });
85
+ }
86
+ catch (err) {
87
+ return {
88
+ ok: false,
89
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
90
+ };
91
+ }
92
+ if (!response.ok) {
93
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
94
+ }
95
+ let payload;
96
+ try {
97
+ payload = (await response.json());
98
+ }
99
+ catch (err) {
100
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
101
+ }
102
+ return {
103
+ ok: payload.Code === 200,
104
+ data: payload.Data,
105
+ message: payload.Message || (payload.Code === 200 ? "ok" : "upstream error"),
106
+ };
107
+ }
108
+ function summarize(item) {
109
+ const id = String(item.JobAdId ?? item.Id ?? "");
110
+ const cities = Array.isArray(item.LocNames) ? item.LocNames.join(", ") : "";
111
+ return {
112
+ post_id: id,
113
+ title: (item.JobAdName ?? "").trim(),
114
+ project: (item.Org ?? "").trim(),
115
+ recruit_label: (item.Category ?? "").trim(),
116
+ bgs: "",
117
+ work_cities: cities,
118
+ apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
119
+ };
120
+ }
121
+ // Beisen tenants encode recruit type via numeric Category IDs that vary by
122
+ // tenant. We don't know iFlytek's exact mapping without probing the
123
+ // taxonomy endpoint, so we leave it open and let CLI users filter by the
124
+ // returned `recruit_label` string client-side. When the mapping is known,
125
+ // add the numeric codes here (vivo uses "3"=intern, "4"=social, "5"=campus).
126
+ function categoryFromRecruitType(_t) {
127
+ return undefined;
128
+ }
129
+ // ---------- searchPositions ----------
130
+ export async function searchPositions(opts = {}) {
131
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
132
+ const page = Math.max(1, opts.page ?? 1);
133
+ // Beisen pageIndex is zero-based.
134
+ const body = {
135
+ PageIndex: page - 1,
136
+ PageSize: pageSize,
137
+ KeyWords: (opts.keyword ?? "").trim().slice(0, 60),
138
+ SpecialType: 0,
139
+ PortalId: "",
140
+ DisplayFields: ["Category", "Kind", "LocId", "Org", "HeadCount", "PostDate", "Salary"],
141
+ };
142
+ const category = categoryFromRecruitType(opts.recruitType);
143
+ if (category)
144
+ body.Category = category;
145
+ const r = await post("/api/Jobad/GetJobAdPageList", body);
146
+ if (!r.ok) {
147
+ return {
148
+ ok: false,
149
+ source: SOURCE,
150
+ message: r.message,
151
+ query: body,
152
+ positions: [],
153
+ };
154
+ }
155
+ const rows = r.data ?? [];
156
+ return {
157
+ ok: true,
44
158
  source: SOURCE,
45
- message: STUB_MESSAGE,
46
- query: {},
47
- positions: [],
159
+ query: body,
160
+ page,
161
+ page_size: pageSize,
162
+ total: r.count ?? rows.length,
163
+ positions: rows.map(summarize),
48
164
  };
49
165
  }
50
- export async function fetchAllPositions(_opts = {}) {
166
+ // ---------- fetchAllPositions ----------
167
+ export async function fetchAllPositions(opts = {}) {
168
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 30));
169
+ const maxPages = Math.max(1, opts.maxPages ?? 30);
170
+ const bucket = [];
171
+ let total;
172
+ for (let page = 1; page <= maxPages; page++) {
173
+ const r = await searchPositions({
174
+ keyword: opts.keyword,
175
+ page,
176
+ pageSize,
177
+ recruitType: opts.recruitType,
178
+ });
179
+ if (!r.ok) {
180
+ return { ok: false, source: SOURCE, message: r.message, total: 0, fetched: bucket.length, positions: bucket };
181
+ }
182
+ if (total === undefined)
183
+ total = r.total;
184
+ if (!r.positions.length)
185
+ break;
186
+ bucket.push(...r.positions);
187
+ if (total !== undefined && bucket.length >= total)
188
+ break;
189
+ }
51
190
  return {
52
- ok: false,
191
+ ok: true,
53
192
  source: SOURCE,
54
- message: STUB_MESSAGE,
55
- total: 0,
56
- fetched: 0,
57
- positions: [],
193
+ total: total ?? bucket.length,
194
+ fetched: bucket.length,
195
+ positions: bucket,
58
196
  };
59
197
  }
198
+ // ---------- fetchPositionDetail ----------
199
+ // Beisen serves the detail page from the same paginated list; there is no
200
+ // per-id REST endpoint that returns plain JSON. We page through and filter.
60
201
  export async function fetchPositionDetail(postId) {
202
+ const id = (postId ?? "").trim();
203
+ if (!id)
204
+ return { ok: false, source: SOURCE, message: "post_id is required" };
205
+ const pageSize = 50;
206
+ const maxPages = 20;
207
+ for (let page = 1; page <= maxPages; page++) {
208
+ const body = {
209
+ PageIndex: page - 1,
210
+ PageSize: pageSize,
211
+ KeyWords: "",
212
+ SpecialType: 0,
213
+ PortalId: "",
214
+ DisplayFields: ["Category", "Org", "LocId", "Kind", "Duty", "Require"],
215
+ };
216
+ const r = await post("/api/Jobad/GetJobAdPageList", body);
217
+ if (!r.ok) {
218
+ return { ok: false, source: SOURCE, post_id: id, message: r.message };
219
+ }
220
+ const posts = r.data ?? [];
221
+ const found = posts.find((p) => String(p.JobAdId ?? p.Id) === id);
222
+ if (found) {
223
+ const summary = summarize(found);
224
+ return {
225
+ ok: true,
226
+ source: SOURCE,
227
+ post_id: id,
228
+ title: found.JobAdName ?? "",
229
+ project: summary.project,
230
+ recruit_label: summary.recruit_label,
231
+ description: found.Duty ?? "",
232
+ requirements: found.Require ?? "",
233
+ head_count: found.HeadCount ?? 0,
234
+ post_date: found.PostDate ?? "",
235
+ work_cities: found.LocNames ?? [],
236
+ apply_url: summary.apply_url,
237
+ };
238
+ }
239
+ if (posts.length < pageSize)
240
+ break;
241
+ }
61
242
  return {
62
243
  ok: false,
63
244
  source: SOURCE,
64
- message: STUB_MESSAGE,
65
- post_id: postId,
245
+ post_id: id,
246
+ message: `post ${id} not found in public search results (scanned up to ${maxPages * pageSize} posts)`,
66
247
  };
67
248
  }
249
+ let _filterCache = null;
68
250
  export async function fetchDictionaries() {
69
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
251
+ if (_filterCache !== null)
252
+ return _filterCache;
253
+ const r = await post("/api/Jobad/GetJobAdSearchConditions", { PortalId: "", SpecialType: 0 });
254
+ if (!r.ok || !r.data) {
255
+ const result = { ok: false, source: SOURCE, message: r.message };
256
+ _filterCache = result;
257
+ return result;
258
+ }
259
+ const conditions = r.data.map((c) => ({
260
+ field: c.Field ?? "",
261
+ name: c.Name ?? "",
262
+ options: (c.Options ?? []).map((o) => ({
263
+ id: o.Id ?? o.Code ?? "",
264
+ name: o.Name ?? "",
265
+ })),
266
+ }));
267
+ const result = { ok: true, source: SOURCE, conditions };
268
+ _filterCache = result;
269
+ return result;
70
270
  }
271
+ // ---------- notices (stub — Beisen tenants have no public notices feed) ----------
272
+ const NOTICES_STUB = {
273
+ ok: false,
274
+ source: SOURCE,
275
+ message: "iFlytek: no public notices endpoint on Beisen tenant",
276
+ };
71
277
  export async function listNotices() {
72
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
278
+ return { ...NOTICES_STUB, notices: [] };
73
279
  }
74
280
  export async function getNotice(noticeId) {
75
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
281
+ return { ...NOTICES_STUB, notice_id: noticeId };
76
282
  }
77
283
  export async function findNoticesByQuestion(question, _opts = {}) {
78
- return {
79
- ok: false,
80
- source: SOURCE,
81
- question,
82
- message: STUB_MESSAGE,
83
- matches: [],
84
- };
284
+ return { ...NOTICES_STUB, question, matches: [] };
85
285
  }
86
- export async function matchResume(text, _opts = {}) {
286
+ // ---------- matchResume ----------
287
+ export async function matchResume(text, opts = {}) {
288
+ const topN = Math.max(1, opts.topN ?? 5);
289
+ const candidates = Math.max(topN, opts.candidates ?? 20);
87
290
  const { terms, cities } = extractResumeSignals(text ?? "");
291
+ if (!terms.length) {
292
+ return {
293
+ ok: false,
294
+ source: SOURCE,
295
+ message: "could not extract any technical signals from the text",
296
+ preview: (text ?? "").slice(0, 120),
297
+ };
298
+ }
299
+ const keyword = terms.slice(0, 3).join(" ");
300
+ const list = await searchPositions({ keyword, page: 1, pageSize: 50 });
301
+ if (!list.ok) {
302
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
303
+ }
304
+ const scored = [];
305
+ for (const p of list.positions) {
306
+ const blob = [p.title, p.project, p.recruit_label, p.work_cities].join(" ");
307
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
308
+ if (score > 0)
309
+ scored.push({ score, position: p, reasons });
310
+ }
311
+ scored.sort((a, b) => b.score - a.score);
312
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
313
+ if (!shortlist.length) {
314
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
315
+ score: 0,
316
+ position,
317
+ reasons: [],
318
+ }));
319
+ }
320
+ const matches = shortlist.slice(0, topN).map((s) => {
321
+ const mr = s.reasons.length > 0
322
+ ? s.reasons.slice(0, 5)
323
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
324
+ return { ...s.position, match_reasons: mr };
325
+ });
88
326
  return {
89
- ok: false,
327
+ ok: true,
90
328
  source: SOURCE,
91
329
  extracted_terms: terms,
92
330
  city_preferences: cities,
93
- matches: [],
94
- message: STUB_MESSAGE,
331
+ matches,
332
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
333
+ "The only authority on selection is HR.",
95
334
  };
96
335
  }
97
336
  export { extractResumeSignals, scoreOverlap };
337
+ // Silence unused warning for the GET helper — kept for future taxonomy/city
338
+ // endpoints that return BeisenEnvelope JSON via GET.
339
+ void get;
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import * as webank from "./webank.js";
51
51
  import * as horizonrobotics from "./horizonrobotics.js";
52
52
  import * as cambricon from "./cambricon.js";
53
53
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
54
- const VERSION = "0.7.0";
54
+ const VERSION = "0.7.2";
55
55
  const HELP = `
56
56
  job-pro — query Chinese big-tech campus recruiting from your terminal
57
57
  (job.ha7ch.com)
@@ -250,57 +250,62 @@ function readResumeArg(arg) {
250
250
  return arg;
251
251
  }
252
252
  }
253
+ // Every company adapter exposes the same set of functions, so one dispatcher
254
+ // can route verbs against any of them. New companies plug in by adding an
255
+ // `import * as <name>` and a line in `ADAPTERS`. The `satisfies` clause
256
+ // makes any contract drift (missing verb, wrong signature) a compile error
257
+ // instead of a silent runtime hazard.
253
258
  const ADAPTERS = {
254
259
  tencent,
255
- bytedance: bytedance,
256
- alibaba: alibaba,
257
- meituan: meituan,
258
- xiaohongshu: xiaohongshu,
259
- jd: jd,
260
- kuaishou: kuaishou,
261
- xiaomi: xiaomi,
262
- baidu: baidu,
263
- netease: netease,
264
- didi: didi,
265
- bilibili: bilibili,
266
- pdd: pdd,
267
- nio: nio,
268
- minimax: minimax,
269
- huawei: huawei,
270
- weibo: weibo,
271
- mihoyo: mihoyo,
272
- pingan: pingan,
273
- sensetime: sensetime,
274
- trip: trip,
275
- unitree: unitree,
276
- byd: byd,
277
- antgroup: antgroup,
278
- liauto: liauto,
279
- moonshot: moonshot,
280
- zhipu: zhipu,
281
- hikvision: hikvision,
282
- iqiyi: iqiyi,
283
- megvii: megvii,
284
- lilith: lilith,
285
- agibot: agibot,
286
- deepseek: deepseek,
287
- zerooneai: zerooneai,
288
- galaxyuniversal: galaxyuniversal,
289
- stepfun: stepfun,
290
- cicc: cicc,
291
- baichuan: baichuan,
292
- xpeng: xpeng,
293
- weride: weride,
294
- hoyoverse: hoyoverse,
295
- iflytek: iflytek,
296
- oppo: oppo,
297
- vivo: vivo,
298
- sf: sf,
299
- cainiao: cainiao,
300
- geely: geely,
301
- webank: webank,
302
- horizonrobotics: horizonrobotics,
303
- cambricon: cambricon,
260
+ bytedance,
261
+ alibaba,
262
+ meituan,
263
+ xiaohongshu,
264
+ jd,
265
+ kuaishou,
266
+ xiaomi,
267
+ baidu,
268
+ netease,
269
+ didi,
270
+ bilibili,
271
+ pdd,
272
+ nio,
273
+ minimax,
274
+ huawei,
275
+ weibo,
276
+ mihoyo,
277
+ pingan,
278
+ sensetime,
279
+ trip,
280
+ unitree,
281
+ byd,
282
+ antgroup,
283
+ liauto,
284
+ moonshot,
285
+ zhipu,
286
+ hikvision,
287
+ iqiyi,
288
+ megvii,
289
+ lilith,
290
+ agibot,
291
+ deepseek,
292
+ zerooneai,
293
+ galaxyuniversal,
294
+ stepfun,
295
+ cicc,
296
+ baichuan,
297
+ xpeng,
298
+ weride,
299
+ hoyoverse,
300
+ iflytek,
301
+ oppo,
302
+ vivo,
303
+ sf,
304
+ cainiao,
305
+ geely,
306
+ webank,
307
+ horizonrobotics,
308
+ cambricon,
304
309
  };
305
310
  async function runCompany(adapter, company, rawArgs) {
306
311
  const [verb, ...rest] = rawArgs;