@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,770 @@
1
+ // Thin client for Tencent's public campus-recruiting API at join.qq.com.
2
+ //
3
+ // IMPORTANT: join.qq.com is a CAMPUS-ONLY portal (校园招聘). It exposes
4
+ // three recruit types — 应届生 (new graduates), 实习生 (interns), and 人才专项
5
+ // (talent programs such as 青云计划 and 技术研发提前批). There is NO social-hire /
6
+ // 社招 / experienced-hire endpoint on this domain; Tencent's social-hire jobs
7
+ // live at a separate site (careers.tencent.com).
8
+ //
9
+ // Filter taxonomy (all IDs are for the searchPosition POST body):
10
+ //
11
+ // projectIdList — leaf codes from getAllProject; controls recruit type
12
+ // 1 应届生
13
+ // 2 实习生
14
+ // 12 项目实习生
15
+ // 14 青云计划-应届生
16
+ // 16 技术研发提前批
17
+ // 20 青云计划-实习生
18
+ //
19
+ // bgList — BusinessGroup codes (data.count is accurate per BG)
20
+ // 953 CDG 企业发展事业群 ~100 positions
21
+ // 29294 CSIG 云与智慧产业事业群 ~206 positions
22
+ // 956 IEG 互动娱乐事业群 ~275 positions
23
+ // 29292 PCG 平台与内容事业群 ~89 positions
24
+ // 14129 WXG 微信事业群 ~152 positions
25
+ // 958 TEG 技术工程事业群 ~198 positions
26
+ // 78 S1 职能系统-职能线 ~36 positions
27
+ // 2233 S2 职能系统-财经线 ~6 positions
28
+ // 2234 S3 职能系统-HR与管理线 ~12 positions
29
+ // 955 其他 ~0 positions
30
+ //
31
+ // positionFidList — sub-family "id" values from getPositionFamily
32
+ // fid 2 技术: 75:软件开发类, 76:技术运营类, 77:安全技术类, 84:测试与质量管理类,
33
+ // 93:算法研究类, 231:解决方案与服务类, 250:硬件开发类
34
+ // fid 3 产品: 79:产品经理培训生, 80:游戏产品类, 83:内容制作类, 94:通用产品类,
35
+ // 219:金融产品类, 253:项目管理类
36
+ // fid 4 设计: 85:游戏美术类, 89:平面交互类
37
+ // fid 5 市场: 78:战略投资类, 82:市场营销类, 96:公共关系类, 192:销售拓展类
38
+ // fid 6 职能: 326:财经分析类, 327:人力资源类, 328:法律与公共策略类, 329:行政支持类
39
+ //
40
+ // workCountryType — 0=不限 (695), 1=国内 (593), 2=海外 (102)
41
+ //
42
+ // workCityList — city codes from getPositionWorkCities (key "1" = 国内, key "2" = 海外)
43
+ // 国内: 1:深圳 (~419), 2:北京 (~252), 3:上海 (~185), 5:广州 (~66),
44
+ // 6:武汉, 7:杭州, 8:成都, 11:南京, 14:重庆, 17:贵阳, 18:长沙,
45
+ // 29:厦门, 30:合肥, 31:天津, 37:中国香港, 190:芜湖, 276:韶关
46
+ // 海外: 138:贝尔维尤, 401:帕罗奥多, 407:洛杉矶, 501:阿姆斯特丹,
47
+ // 601:法兰克福, 701:首尔, 801:东京, 1001:曼谷, 1301:新加坡,
48
+ // 1401:伦敦, 1601:雅加达, 2301:巴黎, 3003:奥克兰
49
+ //
50
+ // recruitCityList — codes from getRecruitCity (interview city, not work city)
51
+ // 1:成都, 3:广州, 5:上海, 11:北京, 13:中国香港, 14:深圳, 27:武汉, 47:远程面试
52
+ //
53
+ // NOTE: data.count is unreliable when projectIdList is the ONLY filter
54
+ // (always returns 695). It IS accurate when bgList or positionFidList are set.
55
+ //
56
+ // All endpoints are unauthenticated; the server just checks Referer/Origin
57
+ // to discourage cross-site embedding. Endpoint inventory:
58
+ //
59
+ // GET /api/v1/position/getAllProject
60
+ // GET /api/v1/position/getPositionFamily?lang=zh-cn
61
+ // GET /api/v1/position/getPositionWorkCities?lang=zh-cn
62
+ // GET /api/v1/position/getRecruitCity?lang=zh-cn
63
+ // GET /api/v1/dictionary/?types=RecruitType,BusinessGroup,RecruitProjectPostList
64
+ // POST /api/v1/position/searchPosition
65
+ // GET /api/v1/jobDetails/getJobDetailsByPostId?postId=<id>
66
+ // GET /api/v1/noticeDynamic/getNoticeDynamicList
67
+ // GET /api/v1/noticeDynamic/getNoticeDynamicById?id=<id>
68
+ const API_ROOT = "https://join.qq.com/api/v1";
69
+ const POSTS_PAGE = "https://join.qq.com/post.html";
70
+ const NOTICE_PAGE = "https://join.qq.com/notice.html";
71
+ const DETAIL_PAGE = (postId) => `https://join.qq.com/post_detail.html?postid=${encodeURIComponent(postId)}`;
72
+ const DEFAULT_HEADERS = {
73
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
74
+ Accept: "application/json, text/plain, */*",
75
+ Origin: "https://join.qq.com",
76
+ };
77
+ async function call(method, path, opts = {}) {
78
+ const sep = path.includes("?") ? "&" : "?";
79
+ const url = `${API_ROOT}${path}${sep}timestamp=${Date.now()}`;
80
+ const headers = {
81
+ ...DEFAULT_HEADERS,
82
+ Referer: opts.referer ?? POSTS_PAGE,
83
+ };
84
+ let body;
85
+ if (opts.body !== undefined) {
86
+ body = JSON.stringify(opts.body);
87
+ headers["Content-Type"] = "application/json;charset=UTF-8";
88
+ }
89
+ let response;
90
+ try {
91
+ response = await fetch(url, { method, headers, body });
92
+ }
93
+ catch (err) {
94
+ return {
95
+ ok: false,
96
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
97
+ };
98
+ }
99
+ if (!response.ok) {
100
+ return { ok: false, message: `HTTP ${response.status}: ${response.statusText}` };
101
+ }
102
+ let payload;
103
+ try {
104
+ payload = (await response.json());
105
+ }
106
+ catch (err) {
107
+ return { ok: false, message: `bad JSON: ${err instanceof Error ? err.message : err}` };
108
+ }
109
+ return {
110
+ ok: payload.status === 0,
111
+ data: payload.data,
112
+ message: payload.message || (payload.status === 0 ? "ok" : "upstream error"),
113
+ };
114
+ }
115
+ // ---------- dictionaries ----------
116
+ /** Retrieve the full filter catalog for join.qq.com, including live position
117
+ * counts per BusinessGroup and per PositionFamily sub-category.
118
+ *
119
+ * Counts are fetched in parallel (one POST per BG / family sub-id).
120
+ * The projectIdList used for count probes is the full set [1,2,12,14,16,20]
121
+ * so counts reflect the whole campus pool.
122
+ *
123
+ * Note: counts under recruit_types are NOT probed here because
124
+ * data.count is unreliable when projectIdList is the sole filter
125
+ * (always returns 695 regardless of which projects are listed).
126
+ */
127
+ export async function fetchDictionaries() {
128
+ const [projects, families, workCities, recruitCities, shared] = await Promise.all([
129
+ call("GET", "/position/getAllProject"),
130
+ call("GET", "/position/getPositionFamily?lang=zh-cn"),
131
+ call("GET", "/position/getPositionWorkCities?lang=zh-cn"),
132
+ call("GET", "/position/getRecruitCity?lang=zh-cn"),
133
+ call("GET", "/dictionary/?types=RecruitType,BusinessGroup,RecruitProjectPostList"),
134
+ ]);
135
+ const allProjectIds = [1, 2, 12, 14, 16, 20];
136
+ // Helper: call searchPosition with a single extra filter, return count.
137
+ async function countWith(extra) {
138
+ const body = {
139
+ projectIdList: allProjectIds,
140
+ keyword: "",
141
+ bgList: [],
142
+ workCountryType: 0,
143
+ workCityList: [],
144
+ recruitCityList: [],
145
+ positionFidList: [],
146
+ pageIndex: 1,
147
+ pageSize: 1,
148
+ ...extra,
149
+ };
150
+ const r = await call("POST", "/position/searchPosition", { body });
151
+ return r.data?.count ?? 0;
152
+ }
153
+ // BG codes from shared.BusinessGroup
154
+ const bgEntries = (shared.data
155
+ ?.BusinessGroup ?? []).filter((e) => e.code && e.code !== "955");
156
+ const familySubIds = [];
157
+ const familyData = families.data;
158
+ if (familyData) {
159
+ for (const entries of Object.values(familyData)) {
160
+ for (const e of entries) {
161
+ if (e.id)
162
+ familySubIds.push(e);
163
+ }
164
+ }
165
+ }
166
+ // Fire all count probes in parallel
167
+ const [bgCounts, familyCounts] = await Promise.all([
168
+ Promise.all(bgEntries.map((bg) => countWith({ bgList: [Number(bg.code)] }))),
169
+ Promise.all(familySubIds.map((f) => countWith({ positionFidList: [f.id] }))),
170
+ ]);
171
+ const bg_counts = {};
172
+ for (let i = 0; i < bgEntries.length; i++) {
173
+ const bg = bgEntries[i];
174
+ bg_counts[bg.code] = { name: bg.name, code: Number(bg.code), count: bgCounts[i] };
175
+ }
176
+ const family_counts = {};
177
+ for (let i = 0; i < familySubIds.length; i++) {
178
+ const f = familySubIds[i];
179
+ family_counts[f.id] = { ...f, count: familyCounts[i] };
180
+ }
181
+ return {
182
+ ok: [projects, families, workCities, recruitCities, shared].every((r) => r.ok),
183
+ source: "join.qq.com",
184
+ api_host: API_ROOT,
185
+ verified_at: new Date().toISOString(),
186
+ campus_only: true,
187
+ recruit_types: shared.data?.RecruitType ?? [],
188
+ recruit_project_post_list: shared.data?.RecruitProjectPostList ?? [],
189
+ business_groups: shared.data?.BusinessGroup ?? [],
190
+ bg_counts,
191
+ position_families: families.data,
192
+ family_counts,
193
+ work_cities: workCities.data,
194
+ recruit_cities: recruitCities.data,
195
+ projects: projects.data,
196
+ shared: shared.data,
197
+ };
198
+ }
199
+ export async function collectAllProjectIds() {
200
+ const response = await call("GET", "/position/getAllProject");
201
+ if (!response.ok || !response.data)
202
+ return [];
203
+ const leaves = [];
204
+ const walk = (nodes) => {
205
+ for (const node of nodes) {
206
+ const kids = node.subDictionary ?? [];
207
+ if (kids.length) {
208
+ walk(kids);
209
+ }
210
+ else if (node.code !== undefined) {
211
+ const id = Number(node.code);
212
+ if (!Number.isNaN(id))
213
+ leaves.push(id);
214
+ }
215
+ }
216
+ };
217
+ walk(response.data);
218
+ return [...new Set(leaves)].sort((a, b) => a - b);
219
+ }
220
+ function summarizePosition(item) {
221
+ const postId = String(item.postId ?? "");
222
+ return {
223
+ post_id: postId,
224
+ title: item.positionTitle ?? "",
225
+ project: item.projectName ?? "",
226
+ recruit_label: item.recruitLabelName ?? "",
227
+ bgs: (item.bgs ?? "").trim(),
228
+ work_cities: (item.workCities ?? "").trim(),
229
+ apply_url: postId ? DETAIL_PAGE(postId) : POSTS_PAGE,
230
+ };
231
+ }
232
+ export async function searchPositions(opts = {}) {
233
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 20));
234
+ const page = Math.max(1, opts.page ?? 1);
235
+ const projectIds = opts.projectIds ?? (await collectAllProjectIds());
236
+ const body = {
237
+ projectIdList: projectIds,
238
+ keyword: (opts.keyword ?? "").trim().slice(0, 30),
239
+ bgList: opts.bgIds ?? [],
240
+ workCountryType: opts.workCountryType ?? 0,
241
+ workCityList: opts.workCityIds ?? [],
242
+ recruitCityList: opts.recruitCityIds ?? [],
243
+ positionFidList: opts.positionFamilyIds ?? [],
244
+ pageIndex: page,
245
+ pageSize,
246
+ };
247
+ const response = await call("POST", "/position/searchPosition", { body });
248
+ if (!response.ok || !response.data) {
249
+ return {
250
+ ok: false,
251
+ message: response.message,
252
+ query: body,
253
+ positions: [],
254
+ };
255
+ }
256
+ const rows = response.data.positionList ?? [];
257
+ return {
258
+ ok: true,
259
+ source: "join.qq.com",
260
+ query: body,
261
+ page,
262
+ page_size: pageSize,
263
+ total: response.data.count ?? rows.length,
264
+ positions: rows.map(summarizePosition),
265
+ };
266
+ }
267
+ export async function fetchAllPositions(opts = {}) {
268
+ const pageSize = Math.max(1, Math.min(100, opts.pageSize ?? 100));
269
+ const maxPages = Math.max(1, opts.maxPages ?? 20);
270
+ const projectIds = await collectAllProjectIds();
271
+ const bucket = [];
272
+ let total;
273
+ for (let page = 1; page <= maxPages; page++) {
274
+ const result = await searchPositions({
275
+ keyword: opts.keyword,
276
+ projectIds,
277
+ page,
278
+ pageSize,
279
+ });
280
+ if (!result.ok) {
281
+ return { ok: false, message: result.message, fetched: bucket.length, positions: bucket };
282
+ }
283
+ if (total === undefined)
284
+ total = result.total;
285
+ if (!result.positions.length)
286
+ break;
287
+ bucket.push(...result.positions);
288
+ if (total !== undefined && bucket.length >= total)
289
+ break;
290
+ }
291
+ return {
292
+ ok: true,
293
+ source: "join.qq.com",
294
+ total: total ?? bucket.length,
295
+ fetched: bucket.length,
296
+ positions: bucket,
297
+ };
298
+ }
299
+ export async function fetchPositionDetail(postId) {
300
+ const id = (postId ?? "").trim();
301
+ if (!id)
302
+ return { ok: false, message: "post_id is required" };
303
+ const response = await call("GET", `/jobDetails/getJobDetailsByPostId?postId=${encodeURIComponent(id)}`, { referer: DETAIL_PAGE(id) });
304
+ if (!response.ok || !response.data) {
305
+ return { ok: false, message: response.message || "no detail returned", post_id: id };
306
+ }
307
+ const raw = response.data;
308
+ const first = (...keys) => {
309
+ for (const key of keys) {
310
+ const v = raw[key];
311
+ if (typeof v === "string" && v.trim())
312
+ return v.trim();
313
+ }
314
+ return "";
315
+ };
316
+ return {
317
+ ok: true,
318
+ source: "join.qq.com",
319
+ post_id: String(raw.postId ?? id),
320
+ title: raw.title ?? "",
321
+ direction: raw.tidName ?? "",
322
+ project: raw.projectName ?? "",
323
+ recruit_label: raw.recruitLabelName ?? "",
324
+ description: first("desc", "topicDetail", "introduction"),
325
+ requirements: first("request", "topicRequirement"),
326
+ work_cities: raw.workCityList ?? [],
327
+ recruit_cities: raw.recruitCityList ?? [],
328
+ is_qingyun: Boolean(raw.isQingyun),
329
+ apply_url: DETAIL_PAGE(String(raw.postId ?? id)),
330
+ };
331
+ }
332
+ export async function listNotices() {
333
+ const response = await call("GET", "/noticeDynamic/getNoticeDynamicList", { referer: NOTICE_PAGE });
334
+ if (!response.ok)
335
+ return { ok: false, message: response.message, notices: [] };
336
+ const items = response.data?.list ?? [];
337
+ return {
338
+ ok: true,
339
+ source: "join.qq.com",
340
+ count: items.length,
341
+ notices: items.map((n) => ({
342
+ id: n.id,
343
+ title: n.title ?? "",
344
+ publish_time: n.publisheTimeTxt || n.publisheTime || "",
345
+ tag: n.noticeTag ?? "",
346
+ detail_url: `https://join.qq.com/detail.html?id=${n.id}`,
347
+ })),
348
+ };
349
+ }
350
+ export async function getNotice(noticeId) {
351
+ const id = String(noticeId ?? "").trim();
352
+ if (!id)
353
+ return { ok: false, message: "notice_id is required" };
354
+ const response = await call("GET", `/noticeDynamic/getNoticeDynamicById?id=${encodeURIComponent(id)}`, { referer: NOTICE_PAGE });
355
+ if (!response.ok || !response.data) {
356
+ return { ok: false, message: response.message || "no notice returned" };
357
+ }
358
+ const raw = response.data;
359
+ return {
360
+ ok: true,
361
+ source: "join.qq.com",
362
+ id: raw.id ?? Number(id),
363
+ title: raw.title ?? "",
364
+ publish_time: raw.publisheTimeTxt || raw.publisheTime || "",
365
+ tag: raw.noticeTag ?? "",
366
+ content_html: raw.cont ?? "",
367
+ detail_url: `https://join.qq.com/detail.html?id=${raw.id ?? id}`,
368
+ };
369
+ }
370
+ // ---------- flow (question-aware notice retrieval) ----------
371
+ function tokenizeQuestion(text) {
372
+ const out = [];
373
+ const seen = new Set();
374
+ const trimmed = (text ?? "").trim();
375
+ if (!trimmed)
376
+ return out;
377
+ for (const m of trimmed.match(/[A-Za-z0-9]{2,}/g) ?? []) {
378
+ const k = m.toLowerCase();
379
+ if (!seen.has(k)) {
380
+ seen.add(k);
381
+ out.push(k);
382
+ }
383
+ }
384
+ for (const run of trimmed.match(/[一-鿿]+/g) ?? []) {
385
+ for (let i = 0; i < run.length - 1; i++) {
386
+ const bigram = run.slice(i, i + 2);
387
+ if (!seen.has(bigram)) {
388
+ seen.add(bigram);
389
+ out.push(bigram);
390
+ }
391
+ if (out.length >= 40)
392
+ return out;
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+ function parseQuestionTime(value) {
398
+ if (!value)
399
+ return undefined;
400
+ const v = value.trim();
401
+ const candidates = [v, v.replace(" ", "T"), `${v}T00:00:00`];
402
+ for (const candidate of candidates) {
403
+ const ts = Date.parse(candidate);
404
+ if (!Number.isNaN(ts))
405
+ return ts;
406
+ }
407
+ return undefined;
408
+ }
409
+ export async function findNoticesByQuestion(question, opts = {}) {
410
+ const listing = await listNotices();
411
+ if (!listing.ok)
412
+ return { ok: false, message: listing.message, matches: [] };
413
+ const cutoff = parseQuestionTime(opts.questionTime);
414
+ const tokens = tokenizeQuestion(question);
415
+ const topK = Math.max(1, opts.topK ?? 3);
416
+ const scored = [];
417
+ for (const notice of listing.notices) {
418
+ const haystack = `${notice.title} ${notice.tag}`.toLowerCase();
419
+ const hits = tokens.filter((t) => haystack.includes(t)).length;
420
+ if (!hits)
421
+ continue;
422
+ let score = hits * 10;
423
+ const publishedAt = parseQuestionTime(notice.publish_time);
424
+ if (cutoff !== undefined && publishedAt !== undefined) {
425
+ if (publishedAt <= cutoff) {
426
+ const monthsBefore = (cutoff - publishedAt) / (86_400_000 * 30);
427
+ score += Math.max(0, 5 - monthsBefore);
428
+ }
429
+ else {
430
+ score -= 1;
431
+ }
432
+ }
433
+ scored.push({ score, notice });
434
+ }
435
+ scored.sort((a, b) => b.score - a.score);
436
+ const stripHtml = (html) => html
437
+ .replace(/<[^>]+>/g, "")
438
+ .replace(/&nbsp;/g, " ")
439
+ .replace(/&amp;/g, "&")
440
+ .replace(/&lt;/g, "<")
441
+ .replace(/&gt;/g, ">")
442
+ .replace(/&quot;/g, '"')
443
+ .replace(/\s+/g, " ")
444
+ .trim()
445
+ .slice(0, 400);
446
+ const matches = [];
447
+ for (const { notice } of scored.slice(0, topK)) {
448
+ const full = await getNotice(String(notice.id));
449
+ const excerpt = full.ok ? stripHtml(full.content_html ?? "") : "";
450
+ matches.push({ ...notice, excerpt });
451
+ }
452
+ return {
453
+ ok: true,
454
+ source: "join.qq.com",
455
+ question,
456
+ question_time: opts.questionTime,
457
+ matched_tokens: tokens,
458
+ matches,
459
+ };
460
+ }
461
+ // ---------- resume matching ----------
462
+ const TECH_VOCAB = new Set([
463
+ // languages
464
+ "python", "java", "go", "golang", "c", "c++", "cpp", "rust", "kotlin",
465
+ "swift", "scala", "javascript", "typescript", "php", "ruby", "lua",
466
+ // web / mobile
467
+ "react", "vue", "angular", "next", "nuxt", "webpack", "vite", "tailwind",
468
+ "flutter", "android", "ios", "react-native",
469
+ // backend
470
+ "spring", "springboot", "django", "flask", "fastapi", "express", "nestjs",
471
+ "grpc", "rest", "graphql", "websocket",
472
+ // data / db
473
+ "mysql", "postgresql", "postgres", "redis", "mongodb", "kafka",
474
+ "rabbitmq", "elasticsearch", "spark", "hadoop", "flink", "clickhouse",
475
+ "hive", "presto",
476
+ // infra
477
+ "docker", "kubernetes", "k8s", "linux", "aws", "gcp", "azure",
478
+ "terraform", "nginx", "envoy",
479
+ // ml / ai
480
+ "pytorch", "tensorflow", "huggingface", "llm", "rag", "transformer",
481
+ "bert", "gpt", "diffusion", "cv", "nlp", "embedding",
482
+ // chinese stack terms
483
+ "后台", "后端", "前端", "服务端", "客户端", "测试", "运维", "安全", "算法",
484
+ "推荐", "搜索", "大模型", "微服务", "分布式", "高并发", "数据库", "数据",
485
+ "机器学习", "深度学习", "强化学习", "多模态", "计算机视觉", "自然语言",
486
+ ]);
487
+ const CITY_VOCAB = new Set([
488
+ "深圳", "北京", "上海", "广州", "杭州", "成都", "武汉", "南京", "苏州",
489
+ "西安", "合肥", "天津", "厦门", "香港", "remote", "远程",
490
+ ]);
491
+ // Match a short vocab term against haystack with word-style boundaries when
492
+ // the term is 1-2 chars long, so "c" matches "C++" / "C language" but NOT the
493
+ // "c" inside "TypeScript". Longer terms keep the original substring match,
494
+ // which is forgiving across camelCase / kebab-case variations like "FastAPI".
495
+ function termMatches(haystack, term) {
496
+ if (term.length >= 3)
497
+ return haystack.includes(term);
498
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
499
+ return new RegExp(`(^|[^a-z0-9])${escaped}(?![a-z0-9])`, "i").test(haystack);
500
+ }
501
+ export function extractResumeSignals(text) {
502
+ const lower = (text ?? "").toLowerCase();
503
+ const terms = [];
504
+ const seen = new Set();
505
+ for (const term of TECH_VOCAB) {
506
+ if (termMatches(lower, term) && !seen.has(term)) {
507
+ terms.push(term);
508
+ seen.add(term);
509
+ }
510
+ }
511
+ // Latin tokens not already captured by the vocab
512
+ for (const tok of text.match(/[A-Za-z][A-Za-z0-9+#.\-]{1,15}/g) ?? []) {
513
+ const norm = tok.toLowerCase();
514
+ if (seen.has(norm) || terms.length >= 30)
515
+ continue;
516
+ if (norm.length < 3)
517
+ continue;
518
+ const stop = new Set(["the", "and", "for", "with", "via", "from", "able", "this", "that", "have"]);
519
+ if (stop.has(norm))
520
+ continue;
521
+ terms.push(tok);
522
+ seen.add(norm);
523
+ }
524
+ const cities = [];
525
+ for (const c of CITY_VOCAB) {
526
+ if (text.includes(c))
527
+ cities.push(c);
528
+ if (cities.length >= 6)
529
+ break;
530
+ }
531
+ return { terms: terms.slice(0, 30), cities };
532
+ }
533
+ export function scoreOverlap(haystack, terms, cities) {
534
+ const hay = haystack.toLowerCase();
535
+ let score = 0;
536
+ const reasons = [];
537
+ for (const t of terms) {
538
+ if (!t)
539
+ continue;
540
+ if (termMatches(hay, t.toLowerCase())) {
541
+ score += t.length > 2 ? 3 : 1;
542
+ if (reasons.length < 4)
543
+ reasons.push(`matched: ${t}`);
544
+ }
545
+ }
546
+ for (const c of cities) {
547
+ if (haystack.includes(c)) {
548
+ score += 2;
549
+ if (reasons.length < 4)
550
+ reasons.push(`city: ${c}`);
551
+ }
552
+ }
553
+ return { score, reasons };
554
+ }
555
+ export async function matchResume(text, opts = {}) {
556
+ const topN = Math.max(1, opts.topN ?? 5);
557
+ const candidates = Math.max(topN, opts.candidates ?? 20);
558
+ const { terms, cities } = extractResumeSignals(text ?? "");
559
+ if (!terms.length) {
560
+ return {
561
+ ok: false,
562
+ message: "could not extract any technical signals from the text",
563
+ preview: (text ?? "").slice(0, 120),
564
+ };
565
+ }
566
+ const keyword = terms.slice(0, 3).join(" ");
567
+ const list = await searchPositions({ keyword, page: 1, pageSize: 100 });
568
+ if (!list.ok)
569
+ return { ok: false, message: list.message, positions: [] };
570
+ const pre = [];
571
+ for (const p of list.positions) {
572
+ const blob = [p.title, p.project, p.recruit_label, p.bgs, p.work_cities].join(" ");
573
+ const { score, reasons } = scoreOverlap(blob, terms, cities);
574
+ if (score > 0)
575
+ pre.push({ score, position: p, reasons });
576
+ }
577
+ pre.sort((a, b) => b.score - a.score);
578
+ let shortlist = pre.slice(0, Math.max(topN, candidates));
579
+ if (!shortlist.length) {
580
+ shortlist = list.positions.slice(0, candidates).map((position) => ({
581
+ score: 0,
582
+ position,
583
+ reasons: [],
584
+ }));
585
+ }
586
+ const enriched = [];
587
+ for (const { score: baseScore, position, reasons: baseReasons } of shortlist.slice(0, candidates)) {
588
+ const detail = await fetchPositionDetail(position.post_id);
589
+ if (!detail.ok)
590
+ continue;
591
+ const jdBlob = [
592
+ detail.title,
593
+ detail.direction,
594
+ detail.description,
595
+ detail.requirements,
596
+ (detail.work_cities ?? []).join(" "),
597
+ ].join(" ");
598
+ const { score: extraScore, reasons: extraReasons } = scoreOverlap(jdBlob, terms, cities);
599
+ const combined = [...new Set([...baseReasons, ...extraReasons])].slice(0, 5);
600
+ if (!combined.length)
601
+ combined.push("no specific keyword overlap — surfaced from initial keyword search");
602
+ enriched.push({
603
+ score: baseScore + extraScore,
604
+ row: {
605
+ ...position,
606
+ title_detail: detail.title,
607
+ direction: detail.direction,
608
+ description: detail.description,
609
+ requirements: detail.requirements,
610
+ match_reasons: combined,
611
+ },
612
+ });
613
+ }
614
+ enriched.sort((a, b) => b.score - a.score);
615
+ return {
616
+ ok: true,
617
+ source: "join.qq.com",
618
+ extracted_terms: terms,
619
+ city_preferences: cities,
620
+ matches: enriched.slice(0, topN).map((e) => e.row),
621
+ note: "match_reasons surfaces overlapping keywords, not a probability of getting an interview. " +
622
+ "The only authority on selection is HR.",
623
+ };
624
+ }
625
+ // ---------- resume self-check ----------
626
+ const PUFFERY = [
627
+ "精通", "唯一", "完美", "顶尖", "领先", "100%",
628
+ "expert", "perfect", "world-class", "best in class",
629
+ ];
630
+ export function checkResume(text) {
631
+ if (!text || !text.trim()) {
632
+ return { ok: false, message: "empty resume text", checks: [] };
633
+ }
634
+ const checks = [];
635
+ const email = /[\w.+-]+@[\w-]+\.[\w.-]+/.test(text);
636
+ const phone = /(?:\+?86[-\s]?)?1[3-9]\d{9}/.test(text);
637
+ if (email || phone) {
638
+ const seen = [email && "email", phone && "phone"].filter(Boolean).join(", ");
639
+ checks.push({ name: "contact-info", status: "pass", hint: `found: ${seen}` });
640
+ }
641
+ else {
642
+ checks.push({
643
+ name: "contact-info",
644
+ status: "fail",
645
+ hint: "no email or 中国大陆 mobile number found — recruiters can't reach you",
646
+ });
647
+ }
648
+ // gradYear: three accept patterns — strict month suffix, "graduated/毕业"
649
+ // proximity, or a school/degree token within ~80 chars of a 20xx year (so
650
+ // a one-liner like "BS Tsinghua 2026" still counts as an education entry).
651
+ const gradYear = /\b20\d{2}\s*(?:年|届|6|7|9|June|July)/i.test(text) ||
652
+ /(?:graduat\w*|毕业|grad)[^]{0,30}\b20\d{2}\b/i.test(text) ||
653
+ /(?:Bachelor|BSc?|BA|Master|MSc?|MA|PhD|本科|硕士|博士|学士|大学|学院|University|College|Tsinghua|Peking|Fudan|Zhejiang|Jiao\s*Tong|USTC|SJTU|PKU|HKU)[^]{0,80}\b20\d{2}\b/i.test(text);
654
+ const school = /(大学|学院|University|College|Tsinghua|Peking|Fudan|Zhejiang|Jiao\s*Tong|USTC|SJTU|PKU|HKU)/i.test(text);
655
+ const major = /(专业|major|本科|硕士|博士|学士|bachelor|master|phd|\bBSc?\b|\bMSc?\b|\bBA\b|\bMA\b|\bMBA\b|\bMEng\b|\bMPhil\b)/i.test(text);
656
+ const eduOk = Number(school) + Number(major) + Number(gradYear);
657
+ if (eduOk === 3) {
658
+ checks.push({
659
+ name: "education",
660
+ status: "pass",
661
+ hint: "school, major, graduation year all present",
662
+ });
663
+ }
664
+ else if (eduOk >= 1) {
665
+ const missing = [
666
+ !school && "school",
667
+ !major && "major",
668
+ !gradYear && "graduation year",
669
+ ].filter(Boolean).join(", ");
670
+ checks.push({ name: "education", status: "warn", hint: `missing: ${missing}` });
671
+ }
672
+ else {
673
+ checks.push({
674
+ name: "education",
675
+ status: "fail",
676
+ hint: "no school / major / graduation year detectable",
677
+ });
678
+ }
679
+ const exp = /(项目|项目经历|实习|实习经历|工作经历|project|internship|experience)/i.test(text);
680
+ if (exp) {
681
+ checks.push({
682
+ name: "experience",
683
+ status: "pass",
684
+ hint: "at least one project or internship section found",
685
+ });
686
+ }
687
+ else {
688
+ checks.push({
689
+ name: "experience",
690
+ status: "fail",
691
+ hint: "no project / internship / experience header — even fresh grads need something here",
692
+ });
693
+ }
694
+ const quant = (text.match(/\d+(?:\.\d+)?\s*(?:%|倍|w|万|k|qps|ms|百万|million|users)/gi) ?? [])
695
+ .length;
696
+ if (quant >= 2) {
697
+ checks.push({
698
+ name: "quantitative-evidence",
699
+ status: "pass",
700
+ hint: `${quant} measurable outcomes found`,
701
+ });
702
+ }
703
+ else if (quant === 1) {
704
+ checks.push({
705
+ name: "quantitative-evidence",
706
+ status: "warn",
707
+ hint: "only one quantified result — recruiters want numbers (latency, QPS, users, savings)",
708
+ });
709
+ }
710
+ else {
711
+ checks.push({
712
+ name: "quantitative-evidence",
713
+ status: "fail",
714
+ hint: "no numeric outcomes — every bullet should answer 'how much / how many / how fast'",
715
+ });
716
+ }
717
+ const flagged = PUFFERY.filter((w) => text.includes(w));
718
+ if (flagged.length) {
719
+ checks.push({
720
+ name: "puffery",
721
+ status: "warn",
722
+ hint: `vague superlatives detected: ${flagged.slice(0, 5).join(", ")} — replace with concrete evidence or remove`,
723
+ });
724
+ }
725
+ else {
726
+ checks.push({ name: "puffery", status: "pass", hint: "no obvious superlative claims" });
727
+ }
728
+ const order = { fail: 0, warn: 1, pass: 2 };
729
+ checks.sort((a, b) => order[a.status] - order[b.status]);
730
+ const summary = { pass: 0, warn: 0, fail: 0 };
731
+ for (const c of checks)
732
+ summary[c.status]++;
733
+ return {
734
+ ok: true,
735
+ summary,
736
+ checks,
737
+ note: "Heuristics only — they don't judge content quality, just whether the skeleton is intact.",
738
+ };
739
+ }
740
+ import { buildBespokeApplySchema as _buildBespokeApplySchema_tencent } from "./apply.js";
741
+ export async function fetchApplicationSchema(postId) {
742
+ const id = (postId ?? "").trim();
743
+ if (!id)
744
+ return { ok: false, source: "join.qq.com", message: "post_id is required" };
745
+ let title = "";
746
+ let applyUrl = "https://join.qq.com";
747
+ try {
748
+ const detail = (await fetchPositionDetail(id));
749
+ if (detail?.ok === false) {
750
+ return { ok: false, source: "join.qq.com", message: detail.message ?? "post not found" };
751
+ }
752
+ title = detail?.title ?? "";
753
+ if (detail?.apply_url)
754
+ applyUrl = detail.apply_url;
755
+ }
756
+ catch { }
757
+ return {
758
+ ok: true,
759
+ schema: _buildBespokeApplySchema_tencent({
760
+ source: "join.qq.com",
761
+ postId: id,
762
+ jobTitle: title,
763
+ applyUrl,
764
+ submitEndpoint: "https://join.qq.com/api/v1/resume/bindResume",
765
+ submitKind: "multipart-session",
766
+ endpointVerified: true,
767
+ submitNotes: "Tencent join.qq.com — POST /api/v1/resume/bindResume with session cookie + CSRF. Endpoint extracted from join.qq.com's p_zh-cn_post_detail.build.js bundle (sibling action endpoints /openResume, /saveResumeInfo, /uploadFile all probed → 200 + {message:\"未登录或登录已过期\",status:401}). bindResume is the route that binds a saved resume to a specific post = the apply action. Body shape still needs validation against a real candidate session.",
768
+ }),
769
+ };
770
+ }