job-pro 0.7.2 → 0.7.4

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/stepfun.js CHANGED
@@ -1,382 +1,23 @@
1
- // 阶跃星辰 / StepFun recruiting via app.mokahr.com.
1
+ // 阶跃星辰 / StepFun careers Moka SSR + AES-128-CBC.
2
2
  //
3
- // ============================================================
4
- // HOW THIS WORKS (probed 2026-05):
5
- //
6
- // SSR HTML at https://app.mokahr.com/social-recruitment/step/94904 embeds
7
- // `<input id="init-data" value="<JSON>">` with the first 15 jobs and
8
- // `jobStats.total`. Deeper pages come from
9
- // POST /api/outer/ats-apply/website/jobs/v2?orgId=step (AES-128-CBC
10
- // envelope: key=necromancer, iv=aesIv from SSR HTML).
11
- //
12
- // CONFIRMED MOKA ORG:
13
- // slug=step, siteId=94904, mode=social
14
- // Portal: https://app.mokahr.com/social-recruitment/step/94904
15
- //
16
- // PositionSummary field mapping:
17
- // post_id ← job.id
18
- // title ← job.title
19
- // project ← job.zhineng?.name (e.g. "算法类")
20
- // recruit_label job.commitment || hireMode label
21
- // bgs ← job.department?.name
22
- // work_cities ← locations[].cityId label via jobsGroupedByLocation
23
- // apply_url ← portal#/jobs/{id}
24
- import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
25
- import { createDecipheriv } from "node:crypto";
26
- export { checkResume, extractResumeSignals, scoreOverlap };
27
- const SOURCE = "app.mokahr.com/step";
28
- const ORG_SLUG = "step";
29
- const SITE_ID = 94904;
30
- const PORTAL_URL = `https://app.mokahr.com/social-recruitment/${ORG_SLUG}/${SITE_ID}`;
31
- const API_ENDPOINT = "https://app.mokahr.com/api/outer/ats-apply/website/jobs/v2";
32
- const DEFAULT_HEADERS = {
33
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
34
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
35
- "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
36
- };
37
- function htmlDecode(s) {
38
- return s
39
- .replace(/&quot;/g, '"')
40
- .replace(/&amp;/g, "&")
41
- .replace(/&lt;/g, "<")
42
- .replace(/&gt;/g, ">")
43
- .replace(/&#x27;/g, "'")
44
- .replace(/&#39;/g, "'");
45
- }
46
- function parseInitData(html) {
47
- const m = html.match(/<input[^>]*id="init-data"[^>]*value="([^"]+)"/);
48
- if (!m)
49
- return null;
50
- try {
51
- return JSON.parse(htmlDecode(m[1]));
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- async function fetchPortalHtml() {
58
- let r1;
59
- try {
60
- r1 = await fetch(PORTAL_URL, { method: "GET", headers: DEFAULT_HEADERS, redirect: "manual" });
61
- }
62
- catch (err) {
63
- return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
64
- }
65
- const cookies = [];
66
- const headersAny = r1.headers;
67
- if (typeof headersAny.getSetCookie === "function") {
68
- for (const v of headersAny.getSetCookie.call(r1.headers) ?? []) {
69
- const c = v.split(";")[0];
70
- if (c)
71
- cookies.push(c);
72
- }
73
- }
74
- if (cookies.length === 0) {
75
- const raw = r1.headers.get("set-cookie");
76
- if (raw)
77
- cookies.push(...raw.split(/,(?=[^;]+=)/).map((c) => c.split(";")[0].trim()));
78
- }
79
- const cookieHeader = cookies.join("; ");
80
- let r2;
81
- try {
82
- r2 = await fetch(PORTAL_URL, {
83
- method: "GET",
84
- headers: { ...DEFAULT_HEADERS, Cookie: cookieHeader },
85
- redirect: "follow",
86
- });
87
- }
88
- catch (err) {
89
- return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
90
- }
91
- if (!r2.ok)
92
- return { ok: false, message: `HTTP ${r2.status}` };
93
- return { ok: true, html: await r2.text(), cookieHeader, message: "ok" };
94
- }
95
- function decryptMoka(envelope, aesIv) {
96
- if (!envelope.data || !envelope.necromancer)
97
- return null;
98
- try {
99
- const decipher = createDecipheriv("aes-128-cbc", Buffer.from(envelope.necromancer, "utf8"), Buffer.from(aesIv, "utf8"));
100
- const plain = Buffer.concat([
101
- decipher.update(Buffer.from(envelope.data, "base64")),
102
- decipher.final(),
103
- ]);
104
- return JSON.parse(plain.toString("utf8"));
105
- }
106
- catch {
107
- return null;
108
- }
109
- }
110
- async function fetchEncryptedPage(pageNum, pageSize, aesIv, cookieHeader) {
111
- let response;
112
- try {
113
- response = await fetch(`${API_ENDPOINT}?orgId=${encodeURIComponent(ORG_SLUG)}`, {
114
- method: "POST",
115
- headers: {
116
- ...DEFAULT_HEADERS,
117
- Accept: "application/json,*/*",
118
- "Content-Type": "application/json",
119
- Origin: "https://app.mokahr.com",
120
- Referer: PORTAL_URL,
121
- Cookie: cookieHeader,
122
- },
123
- body: JSON.stringify({
124
- orgId: ORG_SLUG,
125
- siteId: String(SITE_ID),
126
- pageNum,
127
- pageSize,
128
- needStat: true,
129
- }),
130
- });
131
- }
132
- catch (err) {
133
- return { ok: false, message: `network error: ${err instanceof Error ? err.message : err}` };
134
- }
135
- if (!response.ok)
136
- return { ok: false, message: `HTTP ${response.status}` };
137
- let envelope;
138
- try {
139
- envelope = await response.json();
140
- }
141
- catch {
142
- return { ok: false, message: "bad JSON" };
143
- }
144
- const decoded = decryptMoka(envelope, aesIv);
145
- if (!decoded || decoded.code !== 0 || !decoded.data) {
146
- return { ok: false, message: decoded?.msg || envelope.msg || "decrypt error" };
147
- }
148
- return {
149
- ok: true,
150
- jobs: decoded.data.jobs ?? [],
151
- total: decoded.data.jobStats?.total ?? 0,
152
- message: "ok",
153
- };
154
- }
155
- function buildCityMap(groups) {
156
- const out = {};
157
- if (!groups)
158
- return out;
159
- for (const g of groups) {
160
- if (typeof g.cityId === "number" && g.label)
161
- out[g.cityId] = g.label;
162
- }
163
- return out;
164
- }
165
- function workCities(job, cityMap) {
166
- const uniq = [];
167
- for (const loc of job.locations ?? []) {
168
- const label = (typeof loc.cityId === "number" && cityMap[loc.cityId]) || loc.country || "";
169
- if (label && !uniq.includes(label))
170
- uniq.push(label);
171
- }
172
- return uniq.join(" / ");
173
- }
174
- function recruitLabel(job) {
175
- if (job.commitment)
176
- return job.commitment;
177
- if (job.hireMode === 1)
178
- return "全职";
179
- if (job.hireMode === 2)
180
- return "实习";
181
- return "";
182
- }
183
- function summarize(job, cityMap) {
184
- return {
185
- post_id: String(job.id),
186
- title: job.title ?? "",
187
- project: job.zhineng?.name ?? "",
188
- recruit_label: recruitLabel(job),
189
- bgs: job.department?.name ?? "",
190
- work_cities: workCities(job, cityMap),
191
- apply_url: `${PORTAL_URL}#/jobs/${encodeURIComponent(job.id)}`,
192
- };
193
- }
194
- function matchesKeyword(job, kw) {
195
- if (!kw)
196
- return true;
197
- const lc = kw.toLowerCase();
198
- return ((job.title ?? "").toLowerCase().includes(lc) ||
199
- (job.zhineng?.name ?? "").toLowerCase().includes(lc) ||
200
- (job.department?.name ?? "").toLowerCase().includes(lc));
201
- }
202
- export async function searchPositions(opts = {}) {
203
- const pageSize = opts.pageSize ?? 20;
204
- const page = opts.page ?? 1;
205
- const keyword = opts.keyword ?? "";
206
- const portal = await fetchPortalHtml();
207
- if (!portal.ok || !portal.html) {
208
- return {
209
- ok: false,
210
- source: SOURCE,
211
- message: portal.message,
212
- query: { keyword, page, pageSize },
213
- positions: [],
214
- total: 0,
215
- };
216
- }
217
- const init = parseInitData(portal.html);
218
- if (!init || !init.jobs || !init.jobStats) {
219
- return {
220
- ok: false,
221
- source: SOURCE,
222
- message: "Moka init-data missing jobs/jobStats",
223
- query: { keyword, page, pageSize },
224
- positions: [],
225
- total: 0,
226
- };
227
- }
228
- const cityMap = buildCityMap(init.jobsGroupedByLocation);
229
- let jobs = init.jobs;
230
- const total = init.jobStats.total ?? jobs.length;
231
- if (page > 1 && init.aesIv && portal.cookieHeader) {
232
- const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader);
233
- if (!more.ok || !more.jobs) {
234
- return {
235
- ok: false,
236
- source: SOURCE,
237
- message: `pagination failed: ${more.message}`,
238
- query: { keyword, page, pageSize },
239
- positions: [],
240
- total,
241
- };
242
- }
243
- jobs = more.jobs;
244
- }
245
- const filtered = jobs.filter((j) => matchesKeyword(j, keyword)).slice(0, pageSize);
246
- return {
247
- ok: true,
248
- source: SOURCE,
249
- query: { keyword, page, pageSize },
250
- page,
251
- page_size: pageSize,
252
- total,
253
- positions: filtered.map((j) => summarize(j, cityMap)),
254
- };
255
- }
256
- export async function fetchAllPositions(opts = {}) {
257
- const pageSize = opts.pageSize ?? 20;
258
- const maxPages = Math.max(1, opts.maxPages ?? 50);
259
- const keyword = opts.keyword ?? "";
260
- const portal = await fetchPortalHtml();
261
- if (!portal.ok || !portal.html) {
262
- return {
263
- ok: false,
264
- source: SOURCE,
265
- message: portal.message,
266
- total: 0,
267
- fetched: 0,
268
- positions: [],
269
- };
270
- }
271
- const init = parseInitData(portal.html);
272
- if (!init || !init.jobs || !init.jobStats || !init.aesIv) {
273
- return {
274
- ok: false,
275
- source: SOURCE,
276
- message: "Moka init-data missing required fields",
277
- total: 0,
278
- fetched: 0,
279
- positions: [],
280
- };
281
- }
282
- const cityMap = buildCityMap(init.jobsGroupedByLocation);
283
- const total = init.jobStats.total ?? 0;
284
- const collected = [...init.jobs];
285
- let page = 2;
286
- while (collected.length < total && page <= maxPages) {
287
- const more = await fetchEncryptedPage(page, pageSize, init.aesIv, portal.cookieHeader ?? "");
288
- if (!more.ok || !more.jobs || more.jobs.length === 0)
289
- break;
290
- collected.push(...more.jobs);
291
- page += 1;
292
- }
293
- const filtered = collected.filter((j) => matchesKeyword(j, keyword));
294
- return {
295
- ok: true,
296
- source: SOURCE,
297
- total,
298
- fetched: filtered.length,
299
- positions: filtered.map((j) => summarize(j, cityMap)),
300
- };
301
- }
302
- export async function fetchPositionDetail(postId) {
303
- return {
304
- ok: false,
305
- source: SOURCE,
306
- message: "Moka detail endpoint is also AES-encrypted and not implemented; " +
307
- "use the apply_url deeplink for the full JD.",
308
- post_id: postId,
309
- apply_url: `${PORTAL_URL}#/jobs/${encodeURIComponent(postId)}`,
310
- };
311
- }
312
- export async function fetchDictionaries() {
313
- const portal = await fetchPortalHtml();
314
- if (!portal.ok || !portal.html) {
315
- return { ok: false, source: SOURCE, message: portal.message };
316
- }
317
- const init = parseInitData(portal.html);
318
- if (!init) {
319
- return { ok: false, source: SOURCE, message: "Moka init-data missing" };
320
- }
321
- return {
322
- ok: true,
323
- source: SOURCE,
324
- locations: init.jobsGroupedByLocation ?? [],
325
- moka_org: { slug: ORG_SLUG, siteId: SITE_ID, url: PORTAL_URL },
326
- };
327
- }
328
- export async function listNotices() {
329
- return {
330
- ok: false,
331
- source: SOURCE,
332
- message: "StepFun (阶跃星辰): no public notices endpoint",
333
- };
334
- }
335
- export async function getNotice(_id) {
336
- return {
337
- ok: false,
338
- source: SOURCE,
339
- message: "StepFun (阶跃星辰): no public notices endpoint",
340
- };
341
- }
342
- export async function findNoticesByQuestion(_question, _opts = {}) {
343
- return {
344
- ok: false,
345
- source: SOURCE,
346
- message: "StepFun (阶跃星辰): no public notices endpoint",
347
- };
348
- }
349
- export async function matchResume(text, opts = {}) {
350
- const { terms, cities } = extractResumeSignals(text ?? "");
351
- const candidates = Math.max(20, opts.candidates ?? 100);
352
- const all = await fetchAllPositions({
353
- pageSize: 20,
354
- maxPages: Math.ceil(candidates / 15),
355
- });
356
- if (!all.ok) {
357
- return {
358
- ok: false,
359
- source: SOURCE,
360
- extracted_terms: terms,
361
- city_preferences: cities,
362
- matches: [],
363
- message: all.message,
364
- };
365
- }
366
- const topN = Math.max(1, opts.topN ?? 10);
367
- const scored = all.positions
368
- .map((p) => ({
369
- p,
370
- score: scoreOverlap(`${p.title} ${p.project} ${p.bgs}`, terms, cities).score,
371
- }))
372
- .sort((a, b) => b.score - a.score)
373
- .slice(0, topN)
374
- .map((x) => x.p);
375
- return {
376
- ok: true,
377
- source: SOURCE,
378
- extracted_terms: terms,
379
- city_preferences: cities,
380
- matches: scored,
381
- };
382
- }
3
+ // Portal: https://app.mokahr.com/social-recruitment/step/94904
4
+ // Probed 2026-05; ~79 social-hire positions.
5
+ // See cli/src/moka.ts for the shared factory.
6
+ import { createAdapter } from "./moka.js";
7
+ const adapter = createAdapter({
8
+ orgSlug: "step",
9
+ label: "StepFun",
10
+ channels: [
11
+ { siteId: 94904, kind: "social-recruitment", recruitType: "social" },
12
+ ],
13
+ defaultRecruitType: "social",
14
+ });
15
+ export const searchPositions = adapter.searchPositions;
16
+ export const fetchAllPositions = adapter.fetchAllPositions;
17
+ export const fetchPositionDetail = adapter.fetchPositionDetail;
18
+ export const fetchDictionaries = adapter.fetchDictionaries;
19
+ export const listNotices = adapter.listNotices;
20
+ export const getNotice = adapter.getNotice;
21
+ export const findNoticesByQuestion = adapter.findNoticesByQuestion;
22
+ export const matchResume = adapter.matchResume;
23
+ export const checkResume = adapter.checkResume;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "job-pro",
3
- "version": "0.7.2",
4
- "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 43 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, 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. No signup, no token, no server.",
3
+ "version": "0.7.4",
4
+ "description": "Query Chinese big-tech campus recruiting from your terminal. 50 companies, 46 live (incl. Lilith via local Chrome / puppeteer-core). 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",
@@ -30,9 +30,11 @@
30
30
  "test": "tsx test/smoke.ts",
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
+ "dependencies": {
34
+ "puppeteer-core": "^25.0.2"
35
+ },
33
36
  "devDependencies": {
34
37
  "@types/node": "^20",
35
- "puppeteer-core": "^25.0.2",
36
38
  "tsx": "^4",
37
39
  "typescript": "^5"
38
40
  },