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 +318 -157
- package/dist/cambricon.js +21 -393
- package/dist/geely.js +29 -57
- package/dist/index.js +1 -1
- package/dist/moka.js +412 -0
- package/package.json +2 -2
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 };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-pro",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies,
|
|
3
|
+
"version": "0.7.3",
|
|
4
|
+
"description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 45 live: Tencent, ByteDance, Alibaba, Meituan, Xiaohongshu, JD, Kuaishou, Xiaomi, Baidu, NetEase, Didi, Bilibili, PDD, NIO, MiniMax, Huawei, Weibo, miHoYo, Ping An, SenseTime, Trip.com, Unitree, BYD, Ant Group, Li Auto, Moonshot, Zhipu, iQIYI, Megvii, Agibot, DeepSeek, 01.AI, Galaxy Universal, StepFun, Baichuan, XPeng, WeRide, HoYoverse, iFlytek, OPPO, vivo, SF Express, Horizon Robotics, Cambricon, Geely. No signup, no token, no server.",
|
|
5
5
|
"homepage": "https://job.ha7ch.com",
|
|
6
6
|
"repository": "https://github.com/HA7CH/job-pro",
|
|
7
7
|
"license": "MIT",
|