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/mihoyo.js CHANGED
@@ -1,100 +1,274 @@
1
- // Thin client for 米哈游 / miHoYo's campus careers.
1
+ // Thin client for 米哈游 / miHoYo recruiting portal.
2
2
  //
3
- // STATUS: stub-only (2026-05-14). miHoYo's public careers portal
4
- // (campus.mihoyo.com) is a 2KB SPA shell with no embedded job data and
5
- // no fetch paths visible in the HTML — every dimension loads at runtime
6
- // from JS bundles we can't traverse without a headless browser.
3
+ // Portal: https://jobs.mihoyo.com/ (the old campus.mihoyo.com permanently
4
+ // redirects here)
5
+ // API host: https://ats.openout.mihoyo.com/ats-portal
7
6
  //
8
- // Probe results:
9
- // campus.mihoyo.com → 200, 2KB SPA shell (no useful data inline)
10
- // careers.mihoyo.com → SSL handshake timed out (geo-blocked / VPN-only?)
11
- // hr.mihoyo.com → 404
12
- // mihoyo.jobs.feishu.cn → POST 400 on every channel string tried
13
- // ("campus" / "mihoyo" / "social" / "1" / "school_recruit").
14
- // GET returns a generic Feishu shell — meaning
15
- // miHoYo is NOT a real Feishu Recruiting tenant.
16
- // careers.mihoyo.com via Workday → not configured
17
- // mihoyo.mokahr.com → Moka requires session auth (verified for the
18
- // class of orgs — Moka public anon API is gated)
7
+ // ============================================================
8
+ // Discovery (2026-05):
19
9
  //
