job-pro 0.7.1 → 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/geely.js CHANGED
@@ -1,62 +1,34 @@
1
- // 吉利汽车 (Geely Auto) — stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only. The careers domains do not resolve over public DNS,
4
- // and the third-party ATS slugs (Greenhouse, Lever, Feishu, Moka) all return
5
- // 404 or are unprovisioned. Public-facing recruiting appears to run only
6
- // through WeChat / official-account channels.
1
+ // 吉利汽车 (Geely Auto) careers adapter Moka SSR + AES-128-CBC pagination.
7
2
  //
8
3
  // ============================================================
9
- // RECONNAISSANCE RESULTS (probed 2026-05):
4
+ // API DISCOVERY (probed 2026-05-16)
10
5
  //
11
- // https://career.geely.com 000 (no public DNS / unreachable)
12
- // https://join.geely.com — 000 (no public DNS)
13
- // https://hr.geely.com — 000 (no public DNS)
6
+ // `job.geely.com` is a CNAME that 302-redirects to a Moka tenant:
7
+ // https://app.mokahr.com/social-recruitment/geely/96123/
14
8
  //
15
- // Feishu ATSX: geely.jobs.feishu.cn HTTP 400 (no portal)
16
- // zeekr.jobs.feishu.cn HTTP 400 (no portal)
17
- // Greenhouse: geely / zeekr — HTTP 404 (no board)
18
- // Lever: geely / zeekr — HTTP 404 (no posting)
19
- // Moka: app.mokahr.com/social-recruitment/geely → 302 (slug unprovisioned)
9
+ // (The `198.18.x` IP that `job.geely.com` resolves to is an Alibaba-Cloud
10
+ // front; the actual upstream is `app.mokahr.com`.) The SSR HTML at that
11
+ // URL embeds the standard Moka `<input id="init-data" value="…">` blob
12
+ // containing the first page of jobs + aesIv for AES-128-CBC pagination.
20
13
  //
21
- // Conclusion: Geely's recruiting flow is gated behind WeChat / official-account
22
- // channels and a non-public corporate ATS. No public unauthenticated API
23
- // available. Visit Geely's official WeChat 吉利汽车招聘 for postings.
24
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
25
- export { checkResume };
26
- const SOURCE = "geely.com";
27
- const STUB_MESSAGE = "Geely (吉利汽车): careers subdomains (career / join / hr.geely.com) fail to resolve over public DNS, " +
28
- "and no Greenhouse / Lever / Feishu / Moka tenant is provisioned. Recruiting runs through WeChat " +
29
- "official-account channels. No unauthenticated public API available.";
30
- export async function searchPositions(_opts = {}) {
31
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, query: {}, positions: [] };
32
- }
33
- export async function fetchAllPositions(_opts = {}) {
34
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
35
- }
36
- export async function fetchPositionDetail(postId) {
37
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
38
- }
39
- export async function fetchDictionaries() {
40
- return { ok: false, source: SOURCE, message: STUB_MESSAGE };
41
- }
42
- export async function listNotices() {
43
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
44
- }
45
- export async function getNotice(noticeId) {
46
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
47
- }
48
- export async function findNoticesByQuestion(question, _opts = {}) {
49
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
50
- }
51
- export async function matchResume(text, _opts = {}) {
52
- const { terms, cities } = extractResumeSignals(text ?? "");
53
- return {
54
- ok: false,
55
- source: SOURCE,
56
- extracted_terms: terms,
57
- city_preferences: cities,
58
- matches: [],
59
- message: STUB_MESSAGE,
60
- };
61
- }
62
- export { extractResumeSignals, scoreOverlap };
14
+ // Same factory as `cli/src/moka.ts` (used by megvii / cambricon / etc.).
15
+ // Only the social-recruitment channel is published publicly no
16
+ // campus-recruitment URL is linked from the Geely corporate site.
17
+ import { createAdapter } from "./moka.js";
18
+ const adapter = createAdapter({
19
+ orgSlug: "geely",
20
+ label: "Geely",
21
+ channels: [
22
+ { siteId: 96123, kind: "social-recruitment", recruitType: "social" },
23
+ ],
24
+ defaultRecruitType: "social",
25
+ });
26
+ export const searchPositions = adapter.searchPositions;
27
+ export const fetchAllPositions = adapter.fetchAllPositions;
28
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
29
+ export const fetchDictionaries = adapter.fetchDictionaries;
30
+ export const listNotices = adapter.listNotices;
31
+ export const getNotice = adapter.getNotice;
32
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
33
+ export const matchResume = adapter.matchResume;
34
+ export const checkResume = adapter.checkResume;
@@ -1,94 +1,45 @@
1
- // 地平线 (Horizon Robotics) stub adapter for `job-pro`.
2
- //
3
- // STATUS: stub-only.
1
+ // 地平线 (Horizon Robotics) careers adapter for `job-pro`.
4
2
  //
