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/sensetime.js CHANGED
@@ -1,186 +1,50 @@
1
- // Thin client for 商汤科技 / SenseTime campus-recruiting portal at hr.sensetime.com.
1
+ // 商汤 (SenseTime) careers adapter for `job-pro`.
2
2
  //
3
3
  // ============================================================
4
- // RECONNAISSANCE RESULTS (probed 2026-05):
5
- //
6
- // https://hr.sensetime.com/
7
- // 302 redirect to /SU60fa3bdabef57c1023fc1cbc/pb/social.html
8
- // Platform: "PB" / self-hosted Chinese HRIS (not Feishu, not Workday)
9
- // Vendor fingerprint: /pb/js/vendor.js + /pb/js/{page}.js webpack bundles
10
- //
11
- // https://careers.sensetime.com/
12
- // https://campus.sensetime.com/
13
- // SSL handshake failure (geo-blocked / Apple Private Relay conflict)
14
- //
15
- // ============================================================
16
- // PORTAL STRUCTURE (from JS bundle analysis):
17
- //
18
- // The SPA serves these page bundles:
19
- // /pb/js/social.js → 社招 (social/full-time hire) page
20
- // /pb/js/school.js → 校园 (campus / new-grad + intern) page
21
- // /pb/js/home.js → 首页 (home hub) page
22
- //
23
- // Channel IDs embedded in JS bundles:
24
- // SU60fa3bdabef57c1023fc1cbc — social (社招) channel (main redirect target)
25
- // SU6710d7c21c240e54e1f82a1b — campus (校园) channel (school.html)
26
- //
27
- // recruitType values (from bundle analysis):
28
- // 1 = 校园/campus (new-grad), used by school.js
29
- // 2 = 社招/social (full-time hire), used by social.js
30
- //
31
- // ============================================================
32
- // API DISCOVERY (probed 2026-05, paths extracted from social.js + school.js bundles):
33
- //
34
- // Discovered paths (relative to origin+channelBase):
35
- // POST /positionInfo/listPosition/{channelId}
36
- // Payload: { isFrompb: true, recruitType: 1|2, pageSize: N, currentPage: N,
37
- // postName?: str, postKey?: str, workPlace?: {...}, category?: {...} }
38
- // Response: { state: "200", data: { pageForm: { pageData: [...], currentPage: N },
39
- // positonNum: N } }
40
- //
41
- // POST /positionInfo/listSearchTerm/{channelId}
42
- // Returns filter taxonomies (work cities, departments, job types)
43
- //
44
- // POST /positionInfo/listPositionDetail/{channelId}
45
- // Payload: { postId: str, recruitType: N }
46
- // Returns full JD for a single posting
47
- //
48
- // POST /positionInfo/UnassignedPostDetail/{channelId}
49
- // Returns detail for positions with unassigned departments
50
- //
51
- // GET /suite/post/search/condition/{channelId}
52
- // Returns search filter configuration
53
- //
54
- // Constructed API base:
55
- // https://hr.sensetime.com/{channelId}/pb/{apiPath}/{channelId}
56
- // (the Nginx proxy at /SU.../pb/ maps sub-paths to the backend)
57
- //
58
- // ============================================================
59
- // WHY THIS IS A STUB (unauthenticated access is impossible):
60
- //
61
- // Every POST request to the above paths returns HTTP 405 Method Not Allowed,
62
- // regardless of Origin, Referer, Content-Type, or User-Agent headers.
63
- // GET requests return the SPA HTML shell (client-side routing catch-all).
64
- //
65
- // The Nginx WAF at hr.sensetime.com blocks all unauthenticated POST requests.
66
- // The API requires a valid session cookie / JWT obtained via:
67
- // POST /login/ or POST /ssoLogin
68
- // These are enterprise SSO flows (phone OTP, WeChat OAuth, or SAML enterprise SSO)
69
- // that cannot be automated without a real account.
70
- //
71
- // This is fundamentally different from ByteDance/Tencent/Feishu portals, which
72
- // allow anonymous POST to their search endpoints without any session cookie.
73
- //
74
- // Recommendation: Monitor for:
75
- // (a) A future public campus API at campus.sensetime.com
76
- // (b) A Feishu Recruiting migration (SenseTime does use Feishu internally)
77
- // (c) Third-party job boards (牛客, 实习僧) that scrape SenseTime listings
78
- //
79
- // ============================================================
80
- // STUB CONTRACT: All functions return ok:false with STUB_MESSAGE.
81
- // checkResume is re-exported from tencent.ts (works offline on resume text).
82
- // When/if SenseTime opens a public API, rewrite this file — the export shape
83
- // is already locked in by the PositionSummary interface below.
84
- import { extractResumeSignals, checkResume } from "./tencent.js";
85
- export { checkResume };
86
- const SOURCE = "hr.sensetime.com";
87
- const CAMPUS_URL = "https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/school.html";
88
- const STUB_MESSAGE = "SenseTime (商汤): no public job API — hr.sensetime.com POSTs are blocked by WAF (HTTP 405) " +
89
- "without a valid session cookie; campus.sensetime.com and careers.sensetime.com are " +
90
- "geo-blocked (SSL failure). The HRIS platform (PB/PushB, channel SU6710d7c21c240e54e1f82a1b) " +
91
- "requires enterprise SSO (phone OTP / WeChat OAuth). " +
92
- "Documented in cli/src/sensetime.ts header.";
93
- // ---- searchPositions ----
94
- export async function searchPositions(_opts = {}) {
95
- return {
96
- ok: false,
97
- source: SOURCE,
98
- message: STUB_MESSAGE,
99
- // Expose the discovered endpoint so callers can see what we would have hit
100
- endpoint: `POST https://hr.sensetime.com/SU6710d7c21c240e54e1f82a1b/pb/positionInfo/listPosition/SU6710d7c21c240e54e1f82a1b`,
101
- query: {
102
- isFrompb: true,
103
- recruitType: _opts.recruitType ?? 1,
104
- pageSize: _opts.pageSize ?? 20,
105
- currentPage: _opts.page ?? 1,
106
- ...(_opts.keyword ? { postKey: _opts.keyword } : {}),
4
+ // API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
5
+ //
6
+ // hr.sensetime.com hosts a Beisen Wecruit (北森招聘云) tenant. The published
7
+ // SPA bundles at `/SU…/pb/<channel>.html` ALWAYS return nginx 405 on
8
+ // anonymous POST, regardless of headers; that path is GET-only at the LB.
9
+ //
10
+ // The SPA's real XHR target (uncovered by intercepting page traffic in a
11
+ // headless Chrome instance) is on a sibling `/wecruit/...` prefix:
12
+ //
13
+ // POST https://hr.sensetime.com/wecruit/positionInfo/listPosition/<SU…>
14
+ // ?iSaJAx=isAjax&request_locale=zh_CN&t=<unix-ms>
15
+ //
16
+ // Content-Type: application/x-www-form-urlencoded (NOT JSON)
17
+ // Body: isFrompb=true&recruitType=2&pageSize=15&currentPage=1
18
+ //
19
+ // Anonymous, no token, no cookie, no captcha. Probed 2026-05-16: the
20
+ // social channel `SU60fa3bdabef57c1023fc1cbc` returns ~89 pages × 12 ≈
21
+ // 1068 active social-hire positions across SenseTime and its subsidiaries.
22
+ //
23
+ // hr.sensetime.com root redirects to the social channel (302); the campus
24
+ // SU referenced in earlier reconnaissance notes (`SU6710d7c21c240e54e1f82a1b`)
25
+ // has been reassigned to a different tenant ("安徽新华发行集团" appears in
26
+ // its responses), so we only wire the social channel. If SenseTime
27
+ // rebroadcasts a campus channel later, add it to the `channels` array.
28
+ //
29
+ // See cli/src/wecruit.ts for the shared factory.
30
+ import { createAdapter } from "./wecruit.js";
31
+ const adapter = createAdapter({
32
+ host: "hr.sensetime.com",
33
+ label: "SenseTime",
34
+ channels: [
35
+ {
36
+ channelId: "SU60fa3bdabef57c1023fc1cbc",
37
+ recruitType: "social",
38
+ pagePath: "social",
107
39
  },
108
- positions: [],
109
- total: 0,
110
- };
111
- }
112
- // ---- fetchAllPositions ----
113
- export async function fetchAllPositions(_opts = {}) {
114
- return {
115
- ok: false,
116
- source: SOURCE,
117
- message: STUB_MESSAGE,
118
- total: 0,
119
- fetched: 0,
120
- positions: [],
121
- };
122
- }
123
- // ---- fetchPositionDetail ----
124
- export async function fetchPositionDetail(postId) {
125
- return {
126
- ok: false,
127
- source: SOURCE,
128
- message: STUB_MESSAGE,
129
- post_id: postId,
130
- };
131
- }
132
- // ---- fetchDictionaries ----
133
- //
134
- // When accessible, POST /positionInfo/listSearchTerm/{channelId} returns:
135
- // { state: "200", data: { projectList, provinceList, orgList, postTypeList, salaryList } }
136
- export async function fetchDictionaries() {
137
- return {
138
- ok: false,
139
- source: SOURCE,
140
- message: STUB_MESSAGE,
141
- note: "When API becomes accessible: POST /positionInfo/listSearchTerm/{channelId}",
142
- };
143
- }
144
- // ---- notices (no public endpoint) ----
145
- export async function listNotices() {
146
- return {
147
- ok: false,
148
- source: SOURCE,
149
- message: "SenseTime: no public notices endpoint",
150
- notices: [],
151
- };
152
- }
153
- export async function getNotice(noticeId) {
154
- return {
155
- ok: false,
156
- source: SOURCE,
157
- message: "SenseTime: no public notices endpoint",
158
- notice_id: noticeId,
159
- };
160
- }
161
- export async function findNoticesByQuestion(question, _opts = {}) {
162
- return {
163
- ok: false,
164
- source: SOURCE,
165
- question,
166
- message: "SenseTime: no public notices endpoint",
167
- matches: [],
168
- };
169
- }
170
- // ---- matchResume ----
171
- //
172
- // Because the position search API is inaccessible, we cannot retrieve live listings
173
- // to score against the resume. Return ok:false with the extracted signals so the
174
- // caller can display what terms were parsed (useful for debugging the resume text).
175
- export async function matchResume(text, _opts = {}) {
176
- const { terms, cities } = extractResumeSignals(text ?? "");
177
- return {
178
- ok: false,
179
- source: SOURCE,
180
- extracted_terms: terms,
181
- city_preferences: cities,
182
- matches: [],
183
- message: STUB_MESSAGE,
184
- apply_url: CAMPUS_URL,
185
- };
186
- }
40
+ ],
41
+ });
42
+ export const searchPositions = adapter.searchPositions;
43
+ export const fetchAllPositions = adapter.fetchAllPositions;
44
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
45
+ export const fetchDictionaries = adapter.fetchDictionaries;
46
+ export const listNotices = adapter.listNotices;
47
+ export const getNotice = adapter.getNotice;
48
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
49
+ export const matchResume = adapter.matchResume;
50
+ export const checkResume = adapter.checkResume;
package/dist/sf.js CHANGED
@@ -1,65 +1,278 @@
1
- // 顺丰 (SF Express) campus recruiting — stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. The campus portal lives at campus.sf-express.com but
4
- // the JSON job-list endpoint is gated behind a Spring Security 401 for any
5
- // request lacking a logged-in user session bound to a GeeTest v4 captcha token.
1
+ // 顺丰 (SF Express) campus-recruiting adapter for `job-pro`.
6
2
  //
7
3
  // ============================================================
8
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // API DISCOVERY (probed 2026-05-15)
5
+ //
6
+ // campus.sf-express.com is a Vue SPA built with Webpack. The campus-recruiting
7
+ // flow was originally believed to be GeeTest-gated (POST /api/zp/jobList → 401),
8
+ // but the SPA's actual position-listing chunk (cr/static/js/25.aa149bcb...js)
9
+ // calls a different, fully anonymous route:
10
+ //
11
+ // GET /api/web/position/query?pageNum=&pageSize=&keyword=…
9
12
  //
10
- // https://campus.sf-express.com/ — Vue SPA, /cr/static/js/app.*.js
11
- // ships GeeTest gt4.js for captcha.
12
- // POST https://campus.sf-express.com/api/zp/jobList
13
- // {"timestamp":...,"status":401,"error":"Unauthorized","path":"/zp/jobList"}
14
- // (Spring Security returns 401 immediately when no session JWT is present.)
15
- // GET https://campus.sf-express.com/cr/api/zp/jobList → openresty 404
13
+ // Required headers: a normal browser UA plus the `cr-service` header that the
14
+ // SPA's axios interceptor adds to every request. The interceptor sets
15
+ // cr-service: <url-encoded current location>
16
+ // and the gateway uses it instead of a JWT to scope the response. With both
17
+ // in place the endpoint returns paginated JSON without any captcha or login.
16
18
  //
17
- // The SPA acquires its session by completing a GeeTest captcha after the
18
- // user clicks "查看职位" on the entry page; only then is the bearer token
19
- // injected into subsequent /api/zp/* requests.
19
+ // Endpoint inventory (anonymous GET unless noted):
20
+ // GET /api/web/position/query → paginated positions (campus + intern + mgmt)
21
+ // GET /api/web/position/findById/<id>→ single posting (via /api/position/findById/<id>)
20
22
  //
21
- // Feishu ATSX: sf.jobs.feishu.cn HTTP 400 (no portal configured)
22
- // Moka: app.mokahr.com/social-recruitment/sf → 200 page shell but
23
- // per-slug feed returns "您访问的页面不存在".
23
+ // `positionType` filter values seen in the wild:
24
+ // "consulting" 管理咨询生
25
+ // "managetraniee" 管培生类
26
+ // "" (omitted) 全部
24
27
  //
25
- // Conclusion: no unauthenticated public API. Visit
26
- // https://campus.sf-express.com/ for the official campus portal.
28
+ // ============================================================
27
29
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
28
30
  export { checkResume };
29
31
  const SOURCE = "campus.sf-express.com";
30
- const STUB_MESSAGE = "SF Express (顺丰): campus.sf-express.com /api/zp/jobList returns HTTP 401 (Spring Security) " +
31
- "for unauthenticated requests. Session JWT is only issued after a GeeTest v4 captcha is completed " +
32
- "in-browser. No unauthenticated public API available. Visit https://campus.sf-express.com/ for the portal.";
33
- export async function searchPositions(_opts = {}) {
34
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
32
+ const API_ROOT = "https://campus.sf-express.com";
33
+ const SITE_ROOT = "https://campus.sf-express.com/";
34
+ const DETAIL_PAGE = (id) => `https://campus.sf-express.com/#/postDetail/${encodeURIComponent(id)}`;
35
+ const CR_SERVICE = "https%3A%2F%2Fcampus.sf-express.com%2F";
36
+ const DEFAULT_HEADERS = {
37
+ "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",
38
+ Accept: "application/json, text/plain, */*",
39
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
40
+ Referer: SITE_ROOT,
41
+ Origin: API_ROOT,
42
+ "cr-service": CR_SERVICE,
43
+ };
44
+ async function call(path, query = {}) {
45
+ const params = new URLSearchParams();
46
+ for (const [k, v] of Object.entries(query)) {
47
+ if (v !== undefined && v !== "")
48
+ params.set(k, String(v));
49
+ }
50
+ const qs = params.toString();
51
+ const url = `${API_ROOT}${path}${qs ? `?${qs}` : ""}`;
52
+ let response;
53
+ try {
54
+ response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS });
55
+ }
56
+ catch (err) {
57
+ return {
58
+ ok: false,
59
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
60
+ };
61
+ }
62
+ if (!response.ok) {
63
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
64
+ }
65
+ // SF returns the payload object directly (PageHelper shape: {list, total, …})
66
+ let payload;
67
+ try {
68
+ payload = (await response.json());
69
+ }
70
+ catch (err) {
71
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
72
+ }
73
+ return { ok: true, data: payload.list, total: payload.total, message: "ok" };
74
+ }
75
+ function summarize(item) {
76
+ const id = String(item.id ?? "");
77
+ const city = (item.demandCity ?? item.recruitCity ?? "").toString().trim();
78
+ return {
79
+ post_id: id,
80
+ title: (item.positionName ?? "").trim(),
81
+ project: (item.orgSourceName ?? item.orgSource ?? "").trim(),
82
+ recruit_label: item.seasonType === "1"
83
+ ? "校招"
84
+ : item.seasonType === "2"
85
+ ? "实习"
86
+ : item.seasonType === "3"
87
+ ? "管培"
88
+ : "",
89
+ bgs: (item.positionTypeName ?? "").trim(),
90
+ work_cities: city,
91
+ apply_url: id ? DETAIL_PAGE(id) : SITE_ROOT,
92
+ };
35
93
  }
