job-pro 0.7.2 → 0.7.3

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/antgroup.js CHANGED
@@ -1,201 +1,362 @@
1
- // Thin client for Ant Group's campus-recruiting portal at talent.antgroup.com.
1
+ // 蚂蚁集团 (Ant Group) careers adapter for `job-pro`.
2
2
  //
3
3
  // ============================================================
4
- // API Discovery (probed 2026-05, JS bundle + network analysis):
4
+ // API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
5
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)
6
+ // `talent.antgroup.com` is an Ant Bigfish SPA. Its public-facing job feed
7
+ // is served by `hrcareersweb.antgroup.com` with two anonymous endpoints:
13
8
  //
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.
9
+ // POST /api/campus/position/search — 467 校招 / 实习 positions
10
+ // POST /api/social/position/search — 922 社招 positions
45
11
  //
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:
12
+ // Both accept JSON `{ key, pageIndex, pageSize, channel?, language, … }`
13
+ // and return:
14
+ // { success:true, errorMsg:"成功", content:[…RawPosition], totalCount,
15
+ // pageSize, currentPage }
62
16
  //
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)
17
+ // The `channel` field is required only on the social endpoint
18
+ // (`"group_official_site"`). The `ctoken=…` query parameter that the
19
+ // browser SPA appends is NOT required for unauthenticated reads.
68
20
  //
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>
21
+ // queryCollections / favoritePosition / login-required endpoints return
22
+ // `errorCode:"LOGIN_EXPIRED"` for anonymous callers those are user
23
+ // dashboard surfaces, not the public search.
84
24
  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));
