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/oppo.js CHANGED
@@ -1,94 +1,250 @@
1
- // OPPO stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. OPPO's careers portal exists at careers.oppo.com and
4
- // exposes an `/api/job/list` endpoint, but every probed payload variation
5
- // returns HTTP 500 from the upstream Spring Boot service, suggesting the
6
- // route requires session-bound CSRF / fingerprint headers that the page's
7
- // JS bundle injects only after the browser passes a runtime check.
1
+ // OPPO careers adapter for `job-pro`.
8
2
  //
9
3
  // ============================================================
10
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // API DISCOVERY (probed 2026-05-15)
11
5
  //
12
- // https://careers.oppo.com/ — SPA shell (Vite, /assets/js/campus_oppo-*.js)
13
- // POST https://careers.oppo.com/api/job/list HTTP 500 internal error
14
- // payload variants tried:
15
- // {} → 500
16
- // {"page":1,"pageSize":10} → 500
17
- // {"keyword":"","page":1,"pageSize":10,"recruitType":"campus"} → 500
18
- // {"keyword":"","jobLineCode":"","jobNature":"","cityCode":"","page":1,"pageSize":10,"locale":"zh-CN"} → 500
19
- // All return Spring-style {"timestamp":..,"status":500,"path":"/api/job/list"}.
6
+ // careers.oppo.com is a Vite SPA whose campus job listing is rendered by the
7
+ // dynamically-loaded chunk /assets/js/job-edfe7d6e.js. The chunk exposes two
8
+ // candidate routes:
20
9
  //
21
- // Feishu ATSX tenants:
22
- // oppo.jobs.feishu.cn HTTP 400 (no portal configured)
10
+ // POST /ats-candidate-api/open-api/position/queryPositionList → HTTP 404
11
+ // POST /openapi/position/pageNew → HTTP 200
23
12
  //
24
- // Moka:
25
- // app.mokahr.com/social-recruitment/oppo Moka SPA shell renders but
26
- // per-slug job feed returns the "您访问的页面不存在" error page.
13
+ // The working route is `/openapi/position/pageNew`. It returns a paginated
14
+ // list of all currently-open positions across the OPPO recruiting site without
15
+ // any token or signed header only standard browser headers are required.
16
+ // Both campus (校招/应届生) and intern (实习生) postings live on this endpoint;
17
+ // the `recruitmentType` field on each record distinguishes them.
27
18
  //
28
- // Conclusion: OPPO publishes positions through their own bespoke Spring Boot
29
- // API which requires browser-runtime headers (likely a CSRF + WAF cookie pair)
30
- // before any 200 response is returned. No anonymous JSON path is currently
31
- // reachable. Visit https://careers.oppo.com/ for the official portal.
19
+ // Endpoint inventory (all anon, all on careers.oppo.com):
20
+ // POST /openapi/position/pageNew → paginated job list
21
+ // GET /openapi/position/detail?idRecruitPosition=<id> single posting
22
+ // GET /openapi/position/project/list → recruitment projects
23
+ // GET /openapi/position/relatedPosition?... → related jobs
24
+ // GET /openapi/sec/getRiskReport → WAF risk probe
25
+ // GET /openapi/system/dictionary/queryList → filter taxonomy
26
+ // ============================================================
32
27
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
33
28
  export { checkResume };
34
29
  const SOURCE = "careers.oppo.com";