36
- export async function fetchAllPositions(_opts = {}) {
37
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
94
+ // ---------- searchPositions ----------
95
+ export async function searchPositions(opts = {}) {
96
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
97
+ const page = Math.max(1, opts.page ?? 1);
98
+ const query = {
99
+ pageNum: page,
100
+ pageSize,
101
+ };
102
+ if (opts.keyword)
103
+ query.positionName = opts.keyword.trim().slice(0, 60);
104
+ if (opts.positionType)
105
+ query.positionType = opts.positionType;
106
+ if (opts.seasonType)
107
+ query.seasonType = opts.seasonType;
108
+ const r = await call("/api/web/position/query", query);
109
+ if (!r.ok) {
110
+ return {
111
+ ok: false,
112
+ source: SOURCE,
113
+ message: r.message,
114
+ query,
115
+ positions: [],
116
+ };
117
+ }
118
+ const rows = r.data ?? [];
119
+ return {
120
+ ok: true,
121
+ source: SOURCE,
122
+ query,
123
+ page,
124
+ page_size: pageSize,
125
+ total: r.total ?? rows.length,
126
+ positions: rows.map(summarize),
127
+ };
38
128
  }
129
+ // ---------- fetchAllPositions ----------
130
+ export async function fetchAllPositions(opts = {}) {
131
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 50));
132
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
133
+ const bucket = [];
134
+ let total;
135
+ for (let page = 1; page <= maxPages; page++) {
136
+ const r = await searchPositions({ keyword: opts.keyword, page, pageSize });
137
+ if (!r.ok) {
138
+ return {
139
+ ok: false,
140
+ source: SOURCE,
141
+ message: r.message,
142
+ total: 0,
143
+ fetched: bucket.length,
144
+ positions: bucket,
145
+ };
146
+ }
147
+ if (total === undefined)
148
+ total = r.total;
149
+ if (!r.positions.length)
150
+ break;
151
+ bucket.push(...r.positions);
152
+ if (total !== undefined && bucket.length >= total)
153
+ break;
154
+ }
155
+ return {
156
+ ok: true,
157
+ source: SOURCE,
158
+ total: total ?? bucket.length,
159
+ fetched: bucket.length,
160
+ positions: bucket,
161
+ };
162
+ }
163
+ // ---------- fetchPositionDetail ----------
39
164
  export async function fetchPositionDetail(postId) {
40
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
165
+ const id = (postId ?? "").trim();
166
+ if (!id)
167
+ return { ok: false, source: SOURCE, message: "post_id is required", post_id: id };
168
+ // Some SF builds expose details via /api/position/findById/<id>, others via the
169
+ // SPA's "findById" route — both share the same backend. We always hit /api/...
170
+ const url = `${API_ROOT}/api/position/findById/${encodeURIComponent(id)}`;
171
+ let response;
172
+ try {
173
+ response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS });
174
+ }
175
+ catch (err) {
176
+ return {
177
+ ok: false,
178
+ source: SOURCE,
179
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
180
+ post_id: id,
181
+ };
182
+ }
183
+ if (!response.ok) {
184
+ return {
185
+ ok: false,
186
+ source: SOURCE,
187
+ message: `HTTP ${response.status}: ${response.statusText}`,
188
+ post_id: id,
189
+ };
190
+ }
191
+ let raw;
192
+ try {
193
+ raw = (await response.json());
194
+ }
195
+ catch (err) {
196
+ return {
197
+ ok: false,
198
+ source: SOURCE,
199
+ message: `bad JSON: ${err instanceof Error ? err.message : err}`,
200
+ post_id: id,
201
+ };
202
+ }
203
+ return {
204
+ ok: true,
205
+ source: SOURCE,
206
+ post_id: String(raw.id ?? id),
207
+ title: raw.positionName ?? "",
208
+ project: raw.orgSourceName ?? raw.orgSource ?? "",
209
+ position_type: raw.positionTypeName ?? "",
210
+ description: (raw.postDuty ?? "").toString().trim(),
211
+ requirements: (raw.jobRequirement ?? "").toString().trim(),
212
+ work_city: raw.demandCity ?? "",
213
+ interview_city: raw.recruitCity ?? "",
214
+ education: raw.educationName ?? raw.education ?? "",
215
+ intern_type: raw.internTypeName ?? raw.internType ?? "",
216
+ create_date: raw.createDate ?? "",
217
+ apply_url: DETAIL_PAGE(id),
218
+ };
41
219
  }
