job-pro 0.5.0 → 0.7.0

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/agibot.js ADDED
@@ -0,0 +1,390 @@
1
+ // Thin client for 智元机器人 (Agibot / AGIBOT Innovation) campus & social recruiting.
2
+ //
3
+ // ============================================================
4
+ // API DISCOVERY (probed 2026-05)
5
+ //
6
+ // Infrastructure:
7
+ // The corporate site www.agibot.com links to the Lark Hire (飞书招聘) SaaS portal:
8
+ // https://agirobot.jobs.feishu.cn/
9
+ // which hosts four separate recruiting portals:
10
+ // /index — 高端岗位 (senior / executive) website_id: 7314554416651995443
11
+ // /socialrecruitment — 社会招聘 (social / experienced) website_id: 7212468858346785082
12
+ // /campusrecruitment — 校园招聘 (campus / new-grad) website_id: 7212468542670309689
13
+ // /internrecruitment — 实习招聘 (intern)
14
+ //
15
+ // Dead ends probed:
16
+ // https://www.zhiyuan-robot.com/careers — returns 404 (redirects to agibot.com.cn)
17
+ // https://careers.agibot.com/ — connection refused / no server
18
+ // https://hr.agibot.com/ — connection refused / no server
19
+ // Moka orgId 145143 — auth-gated, not publicly accessible
20
+ //
21
+ // WORKING APPROACH — Lark Hire SaaS JSON API:
22
+ // All four portals share a single unauthenticated POST endpoint:
23
+ // POST https://agirobot.jobs.feishu.cn/api/v1/search/job/posts
24
+ // The API returns all 661+ positions (social + campus + intern combined) without
25
+ // any portal-type filter; the Referer header does not affect which posts are returned.
26
+ //
27
+ // Discovered by reverse-engineering the webpack bundle
28
+ // lf-package-cn.feishucdn.com/…/saas-career/static/js/4026.f23f1edc.js:
29
+ // Module 59235 sets eW = "" (relative host), so i = "/api/v1".
30
+ // getPositionList = i + "/search/job/posts" (POST, page_index + page_size)
31
+ // getPositionDetail = i + "/job/posts/" + id (GET)
32
+ // getPositionFilter = i + "/config/job/filters/" + path (GET)
33
+ //
34
+ // API call details (POST /api/v1/search/job/posts):
35
+ // Request body: { keyword, page_size, page_index, ... }
36
+ // Response: { code:0, data:{ job_post_list:[...], count:<int> } }
37
+ // count: 661 (all portals combined, 2026-05 snapshot)
38
+ //
39
+ // Note: department_id is always null in public search results — no BG/部门 field available.
40
+ //
41
+ // ============================================================
42
+ // PositionSummary field mapping (canonical keys — matches all other adapters):
43
+ // post_id — item.id (string)
44
+ // title — item.title
45
+ // project — item.job_category.name (e.g. "研发" / "智能制造 / 工业互联网")
46
+ // recruit_label — item.recruit_type.name + " / " + item.recruit_type.parent.name
47
+ // (e.g. "全职 / 社招" / "实习 / 校招")
48
+ // bgs — "" (department_id is always null in public API)
49
+ // work_cities — city_list[].name joined with " / " (e.g. "上海" / "北京 / 上海")
50
+ // apply_url — https://agirobot.jobs.feishu.cn/socialrecruitment/position/{id}/detail
51
+ // ============================================================
52
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
53
+ export { checkResume };
54
+ const SOURCE = "agirobot.jobs.feishu.cn";
55
+ const API_ROOT = "https://agirobot.jobs.feishu.cn/api/v1";
56
+ const PORTAL_BASE = "https://agirobot.jobs.feishu.cn";
57
+ const LIST_PAGE = `${PORTAL_BASE}/socialrecruitment`;
58
+ const DETAIL_URL = (id) => `${PORTAL_BASE}/socialrecruitment/position/${encodeURIComponent(id)}/detail`;
59
+ const DEFAULT_HEADERS = {
60
+ "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",
61
+ Accept: "application/json, text/plain, */*",
62
+ "Content-Type": "application/json",
63
+ Origin: PORTAL_BASE,
64
+ Referer: LIST_PAGE,
65
+ };
66
+ async function call(path, body) {
67
+ const url = `${API_ROOT}${path}`;
68
+ let response;
69
+ try {
70
+ response = await fetch(url, {
71
+ method: "POST",
72
+ headers: DEFAULT_HEADERS,
73
+ body: JSON.stringify(body),
74
+ });
75
+ }
76
+ catch (err) {
77
+ return {
78
+ ok: false,
79
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
80
+ };
81
+ }
82
+ if (!response.ok) {
83
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
84
+ }
85
+ let payload;
86
+ try {
87
+ payload = (await response.json());
88
+ }
89
+ catch (err) {
90
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
91
+ }
92
+ return {
93
+ ok: payload.code === 0,
94
+ data: payload.data,
95
+ message: payload.message || (payload.code === 0 ? "ok" : "upstream error"),
96
+ };
97
+ }
98
+ function summarizePosition(item) {
99
+ const id = String(item.id ?? "");
100
+ // work_cities: prefer city_list for multi-city; fall back to city_info
101
+ const cityList = item.city_list ?? [];
102
+ let work_cities;
103
+ if (cityList.length >= 1) {
104
+ work_cities = cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ");
105
+ }
106
+ else {
107
+ work_cities = item.city_info?.name ?? "";
108
+ }
109
+ // recruit_label: "全职 / 社招" or "实习 / 校招" style
110
+ const rt = item.recruit_type;
111
+ const rtName = rt?.name ?? "";
112
+ const rtParent = rt?.parent?.name ?? "";
113
+ const recruit_label = rtParent ? `${rtName} / ${rtParent}` : rtName;
114
+ return {
115
+ post_id: id,
116
+ title: item.title ?? "",
117
+ project: item.job_category?.name ?? "",
118
+ recruit_label,
119
+ bgs: "", // department_id is always null in public search results
120
+ work_cities,
121
+ apply_url: id ? DETAIL_URL(id) : LIST_PAGE,
122
+ };
123
+ }
124
+ // ---------- searchPositions ----------
125
+ export async function searchPositions(opts = {}) {
126
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
127
+ const page = Math.max(1, opts.page ?? 1);
128
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
129
+ const payload = {
130
+ keyword,
131
+ page_size: pageSize,
132
+ page_index: page,
133
+ };
134
+ const response = await call("/search/job/posts", payload);
135
+ if (!response.ok || !response.data) {
136
+ return {
137
+ ok: false,
138
+ message: response.message,
139
+ source: SOURCE,
140
+ query: payload,
141
+ positions: [],
142
+ };
143
+ }
144
+ const rows = response.data.job_post_list ?? [];
145
+ return {
146
+ ok: true,
147
+ source: SOURCE,
148
+ query: payload,
149
+ page,
150
+ page_size: pageSize,
151
+ total: response.data.count ?? rows.length,
152
+ positions: rows.map(summarizePosition),
153
+ };
154
+ }
155
+ // ---------- fetchAllPositions ----------
156
+ export async function fetchAllPositions(opts = {}) {
157
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
158
+ const maxPages = Math.max(1, opts.maxPages ?? 10); // up to 1000 posts
159
+ const bucket = [];
160
+ let total;
161
+ for (let page = 1; page <= maxPages; page++) {
162
+ const result = await searchPositions({ ...opts, page, pageSize });
163
+ if (!result.ok) {
164
+ return {
165
+ ok: false,
166
+ message: result.message,
167
+ source: SOURCE,
168
+ fetched: bucket.length,
169
+ positions: bucket,
170
+ };
171
+ }
172
+ if (total === undefined)
173
+ total = result.total;
174
+ if (!result.positions.length)
175
+ break;
176
+ bucket.push(...result.positions);
177
+ if (total !== undefined && bucket.length >= total)
178
+ break;
179
+ }
180
+ return {
181
+ ok: true,
182
+ source: SOURCE,
183
+ total: total ?? bucket.length,
184
+ fetched: bucket.length,
185
+ positions: bucket,
186
+ };
187
+ }
188
+ export async function fetchPositionDetail(postId) {
189
+ const id = (postId ?? "").trim();
190
+ if (!id) {
191
+ return { ok: false, source: SOURCE, message: "post_id is required" };
192
+ }
193
+ const url = `${API_ROOT}/job/posts/${encodeURIComponent(id)}`;
194
+ let response;
195
+ try {
196
+ response = await fetch(url, {
197
+ method: "GET",
198
+ headers: { ...DEFAULT_HEADERS, Referer: DETAIL_URL(id) },
199
+ });
200
+ }
201
+ catch (err) {
202
+ return {
203
+ ok: false,
204
+ source: SOURCE,
205
+ post_id: id,
206
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
207
+ };
208
+ }
209
+ if (!response.ok) {
210
+ return {
211
+ ok: false,
212
+ source: SOURCE,
213
+ post_id: id,
214
+ message: `HTTP ${response.status}: ${response.statusText}`,
215
+ };
216
+ }
217
+ let payload;
218
+ try {
219
+ payload = (await response.json());
220
+ }
221
+ catch (err) {
222
+ return {
223
+ ok: false,
224
+ source: SOURCE,
225
+ post_id: id,
226
+ message: `bad JSON: ${err instanceof Error ? err.message : err}`,
227
+ };
228
+ }
229
+ if (payload.code !== 0 || !payload.data?.job_post_detail) {
230
+ return {
231
+ ok: false,
232
+ source: SOURCE,
233
+ post_id: id,
234
+ message: payload.message ?? "upstream error",
235
+ };
236
+ }
237
+ const d = payload.data.job_post_detail;
238
+ const cities = (d.city_list ?? []).map((c) => c.name ?? "").filter(Boolean);
239
+ return {
240
+ ok: true,
241
+ source: SOURCE,
242
+ post_id: String(d.id ?? id),
243
+ title: d.title ?? "",
244
+ direction: d.sub_title ?? "",
245
+ project: d.job_category?.name ?? "",
246
+ recruit_label: d.recruit_type?.name ?? "",
247
+ description: d.description ?? "",
248
+ requirements: d.requirement ?? "",
249
+ work_cities: cities,
250
+ apply_url: DETAIL_URL(String(d.id ?? id)),
251
+ };
252
+ }
253
+ export async function fetchDictionaries() {
254
+ const url = `${API_ROOT}/config/job/filters/index`;
255
+ let response;
256
+ try {
257
+ response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS });
258
+ }
259
+ catch (err) {
260
+ return {
261
+ ok: false,
262
+ source: SOURCE,
263
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
264
+ };
265
+ }
266
+ if (!response.ok) {
267
+ return {
268
+ ok: false,
269
+ source: SOURCE,
270
+ message: `HTTP ${response.status}`,
271
+ };
272
+ }
273
+ let payload;
274
+ try {
275
+ payload = (await response.json());
276
+ }
277
+ catch (err) {
278
+ return {
279
+ ok: false,
280
+ source: SOURCE,
281
+ message: `bad JSON: ${err instanceof Error ? err.message : err}`,
282
+ };
283
+ }
284
+ if (payload.code !== 0 || !payload.data) {
285
+ return {
286
+ ok: false,
287
+ source: SOURCE,
288
+ message: payload.message ?? "upstream error",
289
+ };
290
+ }
291
+ const d = payload.data;
292
+ const jobCategories = (d.job_type_list ?? []).map((c) => ({
293
+ id: c.id ?? "",
294
+ name: c.name ?? "",
295
+ en_name: c.en_name ?? "",
296
+ depth: c.depth ?? 1,
297
+ parent_id: c.parent?.id ?? null,
298
+ }));
299
+ const cities = (d.city_list ?? []).map((c) => ({
300
+ code: c.code ?? "",
301
+ name: c.name ?? "",
302
+ en_name: c.en_name ?? "",
303
+ }));
304
+ return {
305
+ ok: true,
306
+ source: SOURCE,
307
+ portal: PORTAL_BASE,
308
+ portals: {
309
+ index: `${PORTAL_BASE}/index`,
310
+ social: `${PORTAL_BASE}/socialrecruitment`,
311
+ campus: `${PORTAL_BASE}/campusrecruitment`,
312
+ intern: `${PORTAL_BASE}/internrecruitment`,
313
+ },
314
+ note: "All four Agibot recruiting portals share a single public API endpoint at " +
315
+ "/api/v1/search/job/posts. department_id is always null in public results " +
316
+ "(no BG/部门 exposed). Total ~661 positions across social + campus + intern.",
317
+ jobCategories,
318
+ cities,
319
+ };
320
+ }
321
+ // ---------- notices (no public endpoint) ----------
322
+ const NOTICES_STUB = {
323
+ ok: false,
324
+ source: SOURCE,
325
+ message: "Agibot: no public notices or announcement endpoint available",
326
+ };
327
+ export async function listNotices() {
328
+ return NOTICES_STUB;
329
+ }
330
+ export async function getNotice(_id) {
331
+ return NOTICES_STUB;
332
+ }
333
+ export async function findNoticesByQuestion(_question, _opts = {}) {
334
+ return NOTICES_STUB;
335
+ }
336
+ // ---------- matchResume ----------
337
+ export async function matchResume(text, opts = {}) {
338
+ const topN = Math.max(1, opts.topN ?? 5);
339
+ const candidates = Math.max(topN, opts.candidates ?? 20);
340
+ const { terms, cities } = extractResumeSignals(text ?? "");
341
+ if (!terms.length) {
342
+ return {
343
+ ok: false,
344
+ source: SOURCE,
345
+ message: "could not extract any technical signals from the text",
346
+ preview: (text ?? "").slice(0, 120),
347
+ };
348
+ }
349
+ const keyword = terms.slice(0, 3).join(" ");
350
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
351
+ if (!list.ok) {
352
+ return {
353
+ ok: false,
354
+ source: SOURCE,
355
+ message: list.message,
356
+ positions: [],
357
+ };
358
+ }
359
+ const scored = [];
360
+ for (const p of list.positions) {
361
+ const blob = [p.title, p.project, p.recruit_label, p.work_cities, p.post_id].join(" ");
362
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
363
+ if (score > 0)
364
+ scored.push({ score, position: p, reasons });
365
+ }
366
+ scored.sort((a, b) => b.score - a.score);
367
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
368
+ if (!shortlist.length) {
369
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
370
+ score: 0,
371
+ position,
372
+ reasons: [],
373
+ }));
374
+ }
375
+ const matches = shortlist.slice(0, topN).map((s) => {
376
+ const mr = s.reasons.length > 0
377
+ ? s.reasons.slice(0, 5)
378
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
379
+ return { ...s.position, match_reasons: mr };
380
+ });
381
+ return {
382
+ ok: true,
383
+ source: SOURCE,
384
+ extracted_terms: terms,
385
+ city_preferences: cities,
386
+ matches,
387
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
388
+ "The only authority on selection is HR.",
389
+ };
390
+ }
@@ -0,0 +1,201 @@
1
+ // Thin client for Ant Group's campus-recruiting portal at talent.antgroup.com.
2
+ //
3
+ // ============================================================
4
+ // API Discovery (probed 2026-05, JS bundle + network analysis):
5
+ //
6
+ // Portal URL: https://talent.antgroup.com/campus-list (public list view)
7
+ // https://talent.antgroup.com/campus-full-list (full list view)
8
+ // JS bundles: gw.alipayobjects.com/render/p/yuyan/180020010001257966/umi.6f081e74.js
9
+ // render.alipay.com/p/yuyan/180020010001257966/p__CampusRecruitment__CRList__index.*.async.js
10
+ // render.alipay.com/p/yuyan/180020010001257966/p__CampusRecruitment__CRFullList__index.*.async.js
11
+ // Gateway host: talent.antgroup.com (Spanner CDN/WAF, Alipay's proprietary gateway)
12
+ // Backend host: antwork-prod.antgroup-inc.cn (actual API server)
13
+ //
14
+ // ============================================================
15
+ // Endpoint inventory (extracted from JS bundle module 64588 + full UMI bundle):
16
+ //
17
+ // POST /api/campus/position/search — paginated job search
18
+ // POST /api/campus/position/detail — single position detail
19
+ // POST /api/campus/position/queryDept — dept tree for a position group
20
+ // POST /api/campus/positionGroup/queryBatchConfig — batch config
21
+ // POST /api/campus/positionGroup/queryBatchDetailById — batch detail
22
+ // POST /api/searchCondition/list — filter taxonomy (categories, cities, depts)
23
+ // POST /api/searchCondition/listPositionGroup
24
+ // POST /api/searchCondition/listTalentPlan
25
+ //
26
+ // Canonical position detail URL: /campus-position?positionId=<id>
27
+ //
28
+ // ============================================================
29
+ // AUTH STATUS — GATED (Alipay OAuth / buservice SDK):
30
+ //
31
+ // EVERY endpoint (including /api/campus/position/search and
32
+ // /api/searchCondition/list) requires an authenticated Alipay/Ant Group
33
+ // session. Without login, the backend returns:
34
+ //
35
+ // { "buserviceErrorCode": "USER_NOT_LOGIN",
36
+ // "buserviceErrorMsg": "https://pubbuservice.alipay.com/…" }
37
+ //
38
+ // The buservice middleware intercepts ALL routes as a catch-all auth gate
39
+ // before any controller logic runs. There is no guest/anonymous tier.
40
+ //
41
+ // The talent.antgroup.com Spanner gateway additionally returns 405 Method
42
+ // Not Allowed for POST requests that lack valid Alipay session cookies,
43
+ // preventing even the USER_NOT_LOGIN response from being seen in most cases.
44
+ // Direct calls to antwork-prod.antgroup-inc.cn reveal the auth error clearly.
45
+ //
46
+ // ============================================================
47
+ // CSRF / session flow (observed but INSUFFICIENT for anonymous access):
48
+ //
49
+ // GET /campus-list sets:
50
+ // ALIPAYJSESSIONID=<token>; domain=.antgroup.com
51
+ // _CHIPS-ALIPAYJSESSIONID=<same_token>; samesite=none; partitioned
52
+ // spanner=<signed_value>; path=/; secure
53
+ //
54
+ // These cookies are required for CORS (Access-Control-Allow-Credentials: true)
55
+ // but the buservice SDK then validates the session against Alipay's auth
56
+ // infrastructure — a simple GET-derived cookie has no authenticated user.
57
+ // Unlike Alibaba's portal (campus-talent.alibaba.com) which only needs an
58
+ // XSRF-TOKEN for public search, Ant Group's portal requires full Alipay OAuth.
59
+ //
60
+ // ============================================================
61
+ // Ant Group vs Alibaba — KEY DIFFERENCES:
62
+ //
63
+ // Portal: talent.antgroup.com vs campus-talent.alibaba.com
64
+ // Auth: Alipay OAuth (gated) vs XSRF-TOKEN only (public search works)
65
+ // CSRF: Not sufficient alone vs Sufficient for anonymous search
66
+ // Backend host: antwork-prod.antgroup-inc.cn vs campus-talent.alibaba.com
67
+ // Auth MW: buservice SDK (blocks all) vs Spring XSRF (only mutating ops)
68
+ //
69
+ // ============================================================
70
+ // FILTER TAXONOMY (from JS bundle, not verified against live API):
71
+ // channel values: "campus_group_official_site" (zh), "en_official_site" (en)
72
+ // searchCondition/list returns: searchItems with types "workCity", "category", "dept", "recruitType"
73
+ // Position fields: id, categoryName, workLocations, graduationTime, circleNames (BU)
74
+ //
75
+ // ============================================================
76
+ // ---- PositionSummary field mapping (Ant Group → canonical) ----
77
+ // post_id ← item.id (stringified)
78
+ // title ← item.name
79
+ // project ← item.categoryName ?? "" (e.g. "技术类", "产品类")
80
+ // recruit_label ← item.recruitType ?? "" (e.g. "实习生", "校招生")
81
+ // bgs ← item.circleNames?.[0] ?? "" (BU / business unit)
82
+ // work_cities ← item.workLocations?.join(" / ") ?? ""
83
+ // apply_url ← https://talent.antgroup.com/campus-position?positionId=<id>
84
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
85
+ export { extractResumeSignals, scoreOverlap, checkResume };
86
+ const PORTAL_ROOT = "https://talent.antgroup.com";
87
+ const CAMPUS_PAGE = `${PORTAL_ROOT}/campus-list`;
88
+ const DETAIL_PAGE = (id) => `${PORTAL_ROOT}/campus-position?positionId=${encodeURIComponent(String(id))}`;
89
+ // ---------- stub reason constant ----------
90
+ const STUB_MESSAGE = "Ant Group (talent.antgroup.com): all API endpoints require Alipay OAuth login. " +
91
+ "POST /api/campus/position/search returns buserviceErrorCode=USER_NOT_LOGIN for " +
92
+ "unauthenticated requests. The Spanner CDN gateway additionally returns HTTP 405 " +
93
+ "for POST requests lacking a valid Alipay session cookie. No anonymous/guest tier exists. " +
94
+ "To use this portal, the user must log in at talent.antgroup.com with an Alipay account " +
95
+ "and supply a valid ALIPAYJSESSIONID cookie.";
96
+ // ---------- searchPositions (stub) ----------
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 channel = opts.channel ?? "campus_group_official_site";
101
+ const query = {
102
+ pageIndex: page,
103
+ pageSize,
104
+ channel,
105
+ language: "zh",
106
+ };
107
+ if (opts.keyword?.trim())
108
+ query.keyword = opts.keyword.trim().slice(0, 60);
109
+ if (opts.category)
110
+ query.category = opts.category;
111
+ if (opts.region)
112
+ query.region = opts.region;
113
+ if (opts.deptCode)
114
+ query.deptCode = opts.deptCode;
115
+ return {
116
+ ok: false,
117
+ source: PORTAL_ROOT,
118
+ message: STUB_MESSAGE,
119
+ query,
120
+ page,
121
+ page_size: pageSize,
122
+ total: null,
123
+ positions: [],
124
+ };
125
+ }
126
+ // ---------- fetchAllPositions (stub) ----------
127
+ export async function fetchAllPositions(opts = {}) {
128
+ return {
129
+ ok: false,
130
+ source: PORTAL_ROOT,
131
+ message: STUB_MESSAGE,
132
+ fetched: 0,
133
+ total: null,
134
+ positions: [],
135
+ };
136
+ }
137
+ // ---------- fetchPositionDetail (stub) ----------
138
+ export async function fetchPositionDetail(postId) {
139
+ const id = (postId ?? "").trim();
140
+ if (!id) {
141
+ return {
142
+ ok: false,
143
+ source: PORTAL_ROOT,
144
+ message: "post_id is required",
145
+ };
146
+ }
147
+ return {
148
+ ok: false,
149
+ source: PORTAL_ROOT,
150
+ message: STUB_MESSAGE,
151
+ post_id: id,
152
+ apply_url: DETAIL_PAGE(id),
153
+ };
154
+ }
155
+ // ---------- fetchDictionaries (stub) ----------
156
+ export async function fetchDictionaries() {
157
+ return {
158
+ ok: false,
159
+ source: PORTAL_ROOT,
160
+ message: STUB_MESSAGE,
161
+ note: "Filter taxonomy (categories, cities, depts) is served via POST /api/searchCondition/list " +
162
+ "with body {channel:'campus_group_official_site', language:'zh'}. " +
163
+ "Response shape: { searchItems: [{type:'workCity'|'category'|'dept'|'recruitType', items:[{label,value}]}] }. " +
164
+ "All require Alipay login.",
165
+ };
166
+ }
167
+ // ---------- notices (stub) ----------
168
+ export async function listNotices() {
169
+ return {
170
+ ok: false,
171
+ source: PORTAL_ROOT,
172
+ message: "Ant Group: no public notices endpoint",
173
+ };
174
+ }
175
+ export async function getNotice(_id) {
176
+ return {
177
+ ok: false,
178
+ source: PORTAL_ROOT,
179
+ message: "Ant Group: no public notices endpoint",
180
+ };
181
+ }
182
+ export async function findNoticesByQuestion(_question, _opts = {}) {
183
+ return {
184
+ ok: false,
185
+ source: PORTAL_ROOT,
186
+ message: "Ant Group: no public notices endpoint",
187
+ matches: [],
188
+ };
189
+ }
190
+ // ---------- matchResume (stub) ----------
191
+ export async function matchResume(text, opts = {}) {
192
+ const { terms, cities } = extractResumeSignals(text ?? "");
193
+ return {
194
+ ok: false,
195
+ source: PORTAL_ROOT,
196
+ message: STUB_MESSAGE,
197
+ extracted_terms: terms,
198
+ city_preferences: cities,
199
+ matches: [],
200
+ };
201
+ }