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/antgroup.js +318 -157
- package/dist/cambricon.js +27 -59
- package/dist/geely.js +29 -57
- package/dist/horizonrobotics.js +43 -92
- package/dist/index.js +1 -1
- package/dist/moka.js +412 -0
- package/dist/sensetime.js +47 -183
- package/dist/wecruit.js +385 -0
- package/package.json +3 -2
package/dist/geely.js
CHANGED
|
@@ -1,62 +1,34 @@
|
|
|
1
|
-
// 吉利汽车 (Geely Auto) —
|
|
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
|
-
//
|
|
4
|
+
// API DISCOVERY (probed 2026-05-16)
|
|
10
5
|
//
|
|
11
|
-
//
|
|
12
|
-
// https://
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
export
|
|
40
|
-
|
|
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;
|
package/dist/horizonrobotics.js
CHANGED
|
@@ -1,94 +1,45 @@
|
|
|
1
|
-
// 地平线 (Horizon Robotics)
|
|
2
|
-
//
|
|
3
|
-
// STATUS: stub-only.
|
|
1
|
+
// 地平线 (Horizon Robotics) careers adapter for `job-pro`.
|
|
4
2
|
//
|
|
5
3
|
// ============================================================
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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¤tPage=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.
|
|
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(/"/g, '"')
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/'/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 };
|