220
+ // ---------- fetchDictionaries (no public dict endpoint) ----------
42
221
  export async function fetchDictionaries() {
43
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
222
+ return {
223
+ ok: false,
224
+ source: SOURCE,
225
+ message: "SF Express does not expose a public filter taxonomy endpoint; positions API accepts " +
226
+ "positionName / positionType / seasonType query params directly.",
227
+ api_host: API_ROOT,
228
+ known_filters: {
229
+ positionType: ["consulting", "managetraniee"],
230
+ seasonType: { "1": "校招", "2": "实习", "3": "管培" },
231
+ },
232
+ };
44
233
  }
234
+ // ---------- notices (no public notices endpoint) ----------
235
+ const NO_NOTICES = "SF Express campus does not expose a public notices/announcements endpoint.";
45
236
  export async function listNotices() {
46
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
237
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notices: [] };
47
238
  }
48
239
  export async function getNotice(noticeId) {
49
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
240
+ return { ok: false, source: SOURCE, message: NO_NOTICES, notice_id: noticeId };
50
241
  }
51
242
  export async function findNoticesByQuestion(question, _opts = {}) {
52
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
243
+ return { ok: false, source: SOURCE, question, message: NO_NOTICES, matches: [] };
53
244
  }
54
- export async function matchResume(text, _opts = {}) {
245
+ // ---------- matchResume ----------
246
+ export async function matchResume(text, opts = {}) {
55
247
  const { terms, cities } = extractResumeSignals(text ?? "");
248
+ const topN = Math.max(1, opts.topN ?? 5);
249
+ const candidates = Math.max(topN, opts.candidates ?? 200);
250
+ const all = await fetchAllPositions({ pageSize: 50, maxPages: Math.ceil(candidates / 50) });
251
+ if (!all.ok) {
252
+ return {
253
+ ok: false,
254
+ source: SOURCE,
255
+ message: all.message,
256
+ extracted_terms: terms,
257
+ city_preferences: cities,
258
+ matches: [],
259
+ };
260
+ }
261
+ const scored = [];
262
+ for (const p of all.positions) {
263
+ const haystack = `${p.title} ${p.project} ${p.bgs} ${p.work_cities}`;
264
+ const score = scoreOverlap(haystack, terms, cities).score;
265
+ if (score > 0)
266
+ scored.push({ score, position: p });
267
+ }
268
+ scored.sort((a, b) => b.score - a.score);
56
269
  return {
57
- ok: false,
270
+ ok: true,
58
271
  source: SOURCE,
59
272
  extracted_terms: terms,
60
273
  city_preferences: cities,
61
- matches: [],
62
- message: STUB_MESSAGE,
274
+ candidate_pool: all.positions.length,
275
+ matches: scored.slice(0, topN).map((s) => s.position),
63
276
  };
64
277
  }
65
278
  export { extractResumeSignals, scoreOverlap };