35
- const STUB_MESSAGE = "OPPO: careers.oppo.com /api/job/list returns HTTP 500 for every anonymous payload " +
36
- "variant probed the endpoint appears to require browser-runtime CSRF / WAF cookies. " +
37
- "No unauthenticated public API available. Visit https://careers.oppo.com/ for the portal.";
38
- export async function searchPositions(_opts = {}) {
30
+ const API_ROOT = "https://careers.oppo.com";
31
+ const SITE_ROOT = "https://careers.oppo.com/";
32
+ const DETAIL_PAGE = (id) => `https://careers.oppo.com/#/campus/talent/positionDetail/${encodeURIComponent(id)}`;
33
+ const DEFAULT_HEADERS = {
34
+ "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",
35
+ Accept: "application/json, text/plain, */*",
36
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
37
+ Referer: SITE_ROOT,
38
+ Origin: "https://careers.oppo.com",
39
+ };
40
+ async function call(method, path, opts = {}) {
41
+ let url = `${API_ROOT}${path}`;
42
+ if (opts.query) {
43
+ const params = new URLSearchParams();
44
+ for (const [k, v] of Object.entries(opts.query)) {
45
+ if (v !== undefined)
46
+ params.set(k, String(v));
47
+ }
48
+ const qs = params.toString();
49
+ if (qs)
50
+ url += (path.includes("?") ? "&" : "?") + qs;
51
+ }
52
+ const headers = { ...DEFAULT_HEADERS };
53
+ let body;
54
+ if (opts.body !== undefined) {
55
+ body = JSON.stringify(opts.body);
56
+ headers["Content-Type"] = "application/json;charset=UTF-8";
57
+ }
58
+ let response;
59
+ try {
60
+ response = await fetch(url, { method, headers, body });
61
+ }
62
+ catch (err) {
63
+ return {
64
+ ok: false,
65
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
66
+ };
67
+ }
68
+ if (!response.ok) {
69
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
70
+ }
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
+ return {
79
+ ok: payload.code === 0,
80
+ data: payload.data,
81
+ message: payload.msg || (payload.code === 0 ? "ok" : "upstream error"),
82
+ };
83
+ }
84
+ function summarize(item) {
85
+ const id = String(item.idRecruitPosition ?? item.idProjPosition ?? item.projectPositionId ?? "");
39
86
  return {
40
- ok: false,
87
+ post_id: id,
88
+ title: (item.positionName ?? item.projectPositionName ?? "").trim(),
89
+ project: (item.projectName ?? "").trim(),
90
+ recruit_label: (item.recruitmentTypeName ?? item.recruitmentType ?? "").trim(),
91
+ bgs: (item.positionTypeName ?? "").trim(),
92
+ work_cities: (item.workCityName ?? "").trim(),
93
+ apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
94
+ };
95
+ }
96
+ // ---------- searchPositions ----------
97
+ export async function searchPositions(opts = {}) {
98
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
99
+ const page = Math.max(1, opts.page ?? 1);
100
+ const body = {
101
+ pageNum: page,
102
+ pageSize,
103
+ };
104
+ if (opts.keyword)
105
+ body.keyword = opts.keyword.trim().slice(0, 60);
106
+ if (opts.recruitType === "campus")
107
+ body.recruitmentType = "Campus";
108
+ else if (opts.recruitType === "intern")
109
+ body.recruitmentType = "Intern";
110
+ if (opts.cityCode)
111
+ body.workCityCode = opts.cityCode;
112
+ const r = await call("POST", "/openapi/position/pageNew", { body });
113
+ if (!r.ok || !r.data) {
114
+ return {
115
+ ok: false,
116
+ source: SOURCE,
117
+ message: r.message,
118
+ query: body,
119
+ positions: [],
120
+ };
121
+ }
122
+ const rows = r.data.records ?? [];
123
+ return {
124
+ ok: true,
41
125
  source: SOURCE,
42
- message: STUB_MESSAGE,
43
- query: {},
44
- positions: [],
126
+ query: body,
127
+ page,
128
+ page_size: pageSize,
129
+ total: r.data.total ?? rows.length,
130
+ positions: rows.map(summarize),
45
131
  };
46
132
  }
47
- export async function fetchAllPositions(_opts = {}) {
133
+ // ---------- fetchAllPositions ----------
134
+ export async function fetchAllPositions(opts = {}) {
135
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 50));
136
+ const maxPages = Math.max(1, opts.maxPages ?? 40);
137
+ const bucket = [];
138
+ let total;
139
+ for (let page = 1; page <= maxPages; page++) {
140
+ const r = await searchPositions({
141
+ keyword: opts.keyword,
142
+ page,
143
+ pageSize,
144
+ recruitType: opts.recruitType,
145
+ });
146
+ if (!r.ok) {
147
+ return { ok: false, source: SOURCE, message: r.message, total: 0, fetched: bucket.length, positions: bucket };
148
+ }
149
+ if (total === undefined)
150
+ total = r.total;
151
+ if (!r.positions.length)
152
+ break;
153
+ bucket.push(...r.positions);
154
+ if (total !== undefined && bucket.length >= total)
155
+ break;
156
+ }
48
157
  return {
49
- ok: false,
158
+ ok: true,
50
159
  source: SOURCE,
51
- message: STUB_MESSAGE,
52
- total: 0,
53
- fetched: 0,
54
- positions: [],
160
+ total: total ?? bucket.length,
161
+ fetched: bucket.length,
162
+ positions: bucket,
55
163
  };
56
164
  }
