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/vivo.js CHANGED
@@ -1,70 +1,320 @@
1
- // vivo stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. vivo's careers portal at hr.vivo.com is a Vite SPA
4
- // hosted on the company's internal BPM platform (it-static.vivo.xyz). The
5
- // job-list endpoints under /wt/vivo/web/* exist but reject every unauthenticated
6
- // POST with HTTP 405 (Method Not Allowed at the nginx layer), and GETs return
7
- // the SPA shell instead of JSON. The platform requires browser-issued
8
- // vmonitor / vui-tracking tokens to be added by the bundle before the API
9
- // gateway will route the request.
1
+ // vivo careers adapter for `job-pro`.
10
2
  //
11
3
  // ============================================================
12
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // API DISCOVERY (probed 2026-05-15)
5
+ //
6
+ // hr.vivo.com is vivo's *internal* BPM portal SPA and serves an all-paths-match
7
+ // catchall that returns its own HTML for every URL. The actual public careers
8
+ // site is a Beisen (北森) recruitment-portal tenant hosted at
9
+ // https://vivo.zhiye.com/ (tenant id 612022).
10
+ //
11
+ // The Beisen 2022 portal exposes a single paginated POST endpoint that backs
12
+ // every job-listing widget (社招 / 校招 / 全部职位 / 实习生):
13
+ //
14
+ // POST /api/Jobad/GetJobAdPageList
13
15
  //
14
- // https://hr.vivo.com/ — Vite SPA, /assets/index.*.js
15
- // POST https://hr.vivo.com/wt/vivo/web/queryJobsList HTTP 405 (nginx)
16
- // POST https://hr.vivo.com/wt/vivo/web/list — HTTP 405 (nginx)
17
- // GET https://hr.vivo.com/wt/vivo/web/index?type=1 — returns SPA HTML
16
+ // The endpoint is anonymous; the only required headers are a real browser
17
+ // User-Agent, Content-Type=application/json, and a vivo.zhiye.com Referer.
18
+ // `Category` filters by recruit type:
18
19
  //
19
- // The page initializer pulls vmonitor.min.js and vui-tracking/index.js
20
- // from it-static.vivo.xyz before any API call is made; these libraries
21
- // are expected to inject runtime headers (suspected MD5-signed timestamp
22
- // + UID) that the gateway validates.
20
+ // "1" 全部 / unspecified (default in widget config)
21
+ // "4" 员工社招 (social hire)
22
+ // "5" 员工校招 (campus hire)
23
+ // "2" 校园招聘 (templated campus)
24
+ // "3" 实习生
23
25
  //
24
- // Feishu ATSX:
25
- // vivo.jobs.feishu.cn — HTTP 400 (no portal configured)
26
+ // Each position record exposes `Category` (Chinese label) and `CategoryId`.
26
27
  //
27
- // Moka:
28
- // app.mokahr.com/social-recruitment/vivo 302 (slug not provisioned)
28
+ // Endpoint inventory (anonymous POST, content-type application/json):
29
+ // POST /api/Jobad/GetJobAdPageList → paginated job list
30
+ // POST /api/Jobad/GetJobAdSearchConditions → filter taxonomy
31
+ // GET /api/Jobad/GetSpecialJobAdList → hot/special jobs
32
+ // GET /api/Jobad/SearchAreasTreeConditions → city tree
33
+ // GET /api/Common/GetPortalAIRobot → portal config
29
34
  //
30
- // Conclusion: no unauthenticated public job-list API is available. Visit
31
- // https://hr.vivo.com/ for the official portal.
35
+ // ============================================================
32
36
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
33
37
  export { checkResume };