25
+ export { checkResume };
26
+ const SOURCE = "hrcareersweb.antgroup.com";
27
+ const API_ROOT = "https://hrcareersweb.antgroup.com/api";
28
+ const CAMPUS_PAGE = "https://talent.antgroup.com/campus-list";
29
+ const SOCIAL_PAGE = "https://talent.antgroup.com/off-campus-position";
30
+ const DETAIL_URL = (recruitType, id) => recruitType === "campus"
31
+ ? `https://talent.antgroup.com/campus-list?positionId=${encodeURIComponent(id)}`
32
+ : `https://talent.antgroup.com/off-campus-position-detail?positionId=${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
+ "Content-Type": "application/json;charset=UTF-8",
38
+ Origin: "https://talent.antgroup.com",
39
+ };
40
+ async function post(path, body, referer) {
41
+ let response;
42
+ try {
43
+ response = await fetch(`${API_ROOT}${path}`, {
44
+ method: "POST",
45
+ headers: { ...DEFAULT_HEADERS, Referer: referer },
46
+ body: JSON.stringify(body),
47
+ });
48
+ }
49
+ catch (err) {
50
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
51
+ }
52
+ if (!response.ok)
53
+ return { ok: false, message: `HTTP ${response.status}` };
54
+ let env;
55
+ try {
56
+ env = (await response.json());
57
+ }
58
+ catch (err) {
59
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
60
+ }
61
+ if (env.success !== true) {
62
+ return { ok: false, message: env.errorMsg ?? `errorCode=${env.errorCode ?? "?"}` };
63
+ }
64
+ return { ok: true, content: env.content, totalCount: env.totalCount ?? 0, message: "ok" };
65
+ }
66
+ function summarize(item, recruitType) {
67
+ const id = String(item.id ?? item.code ?? "");
68
+ const locs = Array.isArray(item.workLocations) ? item.workLocations.filter(Boolean).join(" / ") : "";
69
+ return {
70
+ post_id: id,
71
+ title: (item.name ?? "").trim(),
72
+ project: item.project?.trim() ||
73
+ item.categoryName?.trim() ||
74
+ (Array.isArray(item.categories) ? item.categories.filter(Boolean).join(" / ") : ""),
75
+ recruit_label: (item.positionType ?? "").trim() || (recruitType === "campus" ? "校招" : "社招"),
76
+ bgs: (item.department ?? "").trim(),
77
+ work_cities: locs,
78
+ apply_url: id ? DETAIL_URL(recruitType, id) : recruitType === "campus" ? CAMPUS_PAGE : SOCIAL_PAGE,
79
+ };
80
+ }
81
+ async function searchSingle(recruitType, opts) {
82
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
99
83
  const page = Math.max(1, opts.page ?? 1);
100
- const channel = opts.channel ?? "campus_group_official_site";
101
- const query = {
84
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
85
+ const body = {
86
+ key: keyword,
102
87
  pageIndex: page,
103
88
  pageSize,
104
- channel,
105
89
  language: "zh",
106
90
  };
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;
91
+ if (recruitType === "social") {
92
+ body.channel = "group_official_site";
93
+ body.regions = "";
94
+ body.categories = "";
95
+ body.subCategories = "";
96
+ body.bgCode = opts.bgCode ?? "";
97
+ body.socialQrCode = "";
98
+ }
99
+ const referer = recruitType === "campus" ? CAMPUS_PAGE : SOCIAL_PAGE;
100
+ const r = await post(`/${recruitType}/position/search`, body, referer);
101
+ if (!r.ok) {
102
+ return { ok: false, total: 0, positions: [], message: r.message };
103
+ }
115
104
  return {
116
- ok: false,
117
- source: PORTAL_ROOT,
118
- message: STUB_MESSAGE,
119
- query,
105
+ ok: true,
106
+ total: r.totalCount ?? 0,
107
+ positions: (r.content ?? []).map((p) => summarize(p, recruitType)),
108
+ message: "ok",
109
+ };
110
+ }
111
+ // ---------- searchPositions ----------
112
+ export async function searchPositions(opts = {}) {
113
+ const recruitType = opts.recruitType ?? "all";
114
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 20));
115
+ const page = Math.max(1, opts.page ?? 1);
116
+ if (recruitType === "campus" || recruitType === "social") {
117
+ const r = await searchSingle(recruitType, opts);
118
+ if (!r.ok) {
119
+ return {
120
+ ok: false,
121
+ source: SOURCE,
122
+ message: r.message,
123
+ query: { recruitType, page, pageSize, keyword: opts.keyword ?? "" },
124
+ positions: [],
125
+ };
126
+ }
127
+ return {
128
+ ok: true,
129
+ source: SOURCE,
130
+ query: { recruitType, page, pageSize, keyword: opts.keyword ?? "" },
131
+ page,
132
+ page_size: pageSize,
133
+ total: r.total,
134
+ positions: r.positions,
135
+ };
136
+ }
137
+ // "all" → ask both endpoints for the same page
138
+ const [campus, social] = await Promise.all([
139
+ searchSingle("campus", opts),
140
+ searchSingle("social", opts),
141
+ ]);
142
+ const positions = [...campus.positions, ...social.positions];
143
+ const total = (campus.ok ? campus.total : 0) + (social.ok ? social.total : 0);
144
+ if (!campus.ok && !social.ok) {
145
+ return {
146
+ ok: false,
147
+ source: SOURCE,
148
+ message: campus.message,
149
+ query: { recruitType: "all", page, pageSize, keyword: opts.keyword ?? "" },
150
+ positions: [],
151
+ };
152
+ }
153
+ return {
154
+ ok: true,
155
+ source: SOURCE,
156
+ query: { recruitType: "all", page, pageSize, keyword: opts.keyword ?? "" },
120
157
  page,
121
158
  page_size: pageSize,
122
- total: null,
123
- positions: [],
159
+ total,
160
+ positions,
124
161
  };
125
162
  }
126
- // ---------- fetchAllPositions (stub) ----------
163
+ // ---------- fetchAllPositions ----------
127
164
  export async function fetchAllPositions(opts = {}) {
165
+ const recruitType = opts.recruitType ?? "all";
166
+ const pageSize = Math.max(1, Math.min(50, opts.pageSize ?? 30));
167
+ const maxPages = Math.max(1, opts.maxPages ?? 40);
168
+ async function drain(rt) {
169
+ const bucket = [];
170
+ let total = 0;
171
+ let lastMsg = "ok";
172
+ for (let page = 1; page <= maxPages; page++) {
173
+ const r = await searchSingle(rt, { ...opts, page, pageSize });
174
+ if (!r.ok) {
175
+ lastMsg = r.message;
176
+ if (bucket.length === 0)
177
+ return { ok: false, total: 0, positions: [], message: r.message };
178
+ break;
179
+ }
180
+ if (total === 0)
181
+ total = r.total;
182
+ if (!r.positions.length)
183
+ break;
184
+ bucket.push(...r.positions);
185
+ if (bucket.length >= total)
186
+ break;
187
+ }
188
+ return { ok: true, total, positions: bucket, message: lastMsg };
189
+ }
190
+ if (recruitType === "campus" || recruitType === "social") {
191
+ const r = await drain(recruitType);
192
+ if (!r.ok) {
193
+ return {
194
+ ok: false,
195
+ source: SOURCE,
196
+ message: r.message,
197
+ total: 0,
198
+ fetched: 0,
199
+ positions: [],
200
+ };
201
+ }
202
+ return {
203
+ ok: true,
204
+ source: SOURCE,
205
+ total: r.total,
206
+ fetched: r.positions.length,
207
+ positions: r.positions,
208
+ };
209
+ }
210
+ const [c, s] = await Promise.all([drain("campus"), drain("social")]);
128
211
  return {
129
- ok: false,
130
- source: PORTAL_ROOT,
131
- message: STUB_MESSAGE,
132
- fetched: 0,
133
- total: null,
134
- positions: [],
212
+ ok: true,
213
+ source: SOURCE,
214
+ total: (c.ok ? c.total : 0) + (s.ok ? s.total : 0),
215
+ fetched: c.positions.length + s.positions.length,
216
+ positions: [...c.positions, ...s.positions],
135
217
  };
136
218
  }
137
- // ---------- fetchPositionDetail (stub) ----------
219
+ // ---------- fetchPositionDetail ----------
220
+ // The list endpoint already returns description/requirement/teamDescription
221
+ // inline — no separate detail endpoint needed. Scan campus then social.
138
222
  export async function fetchPositionDetail(postId) {
139
223
  const id = (postId ?? "").trim();
140
- if (!id) {
141
- return {
142
- ok: false,
143
- source: PORTAL_ROOT,
144
- message: "post_id is required",
145
- };
224
+ if (!id)
225
+ return { ok: false, source: SOURCE, message: "post_id is required" };
226
+ for (const rt of ["campus", "social"]) {
227
+ const pageSize = 50;
228
+ const maxPages = 20;
229
+ for (let page = 1; page <= maxPages; page++) {
230
+ const body = {
231
+ key: "",
232
+ pageIndex: page,
233
+ pageSize,
234
+ language: "zh",
235
+ };
236
+ if (rt === "social") {
237
+ body.channel = "group_official_site";
238
+ body.regions = "";
239
+ body.categories = "";
240
+ body.subCategories = "";
241
+ body.bgCode = "";
242
+ body.socialQrCode = "";
243
+ }
244
+ const referer = rt === "campus" ? CAMPUS_PAGE : SOCIAL_PAGE;
245
+ const r = await post(`/${rt}/position/search`, body, referer);
246
+ if (!r.ok)
247
+ break;
248
+ const found = (r.content ?? []).find((p) => String(p.id ?? p.code) === id);
249
+ if (found) {
250
+ return {
251
+ ok: true,
252
+ source: SOURCE,
253
+ post_id: id,
254
+ title: found.name ?? "",
255
+ project: found.project ?? found.categoryName ?? "",
256
+ recruit_label: found.positionType ?? (rt === "campus" ? "校招" : "社招"),
257
+ department: found.department ?? "",
258
+ work_cities: found.workLocations ?? [],
259
+ publish_time: found.publishTime ?? "",
260
+ graduation_time: found.graduationTime ?? "",
261
+ experience: found.experience ?? "",
262
+ degree: found.degree ?? "",
263
+ description: found.description ?? "",
264
+ requirements: found.requirement ?? "",
265
+ team_description: found.teamDescription ?? "",
266
+ apply_url: DETAIL_URL(rt, id),
267
+ };
268
+ }
269
+ if (r.totalCount && (r.content?.length ?? 0) < pageSize)
270
+ break;
271
+ }
146
272
  }
147
273
  return {
148
274
  ok: false,
149
- source: PORTAL_ROOT,
150
- message: STUB_MESSAGE,
275
+ source: SOURCE,
151
276
  post_id: id,
152
- apply_url: DETAIL_PAGE(id),
277
+ message: `post ${id} not found in campus or social feeds`,
153
278
  };
154
279
  }
155
- // ---------- fetchDictionaries (stub) ----------
280
+ // ---------- fetchDictionaries ----------
281
+ let _dictCache = null;
156
282
  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.",
283
+ if (_dictCache !== null)
284
+ return _dictCache;
285
+ const [depRes, regRes, catRes] = await Promise.all([
286
+ post("/social/category/listDept", { channel: "group_official_site", language: "zh" }, SOCIAL_PAGE),
287
+ post("/region/hot", { channel: "group_official_site", language: "zh" }, SOCIAL_PAGE),
288
+ post("/social/category/list", { channel: "group_official_site", language: "zh" }, SOCIAL_PAGE),
289
+ ]);
290
+ if (!depRes.ok && !regRes.ok && !catRes.ok) {
291
+ const r = { ok: false, source: SOURCE, message: depRes.message };
292
+ _dictCache = r;
293
+ return r;
294
+ }
295
+ const result = {
296
+ ok: true,
297
+ source: SOURCE,
298
+ bgs: (depRes.content ?? []).map((d) => ({ code: d.code ?? "", name: d.name ?? "" })),
299
+ regions: (regRes.content ?? []).map((d) => ({ code: d.code ?? "", name: d.name ?? "" })),
300
+ categories: catRes.content ?? [],
165
301
  };
302
+ _dictCache = result;
303
+ return result;
166
304
  }
167
- // ---------- notices (stub) ----------
305
+ // ---------- notices ----------
306
+ const NOTICES_MSG = "Ant Group (蚂蚁集团): no public notices endpoint on hrcareersweb";
168
307
  export async function listNotices() {
169
- return {
170
- ok: false,
171
- source: PORTAL_ROOT,
172
- message: "Ant Group: no public notices endpoint",
173
- };
308
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
174
309
  }
175
- export async function getNotice(_id) {
176
- return {
177
- ok: false,
178
- source: PORTAL_ROOT,
179
- message: "Ant Group: no public notices endpoint",
180
- };
310
+ export async function getNotice(noticeId) {
311
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
181
312
  }
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
- };
313
+ export async function findNoticesByQuestion(question, _opts = {}) {
314
+ return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
189
315
  }
190
- // ---------- matchResume (stub) ----------
316
+ // ---------- matchResume ----------
191
317
  export async function matchResume(text, opts = {}) {
318
+ const topN = Math.max(1, opts.topN ?? 5);
319
+ const candidates = Math.max(topN, opts.candidates ?? 20);
192
320
  const { terms, cities } = extractResumeSignals(text ?? "");
321
+ if (!terms.length) {
322
+ return {
323
+ ok: false,
324
+ source: SOURCE,
325
+ message: "could not extract any technical signals from the text",
326
+ preview: (text ?? "").slice(0, 120),
327
+ };
328
+ }
329
+ const keyword = terms.slice(0, 3).join(" ");
330
+ const list = await searchPositions({ keyword, page: 1, pageSize: 50, recruitType: "all" });
331
+ if (!list.ok) {
332
+ return { ok: false, source: SOURCE, message: list.message, positions: [] };
333
+ }
334
+ const scored = [];
335
+ for (const p of list.positions) {
336
+ const blob = [p.title, p.project, p.recruit_label, p.bgs, p.work_cities].join(" ");
337
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
338
+ if (score > 0)
339
+ scored.push({ score, position: p, reasons });
340
+ }
341
+ scored.sort((a, b) => b.score - a.score);
342
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
343
+ if (!shortlist.length) {
344
+ shortlist = list.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
345
+ }
346
+ const matches = shortlist.slice(0, topN).map((s) => {
347
+ const mr = s.reasons.length > 0
348
+ ? s.reasons.slice(0, 5)
349
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
350
+ return { ...s.position, match_reasons: mr };
351
+ });
193
352
  return {
194
- ok: false,
195
- source: PORTAL_ROOT,
196
- message: STUB_MESSAGE,
353
+ ok: true,
354
+ source: SOURCE,
197
355
  extracted_terms: terms,
198
356
  city_preferences: cities,
199
- matches: [],
357
+ matches,
358
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
359
+ "The only authority on selection is HR.",
200
360
  };
201
361
  }
362
+ export { extractResumeSignals, scoreOverlap };