5
3
  // ============================================================
6
- // RECONNAISSANCE RESULTS (probed 2026-05):
7
- //
8
- // The earlier note in this file (Moka careers portal slug=horizonrobotics)
9
- // is INCORRECT. The current public Horizon homepage is www.horizon.auto,
10
- // and its "加入我们" links point to a Beisen wecruit (北森) ATS, NOT Moka:
11
- //
12
- // https://wecruit.hotjob.cn/SU6409ef49bef57c635fd390a6/pb/interns.html
13
- // https://wecruit.hotjob.cn/SU6409ef49bef57c635fd390a6/pb/school.html
14
- // https://wecruit.hotjob.cn/SU64819a4f2f9d2433ba8b043a/pb/custom.html
15
- // https://wecruit.hotjob.cn/SU64819a4f2f9d2433ba8b043a/pb/social.html
16
- //
17
- // The Beisen wecruit positionInfo/listPosition endpoint is the same
18
- // stack as SenseTime (see cli/src/sensetime.ts header for full details):
19
- // every POST to /positionInfo/listPosition/{channelId} (with or without
20
- // /pb/ prefix, with or without /SU{id}/ prefix) returns HTTP 405 from
21
- // the Nginx WAF unless a valid session cookie / JWT is attached, which
22
- // only comes from enterprise SSO (phone OTP / WeChat OAuth / SAML).
23
- //
24
- // Moka legacy probes (`horizonrobotics` slug at app.mokahr.com) now
25
- // return 404 — that portal has been retired.
26
- //
27
- // ============================================================
28
- // WHY THIS IS A STUB (unauthenticated access is impossible):
29
- //
30
- // Horizon Robotics outsources recruiting to Beisen wecruit, whose WAF
31
- // blocks all anonymous POST traffic to the public positionInfo paths.
32
- // There is no anonymous JSON job-listing endpoint at any Horizon-owned
33
- // domain (www.horizon.auto has no inline job data either — it links
34
- // straight out to the Beisen portal).
35
- //
36
- // Alternatives for job discovery:
37
- // (a) Apply via https://wecruit.hotjob.cn/SU64819a4f2f9d2433ba8b043a/pb/social.html
38
- // (requires WeChat OAuth / phone OTP)
39
- // (b) Monitor third-party boards: 牛客网, 实习僧, BOSS直聘 for Horizon listings
40
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
41
- export { checkResume };
42
- const SOURCE = "wecruit.hotjob.cn/horizon";
43
- const SOCIAL_URL = "https://wecruit.hotjob.cn/SU64819a4f2f9d2433ba8b043a/pb/social.html";
44
- const CAMPUS_URL = "https://wecruit.hotjob.cn/SU6409ef49bef57c635fd390a6/pb/school.html";
45
- const STUB_MESSAGE = "Horizon Robotics (地平线): no public job API — careers run through Beisen wecruit " +
46
- "(channel SU64819a4f2f9d2433ba8b043a for social, SU6409ef49bef57c635fd390a6 for campus). " +
47
- "The Beisen WAF blocks all anonymous POSTs to /positionInfo/listPosition with HTTP 405. " +
48
- "Session cookies are only minted by enterprise SSO (phone OTP / WeChat OAuth). " +
49
- "No unauthenticated public API available.";
50
- export async function searchPositions(_opts = {}) {
51
- return {
52
- ok: false,
53
- source: SOURCE,
54
- message: STUB_MESSAGE,
55
- query: {},
56
- positions: [],
57
- apply_url: SOCIAL_URL,
58
- };
59
- }
60
- export async function fetchAllPositions(_opts = {}) {
61
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, total: 0, fetched: 0, positions: [] };
62
- }
63
- export async function fetchPositionDetail(postId) {
64
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, post_id: postId };
65
- }
66
- export async function fetchDictionaries() {
67
- return {
68
- ok: false,
69
- source: SOURCE,
70
- message: STUB_MESSAGE,
71
- portals: { social: SOCIAL_URL, campus: CAMPUS_URL },
72
- };
73
- }
74
- export async function listNotices() {
75
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notices: [] };
76
- }
77
- export async function getNotice(noticeId) {
78
- return { ok: false, source: SOURCE, message: STUB_MESSAGE, notice_id: noticeId };
79
- }
80
- export async function findNoticesByQuestion(question, _opts = {}) {
81
- return { ok: false, source: SOURCE, question, message: STUB_MESSAGE, matches: [] };
82
- }
83
- export async function matchResume(text, _opts = {}) {
84
- const { terms, cities } = extractResumeSignals(text ?? "");
85
- return {
86
- ok: false,
87
- source: SOURCE,
88
- extracted_terms: terms,
89
- city_preferences: cities,
90
- matches: [],
91
- message: STUB_MESSAGE,
92
- };
93
- }
94
- export { extractResumeSignals, scoreOverlap };
4
+ // API DISCOVERY (probed 2026-05-16 via puppeteer-core network capture)
5
+ //
6
+ // Horizon's careers run on `wecruit.hotjob.cn`, the same Beisen Wecruit
7
+ // stack as SenseTime (see cli/src/sensetime.ts). The `/{SU…}/pb/<channel>.html`
8
+ // SPA path returns nginx 405 on any anonymous POST. The real XHR is fired
9
+ // at the sibling `/wecruit/positionInfo/listPosition/{SU…}` route.
10
+ //
11
+ // Channels (probed 2026-05-16):
12
+ // * school — `SU6409ef49bef57c635fd390a6` (校园招聘 / 实习生) ~84 positions
13
+ // * social — `SU64819a4f2f9d2433ba8b043a` (社会招聘) ~216 positions
14
+ //
15
+ // Anonymous, no token, no cookie. See cli/src/wecruit.ts for the shared
16
+ // factory: POST to `/wecruit/positionInfo/listPosition/{channelId}` with
17
+ // `application/x-www-form-urlencoded` body containing
18
+ // `isFrompb=true&recruitType=<1|2>&pageSize=N&currentPage=N`. Response is
19
+ // `{ data:{ pageForm:{ totalPage, pageData[…] } }, state:"200" }`.
20
+ import { createAdapter } from "./wecruit.js";
21
+ const adapter = createAdapter({
22
+ host: "wecruit.hotjob.cn",
23
+ label: "Horizon Robotics",
24
+ channels: [
25
+ {
26
+ channelId: "SU6409ef49bef57c635fd390a6",
27
+ recruitType: "campus",
28
+ pagePath: "school",
29
+ },
30
+ {
31
+ channelId: "SU64819a4f2f9d2433ba8b043a",
32
+ recruitType: "social",
33
+ pagePath: "social",
34
+ },
35
+ ],
36
+ });
37
+ export const searchPositions = adapter.searchPositions;
38
+ export const fetchAllPositions = adapter.fetchAllPositions;
39
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
40
+ export const fetchDictionaries = adapter.fetchDictionaries;
41
+ export const listNotices = adapter.listNotices;
42
+ export const getNotice = adapter.getNotice;
43
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
44
+ export const matchResume = adapter.matchResume;
45
+ export const checkResume = adapter.checkResume;
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import * as webank from "./webank.js";
51
51
  import * as horizonrobotics from "./horizonrobotics.js";