34
- const SOURCE = "hr.vivo.com";
35
- const STUB_MESSAGE = "vivo: hr.vivo.com /wt/vivo/web/* endpoints reject anonymous requests with HTTP 405 (nginx). " +
36
- "API gateway requires browser-runtime tokens injected by vmonitor + vui-tracking bundles. " +
37
- "No unauthenticated public API available. Visit https://hr.vivo.com/ for the portal.";
38
- export async function searchPositions(_opts = {}) {
39
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
38
+ const SOURCE = "vivo.zhiye.com";
39
+ const API_ROOT = "https://vivo.zhiye.com";
40
+ const SITE_ROOT = "https://vivo.zhiye.com/jobs";
41
+ const DETAIL_PAGE = (id) => `https://vivo.zhiye.com/jobs?jobAdId=${encodeURIComponent(id)}`;
42
+ const DEFAULT_HEADERS = {
43
+ "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",
44
+ Accept: "application/json",
45
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
46
+ Referer: SITE_ROOT,
47
+ Origin: API_ROOT,
48
+ "x-requested-with": "xmlhttprequest",
49
+ langtype: "zh_CN",
50
+ };
51
+ async function post(path, body) {
52
+ let response;
53
+ try {
54
+ response = await fetch(`${API_ROOT}${path}`, {
55
+ method: "POST",
56
+ headers: { ...DEFAULT_HEADERS, "Content-Type": "application/json" },
57
+ body: JSON.stringify(body),
58
+ });
59
+ }
60
+ catch (err) {
61
+ return {
62
+ ok: false,
63
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
64
+ };
65
+ }
66
+ if (!response.ok) {
67
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
68
+ }
69
+ let payload;
70
+ try {
71
+ payload = (await response.json());
72
+ }
73
+ catch (err) {
74
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
75
+ }
76
+ return {
77
+ ok: payload.Code === 200,
78
+ data: payload.Data,
79
+ count: payload.Count ?? payload.Total,
80
+ message: payload.Message || (payload.Code === 200 ? "ok" : "upstream error"),
81
+ };
40
82
  }
41
- export async function fetchAllPositions(_opts = {}) {
42
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
83
+ async function get(path) {
84
+ let response;
85
+ try {
86
+ response = await fetch(`${API_ROOT}${path}`, { method: "GET", headers: DEFAULT_HEADERS });
87
+ }
88
+ catch (err) {
89
+ return {
90
+ ok: false,
91
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
92
+ };
93
+ }
94
+ if (!response.ok) {
95
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
96
+ }
97
+ let payload;
98
+ try {
99
+ payload = (await response.json());
100
+ }
101
+ catch (err) {
102
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
103
+ }
104
+ return {
105
+ ok: payload.Code === 200,
106
+ data: payload.Data,
107
+ message: payload.Message || (payload.Code === 200 ? "ok" : "upstream error"),
108
+ };
109
+ }
110
+ function summarize(item) {
111
+ const id = String(item.JobAdId ?? item.Id ?? "");
112
+ const cities = Array.isArray(item.LocNames) ? item.LocNames.join(", ") : "";
113
+ return {
114
+ post_id: id,
115
+ title: (item.JobAdName ?? "").trim(),
116
+ project: (item.Org ?? "").trim(),
117
+ recruit_label: (item.Category ?? "").trim(),
118
+ bgs: "",
119
+ work_cities: cities,
120
+ apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
121
+ };
43
122
  }
123
+ function categoryFromRecruitType(t) {
124
+ switch (t) {
125
+ case "social":
126
+ return ["4"];
127
+ case "campus":
128
+ return ["5"];
129
+ case "intern":
130
+ return ["3"];
131
+ case "all":
132
+ default:
133
+ return undefined;
134
+ }
135
+ }
136
+ // ---------- searchPositions ----------
137
+ export async function searchPositions(opts = {}) {
138
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
139
+ const page = Math.max(1, opts.page ?? 1);
140
+ // Beisen pageIndex is zero-based.
141
+ const body = {
142
+ PageIndex: page - 1,
143
+ PageSize: pageSize,
144
+ KeyWords: (opts.keyword ?? "").trim().slice(0, 60),
145
+ SpecialType: 0,
146
+ PortalId: "",
147
+ DisplayFields: ["Category", "Kind", "LocId", "Org", "HeadCount", "PostDate", "Salary"],
148
+ };
149
+ const category = categoryFromRecruitType(opts.recruitType);
150
+ if (category)
151
+ body.Category = category;
152
+ const r = await post("/api/Jobad/GetJobAdPageList", body);
153
+ if (!r.ok) {
154
+ return {
155
+ ok: false,
156
+ source: SOURCE,
157
+ message: r.message,
158
+ query: body,
159
+ positions: [],
160
+ };
161
+ }
162
+ const rows = r.data ?? [];
163
+ return {
164
+ ok: true,
165
+ source: SOURCE,
166
+ query: body,
167
+ page,
168
+ page_size: pageSize,
169
+ total: r.count ?? rows.length,
170
+ positions: rows.map(summarize),
171
+ };
172
+ }
173
+ // ---------- fetchAllPositions ----------
174
+ export async function fetchAllPositions(opts = {}) {
175
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 30));
176
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
177
+ const bucket = [];
178
+ let total;
179
+ for (let page = 1; page <= maxPages; page++) {
180
+ const r = await searchPositions({
181
+ keyword: opts.keyword,
182
+ page,
183
+ pageSize,
184
+ recruitType: opts.recruitType,
185
+ });
186
+ if (!r.ok) {
187
+ return {
188
+ ok: false,
189
+ source: SOURCE,
190
+ message: r.message,
191
+ total: 0,
192
+ fetched: bucket.length,
193
+ positions: bucket,
194
+ };
195
+ }
196
+ if (total === undefined)
197
+ total = r.total;
198
+ if (!r.positions.length)
199
+ break;
200
+ bucket.push(...r.positions);
201
+ if (total !== undefined && bucket.length >= total)
202
+ break;
203
+ }
204
+ return {
205
+ ok: true,
206
+ source: SOURCE,
207
+ total: total ?? bucket.length,
208
+ fetched: bucket.length,
209
+ positions: bucket,
210
+ };
211
+ }
212
+ // ---------- fetchPositionDetail ----------
213
+ //
214
+ // Beisen returns the full duty/require text directly on the list endpoint
215
+ // when DisplayFields is omitted or includes those keys. We therefore call
216
+ // GetJobAdPageList with the exact JobAdId to recover a single record.
44
217
  export async function fetchPositionDetail(postId) {
45
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
218
+ const id = (postId ?? "").trim();
219
+ if (!id)
220
+ return { ok: false, source: SOURCE, message: "post_id is required", post_id: id };
221
+ const r = await post("/api/Jobad/GetJobAdPageList", {
222
+ PageIndex: 0,
223
+ PageSize: 1,
224
+ KeyWords: "",
225
+ SpecialType: 0,
226
+ PortalId: "",
227
+ JobAdIds: [Number(id) || id],
228
+ DisplayFields: [
229
+ "Category",
230
+ "Kind",
231
+ "LocId",
232
+ "Org",
233
+ "HeadCount",
234
+ "PostDate",
235
+ "Salary",
236
+ "DetailAddress",
237
+ ],
238
+ });
239
+ if (!r.ok || !r.data || !r.data.length) {
240
+ return { ok: false, source: SOURCE, message: r.message || "no detail returned", post_id: id };
241
+ }
242
+ const raw = r.data[0];
243
+ return {
244
+ ok: true,
245
+ source: SOURCE,
246
+ post_id: String(raw.JobAdId ?? id),
247
+ title: raw.JobAdName ?? "",
248
+ project: raw.Org ?? "",
249
+ recruit_label: raw.Category ?? "",
250
+ description: (raw.Duty ?? "").trim(),
251
+ requirements: (raw.Require ?? "").trim(),
252
+ work_cities: Array.isArray(raw.LocNames) ? raw.LocNames.join(", ") : "",
253
+ salary: raw.Salary ?? "",
254
+ kind: raw.Kind ?? "",
255
+ head_count: raw.HeadCount,
256
+ post_date: raw.PostDate ?? "",
257
+ apply_url: DETAIL_PAGE(String(raw.JobAdId ?? id)),
258
+ };
46
259
  }
