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/lilith.js CHANGED
@@ -1,175 +1,290 @@
1
- // 莉莉丝游戏 (Lilith Games) campus-recruiting adapter.
1
+ // 莉莉丝游戏 (Lilith Games) careers adapter — Feishu portal_type=6 via CDP.
2
2
  //
3
3
  // ============================================================
4
- // API DISCOVERY (probed 2026-05-14)
4
+ // API DISCOVERY (probed 2026-05-16)
5
5
  //
6
- // The canonical career entry point is https://jobs.lilith.com/
7
- // The page hosts navigation links (social/campus/intern) that all
8
- // redirect to the Feishu Recruitment (飞书招聘) portal:
6
+ // Lilith's careers feed is hosted at `lilithgames.jobs.feishu.cn` (Feishu
7
+ //招聘 / ATSX). It looks like a standard Feishu tenant on the surface, BUT
8
+ // the `/api/v1/search/job/posts` POST is rejected with `HTTP 405` from
9
+ // ByteDance Tengine for any anonymous caller — Lilith's tenant is one of
10
+ // the few that requires the in-browser `_signature` anti-bot token. The
11
+ // signature is computed by `verifycenter` (`lf-cdn-tos.bytescm.com/.../rc-verifycenter`)
12
+ // at runtime and appended to the URL query string + headers; it's
13
+ // session-bound and short-lived.
9
14
  //
10
- // Social hire: https://lilithgames.jobs.feishu.cn/career
11
- // Campus hire: https://lilithgames.jobs.feishu.cn/campus
12
- // Intern hire: https://lilithgames.jobs.feishu.cn/intern
15
+ // Reverse-engineering verifycenter is non-trivial. We work around it by
16
+ // using `puppeteer-core` to drive the user's real Chrome (see cli/src/cdp.ts):
17
+ // navigate to the careers page, wait for the SPA's own `search/job/posts`
18
+ // XHR, and read the JSON straight off the network response. Same data
19
+ // shape as `cli/src/feishu.ts`, just sourced through a real browser.
13
20
  //
14
- // Reconnaissance also flagged a Moka org_id 7803 but
15
- // app.mokahr.com/campus-recruitment/lilith/7803 returns
16
- // "当前网页已关停" (page suspended).
17
- //
18
- // ============================================================
19
- // Feishu Recruitment API (reverse-engineered from the saas-career JS bundle,
20
- // chunk 4026.f23f1edc.js, fetched 2026-05-14)
21
- //
22
- // POST https://lilithgames.jobs.feishu.cn/api/v1/search/job/posts
23
- // Headers: Content-Type: application/json
24
- // Referer: https://lilithgames.jobs.feishu.cn/career
25
- // Payload:
26
- // {
27
- // keyword: string, // search term
28
- // limit: number, // page size
29
- // offset: number, // (current-1)*limit
30
- // job_hot_flag: undefined,
31
- // portal_type: 6, // SaasCareer portal type
32
- // job_category_id_list: string[], // category filter
33
- // tag_id_list: string[],
34
- // location_code_list: string[], // CT_11=北京, CT_125=上海, etc.
35
- // subject_id_list: string[],
36
- // recruitment_id_list: string[],
37
- // job_function_id_list: string[],
38
- // storefront_id_list: string[],
39
- // }
40
- // Response: { code: 0, data: { job_post_list: RawJobPost[], count: number }, message: "ok" }
41
- //
42
- // Raw job post field mapping (from N() mapper in bundle):
43
- // id → post_id
44
- // title → title
45
- // job_category.name → project
46
- // recruit_type.name → recruit_label
47
- // department_info → bgs (Lilith does not expose BG in the public payload)
48
- // city_info.name → work_cities (or city_info_list_for_delivery for multi-city)
49
- //
50
- // ============================================================
51
- // NETWORK ACCESSIBILITY (probed 2026-05-14)
52
- //
53
- // lilithgames.jobs.feishu.cn resolves to 198.18.1.152 (IANA RFC 2544
54
- // benchmarking range). All feishu.cn/larksuite.com subdomains resolve
55
- // into 198.18.0.0/15 from the current environment, indicating a
56
- // DNS-level network block. TLS connects but every HTTP path (including
57
- // /api/v1/search/job/posts) is answered by a ByteDance headhunter
58
- // platform stub page rather than the Feishu Recruitment API.
59
- // The Feishu API is structurally identical to ByteDance's campus API
60
- // (same city-code format CT_XX, same payload shape, same response envelope)
61
- // but is NOT callable without a network path that bypasses the block.
62
- //
63
- // VERDICT: API is fully discovered but unreachable from this environment.
64
- // This adapter is an honest stub that returns ok:false with a clear
65
- // message. The apply_url values point to the live portal.
66
- //
67
- // ============================================================
68
- // PositionSummary field mapping (canonical keys, matches all other adapters)
69
- // post_id — string job identifier
70
- // title — position title
71
- // project — job category (job_category.name)
72
- // recruit_label — recruit type label (recruit_type.name)
73
- // bgs — business group (not exposed in public API payload, always "")
74
- // work_cities — work location (city_info.name)
75
- // apply_url — deep link to the Feishu Recruitment job detail
76
- // ============================================================
21
+ // Probed 2026-05-16: portal_type=6, channel id 7055353811552127239, default
22
+ // limit=10. The career page filters by `location_code_list` query string;
23
+ // we pass through search options the same way.
77
24
  import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