57
165
  export async function fetchPositionDetail(postId) {
166
+ const id = (postId ?? "").trim();
167
+ if (!id)
168
+ return { ok: false, source: SOURCE, message: "post_id is required", post_id: id };
169
+ const r = await call("GET", "/openapi/position/detail", {
170
+ query: { idRecruitPosition: id },
171
+ });
172
+ if (!r.ok || !r.data) {
173
+ return { ok: false, source: SOURCE, message: r.message || "no detail returned", post_id: id };
174
+ }
175
+ const raw = r.data;
58
176
  return {
59
- ok: false,
177
+ ok: true,
60
178
  source: SOURCE,
61
- message: STUB_MESSAGE,
62
- post_id: postId,
179
+ post_id: String(raw.idRecruitPosition ?? id),
180
+ title: raw.positionName ?? raw.projectPositionName ?? "",
181
+ project: raw.projectName ?? "",
182
+ recruit_label: raw.recruitmentTypeName ?? raw.recruitmentType ?? "",
183
+ position_type: raw.positionTypeName ?? "",
184
+ description: (raw.positionDesc ?? raw.projectPositionDesc ?? "").trim(),
185
+ requirements: (raw.positionRequire ?? raw.projectPositionRequire ?? "").trim(),
186
+ work_city: raw.workCityName ?? "",
187
+ work_city_code: raw.workCityCode ?? "",
188
+ head_count: raw.positionNum,
189
+ release_time: raw.releaseTime ?? "",
190
+ apply_url: DETAIL_PAGE(id),
63
191
  };
64
192
  }
193
+ // ---------- fetchDictionaries ----------
65
194
  export async function fetchDictionaries() {
66
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
195
+ const r = await call("GET", "/openapi/system/dictionary/queryList");
196
+ if (!r.ok)
197
+ return { ok: false, source: SOURCE, message: r.message };
198
+ return {
199
+ ok: true,
200
+ source: SOURCE,
201
+ api_host: API_ROOT,
202
+ verified_at: new Date().toISOString(),
203
+ dictionaries: r.data,
204
+ };
67
205
  }
206
+ // ---------- notices (not exposed publicly) ----------
207
+ const NO_NOTICES = "OPPO careers does not expose a public notices/announcements endpoint.";
68
208
  export async function listNotices() {
69
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
209
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notices: [] };
70
210
  }
71
211
  export async function getNotice(noticeId) {
72
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
212
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notice_id: noticeId };
73
213
  }
74
214
  export async function findNoticesByQuestion(question, _opts = {}) {
75
- return {
76
- ok: false,
77
- source: SOURCE,
78
- question,
79
- message: STUB_MESSAGE,
80
- matches: [],
81
- };
215
+ return { ok: false, source: SOURCE, question, message: NO_NOTICES, matches: [] };
82
216
  }
83
- export async function matchResume(text, _opts = {}) {
217
+ // ---------- matchResume ----------
218
+ export async function matchResume(text, opts = {}) {
84
219
  const { terms, cities } = extractResumeSignals(text ?? "");
220
+ const topN = Math.max(1, opts.topN ?? 5);
221
+ const candidates = Math.max(topN, opts.candidates ?? 200);
222
+ const all = await fetchAllPositions({ pageSize: 50, maxPages: Math.ceil(candidates / 50) });
223
+ if (!all.ok) {
224
+ return {
225
+ ok: false,
226
+ source: SOURCE,
227
+ message: all.message,
228
+ extracted_terms: terms,
229
+ city_preferences: cities,
230
+ matches: [],
231
+ };
232
+ }
233
+ const scored = [];
234
+ for (const p of all.positions) {
235
+ const haystack = `${p.title} ${p.project} ${p.bgs} ${p.work_cities}`;
236
+ const score = scoreOverlap(haystack, terms, cities).score;
237
+ if (score > 0)
238
+ scored.push({ score, position: p });
239
+ }
240
+ scored.sort((a, b) => b.score - a.score);
85
241
  return {
86
- ok: false,
242
+ ok: true,
87
243
  source: SOURCE,
88
244
  extracted_terms: terms,
89
245
  city_preferences: cities,
90
- matches: [],
91
- message: STUB_MESSAGE,
246
+ candidate_pool: all.positions.length,
247
+ matches: scored.slice(0, topN).map((s) => s.position),
92
248
  };
93
249
  }
94
250
  export { extractResumeSignals, scoreOverlap };