@ha7ch/job-pro 1.0.93

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.
Files changed (68) hide show
  1. package/dist/adapter.js +17 -0
  2. package/dist/agibot.js +399 -0
  3. package/dist/alibaba.js +509 -0
  4. package/dist/antgroup.js +397 -0
  5. package/dist/apply.js +1373 -0
  6. package/dist/baichuan.js +49 -0
  7. package/dist/baidu.js +452 -0
  8. package/dist/bilibili.js +455 -0
  9. package/dist/byd.js +412 -0
  10. package/dist/bytedance.js +619 -0
  11. package/dist/cainiao.js +56 -0
  12. package/dist/cambricon.js +33 -0
  13. package/dist/cdp.js +237 -0
  14. package/dist/cicc.js +56 -0
  15. package/dist/coverage.js +60 -0
  16. package/dist/deepseek.js +25 -0
  17. package/dist/didi.js +381 -0
  18. package/dist/feishu.js +577 -0
  19. package/dist/galaxyuniversal.js +24 -0
  20. package/dist/geely.js +35 -0
  21. package/dist/greenhouse.js +432 -0
  22. package/dist/hikvision.js +58 -0
  23. package/dist/horizonrobotics.js +46 -0
  24. package/dist/hoyoverse.js +26 -0
  25. package/dist/huawei.js +537 -0
  26. package/dist/iflytek.js +380 -0
  27. package/dist/index.js +1828 -0
  28. package/dist/iqiyi.js +494 -0
  29. package/dist/jd.js +559 -0
  30. package/dist/kuaishou.js +496 -0
  31. package/dist/lever.js +455 -0
  32. package/dist/liauto.js +393 -0
  33. package/dist/liepin.js +357 -0
  34. package/dist/lilith.js +300 -0
  35. package/dist/megvii.js +27 -0
  36. package/dist/meituan.js +633 -0
  37. package/dist/memory.js +76 -0
  38. package/dist/mihoyo.js +308 -0
  39. package/dist/minimax.js +32 -0
  40. package/dist/moka.js +473 -0
  41. package/dist/moonshot.js +24 -0
  42. package/dist/netease.js +424 -0
  43. package/dist/nio.js +24 -0
  44. package/dist/oppo.js +285 -0
  45. package/dist/pdd.js +614 -0
  46. package/dist/pingan.js +493 -0
  47. package/dist/sensetime.js +51 -0
  48. package/dist/sf.js +310 -0
  49. package/dist/stepfun.js +24 -0
  50. package/dist/tencent.js +770 -0
  51. package/dist/trip.js +396 -0
  52. package/dist/unitree.js +418 -0
  53. package/dist/vivo.js +361 -0
  54. package/dist/webank.js +55 -0
  55. package/dist/wecruit.js +438 -0
  56. package/dist/weibo.js +337 -0
  57. package/dist/weride.js +29 -0
  58. package/dist/xiaohongshu.js +480 -0
  59. package/dist/xiaomi.js +529 -0
  60. package/dist/xpeng.js +34 -0
  61. package/dist/zerooneai.js +42 -0
  62. package/dist/zhipu.js +478 -0
  63. package/extension/README.md +79 -0
  64. package/extension/background.js +177 -0
  65. package/extension/manifest.json +55 -0
  66. package/extension/popup.html +37 -0
  67. package/extension/popup.js +54 -0
  68. package/package.json +61 -0