52
52
  import * as cambricon from "./cambricon.js";
53
53
  import { memoryList, memoryGet, memorySet, memoryEvent, memoryClear, } from "./memory.js";
54
- const VERSION = "0.7.1";
54
+ const VERSION = "0.7.3";
55
55
  const HELP = `
56
56
  job-pro — query Chinese big-tech campus recruiting from your terminal
57
57
  (job.ha7ch.com)
package/dist/moka.js ADDED
@@ -0,0 +1,412 @@
1
+ // Generic Moka (北森外 — `app.mokahr.com` 招聘) adapter factory.
2
+ //
3
+ // Moka is a SaaS ATS used by many Chinese tech companies (Megvii, DeepSeek,
4
+ // Galaxy Universal, StepFun, Cambricon, Geely, …). Each tenant publishes a
5
+ // public portal at one of these URL shapes:
6
+ //
7
+ // https://app.mokahr.com/campus-recruitment/<orgSlug>/<siteId>
8
+ // https://app.mokahr.com/campus_apply/<orgSlug>/<siteId>
9
+ // https://app.mokahr.com/social-recruitment/<orgSlug>/<siteId>
10
+ // https://app.mokahr.com/recommendation-recruitment/<orgSlug>/<siteId>
11
+ //
12
+ // The SSR HTML always embeds an `<input id="init-data" value="<HTML-escaped JSON>">`
13
+ // containing the first page of jobs + an `aesIv` constant. For deeper
14
+ // pagination the SPA POSTs to
15
+ // /api/outer/ats-apply/website/jobs/v2?orgId=<slug>
16
+ // and receives an AES-CBC encrypted envelope `{data, necromancer}`. We
17
+ // decrypt with key=necromancer (utf8) and iv=aesIv (utf8) to obtain the
18
+ // plain JSON page.
19
+ //
20
+ // This factory hides that machinery. Adapters declare `{ orgSlug, channels }`
21
+ // (one channel per public portal URL) and get the eight canonical verbs.
22
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
23
+ import { createDecipheriv } from "node:crypto";
24
+ export { checkResume };
25
+ // ---------- shared headers ----------
26
+ const DEFAULT_HEADERS = {
27
+ "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",
28
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
29
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
30
+ };
31
+ const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
32
+ // ---------- shared helpers ----------
33
+ function htmlDecode(s) {
34
+ return s
35
+ .replace(/&quot;/g, '"')
36
+ .replace(/&amp;/g, "&")
37
+ .replace(/&lt;/g, "<")
38
+ .replace(/&gt;/g, ">")
39
+ .replace(/&#x27;/g, "'")
40
+ .replace(/&#39;/g, "'");
41
+ }
42
+ function parseInitData(html) {
43
+ const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
44
+ if (!m)
45
+ return null;
46
+ try {
47
+ return JSON.parse(htmlDecode(m[1]));
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ async function fetchPortalHtml(url) {
54
+ // Moka does a locale-cookie redirect dance: first request returns 302 +
55
+ // Set-Cookie; we capture them, then re-issue.
56
+ let response;
57
+ try {
58
+ response = await fetch(url, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
59
+ }
60
+ catch (err) {
61
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
62
+ }
63
+ const cookies = [];
64
+ const headersAny = response.headers;
65
+ if (typeof headersAny.getSetCookie === "function") {
66
+ for (const v of headersAny.getSetCookie.call(response.headers) ?? []) {
67
+ const c = v.split(";")[0];
68
+ if (c)
69
+ cookies.push(c);
70
+ }
71
+ }
72
+ if (cookies.length === 0) {
73
+ const raw = response.headers.get("set-cookie");
74
+ if (raw)
75
+ cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
76
+ }
77
+ const cookieHeader = cookies.join("; ");
78
+ let r2;
79
+ try {
80
+ r2 = await fetch(url, {
81
+ method: "GET",
82
+ headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
83
+ redirect: "follow",
84
+ });
85
+ }
86
+ catch (err) {
87
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
88
+ }
89
+ if (!r2.ok)
90
+ return { ok: false, message: `HTTP ${r2.status}` };
91
+ const html = await r2.text();
92
+ return { ok: true, html, cookieHeader, message: "ok" };
93
+ }
94
+ function decryptMokaEnvelope(envelope, aesIv) {
95
+ if (!envelope.data || !envelope.necromancer)
96
+ return null;
97
+ try {
98
+ const key = Buffer.from(envelope.necromancer, "utf8");
99
+ const iv = Buffer.from(aesIv, "utf8");
100
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
101
+ const plain = Buffer.concat([
102
+ decipher.update(Buffer.from(envelope.data, "base64")),
103
+ decipher.final(),
104
+ ]);
105
+ return JSON.parse(plain.toString("utf8"));
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ async function fetchEncryptedPage(orgSlug, siteId, pageNum, pageSize, aesIv, cookieHeader, portalUrl) {
112
+ const url = `${API_ENDPOINT}?orgId=${encodeURIComponent(orgSlug)}`;
113
+ const body = {
114
+ orgId: orgSlug,
115
+ siteId: String(siteId),
116
+ pageNum,
117
+ pageSize,
118
+ needStat: true,
119
+ };
120
+ let response;
121
+ try {
122
+ response = await fetch(url, {
123
+ method: "POST",
124
+ headers: {
125
+ ...DEFAULT_HEADERS,
126
+ Accept: "application/json,*/*",
127
+ "Content-Type": "application/json",
128
+ Origin: "https://app.mokahr.com",
129
+ Referer: portalUrl,
130
+ Cookie: cookieHeader,
131
+ },
132
+ body: JSON.stringify(body),
133
+ });
134
+ }
135
+ catch (err) {
136
+ return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
137
+ }
138
+ if (!response.ok)
139
+ return { ok: false, message: `HTTP ${response.status}` };
140
+ let envelope;
141
+ try {
142
+ envelope = await response.json();
143
+ }
144
+ catch {
145
+ return { ok: false, message: "bad JSON from upstream" };
146
+ }
147
+ const decoded = decryptMokaEnvelope(envelope, aesIv);
148
+ if (!decoded || decoded.code !== 0 || !decoded.data) {
149
+ return { ok: false, message: decoded?.msg || envelope?.msg || "decrypt or upstream error" };
150
+ }
151
+ return {
152
+ ok: true,
153
+ jobs: decoded.data.jobs ?? [],
154
+ total: decoded.data.jobStats?.total ?? 0,
155
+ message: "ok",
156
+ };
157
+ }
158
+ function buildCityMap(groups) {
159
+ const out = {};
160
+ if (!groups)
161
+ return out;
162
+ for (const g of groups) {
163
+ if (typeof g.cityId === "number" && g.label)
164
+ out[g.cityId] = g.label;
165
+ }
166
+ return out;
167
+ }
168
+ function workCitiesFor(job, cityMap) {
169
+ const cities = (job.locations ?? [])
170
+ .map((l) => {
171
+ if (typeof l.cityId === "number" && cityMap[l.cityId])
172
+ return cityMap[l.cityId];
173
+ return l.country || "";
174
+ })
175
+ .filter((s) => s.length > 0);
176
+ const uniq = [];
177
+ for (const c of cities)
178
+ if (!uniq.includes(c))
179
+ uniq.push(c);
180
+ return uniq.join(" / ");
181
+ }
182
+ function commitmentFor(job) {
183
+ if (typeof job.commitment === "string" && job.commitment.length > 0)
184
+ return job.commitment;
185
+ if (job.hireMode === 1)
186
+ return "全职";
187
+ if (job.hireMode === 2)
188
+ return "实习";
189
+ return "";
190
+ }
191
+ function matchesKeyword(job, kw) {
192
+ if (!kw)
193
+ return true;
194
+ const lc = kw.toLowerCase();
195
+ return ((job.title ?? "").toLowerCase().includes(lc) ||
196
+ (job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
197
+ (job.department?.name ?? "").toLowerCase().includes(lc));
198
+ }
199
+ // ---------- createAdapter ----------
200
+ export function createAdapter(cfg) {
201
+ const SOURCE = `app.mokahr.com/${cfg.orgSlug}`;
202
+ const portalUrl = (ch) => `https://app.mokahr.com/${ch.kind}/${cfg.orgSlug}/${ch.siteId}`;
203
+ function pickChannel(recruitType) {
204
+ const want = recruitType ?? cfg.defaultRecruitType ?? "social";
205
+ return cfg.channels.find((c) => c.recruitType === want) ?? cfg.channels[0];
206
+ }
207
+ function summarize(job, cityMap, ch) {
208
+ return {
209
+ post_id: String(job.id),
210
+ title: job.title ?? "",
211
+ project: job.zhineng?.name ?? "",
212
+ recruit_label: commitmentFor(job),
213
+ bgs: job.department?.name ?? "",
214
+ work_cities: workCitiesFor(job, cityMap),
215
+ apply_url: `${portalUrl(ch)}#/jobs/${encodeURIComponent(job.id)}`,
216
+ };
217
+ }
218
+ async function searchPositions(opts = {}) {
219
+ const ch = pickChannel(opts.recruitType);
220
+ const url = portalUrl(ch);
221
+ const pageSize = opts.pageSize ?? 20;
222
+ const page = opts.page ?? 1;
223
+ const keyword = opts.keyword ?? "";
224
+ const portal = await fetchPortalHtml(url);
225
+ if (!portal.ok || !portal.html) {
226
+ return {
227
+ ok: false,
228
+ source: SOURCE,
229
+ message: portal.message,
230
+ query: { recruitType: ch.recruitType, keyword, page, pageSize },
231
+ positions: [],
232
+ total: 0,
233
+ };
234
+ }
235
+ const init = parseInitData(portal.html);
236
+ if (!init || !init.jobs || !init.jobStats) {
237
+ return {
238
+ ok: false,
239
+ source: SOURCE,
240
+ message: "Moka init-data missing jobs/jobStats",
241
+ query: { recruitType: ch.recruitType, keyword, page, pageSize },
242
+ positions: [],
243
+ total: 0,
244
+ };
245
+ }
246
+ const cityMap = buildCityMap(init.jobsGroupedByLocation);
247
+ let jobs = init.jobs;
248
+ const total = init.jobStats.total ?? jobs.length;
249
+ if (page > 1 && init.aesIv && portal.cookieHeader) {
250
+ const more = await fetchEncryptedPage(cfg.orgSlug, ch.siteId, page, pageSize, init.aesIv, portal.cookieHeader, url);
251
+ if (!more.ok || !more.jobs) {
252
+ return {
253
+ ok: false,
254
+ source: SOURCE,
255
+ message: `pagination failed: ${more.message}`,
256
+ query: { recruitType: ch.recruitType, keyword, page, pageSize },
257
+ positions: [],
258
+ total,
259
+ };
260
+ }
261
+ jobs = more.jobs;
262
+ }
263
+ const filtered = jobs.filter((j) => matchesKeyword(j, keyword));
264
+ const sliced = filtered.slice(0, pageSize);
265
+ return {
266
+ ok: true,
267
+ source: SOURCE,
268
+ query: { recruitType: ch.recruitType, keyword, page, pageSize },
269
+ page,
270
+ page_size: pageSize,
271
+ total,
272
+ positions: sliced.map((j) => summarize(j, cityMap, ch)),
273
+ };
274
+ }
275
+ async function fetchAllPositions(opts = {}) {
276
+ const ch = pickChannel(opts.recruitType);
277
+ const url = portalUrl(ch);
278
+ const pageSize = opts.pageSize ?? 20;
279
+ const maxPages = Math.max(1, opts.maxPages ?? 50);
280
+ const keyword = opts.keyword ?? "";
281
+ const portal = await fetchPortalHtml(url);
282
+ if (!portal.ok || !portal.html) {
283
+ return {
284
+ ok: false,
285
+ source: SOURCE,
286
+ message: portal.message,
287
+ total: 0,
288
+ fetched: 0,
289
+ positions: [],
290
+ };
291
+ }
292
+ const init = parseInitData(portal.html);
293
+ if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
294
+ return {
295
+ ok: false,
296
+ source: SOURCE,
297
+ message: "Moka init-data missing required fields",
298
+ total: 0,
299
+ fetched: 0,
300
+ positions: [],
301
+ };
302
+ }
303
+ const cityMap = buildCityMap(init.jobsGroupedByLocation);
304
+ const total = init.jobStats.total ?? 0;
305
+ const collected = [...init.jobs];
306
+ let page = 2;
307
+ while (collected.length < total && page <= maxPages) {
308
+ const more = await fetchEncryptedPage(cfg.orgSlug, ch.siteId, page, pageSize, init.aesIv, portal.cookieHeader ?? "", url);
309
+ if (!more.ok || !more.jobs || more.jobs.length === 0)
310
+ break;
311
+ collected.push(...more.jobs);
312
+ page += 1;
313
+ }
314
+ const filtered = collected.filter((j) => matchesKeyword(j, keyword));
315
+ return {
316
+ ok: true,
317
+ source: SOURCE,
318
+ total,
319
+ fetched: filtered.length,
320
+ positions: filtered.map((j) => summarize(j, cityMap, ch)),
321
+ };
322
+ }
323
+ async function fetchPositionDetail(postId) {
324
+ const ch = pickChannel();
325
+ return {
326
+ ok: false,
327
+ source: SOURCE,
328
+ message: "Moka detail endpoint requires the same encrypted-session flow; not implemented. " +
329
+ "Use the apply_url deeplink for the full JD.",
330
+ post_id: postId,
331
+ apply_url: `${portalUrl(ch)}#/jobs/${encodeURIComponent(postId)}`,
332
+ };
333
+ }
334
+ async function fetchDictionaries() {
335
+ const ch = pickChannel();
336
+ const url = portalUrl(ch);
337
+ const portal = await fetchPortalHtml(url);
338
+ if (!portal.ok || !portal.html) {
339
+ return { ok: false, source: SOURCE, message: portal.message };
340
+ }
341
+ const init = parseInitData(portal.html);
342
+ if (!init)
343
+ return { ok: false, source: SOURCE, message: "Moka init-data missing" };
344
+ return {
345
+ ok: true,
346
+ source: SOURCE,
347
+ locations: init.jobsGroupedByLocation ?? [],
348
+ moka_orgs: cfg.channels.map((c) => ({
349
+ slug: cfg.orgSlug,
350
+ id: c.siteId,
351
+ url: portalUrl(c),
352
+ recruitType: c.recruitType,
353
+ })),
354
+ };
355
+ }
356
+ const NOTICES_MSG = `${cfg.label}: no public notices endpoint on Moka tenant`;
357
+ async function listNotices() {
358
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
359
+ }
360
+ async function getNotice(noticeId) {
361
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
362
+ }
363
+ async function findNoticesByQuestion(question, _opts = {}) {
364
+ return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
365
+ }
366
+ async function matchResume(text, opts = {}) {
367
+ const { terms, cities } = extractResumeSignals(text ?? "");
368
+ const candidates = Math.max(20, opts.candidates ?? 100);
369
+ const search = await fetchAllPositions({
370
+ pageSize: 20,
371
+ maxPages: Math.ceil(candidates / 15),
372
+ });
373
+ if (!search.ok) {
374
+ return {
375
+ ok: false,
376
+ source: SOURCE,
377
+ extracted_terms: terms,
378
+ city_preferences: cities,
379
+ matches: [],
380
+ message: search.message,
381
+ };
382
+ }
383
+ const topN = Math.max(1, opts.topN ?? 10);
384
+ const scored = search.positions
385
+ .map((p) => ({
386
+ p,
387
+ score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
388
+ }))
389
+ .sort((a, b) => b.score - a.score)
390
+ .slice(0, topN)
391
+ .map((x) => x.p);
392
+ return {
393
+ ok: true,
394
+ source: SOURCE,
395
+ extracted_terms: terms,
396
+ city_preferences: cities,
397
+ matches: scored,
398
+ };
399
+ }
400
+ return {
401
+ searchPositions,
402
+ fetchAllPositions,
403
+ fetchPositionDetail,
404
+ fetchDictionaries,
405
+ listNotices,
406
+ getNotice,
407
+ findNoticesByQuestion,
408
+ matchResume,
409
+ checkResume,
410
+ };
411
+ }
412
+ export { extractResumeSignals, scoreOverlap };