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/byd.js CHANGED
@@ -1,160 +1,381 @@
1
- // Thin client stub for BYD (比亚迪) campus-recruiting portal at job.byd.com.
1
+ // BYD (比亚迪) recruiting adapter job.byd.com.
2
2
  //
3
3
  // ============================================================
4
- // Endpoint discovery (probed 2026-05, JS bundle app.e46eb97b.js +
5
- // chunk-e8fe.d262cda1.js, chunk-ac75.7dee0692.js, chunk-a7e5.62aed375.js,
6
- // chunk-76ac.cedb4013.js, chunk-dbeb.0075e53e.js):
4
+ // API DISCOVERY (probed 2026-05-15)
7
5
  //
8
- // Portal entry:
9
- // https://job.byd.com/ → redirects to https://job.byd.com/portal/pc/
10
- // https://careers.byd.com/ → Vite/Vue marketing page (static, no job listings)
11
- // https://job.byd.com/portal/pc/ → main Vue SPA (webpack, ElementUI)
6
+ // The job.byd.com SPA exposes two distinct API namespaces:
12
7
  //
13
- // Axios instance (t3Un module in app.e46eb97b.js):
14
- // baseURL = "/portal/api"
15
- // Interceptor: adds header Authorization: "bearer <token>" from Vuex store.
16
- // Code 4001 "Token无效或已过期: Not Authenticated" (auto-redirect to login).
8
+ // /portal/api/... → authenticated; every endpoint returns
9
+ // {"code":4001,"msg":"Token无效或已过期"}
10
+ // for unauthenticated requests.
11
+ // /portal/api/portal-api/... → ANONYMOUS public endpoints used by the SPA's
12
+ // home/experienced/campus landing flows. These
13
+ // return job listings, notices, materials, and
14
+ // recruit topics without any token.
17
15
  //
18
- // Campus-related API endpoints found in JS bundles:
19
- // POST /portal/api/school/queryJobList → campus job list
20
- // POST /portal/api/position/queryList → position list (also skiller/social)
21
- // POST /portal/api/position/queryDetail → position detail
22
- // POST /portal/api/other-info/notice/query-list → campus notices
23
- // POST /portal/api/position/schedule/query-list → campus schedule / timeline
24
- // GET /portal/api/siteInfo/faq → FAQ
25
- // POST /portal/api/common/queryCodeTree → code dictionary
16
+ // The working anonymous search endpoint is:
26
17
  //
27
- // All endpoints probed 2026-05: EVERY request returns:
28
- // HTTP 200, body: {"code":4001,"timestamp":...,"msg":"Token无效或已过期: Not Authenticated"}
18
+ // POST /portal/api/portal-api/position/queryList
29
19
  //
30
- // Auth model:
31
- // Requires a JWT bearer token obtained through BYD account login
32
- // (POST /portal/api/account/login, then GET /portal/api/account/user-info).
33
- // There is NO public/anonymous browsing API — even the FAQ and code-tree
34
- // endpoints are gated behind a valid token.
20
+ // Required headers: a normal Chrome User-Agent, Content-Type application/json,
21
+ // a job.byd.com Referer, and `lang: en_US` (vivo accepts both en_US and zh_CN).
35
22
  //
36
- // careers.byd.com investigation:
37
- // careers.byd.com is an internationalised marketing SPA (Vite + Vue 3).
38
- // Its BydPage-6104aa3e.js uses baseURL "/global-portal/api" with two
39
- // known endpoints:
40
- // GET /global-portal-api/global-material/getGlobalMaterial → 404
41
- // GET /global-portal-api/global-country/getCountryNetwork → 404
42
- // The site is a static landing page that links to job.byd.com; it does
43
- // not expose any independent job-search API.
23
+ // Body shape:
24
+ // {
25
+ // positionTypeArr: [], // 职位类型 codes
26
+ // positionProvinceArr: [], // 省 codes
27
+ // positionCityArr: [], // codes
28
+ // positionOrgArr: [], // 事业群 codes
29
+ // vagueCondition: "", // free-text keyword (matches title)
30
+ // searchType: 1, // 1 = title search
31
+ // zpType: "00251", // 招聘类型 — see table below
32
+ // pageNum: 0, // zero-based
33
+ // pageSize: 20
34
+ // }
44
35
  //