@@ -0,0 +1,418 @@
1
+ // Thin client for 宇树科技 (Unitree Robotics) campus recruiting.
2
+ //
3
+ // ============================================================
4
+ // API DISCOVERY (probed 2026-05)
5
+ //
6
+ // Infrastructure:
7
+ // https://www.unitree.com/position/ (and /cn/position/) →
8
+ // Nuxt 3 SPA that inlines all job listings in the server-rendered HTML.
9
+ // The apiBase revealed in window.__NUXT__.config is:
10
+ // https://api.unitree.com/website
11
+ // with routes GET_JOB_LIST: "/job/list" and GET_JOB_DETAIL: "/job/info"
12
+ // (found in /_nuxt/Cd6-Y0rS.js bundle, 2026-05).
13
+ //
14
+ // Dead ends probed:
15
+ // career.unitree.com — resolves to 198.18.x.x (IANA reserved / unreachable)
16
+ // unitree.app.mokahr.com — same IANA block; no Moka tenant
17
+ // https://api.unitree.com/website/job/list (GET or POST, any headers) →
18
+ // HTTP 567 "请求已被站点的安全策略拦截" from Tencent Cloud EdgeOne WAF.
19
+ // The WAF blocks all non-browser clients regardless of UA/Referer/Origin spoofing.
20
+ // The endpoint is real (the SPA uses it from a browser context) but is entirely
21
+ // inaccessible to server-side HTTP clients.
22
+ //
23
+ // WORKING APPROACH — parse SSR HTML from www.unitree.com/position/:
24
+ // The Nuxt SPA is configured with ssr:false in its __NUXT_DATA__ state
25
+ // (serverRendered:false), yet the site's CDN pre-renders the page HTML via
26
+ // a build-time static pass. The full position list (typically ~20-25 jobs)
27
+ // is embedded verbatim in the returned HTML, including job IDs, titles, city,
28
+ // category, department, and hot/urgent flags.
29
+ //
30
+ // HTML job entry format (stripped from tags):
31
+ // {Title}({JobCode}) 热招 [急招] {City} | {Category} | {Department} {JD text...}
32
+ // Some newer listings omit the job code:
33
+ // {Title} 热招 [急招] {City} | {Category} | {Department} {JD text...}
34
+ //
35
+ // Job detail deep-links use SPA routing at /position/{JobCode} or /cn/position/{JobCode}.
36
+ // These return 404 from the CDN (SPA-only routes) but are still the canonical apply URLs.
37
+ //
38
+ // ============================================================
39
+ // PositionSummary field mapping (canonical keys — matches all other adapters):
40
+ // post_id — job code (e.g. "J10034") or a slug derived from the title
41
+ // title — position title (Chinese)
42
+ // project — job category (e.g. "技术类" / "销售类")
43
+ // recruit_label — "热招" / "热招|急招" / "" depending on status flags
44
+ // bgs — department (e.g. "研发部" / "销售服务体系")
45
+ // work_cities — work location (e.g. "杭州市")
46
+ // apply_url — deep link to the SPA position page
47
+ // ============================================================
48
+ import { extractResumeSignals, scoreOverlap, checkResume } from "./tencent.js";
49
+ export { checkResume };
50
+ const SOURCE = "unitree.com";
51
+ const POSITION_PAGE = "https://www.unitree.com/position/";
52
+ const DETAIL_URL = (jobCode) => `https://www.unitree.com/position/${encodeURIComponent(jobCode)}`;
53
+ const DEFAULT_HEADERS = {
54
+ "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",
55
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
56
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
57
+ };
58
+ // ---------- HTML parser ----------
59
+ function stripTags(html) {
60
+ return html
61
+ .replace(/<[^>]+>/g, " ")
62
+ .replace(/&nbsp;/g, " ")
63
+ .replace(/&amp;/g, "&")
64
+ .replace(/&lt;/g, "<")
65
+ .replace(/&gt;/g, ">")
66
+ .replace(/&quot;/g, '"')
67
+ .replace(/\s+/g, " ");
68
+ }
69
+ function slugify(title) {
70
+ // Build a stable stub ID for un-coded listings
71
+ return title
72
+ .replace(/[^\w一-鿿]/g, "-")
73
+ .replace(/-+/g, "-")
74
+ .replace(/^-|-$/g, "")
75
+ .slice(0, 40);
76
+ }
77
+ function parsePositions(html) {
78
+ const text = stripTags(html);
79
+ const positions = [];
80
+ // ---- Pass 1: jobs with explicit job-code like (J10034) ----
81
+ // Context before the (Jxxxxx) anchor is the title; after it are the status / city / category / dept.
82
+ const idPattern = /\(J(\d+)\)/g;
83
+ let m;
84
+ const seen = new Set();
85
+ while ((m = idPattern.exec(text)) !== null) {
86
+ const jobCode = `J${m[1]}`;
87
+ if (seen.has(jobCode))
88
+ continue;
89
+ seen.add(jobCode);
90
+ // Title: scan backwards from the match start for a Chinese/ASCII job title.
91
+ // Titles immediately precede the job code in the stripped text.
92
+ // Note: some titles include full-width parentheses, e.g. 嵌入式软件工程师(Linux)
93
+ // so we must not treat ( as a hard word boundary.
94
+ const beforeSlice = text.slice(Math.max(0, m.index - 140), m.index);
95
+ // Take the last "word" cluster that looks like a job title.
96
+ // Allow full-width ()inside the title but stop at half-width ( and common separators.
97
+ const titleMatch = beforeSlice.match(/([A-Za-z+#()一-鿿][^\s·|。;:(]{1,50}(?:\/[^\s|·。;:(]{2,20})?)\s*$/);
98
+ const rawTitle = titleMatch ? titleMatch[1].trim() : "";
99
+ // Strip any description text that bled in (heuristic: keep last segment after 。 or ;)
100
+ const title = rawTitle.split(/[。;]\s*/).pop()?.trim() ?? rawTitle;
101
+ // Status / city / category / dept: scan forward from end of job code
102
+ const afterSlice = text.slice(m.index + m[0].length, m.index + m[0].length + 200);
103
+ const isHot = afterSlice.slice(0, 30).includes("热招");
104
+ const isUrgent = afterSlice.slice(0, 30).includes("急招");
105
+ const metaMatch = afterSlice.match(/([一-鿿]{2,6}市)\s*\|\s*([^|]{2,20}?)\s*\|\s*([^\s|·。]{2,30})/);
106
+ const city = metaMatch ? metaMatch[1].trim() : "杭州市";
107
+ const category = metaMatch ? metaMatch[2].trim() : "";
108
+ const dept = metaMatch ? metaMatch[3].trim() : "";
109
+ const recruitParts = [];
110
+ if (isHot)
111
+ recruitParts.push("热招");
112
+ if (isUrgent)
113
+ recruitParts.push("急招");
114
+ const recruit_label = recruitParts.join("|");
115
+ positions.push({
116
+ post_id: jobCode,
117
+ title: title || jobCode,
118
+ project: category,
119
+ recruit_label,
120
+ bgs: dept,
121
+ work_cities: city,
122
+ apply_url: DETAIL_URL(jobCode),
123
+ });
124
+ }
125
+ // ---- Pass 2: jobs without a (Jxxxxx) code ----
126
+ // Pattern: ChineseTitle 热招 [急招] City | Category | Dept
127
+ const noIdPattern = /([^\s·|:;。]{3,30}(?:工程师|设计师|经理|专员|研究员|架构师|科学家|运营|专家|分析师|顾问))\s+热招(?!\s*\()(\s*急招)?\s+([一-鿿]{2,6}市)\s*\|\s*([^|]{2,20}?)\s*\|\s*([^\s|·。]{2,30})/g;
128
+ let m2;
129
+ while ((m2 = noIdPattern.exec(text)) !== null) {
130
+ const title = m2[1].trim();
131
+ const slug = slugify(title);
132
+ if (seen.has(slug))
133
+ continue;
134
+ seen.add(slug);
135
+ const isUrgent = Boolean(m2[2]?.trim());
136
+ const city = m2[3].trim();
137
+ const category = m2[4].trim();
138
+ const dept = m2[5].trim();
139
+ const recruit_label = isUrgent ? "热招|急招" : "热招";
140
+ positions.push({
141
+ post_id: slug,
142
+ title,
143
+ project: category,
144
+ recruit_label,
145
+ bgs: dept,
146
+ work_cities: city,
147
+ apply_url: POSITION_PAGE,
148
+ });
149
+ }
150
+ return positions;
151
+ }
152
+ // ---------- fetch helper ----------
153
+ async function fetchPositionHtml() {
154
+ let response;
155
+ try {
156
+ response = await fetch(POSITION_PAGE, { headers: DEFAULT_HEADERS });
157
+ }
158
+ catch (err) {
159
+ return {
160
+ ok: false,
161
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
162
+ };
163
+ }
164
+ if (!response.ok) {
165
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
166
+ }
167
+ let html;
168
+ try {
169
+ html = await response.text();
170
+ }
171
+ catch (err) {
172
+ return {
173
+ ok: false,
174
+ message: `body read error: ${err instanceof Error ? err.message : String(err)}`,
175
+ };
176
+ }
177
+ return { ok: true, html, message: "ok" };
178
+ }
179
+ // ---------- In-process cache ----------
180
+ // The position list rarely changes; one fetch per Node process is enough.
181
+ let _posCache = null;
182
+ async function getAllPositions() {
183
+ const now = Date.now();
184
+ // Cache valid for 5 minutes
185
+ if (_posCache && now - _posCache.fetchedAt < 5 * 60 * 1000) {
186
+ return { ok: true, positions: _posCache.positions, message: "ok (cached)", total: _posCache.positions.length };
187
+ }
188
+ const result = await fetchPositionHtml();
189
+ if (!result.ok || !result.html) {
190
+ return { ok: false, positions: [], message: result.message, total: 0 };
191
+ }
192
+ const positions = parsePositions(result.html);
193
+ _posCache = { positions, fetchedAt: now };
194
+ return { ok: true, positions, message: "ok", total: positions.length };
195
+ }
196
+ // ---------- searchPositions ----------
197
+ export async function searchPositions(opts = {}) {
198
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
199
+ const page = Math.max(1, opts.page ?? 1);
200
+ const keyword = (opts.keyword ?? "").trim().toLowerCase();
201
+ const pool = await getAllPositions();
202
+ if (!pool.ok) {
203
+ return {
204
+ ok: false,
205
+ source: SOURCE,
206
+ message: pool.message,
207
+ apply_url: POSITION_PAGE,
208
+ positions: [],
209
+ };
210
+ }
211
+ let filtered = pool.positions;
212
+ if (keyword) {
213
+ filtered = filtered.filter((p) => {
214
+ const blob = [p.title, p.project, p.bgs, p.work_cities, p.post_id]
215
+ .join(" ")
216
+ .toLowerCase();
217
+ return blob.includes(keyword);
218
+ });
219
+ }
220
+ const offset = (page - 1) * pageSize;
221
+ const paginated = filtered.slice(offset, offset + pageSize);
222
+ return {
223
+ ok: true,
224
+ source: SOURCE,
225
+ page,
226
+ page_size: pageSize,
227
+ total: filtered.length,
228
+ positions: paginated,
229
+ };
230
+ }
231
+ // ---------- fetchAllPositions ----------
232
+ export async function fetchAllPositions(opts = {}) {
233
+ const keyword = (opts.keyword ?? "").trim().toLowerCase();
234
+ const pool = await getAllPositions();
235
+ if (!pool.ok) {
236
+ return {
237
+ ok: false,
238
+ source: SOURCE,
239
+ message: pool.message,
240
+ apply_url: POSITION_PAGE,
241
+ fetched: 0,
242
+ positions: [],
243
+ };
244
+ }
245
+ const positions = keyword
246
+ ? pool.positions.filter((p) => {
247
+ const blob = [p.title, p.project, p.bgs, p.work_cities, p.post_id]
248
+ .join(" ")
249
+ .toLowerCase();
250
+ return blob.includes(keyword);
251
+ })
252
+ : pool.positions;
253
+ return {
254
+ ok: true,
255
+ source: SOURCE,
256
+ total: positions.length,
257
+ fetched: positions.length,
258
+ positions,
259
+ };
260
+ }
261
+ // ---------- fetchPositionDetail ----------
262
+ // The SSR HTML contains the JD text for each position but does not expose it
263
+ // in a clean structured field. We extract a best-effort description snippet.
264
+ export async function fetchPositionDetail(postId) {
265
+ const id = (postId ?? "").trim();
266
+ if (!id) {
267
+ return { ok: false, source: SOURCE, message: "post_id is required" };
268
+ }
269
+ const result = await fetchPositionHtml();
270
+ if (!result.ok || !result.html) {
271
+ return { ok: false, source: SOURCE, post_id: id, message: result.message };
272
+ }
273
+ const text = stripTags(result.html);
274
+ // Find the job code anchor or slug and extract surrounding JD text
275
+ const anchor = id.startsWith("J") ? `(${id})` : id.replace(/-/g, "");
276
+ const idx = text.indexOf(anchor);
277
+ if (idx === -1) {
278
+ return {
279
+ ok: false,
280
+ source: SOURCE,
281
+ post_id: id,
282
+ message: `post ${id} not found in current page snapshot`,
283
+ };
284
+ }
285
+ // Extract up to 600 chars of JD text following the city|category|dept line
286
+ const after = text.slice(idx, idx + 800);
287
+ const descMatch = after.match(/[一-鿿]{2,5}市\s*\|\s*[^|]+\|\s*[^\s|]+\s+(.{50,600})/);
288
+ const description = descMatch ? descMatch[1].trim() : "";
289
+ const pool = await getAllPositions();
290
+ const pos = pool.positions.find((p) => p.post_id === id);
291
+ return {
292
+ ok: true,
293
+ source: SOURCE,
294
+ post_id: id,
295
+ title: pos?.title ?? id,
296
+ project: pos?.project ?? "",
297
+ bgs: pos?.bgs ?? "",
298
+ recruit_label: pos?.recruit_label ?? "",
299
+ description,
300
+ work_cities: pos?.work_cities ?? "",
301
+ apply_url: pos?.apply_url ?? DETAIL_URL(id),
302
+ };
303
+ }
304
+ // ---------- fetchDictionaries ----------
305
+ // Returns the known static taxonomy (Unitree does not expose a filter catalog).
306
+ export async function fetchDictionaries() {
307
+ const pool = await getAllPositions();
308
+ return {
309
+ ok: pool.ok,
310
+ source: SOURCE,
311
+ scrape_url: POSITION_PAGE,
312
+ note: "Unitree's ATS API (api.unitree.com/website) is protected by Tencent Cloud EdgeOne WAF " +
313
+ "(HTTP 567) and is inaccessible from server-side clients. " +
314
+ "Job listings are parsed from the SSR HTML of www.unitree.com/position/ instead.",
315
+ positions_scraped: pool.total,
316
+ categories: ["技术类", "销售类"],
317
+ departments: ["研发部", "销售服务体系"],
318
+ cities: ["杭州市"],
319
+ message: pool.message,
320
+ };
321
+ }
322
+ // ---------- notices (no public endpoint) ----------
323
+ const NOTICES_STUB = {
324
+ ok: false,
325
+ source: SOURCE,
326
+ message: "Unitree: no public notices or announcement endpoint available",
327
+ };
328
+ export async function listNotices() {
329
+ return NOTICES_STUB;
330
+ }
331
+ export async function getNotice(_id) {
332
+ return NOTICES_STUB;
333
+ }
334
+ export async function findNoticesByQuestion(_question, _opts = {}) {
335
+ return NOTICES_STUB;
336
+ }
337
+ // ---------- matchResume ----------
338
+ // Extract technical signals from resume text, filter the scraped position list,
339
+ // and return top N by keyword overlap score.
340
+ export async function matchResume(text, opts = {}) {
341
+ const topN = Math.max(1, opts.topN ?? 5);
342
+ const candidates = Math.max(topN, opts.candidates ?? 20);
343
+ const { terms, cities } = extractResumeSignals(text ?? "");
344
+ if (!terms.length) {
345
+ return {
346
+ ok: false,
347
+ source: SOURCE,
348
+ message: "could not extract any technical signals from the text",
349
+ preview: (text ?? "").slice(0, 120),
350
+ };
351
+ }
352
+ const pool = await getAllPositions();
353
+ if (!pool.ok) {
354
+ return { ok: false, source: SOURCE, message: pool.message, positions: [] };
355
+ }
356
+ const scored = [];
357
+ for (const p of pool.positions) {
358
+ const blob = [p.title, p.project, p.bgs, p.work_cities, p.recruit_label].join(" ");
359
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
360
+ if (score > 0)
361
+ scored.push({ score, position: p, reasons });
362
+ }
363
+ scored.sort((a, b) => b.score - a.score);
364
+ let shortlist = scored.slice(0, Math.max(topN, candidates));
365
+ if (!shortlist.length) {
366
+ shortlist = pool.positions.slice(0, candidates).map((position) => ({
367
+ score: 0,
368
+ position,
369
+ reasons: [],
370
+ }));
371
+ }
372
+ const matches = shortlist.slice(0, topN).map((s) => {
373
+ const mr = s.reasons.length > 0
374
+ ? s.reasons.slice(0, 5)
375
+ : ["no specific keyword overlap — surfaced from full position list"];
376
+ return { ...s.position, match_reasons: mr };
377
+ });
378
+ return {
379
+ ok: true,
380
+ source: SOURCE,
381
+ extracted_terms: terms,
382
+ city_preferences: cities,
383
+ matches,
384
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
385
+ "The only authority on selection is HR.",
386
+ };
387
+ }
388
+ export { extractResumeSignals, scoreOverlap };
389
+ import { buildBespokeApplySchema as _buildBespokeApplySchema_unitree } from "./apply.js";
390
+ export async function fetchApplicationSchema(postId) {
391
+ const id = (postId ?? "").trim();
392
+ if (!id)
393
+ return { ok: false, source: "unitree.com", message: "post_id is required" };
394
+ let title = "";
395
+ let applyUrl = "https://unitree.com";
396
+ try {
397
+ const detail = (await fetchPositionDetail(id));
398
+ if (detail?.ok === false) {
399
+ return { ok: false, source: "unitree.com", message: detail.message ?? "post not found" };
400
+ }
401
+ title = detail?.title ?? "";
402
+ if (detail?.apply_url)
403
+ applyUrl = detail.apply_url;
404
+ }
405
+ catch { }
406
+ return {
407
+ ok: true,
408
+ schema: _buildBespokeApplySchema_unitree({
409
+ source: "unitree.com",
410
+ postId: id,
411
+ jobTitle: title,
412
+ applyUrl,
413
+ submitEndpoint: undefined,
414
+ submitKind: "external",
415
+ submitNotes: "Unitree — recruiting funnel runs through a WeChat mini-program; no public submit API. Open apply_url in browser to scan the WeChat QR.",
416
+ }),
417
+ };
418
+ }