20
- // So this adapter ships as an honest stub. listNotices / getNotice /
21
- // findNoticesByQuestion / fetchDictionaries all return { ok: false }
22
- // with the documented reason; matchResume returns 0 matches. Smoke test
23
- // will tag it WARN (limited). When/if miHoYo opens a public job API we
24
- // rewrite this file in one pass; the dispatcher contract is preserved.
25
- import { extractResumeSignals, checkResume } from "./tencent.js";
26
- export { checkResume };
27
- const SOURCE = "campus.mihoyo.com";
28
- const STUB_MESSAGE = "miHoYo: no public job API — campus.mihoyo.com is a SPA loading data at runtime; " +
29
- "Feishu tenant returns 400 on all channels; Moka org requires session auth. " +
30
- "Documented in cli/src/mihoyo.ts header.";
31
- export async function searchPositions(_opts = {}) {
10
+ // campus.mihoyo.com → permanently redirects to jobs.mihoyo.com
11
+ // jobs.mihoyo.com → React SPA shell
12
+ // ats.openout.mihoyo.com → real ATS backend (in the bundle: baseURL)
13
+ //
14
+ // The bundle whitelist contains /v1/job/category/list, /v1/job/get/id_list,
15
+ // /v1/job/project_count/list but the actual search endpoint that returns
16
+ // summarized job rows (the one the SPA hits to render the list page) is
17
+ // /v1/job/list (probed; unauth-OK with channelDetailIds + hireType + pageNo).
18
+ // /v1/job/info gives full per-position detail.
19
+ //
20
+ // "channel" semantics (decoded from the bundle's enums):
21
+ // R.CAMPUS = 1, R.JOBS = 1 (same value), R.RECOMMEND = 2
22
+ // hireType enum: JOBS = 0 (social), CAMPUS = 1
23
+ // Default surface = social: channelDetailIds=[1], hireType=0.
24
+ //
25
+ // ============================================================
26
+ // Response shape (probed 2026-05):
27
+ // data.list[]:
28
+ // id, title, competencyType, jobNature, projectName,
29
+ // addressDetailList[].addressDetail, channelDetailIds
30
+ // data.total — canonical total count
31
+ //
32
+ // PositionSummary field mapping:
33
+ // post_id ← String(job.id)
34
+ // title ← job.title
35
+ // project ← job.competencyType (job category)
36
+ // recruit_label ← job.jobNature ("全职" / "实习")
37
+ // bgs ← job.projectName ("社会招聘" / "校园招聘")
38
+ // work_cities ← addressDetailList[].addressDetail joined " / "
39
+ // apply_url ← https://jobs.mihoyo.com/#/position/${id}
40
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
41
+ export { checkResume, extractResumeSignals, scoreOverlap };
42
+ const SOURCE = "jobs.mihoyo.com";
43
+ const API_ROOT = "https://ats.openout.mihoyo.com/ats-portal";
44
+ const PORTAL_URL = "https://jobs.mihoyo.com";
45
+ const APPLY_URL_PREFIX = `${PORTAL_URL}/#/position`;
46
+ // Default channel: social ("社招"). Bundle constant R.JOBS = 1.
47
+ const CHANNEL_DETAIL_IDS = [1];
48
+ const HIRE_TYPE_SOCIAL = 0;
49
+ const HEADERS = {
50
+ "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",
51
+ Accept: "application/json, text/plain, */*",
52
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
53
+ "Content-Type": "application/json",
54
+ Origin: PORTAL_URL,
55
+ Referer: `${PORTAL_URL}/`,
56
+ };
57
+ async function postJson(path, body) {
58
+ let response;
59
+ try {
60
+ response = await fetch(`${API_ROOT}${path}`, {
61
+ method: "POST",
62
+ headers: HEADERS,
63
+ body: JSON.stringify(body),
64
+ });
65
+ }
66
+ catch (err) {
67
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
68
+ }
69
+ if (!response.ok)
70
+ return { ok: false, message: `HTTP ${response.status}` };
71
+ let payload;
72
+ try {
73
+ payload = (await response.json());
74
+ }
75
+ catch (err) {
76
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
77
+ }
78
+ if (payload.code !== 0 || !payload.data) {
79
+ return { ok: false, message: payload.message || "upstream error" };
80
+ }
81
+ return { ok: true, data: payload.data, message: "ok" };
82
+ }
83
+ function summarize(row) {
84
+ const id = String(row.id ?? "");
85
+ const cities = (row.addressDetailList ?? [])
86
+ .map((a) => a.addressDetail ?? "")
87
+ .filter(Boolean);
32
88
  return {
33
- ok: false,
89
+ post_id: id,
90
+ title: row.title ?? "",
91
+ project: row.competencyType ?? "",
92
+ recruit_label: row.jobNature ?? "",
93
+ bgs: row.projectName ?? "",
94
+ work_cities: cities.join(" / "),
95
+ apply_url: id ? `${APPLY_URL_PREFIX}/${encodeURIComponent(id)}` : PORTAL_URL,
96
+ };
97
+ }
98
+ // ---------- searchPositions ----------
99
+ export async function searchPositions(opts = {}) {
100
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
101
+ const page = Math.max(1, opts.page ?? 1);
102
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
103
+ const body = {
104
+ channelDetailIds: opts.channelDetailIds ?? CHANNEL_DETAIL_IDS,
105
+ hireType: opts.hireType ?? HIRE_TYPE_SOCIAL,
106
+ pageSize,
107
+ pageNo: page,
108
+ };
109
+ if (keyword)
110
+ body.jobName = keyword;
111
+ const response = await postJson("/v1/job/list", body);
112
+ if (!response.ok || !response.data) {
113
+ return {
114
+ ok: false,
115
+ message: response.message,
116
+ source: SOURCE,
117
+ query: body,
118
+ positions: [],
119
+ };
120
+ }
121
+ const rows = response.data.list ?? [];
122
+ return {
123
+ ok: true,
34
124
  source: SOURCE,
35
- message: STUB_MESSAGE,
36
- query: {},
37
- positions: [],
125
+ query: body,
126
+ page,
127
+ page_size: pageSize,
128
+ total: response.data.total ?? rows.length,
129
+ positions: rows.map(summarize),
38
130
  };
39
131
  }
40
- export async function fetchAllPositions(_opts = {}) {
132
+ // ---------- fetchAllPositions ----------
133
+ export async function fetchAllPositions(opts = {}) {
134
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
135
+ const maxPages = Math.max(1, opts.maxPages ?? 10);
136
+ const bucket = [];
137
+ let total;
138
+ for (let page = 1; page <= maxPages; page++) {
139
+ const result = await searchPositions({ ...opts, page, pageSize });
140
+ if (!result.ok) {
141
+ return {
142
+ ok: false,
143
+ message: result.message,
144
+ source: SOURCE,
145
+ fetched: bucket.length,
146
+ positions: bucket,
147
+ };
148
+ }
149
+ if (total === undefined)
150
+ total = result.total;
151
+ if (!result.positions.length)
152
+ break;
153
+ bucket.push(...result.positions);
154
+ if (total !== undefined && bucket.length >= total)
155
+ break;
156
+ }
41
157
  return {
42
- ok: false,
158
+ ok: true,
43
159
  source: SOURCE,
44
- message: STUB_MESSAGE,
45
- total: 0,
46
- fetched: 0,
47
- positions: [],
160
+ total: total ?? bucket.length,
161
+ fetched: bucket.length,
162
+ positions: bucket,
48
163
  };
49
164
  }
165
+ // ---------- fetchPositionDetail ----------
50
166
  export async function fetchPositionDetail(postId) {
167
+ const id = (postId ?? "").trim();
168
+ if (!id)
169
+ return { ok: false, source: SOURCE, message: "post_id is required" };
170
+ const response = await postJson("/v1/job/info", { id });
171
+ if (!response.ok || !response.data) {
172
+ return { ok: false, source: SOURCE, message: response.message, post_id: id };
173
+ }
174
+ const d = response.data;
175
+ const summary = summarize(d);
51
176
  return {
52
- ok: false,
177
+ ok: true,
53
178
  source: SOURCE,
54
- message: STUB_MESSAGE,
55
- post_id: postId,
179
+ post_id: summary.post_id,
180
+ title: d.title ?? "",
181
+ direction: d.objectName ?? "",
182
+ description: d.description ?? "",
183
+ requirements: d.jobRequire ?? "",
184
+ addition: d.addition ?? "",
185
+ work_cities: d.addressDetailList ?? [],
186
+ project: d.competencyType ?? "",
187
+ recruit_label: d.jobNature ?? "",
188
+ hire_type_name: d.hireTypeName ?? "",
189
+ apply_url: summary.apply_url,
56
190
  };
57
191
  }
192
+ // ---------- fetchDictionaries ----------
193
+ let _filterCache = null;
58
194
  export async function fetchDictionaries() {
59
- return {
60
- ok: false,
195
+ if (_filterCache !== null)
196
+ return _filterCache;
197
+ const social = await postJson("/v1/job/category/list", {
198
+ channelDetailIds: CHANNEL_DETAIL_IDS,
199
+ hireType: HIRE_TYPE_SOCIAL,
200
+ });
201
+ const campus = await postJson("/v1/job/category/list", { channelDetailIds: CHANNEL_DETAIL_IDS, hireType: 1 });
202
+ if (!social.ok && !campus.ok) {
203
+ const result = {
204
+ ok: false,
205
+ source: SOURCE,
206
+ message: social.message || campus.message,
207
+ };
208
+ _filterCache = result;
209
+ return result;
210
+ }
211
+ const mapList = (list) => list.map((c) => ({
212
+ competencyType: c.competencyType ?? "",
213
+ competencyTypeName: c.competencyTypeName ?? "",
214
+ competencyTypeEnName: c.competencyTypeEnName ?? "",
215
+ count: c.count ?? 0,
216
+ }));
217
+ const result = {
218
+ ok: true,
61
219
  source: SOURCE,
62
- message: STUB_MESSAGE,
220
+ categories_social: social.ok && social.data ? mapList(social.data) : [],
221
+ categories_campus: campus.ok && campus.data ? mapList(campus.data) : [],
63
222
  };
223
+ _filterCache = result;
224
+ return result;
64
225
  }
226
+ // ---------- stub notices ----------
227
+ const NOTICES_STUB = {
228
+ ok: false,
229
+ source: SOURCE,
230
+ message: "miHoYo: no public notices endpoint",
231
+ };
65
232
  export async function listNotices() {
66
- return {
67
- ok: false,
68
- source: SOURCE,
69
- message: STUB_MESSAGE,
70
- notices: [],
71
- };
233
+ return { ...NOTICES_STUB, notices: [] };
72
234
  }
73
235
  export async function getNotice(noticeId) {
74
- return {
75
- ok: false,
76
- source: SOURCE,
77
- message: STUB_MESSAGE,
78
- notice_id: noticeId,
79
- };
236
+ return { ...NOTICES_STUB, notice_id: noticeId };
80
237
  }
81
238
  export async function findNoticesByQuestion(question, _opts = {}) {
82
- return {
83
- ok: false,
84
- source: SOURCE,
85
- question,
86
- message: STUB_MESSAGE,
87
- matches: [],
88
- };
239
+ return { ...NOTICES_STUB, question, matches: [] };
89
240
  }
90
- export async function matchResume(text, _opts = {}) {
241
+ // ---------- matchResume ----------
242
+ export async function matchResume(text, opts = {}) {
243
+ const topN = Math.max(1, opts.topN ?? 5);
244
+ const candidates = Math.max(topN, opts.candidates ?? 100);
91
245
  const { terms, cities } = extractResumeSignals(text ?? "");
246
+ if (!terms.length) {
247
+ return {
248
+ ok: false,
249
+ source: SOURCE,
250
+ message: "could not extract any technical signals from the text",
251
+ preview: (text ?? "").slice(0, 120),
252
+ };
253
+ }
254
+ const keyword = terms[0];
255
+ const list = await searchPositions({ keyword, page: 1, pageSize: Math.min(100, candidates) });
256
+ if (!list.ok) {
257
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
258
+ }
259
+ const scored = (list.positions ?? [])
260
+ .map((p) => ({
261
+ p,
262
+ score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
263
+ }))
264
+ .sort((a, b) => b.score - a.score)
265
+ .slice(0, topN)
266
+ .map((x) => x.p);
92
267
  return {
93
- ok: false,
268
+ ok: true,
94
269
  source: SOURCE,
95
270
  extracted_terms: terms,
96
271
  city_preferences: cities,
97
- matches: [],
98
- message: STUB_MESSAGE,
272
+ matches: scored,
99
273
  };
100
274
  }