45
- // ============================================================
46
- // Summary: BYD has no publicly accessible campus-job search API.
47
- // All API calls require a logged-in user JWT.
48
- // This adapter is an honest stub every function returns ok:false with
49
- // an informative message. It will be upgraded once an authenticated
50
- // (scrape-friendly) path is identified.
36
+ // `zpType` controls the recruit channel:
37
+ // "00251" 社招 (Experienced; 1647+ live postings)
38
+ // "00252" 技师 (Technician empty as of probe)
39
+ // "00253" 操作工 (Operator / blue-collarempty as of probe)
40
+ // (Campus 校招 listings live behind a separate `school/*` flow that is fully
41
+ // auth-gated; the public anon channel exposes social hire only.)
51
42
  //
52
- // ============================================================
53
- // PositionSummary field mapping (BYD → canonical, documented for future use):
54
- // post_id ← item.positionId or item.id (string)
55
- // title ← item.positionName (e.g. "校招-软件开发工程师")
56
- // project ← item.positionTypeName (职位类型, e.g. "研发")
57
- // recruit_label ← item.recruitTypeName (e.g. "应届生" / "实习生")
58
- // bgs ← "" (not exposed in API)
59
- // work_cities ← item.workPlace or item.city (free-text Chinese city string)
60
- // apply_url ← https://job.byd.com/portal/pc/school/schoolPositionApply
61
- // ?positionId={id}
43
+ // Response envelope: {"code":0, "data":{"total":N, "data":[...]}}.
44
+ //
45
+ // Endpoint inventory (anonymous):
46
+ // POST /portal/api/portal-api/position/queryList → paginated jobs
47
+ // GET /portal/api/portal-api/material/getMaterial?ids=… → site materials
48
+ // POST /portal/api/portal-api/other-info/notice/query-list → notices
49
+ // POST /portal/api/portal-api/other-info/resource/query-list→ downloadables
50
+ // GET /portal/api/portal-api/common/queryCodeTree?ids=… → filter taxonomy
51
+ // POST /portal/api/portal-api/common/queryDeptTree → org tree
52
+ // POST /portal/api/portal-api/Recruitment/getMessageList → marketing msgs
53
+ // GET /portal/api/portal-api/resumeSend/school-topic/info?zpNature=…
54
+ // → campus topics
62
55
  //
63
56
  // ============================================================
64
57
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
65
58
  export { checkResume };
66
59
  const SOURCE = "job.byd.com";