260
+ // ---------- fetchDictionaries ----------
47
261
  export async function fetchDictionaries() {
48
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
262
+ const [conditions, areas] = await Promise.all([
263
+ post("/api/Jobad/GetJobAdSearchConditions", { Category: [] }),
264
+ get("/api/Jobad/SearchAreasTreeConditions"),
265
+ ]);
266
+ return {
267
+ ok: conditions.ok || areas.ok,
268
+ source: SOURCE,
269
+ api_host: API_ROOT,
270
+ verified_at: new Date().toISOString(),
271
+ search_conditions: conditions.data ?? null,
272
+ areas_tree: areas.data ?? null,
273
+ category_map: { "4": "员工社招", "5": "员工校招", "2": "校园招聘", "3": "实习生" },
274
+ };
49
275
  }
276
+ // ---------- notices ----------
277
+ const NO_NOTICES = "vivo careers (Beisen tenant 612022) does not expose a public notices endpoint.";
50
278
  export async function listNotices() {
51
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
279
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notices: [] };
52
280
  }
53
281
  export async function getNotice(noticeId) {
54
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
282
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notice_id: noticeId };
55
283
  }
56
284
  export async function findNoticesByQuestion(question, _opts = {}) {
57
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
285
+ return { ok: false, source: SOURCE, question, message: NO_NOTICES, matches: [] };
58
286
  }
59
- export async function matchResume(text, _opts = {}) {
287
+ // ---------- matchResume ----------
288
+ export async function matchResume(text, opts = {}) {
60
289
  const { terms, cities } = extractResumeSignals(text ?? "");
290
+ const topN = Math.max(1, opts.topN ?? 5);
291
+ const candidates = Math.max(topN, opts.candidates ?? 200);
292
+ const all = await fetchAllPositions({ pageSize: 30, maxPages: Math.ceil(candidates / 30) });
293
+ if (!all.ok) {
294
+ return {
295
+ ok: false,
296
+ source: SOURCE,
297
+ message: all.message,
298
+ extracted_terms: terms,
299
+ city_preferences: cities,
300
+ matches: [],
301
+ };
302
+ }
303
+ const scored = [];
304
+ for (const p of all.positions) {
305
+ const haystack = `${p.title} ${p.project} ${p.bgs} ${p.work_cities}`;
306
+ const score = scoreOverlap(haystack, terms, cities).score;
307
+ if (score > 0)
308
+ scored.push({ score, position: p });
309
+ }
310
+ scored.sort((a, b) => b.score - a.score);
61
311
  return {
62
- ok: false,
312
+ ok: true,
63
313
  source: SOURCE,
64
314
  extracted_terms: terms,
65
315
  city_preferences: cities,
66
- matches: [],
67
- message: STUB_MESSAGE,
316
+ candidate_pool: all.positions.length,
317
+ matches: scored.slice(0, topN).map((s) => s.position),
68
318
  };
69
319
  }
70
320
  export { extractResumeSignals, scoreOverlap };