25
+ import { withPage } from "./cdp.js";
78
26
  export { checkResume };
79
27
  const SOURCE = "lilithgames.jobs.feishu.cn";
80
- const CAREER_PAGE = "https://lilithgames.jobs.feishu.cn/career";
81
- const CAMPUS_PAGE = "https://lilithgames.jobs.feishu.cn/campus";
82
- const INTERN_PAGE = "https://lilithgames.jobs.feishu.cn/intern";
83
- // ---------- stub message ----------
84
- const STUB_MSG = "Lilith Games (莉莉丝游戏) recruiting is hosted on Feishu Recruitment (飞书招聘) at " +
85
- "lilithgames.jobs.feishu.cn. The API endpoint POST /api/v1/search/job/posts has been " +
86
- "reverse-engineered (portal_type:6, same payload shape as ByteDance campus API) but the " +
87
- "domain resolves to IANA-reserved 198.18.x.x from this environment — a DNS-level network " +
88
- "block prevents all API calls. The Moka org (org_id 7803) page is also suspended. " +
89
- `Apply directly at ${CAREER_PAGE} (社会招聘) or ${CAMPUS_PAGE} (校园招聘).`;
90
- // ---------- searchPositions ----------
91
- export async function searchPositions(_opts = {}) {
28
+ const HOST = "https://lilithgames.jobs.feishu.cn";
29
+ const CAREER_PAGE = `${HOST}/career/`;
30
+ const DETAIL_PAGE = (id) => `${HOST}/career/${encodeURIComponent(id)}/detail`;
31
+ function summarize(item) {
32
+ const id = String(item.id ?? "");
33
+ const cityList = item.city_list ?? [];
34
+ const work_cities = cityList.length > 1
35
+ ? cityList.map((c) => c.name ?? "").filter(Boolean).join(" / ")
36
+ : cityList[0]?.name ?? item.city_info?.name ?? "";
37
+ const project = item.job_category?.name ?? item.job_function?.name ?? "";
92
38
  return {
93
- ok: false,
94
- source: SOURCE,
95
- message: STUB_MSG,
96
- apply_url: CAREER_PAGE,
97
- positions: [],
39
+ post_id: id,
40
+ title: item.title ?? "",
41
+ project,
42
+ recruit_label: item.recruit_type?.name ?? "",
43
+ bgs: "",
44
+ work_cities,
45
+ apply_url: id ? DETAIL_PAGE(id) : CAREER_PAGE,
98
46
  };
99
47
  }
100
- // ---------- fetchAllPositions ----------
101
- export async function fetchAllPositions(_opts = {}) {
102
- return {
103
- ok: false,
104
- source: SOURCE,
105
- message: STUB_MSG,
106
- apply_url: CAREER_PAGE,
107
- fetched: 0,
108
- positions: [],
109
- };
48
+ function STUB_MESSAGE(reason) {
49
+ return ("Lilith Games (莉莉丝): feishu portal_type=6 requires a browser-minted " +
50
+ "`_signature` ByteDance anti-bot token. " +
51
+ `Could not run the browser fallback: ${reason}. ` +
52
+ "Install Google Chrome (or set $JOB_PRO_CHROME=/path/to/chrome) and " +
53
+ "ensure puppeteer-core is installed (it ships with this CLI by default).");
110
54
  }
111
- // ---------- fetchPositionDetail ----------
112
- export async function fetchPositionDetail(_postId) {
55
+ async function searchViaBrowser(opts) {
56
+ const limit = Math.max(1, Math.min(50, opts.pageSize ?? 10));
57
+ const keyword = (opts.keyword ?? "").trim().slice(0, 60);
58
+ const cityCode = (opts.cityCode ?? "").trim();
59
+ // The career page URL itself drives the SPA's initial XHR with the
60
+ // matching filters baked in. We construct a URL that yields the desired
61
+ // search response without needing post-load interactions.
62
+ const params = new URLSearchParams({
63
+ keywords: keyword,
64
+ location: cityCode,
65
+ project: "",
66
+ type: "",
67
+ category: "",
68
+ current: String(opts.page ?? 1),
69
+ limit: String(limit),
70
+ functionCategory: "",
71
+ });
72
+ const targetUrl = `${CAREER_PAGE}?${params.toString()}`;
73
+ const r = await withPage(async (page) => {
74
+ // We arm a response waiter BEFORE goto so we don't miss the XHR.
75
+ // The Feishu SPA fires multiple identical XHRs (one for filters, one
76
+ // for the actual search); we filter to the one that includes
77
+ // `search/job/posts` in the URL AND has non-zero content-length.
78
+ const responsePromise = page.waitForResponse((resp) => {
79
+ const u = resp.url();
80
+ return resp.status() === 200 && /\/api\/v1\/search\/job\/posts/.test(u);
81
+ }, { timeout: 25000 });
82
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
83
+ const resp = await responsePromise;
84
+ return (await resp.json());
85
+ });
86
+ if (!r.ok) {
87
+ return { ok: false, message: STUB_MESSAGE(r.error.message) };
88
+ }
89
+ const env = r.value;
90
+ if (env.code !== 0 || !env.data) {
91
+ return {
92
+ ok: false,
93
+ message: `upstream returned code=${env.code} (${env.message ?? "unknown"})`,
94
+ };
95
+ }
96
+ const rawJobs = env.data.job_post_list ?? [];
113
97
  return {
114
- ok: false,
115
- source: SOURCE,
116
- message: STUB_MSG,
117
- apply_url: CAREER_PAGE,
98
+ ok: true,
99
+ result: {
100
+ ok: true,
101
+ total: env.data.count ?? rawJobs.length,
102
+ positions: rawJobs.map(summarize),
103
+ rawJobs,
104
+ },
118
105
  };
119
106
  }
120
- // ---------- fetchDictionaries ----------
121
- export async function fetchDictionaries() {
107
+ // ---------- public API ----------
108
+ export async function searchPositions(opts = {}) {
109
+ const r = await searchViaBrowser(opts);
110
+ if (!r.ok) {
111
+ return {
112
+ ok: false,
113
+ source: SOURCE,
114
+ message: r.message,
115
+ query: opts,
116
+ positions: [],
117
+ };
118
+ }
122
119
  return {
123
- ok: false,
120
+ ok: true,
124
121
  source: SOURCE,
125
- message: STUB_MSG,
126
- apply_url: CAREER_PAGE,
122
+ query: opts,
123
+ page: opts.page ?? 1,
124
+ page_size: opts.pageSize ?? 10,
125
+ total: r.result.total,
126
+ positions: r.result.positions,
127
127
  };
128
128
  }
129
- // ---------- notices (no public endpoint) ----------
130
- export async function listNotices() {
129
+ export async function fetchAllPositions(opts = {}) {
130
+ const limit = Math.max(1, Math.min(50, opts.pageSize ?? 30));
131
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
132
+ const bucket = [];
133
+ let total = 0;
134
+ for (let page = 1; page <= maxPages; page++) {
135
+ const r = await searchViaBrowser({ ...opts, page, pageSize: limit });
136
+ if (!r.ok) {
137
+ if (bucket.length === 0) {
138
+ return {
139
+ ok: false,
140
+ source: SOURCE,
141
+ message: r.message,
142
+ total: 0,
143
+ fetched: 0,
144
+ positions: [],
145
+ };
146
+ }
147
+ break;
148
+ }
149
+ if (page === 1)
150
+ total = r.result.total;
151
+ if (!r.result.positions.length)
152
+ break;
153
+ bucket.push(...r.result.positions);
154
+ if (bucket.length >= total)
155
+ break;
156
+ }
131
157
  return {
132
- ok: false,
158
+ ok: true,
133
159
  source: SOURCE,
134
- message: "Lilith Games: no public notices endpoint",
160
+ total,
161
+ fetched: bucket.length,
162
+ positions: bucket,
135
163
  };
136
164
  }
137
- export async function getNotice(_id) {
165
+ // fetchPositionDetail: Feishu has no per-id REST endpoint; scan via search.
166
+ export async function fetchPositionDetail(postId) {
167
+ const id = (postId ?? "").trim();
168
+ if (!id)
169
+ return { ok: false, source: SOURCE, message: "post_id is required" };
170
+ const limit = 50;
171
+ const maxPages = 10;
172
+ for (let page = 1; page <= maxPages; page++) {
173
+ const r = await searchViaBrowser({ page, pageSize: limit });
174
+ if (!r.ok)
175
+ return { ok: false, source: SOURCE, post_id: id, message: r.message };
176
+ const found = r.result.rawJobs.find((p) => String(p.id) === id);
177
+ if (found) {
178
+ const summary = summarize(found);
179
+ return {
180
+ ok: true,
181
+ source: SOURCE,
182
+ post_id: id,
183
+ title: found.title ?? "",
184
+ project: summary.project,
185
+ recruit_label: summary.recruit_label,
186
+ description: found.description ?? "",
187
+ requirements: found.requirement ?? "",
188
+ work_cities: found.city_list ?? (found.city_info ? [found.city_info] : []),
189
+ apply_url: summary.apply_url,
190
+ };
191
+ }
192
+ if (r.result.rawJobs.length < limit)
193
+ break;
194
+ }
138
195
  return {
139
196
  ok: false,
140
197
  source: SOURCE,
141
- message: "Lilith Games: no public notices endpoint",
198
+ post_id: id,
199
+ message: `post ${id} not found in browser-driven search (scanned up to ${maxPages * limit} posts)`,
142
200
  };
143
201
  }
144
- export async function findNoticesByQuestion(_question, _opts = {}) {
145
- return {
146
- ok: false,
202
+ // fetchDictionaries: synthesize from one page of results.
203
+ let _dictCache = null;
204
+ export async function fetchDictionaries() {
205
+ if (_dictCache !== null)
206
+ return _dictCache;
207
+ const r = await searchViaBrowser({ pageSize: 50 });
208
+ if (!r.ok) {
209
+ const result = { ok: false, source: SOURCE, message: r.message };
210
+ _dictCache = result;
211
+ return result;
212
+ }
213
+ const cats = new Set();
214
+ const cities = new Set();
215
+ for (const j of r.result.rawJobs) {
216
+ const name = j.job_category?.name ?? j.job_function?.name;
217
+ if (name)
218
+ cats.add(name);
219
+ for (const c of j.city_list ?? [])
220
+ if (c.name)
221
+ cities.add(c.name);
222
+ if (j.city_info?.name)
223
+ cities.add(j.city_info.name);
224
+ }
225
+ const result = {
226
+ ok: true,
147
227
  source: SOURCE,
148
- message: "Lilith Games: no public notices endpoint",
228
+ total: r.result.total,
229
+ sample_categories: [...cats].sort(),
230
+ sample_cities: [...cities].sort(),
149
231
  };
232
+ _dictCache = result;
233
+ return result;
234
+ }
235
+ const NOTICES_MSG = "Lilith Games (莉莉丝): no public notices endpoint on Feishu tenant";
236
+ export async function listNotices() {
237
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notices: [] };
238
+ }
239
+ export async function getNotice(noticeId) {
240
+ return { ok: false, source: SOURCE, message: NOTICES_MSG, notice_id: noticeId };
241
+ }
242
+ export async function findNoticesByQuestion(question, _opts = {}) {
243
+ return { ok: false, source: SOURCE, question, message: NOTICES_MSG, matches: [] };
150
244
  }
151
- // ---------- matchResume ----------
152
- // Resume matching cannot fetch live position data.
153
- // We surface signals extracted from the resume and direct the user to
154
- // the Lilith Games career portal for manual search.
155
245
  export async function matchResume(text, opts = {}) {
156
- void opts;
246
+ const topN = Math.max(1, opts.topN ?? 5);
247
+ const candidates = Math.max(topN, opts.candidates ?? 20);
157
248
  const { terms, cities } = extractResumeSignals(text ?? "");
249
+ if (!terms.length) {
250
+ return {
251
+ ok: false,
252
+ source: SOURCE,
253
+ message: "could not extract any technical signals from the text",
254
+ preview: (text ?? "").slice(0, 120),
255
+ };
256
+ }
257
+ const keyword = terms.slice(0, 3).join(" ");
258
+ const r = await searchViaBrowser({ keyword, pageSize: 50 });
259
+ if (!r.ok) {
260
+ return { ok: false, source: SOURCE, message: r.message, positions: [] };
261
+ }
262
+ const scored = [];
263
+ for (const p of r.result.positions) {
264
+ const blob = [p.title, p.project, p.recruit_label, p.work_cities].join(" ");
265
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
266
+ if (score > 0)
267
+ scored.push({ score, position: p, reasons });
268
+ }
269
+ scored.sort((a, b) => b.score - a.score);
270
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
271
+ if (!shortlist.length) {
272
+ shortlist = r.result.positions.slice(0, candidates).map((position) => ({ score: 0, position, reasons: [] }));
273
+ }
274
+ const matches = shortlist.slice(0, topN).map((s) => {
275
+ const mr = s.reasons.length > 0
276
+ ? s.reasons.slice(0, 5)
277
+ : ["no specific keyword overlap — surfaced from initial keyword search"];
278
+ return { ...s.position, match_reasons: mr };
279
+ });
158
280
  return {
159
- ok: false,
281
+ ok: true,
160
282
  source: SOURCE,
161
- message: STUB_MSG,
162
- apply_url: CAREER_PAGE,
163
283
  extracted_terms: terms,
164
284
  city_preferences: cities,
285
+ matches,
286
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
287
+ "The only authority on selection is HR.",
165
288
  };
166
289
  }
167
- // Export helpers so callers that import from this module can use them.
168
290
  export { extractResumeSignals, scoreOverlap };
169
- // Expose portal page URLs for external reference.
170
- export const PORTAL_URLS = {
171
- social: CAREER_PAGE,
172
- campus: CAMPUS_PAGE,
173
- intern: INTERN_PAGE,
174
- homepage: "https://jobs.lilith.com/",
175
- };