67
- const CAMPUS_PAGE = "https://job.byd.com/portal/pc/school/home";
68
- // ---- stub reason ----
69
- const STUB_REASON = "BYD job.byd.com: all API endpoints require a valid JWT bearer token " +
70
- "(code 4001 Token无效或已过期). No public/anonymous job search API exists. " +
71
- "Visit https://job.byd.com/portal/pc/school/home to browse positions after login.";
72
- // ---- searchPositions ----
73
- export async function searchPositions(_opts = {}) {
60
+ const API_ROOT = "https://job.byd.com";
61
+ const SITE_ROOT = "https://job.byd.com/portal/pc/";
62
+ const DETAIL_PAGE = (id) => `https://job.byd.com/portal/pc/#/social/detail?positionCode=${encodeURIComponent(id)}`;
63
+ const DEFAULT_HEADERS = {
64
+ "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",
65
+ Accept: "application/json, text/plain, */*",
66
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
67
+ Referer: SITE_ROOT,
68
+ Origin: API_ROOT,
69
+ lang: "zh_CN",
70
+ };
71
+ async function call(method, path, opts = {}) {
72
+ let url = `${API_ROOT}${path}`;
73
+ if (opts.query) {
74
+ const params = new URLSearchParams();
75
+ for (const [k, v] of Object.entries(opts.query)) {
76
+ if (v !== undefined && v !== "")
77
+ params.set(k, String(v));
78
+ }
79
+ const qs = params.toString();
80
+ if (qs)
81
+ url += (path.includes("?") ? "&" : "?") + qs;
82
+ }
83
+ const headers = { ...DEFAULT_HEADERS };
84
+ let body;
85
+ if (opts.body !== undefined) {
86
+ body = JSON.stringify(opts.body);
87
+ headers["Content-Type"] = "application/json;charset=UTF-8";
88
+ }
89
+ let response;
90
+ try {
91
+ response = await fetch(url, { method, headers, body });
92
+ }
93
+ catch (err) {
94
+ return {
95
+ ok: false,
96
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
97
+ };
98
+ }
99
+ if (!response.ok) {
100
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
101
+ }
102
+ let payload;
103
+ try {
104
+ payload = (await response.json());
105
+ }
106
+ catch (err) {
107
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
108
+ }
74
109
  return {
75
- ok: false,
76
- source: SOURCE,
77
- message: STUB_REASON,
78
- campus_page: CAMPUS_PAGE,
79
- positions: [],
110
+ ok: payload.code === 0,
111
+ data: payload.data,
112
+ message: payload.msg || payload.message || (payload.code === 0 ? "ok" : "upstream error"),
80
113
  };
81
114
  }
82
- // ---- fetchAllPositions ----
83
- export async function fetchAllPositions(_opts = {}) {
115
+ function summarize(item) {
116
+ const id = String(item.positionCode ?? item.id ?? "");
117
+ const city = [item.province, item.city].filter(Boolean).join("·");
84
118
  return {
85
- ok: false,
119
+ post_id: id,
120
+ title: (item.positionName ?? "").trim(),
121
+ project: (item.fatherOrgAliasName ?? item.fatherOrgName ?? "").trim(),
122
+ recruit_label: "社招",
123
+ bgs: (item.orgAliasName ?? item.orgName ?? "").trim(),
124
+ work_cities: city,
125
+ apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
126
+ };
127
+ }
128
+ // ---------- searchPositions ----------
129
+ export async function searchPositions(opts = {}) {
130
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
131
+ const page = Math.max(1, opts.page ?? 1);
132
+ const body = {
133
+ positionTypeArr: opts.positionTypeIds ?? [],
134
+ positionProvinceArr: opts.provinceCodes ?? [],
135
+ positionCityArr: opts.cityCodes ?? [],
136
+ positionOrgArr: opts.orgCodes ?? [],
137
+ vagueCondition: (opts.keyword ?? "").trim().slice(0, 60),
138
+ searchType: 1,
139
+ zpType: opts.zpType ?? "00251",
140
+ pageNum: page - 1, // BYD uses 0-based
141
+ pageSize,
142
+ };
143
+ const r = await call("POST", "/portal/api/portal-api/position/queryList", { body });
144
+ if (!r.ok || !r.data) {
145
+ return {
146
+ ok: false,
147
+ source: SOURCE,
148
+ message: r.message,
149
+ query: body,
150
+ positions: [],
151
+ };
152
+ }
153
+ const rows = r.data.data ?? [];
154
+ return {
155
+ ok: true,
86
156
  source: SOURCE,
87
- message: STUB_REASON,
88
- campus_page: CAMPUS_PAGE,
89
- fetched: 0,
90
- positions: [],
157
+ query: body,
158
+ page,
159
+ page_size: pageSize,
160
+ total: r.data.total ?? rows.length,
161
+ positions: rows.map(summarize),
91
162
  };
92
163
  }
93
- // ---- fetchPositionDetail ----
94
- export async function fetchPositionDetail(_postId) {
95
- return { ok: false, source: SOURCE, message: STUB_REASON };
164
+ // ---------- fetchAllPositions ----------
165
+ export async function fetchAllPositions(opts = {}) {
166
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 50));
167
+ const maxPages = Math.max(1, opts.maxPages ?? 40);
168
+ const bucket = [];
169
+ let total;
170
+ for (let page = 1; page <= maxPages; page++) {
171
+ const r = await searchPositions({
172
+ keyword: opts.keyword,
173
+ page,
174
+ pageSize,
175
+ zpType: opts.zpType,
176
+ });
177
+ if (!r.ok) {
178
+ return {
179
+ ok: false,
180
+ source: SOURCE,
181
+ message: r.message,
182
+ total: 0,
183
+ fetched: bucket.length,
184
+ positions: bucket,
185
+ };
186
+ }
187
+ if (total === undefined)
188
+ total = r.total;
189
+ if (!r.positions.length)
190
+ break;
191
+ bucket.push(...r.positions);
192
+ if (total !== undefined && bucket.length >= total)
193
+ break;
194
+ }
195
+ return {
196
+ ok: true,
197
+ source: SOURCE,
198
+ total: total ?? bucket.length,
199
+ fetched: bucket.length,
200
+ positions: bucket,
201
+ };
96
202
  }
97
- // ---- fetchDictionaries ----
98
- export async function fetchDictionaries() {
203
+ // ---------- fetchPositionDetail ----------
204
+ //
205
+ // The detail endpoint /portal/api/position/queryDetail requires auth, but the
206
+ // public list endpoint returns enough info per row that we surface a "row+link"
207
+ // detail instead of a fully gated 4001 stub.
208
+ export async function fetchPositionDetail(postId) {
209
+ const id = (postId ?? "").trim();
210
+ if (!id)
211
+ return { ok: false, source: SOURCE, message: "post_id is required", post_id: id };
212
+ // Page through the social-hire list looking for the row. This is the best we
213
+ // can do without a logged-in JWT; in practice the row is usually within the
214
+ // first few hundred records and matchResume already pages through the full
215
+ // catalogue.
216
+ const r = await searchPositions({ keyword: id, pageSize: 5 });
217
+ const hit = r.ok ? r.positions.find((p) => p.post_id === id) : undefined;
218
+ if (!hit) {
219
+ return {
220
+ ok: false,
221
+ source: SOURCE,
222
+ message: "Position detail endpoint (POST /portal/api/position/queryDetail) requires a logged-in JWT. " +
223
+ "Public anon API can list positions but not return per-position bodies.",
224
+ post_id: id,
225
+ apply_url: DETAIL_PAGE(id),
226
+ };
227
+ }
99
228
  return {
100
- ok: false,
229
+ ok: true,
101
230
  source: SOURCE,
102
- message: STUB_REASON,
103
- note: "BYD: no public filter-taxonomy endpoint. " +
104
- "POST /portal/api/common/queryCodeTree returns 4001 without a token.",
105
- known_endpoints: [
106
- "POST /portal/api/school/queryJobList (campus job list — auth required)",
107
- "POST /portal/api/position/queryList (position list — auth required)",
108
- "POST /portal/api/position/queryDetail (position detail — auth required)",
109
- "POST /portal/api/other-info/notice/query-list (notices — auth required)",
110
- "POST /portal/api/position/schedule/query-list (campus schedule — auth required)",
111
- "GET /portal/api/siteInfo/faq (FAQ auth required)",
112
- "POST /portal/api/common/queryCodeTree (code tree auth required)",
113
- ],
231
+ post_id: hit.post_id,
232
+ title: hit.title,
233
+ project: hit.project,
234
+ bgs: hit.bgs,
235
+ recruit_label: hit.recruit_label,
236
+ work_cities: hit.work_cities,
237
+ description: "",
238
+ requirements: "",
239
+ apply_url: hit.apply_url,
240
+ note: "Description and requirements are not available without authentication; " +
241
+ "visit apply_url for the full posting after login.",
114
242
  };
115
243
  }
116
- // ---- listNotices ----
117
- export async function listNotices() {
244
+ // ---------- fetchDictionaries ----------
245
+ export async function fetchDictionaries() {
246
+ const [codeTree, deptTree] = await Promise.all([
247
+ call("GET", "/portal/api/portal-api/common/queryCodeTree", {
248
+ query: { ids: "0009,0030" },
249
+ }),
250
+ call("POST", "/portal/api/portal-api/common/queryDeptTree", { body: {} }),
251
+ ]);
118
252
  return {
119
- ok: false,
253
+ ok: codeTree.ok || deptTree.ok,
120
254
  source: SOURCE,
121
- message: "BYD: notices endpoint (POST /portal/api/other-info/notice/query-list) requires authentication.",
255
+ api_host: API_ROOT,
256
+ verified_at: new Date().toISOString(),
257
+ code_tree: codeTree.data ?? null,
258
+ dept_tree: deptTree.data ?? null,
259
+ zp_types: {
260
+ "00251": "社招 (Experienced)",
261
+ "00252": "技师 (Technician)",
262
+ "00253": "操作工 (Operator)",
263
+ },
264
+ note: "Campus (校招) jobs are not exposed by the anon public API — the school/* " +
265
+ "endpoints all require a JWT bearer token.",
122
266
  };
123
267
  }
124
- // ---- getNotice ----
125
- export async function getNotice(_id) {
268
+ export async function listNotices() {
269
+ const r = await call("POST", "/portal/api/portal-api/other-info/notice/query-list", { body: { pageNum: 0, pageSize: 30 } });
270
+ if (!r.ok)
271
+ return { ok: false, source: SOURCE, message: r.message, notices: [] };
272
+ const items = r.data?.data ?? r.data?.list ?? [];
126
273
  return {
127
- ok: false,
274
+ ok: true,
128
275
  source: SOURCE,
129
- message: "BYD: no public notices endpoint (auth required).",
276
+ count: items.length,
277
+ notices: items.map((n) => ({
278
+ id: String(n.id ?? ""),
279
+ title: n.title ?? n.noticeTitle ?? "",
280
+ publish_time: n.publishTime ?? n.createTime ?? "",
281
+ tag: n.noticeType ?? "",
282
+ detail_url: SITE_ROOT,
283
+ })),
130
284
  };
131
285
  }
132
- // ---- findNoticesByQuestion ----
133
- export async function findNoticesByQuestion(_question, _opts = {}) {
286
+ export async function getNotice(noticeId) {
287
+ const id = (noticeId ?? "").trim();
288
+ if (!id)
289
+ return { ok: false, source: SOURCE, message: "notice_id is required" };
290
+ const all = await listNotices();
291
+ if (!all.ok)
292
+ return { ok: false, source: SOURCE, message: all.message };
293
+ const hit = all.notices.find((n) => n.id === id);
294
+ if (!hit)
295
+ return {
296
+ ok: false,
297
+ source: SOURCE,
298
+ message: `notice ${id} not in the latest /notice/query-list page`,
299
+ };
300
+ return { ok: true, source: SOURCE, ...hit, content_html: "" };
301
+ }
302
+ export async function findNoticesByQuestion(question, opts = {}) {
303
+ const listing = await listNotices();
304
+ if (!listing.ok)
305
+ return { ok: false, source: SOURCE, message: listing.message, matches: [] };
306
+ const tokens = [];
307
+ const seen = new Set();
308
+ const text = (question ?? "").trim();
309
+ for (const m of text.match(/[A-Za-z0-9]{2,}/g) ?? []) {
310
+ const k = m.toLowerCase();
311
+ if (!seen.has(k)) {
312
+ seen.add(k);
313
+ tokens.push(k);
314
+ }
315
+ }
316
+ for (const run of text.match(/[一-鿿]+/g) ?? []) {
317
+ for (let i = 0; i < run.length - 1; i++) {
318
+ const bigram = run.slice(i, i + 2);
319
+ if (!seen.has(bigram)) {
320
+ seen.add(bigram);
321
+ tokens.push(bigram);
322
+ }
323
+ if (tokens.length >= 40)
324
+ break;
325
+ }
326
+ }
327
+ const topK = Math.max(1, opts.topK ?? 3);
328
+ const scored = listing.notices
329
+ .map((n) => {
330
+ const hay = `${n.title} ${n.tag}`.toLowerCase();
331
+ const score = tokens.filter((t) => hay.includes(t)).length;
332
+ return { score, notice: n };
333
+ })
334
+ .filter((s) => s.score > 0)
335
+ .sort((a, b) => b.score - a.score);
134
336
  return {
135
- ok: false,
337
+ ok: true,
136
338
  source: SOURCE,
137
- message: "BYD: no public notices endpoint (auth required).",
339
+ question,
340
+ question_time: opts.questionTime,
341
+ matched_tokens: tokens,
342
+ matches: scored.slice(0, topK).map((s) => ({ ...s.notice, excerpt: "" })),
138
343
  };
139
344
  }
140
- // ---- matchResume ----
141
- // Resume matching is best-effort using extractResumeSignals/scoreOverlap from
142
- // tencent.ts, but since the position listing API is gated, we can only return
143
- // a stub with the extracted signals and a pointer to the campus page.
345
+ // ---------- matchResume ----------
144
346
  export async function matchResume(text, opts = {}) {
145
- // Extract signals so the caller knows what was parsed from the resume
146
347
  const { terms, cities } = extractResumeSignals(text ?? "");
147
- void opts; // unused until API becomes accessible
348
+ const topN = Math.max(1, opts.topN ?? 5);
349
+ const candidates = Math.max(topN, opts.candidates ?? 200);
350
+ const all = await fetchAllPositions({
351
+ pageSize: 50,
352
+ maxPages: Math.ceil(candidates / 50),
353
+ });
354
+ if (!all.ok) {
355
+ return {
356
+ ok: false,
357
+ source: SOURCE,
358
+ message: all.message,
359
+ extracted_terms: terms,
360
+ city_preferences: cities,
361
+ matches: [],
362
+ };
363
+ }
364
+ const scored = [];
365
+ for (const p of all.positions) {
366
+ const haystack = `${p.title} ${p.project} ${p.bgs} ${p.work_cities}`;
367
+ const score = scoreOverlap(haystack, terms, cities).score;
368
+ if (score > 0)
369
+ scored.push({ score, position: p });
370
+ }
371
+ scored.sort((a, b) => b.score - a.score);
148
372
  return {
149
- ok: false,
373
+ ok: true,
150
374
  source: SOURCE,
151
- message: "BYD: cannot search positions — API requires authentication. " +
152
- `Extracted resume signals: [${terms.slice(0, 10).join(", ")}]. ` +
153
- "Visit the campus page to search manually.",
154
- campus_page: CAMPUS_PAGE,
155
375
  extracted_terms: terms,
156
376
  city_preferences: cities,
377
+ candidate_pool: all.positions.length,
378
+ matches: scored.slice(0, topN).map((s) => s.position),
157
379
  };
158
380
  }
159
- // ---- re-export helpers so the tencent resume signals are accessible ----
160
381
  export { extractResumeSignals, scoreOverlap };