amlei-skills 0.1.0

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 (36) hide show
  1. package/.claude-plugin/plugin.json +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/package.json +35 -0
  5. package/skills/academic-ref-retrieval/SKILL.md +103 -0
  6. package/skills/academic-ref-retrieval/resources/arxiv-api-example.py +42 -0
  7. package/skills/academic-ref-retrieval/resources/cnki-scraper-example.py +58 -0
  8. package/skills/amlei-git-gh/SKILL.md +156 -0
  9. package/skills/amlei-resume/SKILL.md +204 -0
  10. package/skills/amlei-resume/assets/preview-shell.html +438 -0
  11. package/skills/amlei-resume/assets/sample-preview.html +361 -0
  12. package/skills/amlei-resume/assets/sample-resume.md +57 -0
  13. package/skills/amlei-resume/references/export.md +76 -0
  14. package/skills/amlei-resume/references/icons.md +35 -0
  15. package/skills/amlei-resume/references/profile-evaluator.md +44 -0
  16. package/skills/amlei-resume/references/profile.md +43 -0
  17. package/skills/amlei-resume/references/projects.md +76 -0
  18. package/skills/amlei-resume/references/resume-evaluator.md +121 -0
  19. package/skills/amlei-resume/references/revise.md +11 -0
  20. package/skills/amlei-resume/references/themes/theme-academic.md +238 -0
  21. package/skills/amlei-resume/references/themes/theme-content-green.md +138 -0
  22. package/skills/amlei-resume/references/themes/theme-english-mnc.md +127 -0
  23. package/skills/amlei-resume/references/themes/theme-index.md +46 -0
  24. package/skills/amlei-resume/references/themes/theme-sidebar-creative.md +163 -0
  25. package/skills/amlei-resume/references/themes/theme-soe-formal.md +153 -0
  26. package/skills/amlei-resume/references/themes/theme-tech-dense.md +157 -0
  27. package/skills/amlei-resume/references/write.md +14 -0
  28. package/skills/amlei-resume/scripts/boss_zhipin.py +676 -0
  29. package/skills/amlei-resume/scripts/extract_avatar.py +157 -0
  30. package/skills/amlei-resume/scripts/profile.py +465 -0
  31. package/skills/amlei-resume/scripts/validate_resume.py +195 -0
  32. package/skills/amlei-resume/scripts/wrap_preview.py +104 -0
  33. package/skills/amlei-story-generator/SKILL.md +67 -0
  34. package/skills/amlei-story-generator/resources/narrative-craft.md +74 -0
  35. package/skills/amlei-story-generator/resources/rubric.md +28 -0
  36. package/skills/amlei-story-generator/resources/source-frameworks.md +89 -0
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env python3
2
+ """Boss直聘 自动化 — 基于 CloakBrowser Python SDK。
3
+
4
+ 每个命令独立启动浏览器,通过 storage_state 持久化登录态。
5
+ 供 Agent 以 subprocess 方式复用。
6
+
7
+ 用法:
8
+ boss_zhipin.py login 登录 → APP 扫码 → 保存状态
9
+ boss_zhipin.py resume-session 恢复会话
10
+ boss_zhipin.py check-messages 检查消息回复
11
+ boss_zhipin.py fetch-jd <名称> <job_id> [目录] 抓取完整 JD(含薪资)
12
+ boss_zhipin.py batch-jds <目录> <名称:job_id>... 批量抓取
13
+ boss_zhipin.py search <关键词> [--city ...] [--salary ...] 搜索岗位
14
+ boss_zhipin.py state-path 打印状态文件路径
15
+ 状态文件:<cwd>/.amlei-skill/resume/boss_state.json 或 ~/.amlei-skill/resume/
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import random
22
+ import re
23
+ import sys
24
+ import time
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+
28
+ try:
29
+ from cloakbrowser import launch_context
30
+ except ImportError:
31
+ print("✗ 缺少 cloakbrowser。运行: pip install cloakbrowser", file=sys.stderr)
32
+ sys.exit(1)
33
+
34
+ # playwright is required by cloakbrowser - install: pip install playwright && playwright install chromium
35
+
36
+ _SCRIPT_DIR = Path(__file__).parent.resolve()
37
+
38
+ def _find_state():
39
+ """优先项目级,否则用户根目录(与 profile.py 统一)。"""
40
+ for base in (Path.cwd(), Path.home()):
41
+ p = base / ".amlei-skill" / "resume" / "boss_state.json"
42
+ if p.exists():
43
+ return p
44
+ return Path.cwd() / ".amlei-skill" / "resume" / "boss_state.json"
45
+
46
+ STATE_FILE = _find_state()
47
+
48
+ # ── 招呼语(在脚本里直接修改,不由 CLI 传入) ──────────────────────────
49
+ # 点击「立即沟通」后自动发送这条消息给对方。
50
+ GREETING = "您好,看到您在招这个岗位,我最近在做 AI Agent 相关的开发,对方向很感兴趣,希望能聊聊看有没有合作的机会。"
51
+
52
+ # ── 拟人化延时范围(秒) ────────────────────────────────────────────
53
+ # 每个操作在 [min, max] 区间内随机等待,避免固定间隔被风控。
54
+ SLEEP_NAV = (2, 5) # 页面导航 → 交互
55
+ SLEEP_BEFORE_CLICK = (0.5, 1.5) # 点击前停留(模拟阅读/寻找)
56
+ SLEEP_AFTER_CLICK = (1, 3) # 点击后等待响应
57
+ SLEEP_TYPE = (0.02, 0.08) # 每个字符的键入间隔
58
+ SLEEP_AFTER_TYPE = (0.5, 1.5) # 键入完成 → 点击发送
59
+ SLEEP_BATCH_GAP = (2, 5) # 批量操作之间的间隔
60
+ SCROLL_PAUSE = (0.3, 0.8) # 每次滚动后的停顿
61
+
62
+ # ── 筛选参数字典(Boss直聘 URL query 参数值) ────────────────────────
63
+ CITY = {
64
+ "全国": "", "北京": "101010100", "上海": "101020100", "广州": "101280100",
65
+ "深圳": "101280600", "杭州": "101210100", "成都": "101270100", "南京": "101190100",
66
+ "武汉": "101200100", "西安": "101110100", "长沙": "101250100",
67
+ }
68
+ JOB_TYPE = {"全职": 1901, "兼职": 1902, "实习": 1903}
69
+ EXP = {
70
+ "不限": "", "应届": 101, "1年以下": 102, "1-3年": 103, "3-5年": 104,
71
+ "5-10年": 105, "10年以上": 106,
72
+ }
73
+ DEGREE = {
74
+ "不限": "", "初中及以下": 201, "高中": 202, "中专/中技": 203,
75
+ "大专": 204, "本科": 205, "硕士": 206, "博士": 207,
76
+ }
77
+ SALARY = {
78
+ "不限": "", "2K以下": 401, "2K-5K": 402, "5K-10K": 403,
79
+ "10K-15K": 404, "15K-20K": 405, "20K-30K": 406, "30K-50K": 407, "50K以上": 408,
80
+ }
81
+ SCALE = {
82
+ "不限": "", "0-20人": 301, "20-99人": 302, "100-499人": 303,
83
+ "500-999人": 304, "1000-9999人": 305, "10000人以上": 306,
84
+ }
85
+
86
+
87
+ def die(msg: str):
88
+ print(f"✗ {msg}", file=sys.stderr)
89
+ sys.exit(1)
90
+
91
+
92
+ def _ensure_state_or_login():
93
+ if STATE_FILE.exists():
94
+ return
95
+ print("⚠ 未检测到登录状态,即将打开浏览器扫码登录...", file=sys.stderr)
96
+ cmd_login()
97
+
98
+
99
+ def _sleep(lo: float, hi: float):
100
+ time.sleep(random.uniform(lo, hi))
101
+
102
+
103
+ def _rand_scroll(page):
104
+ """模拟浏览行为:随机滚动到几个位置,每步停顿随机时长。"""
105
+ for _ in range(random.randint(2, 5)):
106
+ target = random.randint(100, 1200)
107
+ page.evaluate(f"window.scrollTo({{top: {target}, behavior: 'smooth'}})")
108
+ _sleep(*SCROLL_PAUSE)
109
+
110
+
111
+ def _rand_hover(page):
112
+ """在几个随机可见元素上悬停,模拟鼠标移动。"""
113
+ els = page.query_selector_all("a, button, .job-card, .job-list-item, .job-title")
114
+ if not els:
115
+ return
116
+ targets = random.sample(els, min(random.randint(1, 3), len(els)))
117
+ for el in targets:
118
+ try:
119
+ if el.is_visible():
120
+ el.hover()
121
+ _sleep(0.1, 0.3)
122
+ except Exception:
123
+ pass
124
+
125
+
126
+ def humanize(page):
127
+ """一次拟人化动作:随机滚动 + 悬停。"""
128
+ _rand_scroll(page)
129
+ _rand_hover(page)
130
+
131
+
132
+ def _decode_boss_text(text: str) -> str:
133
+ """Boss直聘用 U+E030-U+E039 编码数字,转回 ASCII 数字 '0'-'9'。"""
134
+ return "".join(
135
+ chr(ord(c) - 0xE030 + 0x30) if 0xE030 <= ord(c) <= 0xE039 else c
136
+ for c in text
137
+ )
138
+
139
+
140
+ def _extract_search_results(page) -> list[dict]:
141
+ """从搜索列表页提取岗位卡片,返回结构化列表。"""
142
+ cards = page.query_selector_all(".job-card-box")
143
+ results = []
144
+ for c in cards:
145
+ try:
146
+ name_el = c.query_selector(".job-name")
147
+ salary_el = c.query_selector(".job-salary")
148
+ tags_el = c.query_selector_all(".tag-list li")
149
+ company_el = c.query_selector(".boss-name")
150
+ area_el = c.query_selector(".company-location")
151
+ href = name_el.get_attribute("href") if name_el else ""
152
+ job_id = ""
153
+ if href:
154
+ m = re.search(r"/(\w+)\.html", href)
155
+ if m:
156
+ job_id = m.group(1)
157
+ results.append({
158
+ "name": _decode_boss_text(name_el.inner_text().strip()) if name_el else "",
159
+ "salary": _decode_boss_text(salary_el.inner_text().strip()) if salary_el else "",
160
+ "company": company_el.inner_text().strip() if company_el else "",
161
+ "area": area_el.inner_text().strip() if area_el else "",
162
+ "tags": [_decode_boss_text(t.inner_text().strip()) for t in tags_el],
163
+ "job_id": job_id,
164
+ })
165
+ except Exception:
166
+ continue
167
+ return results
168
+
169
+
170
+ _JD_SKIP_REGEX = (
171
+ "感兴趣",
172
+ "立即沟通",
173
+ "继续沟通",
174
+ "完善在线简历",
175
+ "上传附件简历",
176
+ "查看全部职位",
177
+ "下载App, 不错过Boss每一条消息",
178
+ "微信扫码",
179
+ "扫码分享",
180
+ )
181
+
182
+
183
+ def _skip_line(s: str) -> bool:
184
+ if not s.strip():
185
+ return True
186
+ if len(s) == 1:
187
+ return True
188
+ if s in ("收藏", "举报", "分享"):
189
+ return True
190
+ for p in _JD_SKIP_REGEX:
191
+ if p in s:
192
+ return True
193
+ return False
194
+ _JD_END = {"竞争力分析", "BOSS 安全提示", "工商信息", "更多职位", "看过该职位的人还看了",
195
+ "精选职位", "页面更新时间", "城市招聘", "热门职位", "推荐公司"}
196
+
197
+ _ACTIVE_MARKERS = ("3日内活跃", "今日活跃", "本周活跃", "刚刚活跃", "本月活跃")
198
+
199
+
200
+ def _extract_jd_md(page) -> str:
201
+ text = _decode_boss_text(page.inner_text("body"))
202
+ lines = text.split("\n")
203
+
204
+ # 找到首个含薪资的行 → 内容起点
205
+ start = -1
206
+ for i, line in enumerate(lines):
207
+ if any(k in line for k in ("K", "面议", "元/天")):
208
+ start = i
209
+ break
210
+ if start < 0:
211
+ return text
212
+
213
+ # 收集行,跳过噪声,在终点标记处截断
214
+ buf = []
215
+ for line in lines[start:]:
216
+ s = line.strip()
217
+ if _skip_line(s):
218
+ continue
219
+ if any(k in s for k in _JD_END):
220
+ break
221
+ buf.append(s)
222
+
223
+ # ── 分区块 ──
224
+ header_buf = []
225
+ desc_buf = []
226
+ company_buf = []
227
+ active = ""
228
+ state = "header"
229
+
230
+ for s in buf:
231
+ if s == "职位描述":
232
+ state = "desc"
233
+ continue
234
+ if s == "公司基本信息":
235
+ state = "company"
236
+ company_buf.append("")
237
+ company_buf.append("**公司基本信息**")
238
+ continue
239
+ if any(m in s for m in _ACTIVE_MARKERS):
240
+ active = s
241
+ continue
242
+
243
+ if state == "header":
244
+ header_buf.append(s)
245
+ elif state == "desc":
246
+ desc_buf.append(s)
247
+ elif state == "company":
248
+ company_buf.append(s)
249
+
250
+ # ── 清理描述末尾的招聘者信息 ──
251
+ while len(desc_buf) >= 3:
252
+ n1, n2, n3 = desc_buf[-3:]
253
+ if (2 <= len(n1) <= 4 and all('\u4e00' <= c <= '\u9fff' for c in n1)
254
+ and any(k in n2 for k in ("公司", "有限", "集团", "股份"))
255
+ and len(n3) >= 2):
256
+ desc_buf = desc_buf[:-3]
257
+ else:
258
+ break
259
+
260
+ # ── 组装 ──
261
+ out = []
262
+
263
+ # Header: 重组为「## 公司、岗位」+ 「岗位、薪资、地区、学历」
264
+ h = " ".join(header_buf)
265
+ for token in ("查看所有职位", "感兴趣", "立即沟通", "继续沟通"):
266
+ h = h.replace(token, "")
267
+ h = re.sub(r"\s+", " ", h).strip()
268
+
269
+ sal = ""
270
+ m = re.search(r"([\d.]+[Kk]?[-~到][\d.]+[Kk]?(?:·\d+薪)?)", h)
271
+ if m:
272
+ sal = m.group(1)
273
+
274
+ # 提取字段
275
+ area = ""
276
+ for a in ("深圳", "广州", "北京", "上海", "杭州", "成都", "武汉", "南京", "西安", "长沙"):
277
+ if a in h:
278
+ area = a
279
+ break
280
+ # 子区域(带 · 的详细地址)
281
+ area_detail = ""
282
+ for part in h.split():
283
+ if "·" in part and any(c in part for c in ("区", "街", "路", "道", "镇", "乡")):
284
+ area_detail = part
285
+ break
286
+
287
+ tags = [t for t in h.split() if any(k in t for k in ("年", "历", "届", "在读", "以下", "不限", "博士", "硕士", "本科", "大专"))]
288
+
289
+ # 公司名从 company_buf 取(页面底部公司信息区域)
290
+ company = ""
291
+ for s in company_buf:
292
+ if any(k in s for k in ("公司", "有限", "集团", "股份")) and not s.startswith("**"):
293
+ company = s
294
+ break
295
+ # fallback: 从 header 尾部取含公司的词段
296
+ if not company:
297
+ for part in reversed(h.split()):
298
+ if any(k in part for k in ("公司", "有限", "集团", "股份")):
299
+ company = part
300
+ break
301
+
302
+ # 岗位名 = h 中第一个薪资之前的连续文本
303
+ before_sal = h.split(sal)[0].strip() if sal else h
304
+ title = re.sub(r"\s+", " ", before_sal).strip()
305
+ if not title:
306
+ title = h.split()[0] if h.split() else ""
307
+
308
+ section = company or title
309
+ if section:
310
+ out.append(f"# {section}")
311
+ out.append("")
312
+ if company:
313
+ out.append(f"公司: {company}")
314
+ if title:
315
+ out.append(f"岗位: {title}")
316
+ if sal:
317
+ out.append(f"薪资: {sal}")
318
+ if area:
319
+ out.append(f"地点: {area}")
320
+ for t in tags:
321
+ out.append(f"学历: {t}")
322
+ # 公司规模 / 行业 / 活跃状态(从 company_buf 提取)
323
+ info_lines = [s for s in company_buf if s and not s.startswith("**") and s != company]
324
+ for s in info_lines:
325
+ if s.endswith("人"):
326
+ out.append(f"规模: {s}")
327
+ elif any('\u4e00' <= c <= '\u9fff' for c in s):
328
+ out.append(f"行业: {s}")
329
+ if active:
330
+ out.append(f"活跃: {active}")
331
+
332
+ if desc_buf:
333
+ out.append("")
334
+ out.append("# 职位描述")
335
+ out.append("")
336
+ out.append("\n".join(desc_buf))
337
+
338
+ return "\n".join(out).strip()
339
+
340
+
341
+ # ── 命令 ──────────────────────────────────────────────────────────────────
342
+
343
+ def cmd_state_path():
344
+ print(STATE_FILE.resolve())
345
+
346
+
347
+ def cmd_login():
348
+ ctx = launch_context(headless=False)
349
+ try:
350
+ page = ctx.new_page()
351
+ page.goto("https://www.zhipin.com/web/user/?ka=header-login", wait_until="domcontentloaded", timeout=60000)
352
+ _sleep(*SLEEP_NAV)
353
+ page.eval_on_selector(".btn-sign-switch.ewm-switch", "el => el.click()")
354
+ _sleep(*SLEEP_AFTER_CLICK)
355
+
356
+ print("\n" + "=" * 60)
357
+ print(" 二维码已显示在浏览器窗口中。")
358
+ print(" 请打开手机 Boss 直聘 APP 扫码登录。")
359
+ print("=" * 60 + "\n")
360
+
361
+ # Stage 1: 等待扫码完成
362
+ print("⏳ 等待扫码...", file=sys.stderr)
363
+ for _ in range(100):
364
+ time.sleep(3)
365
+ scanned = page.query_selector(".login-step-box")
366
+ if scanned:
367
+ title_el = scanned.query_selector(".login-step-title")
368
+ if title_el and "扫描成功" in (title_el.inner_text() or ""):
369
+ print("✓ 扫码成功,请在手机上确认登录...", file=sys.stderr)
370
+ _sleep(*SLEEP_AFTER_CLICK)
371
+ break
372
+ else:
373
+ die("扫码超时(5 分钟)。")
374
+
375
+ # Stage 2: 等待确认登录(页面跳离 /user/)
376
+ for _ in range(100):
377
+ time.sleep(3)
378
+ if "user" not in page.url:
379
+ break
380
+ else:
381
+ die("登录确认超时(5 分钟)。")
382
+
383
+ humanize(page)
384
+ ctx.storage_state(path=str(STATE_FILE))
385
+ print(f"✓ 登录成功!{page.url}")
386
+ print(f"✓ 状态已保存: {STATE_FILE.resolve()}")
387
+ finally:
388
+ ctx.close()
389
+
390
+
391
+ def cmd_resume_session():
392
+ _ensure_state_or_login()
393
+
394
+ ctx = launch_context(storage_state=str(STATE_FILE))
395
+ try:
396
+ page = ctx.new_page()
397
+ page.goto("https://www.zhipin.com/web/geek/job-recommend", wait_until="domcontentloaded", timeout=60000)
398
+ _sleep(*SLEEP_NAV)
399
+ humanize(page)
400
+ print(f"✓ 会话已恢复({page.url})")
401
+ finally:
402
+ ctx.close()
403
+
404
+
405
+ def cmd_fetch_jd(name: str, job_id: str, out_dir: str = "."):
406
+ _ensure_state_or_login()
407
+
408
+ url = f"https://www.zhipin.com/job_detail/{job_id}/"
409
+ ctx = launch_context(storage_state=str(STATE_FILE))
410
+ try:
411
+ print(f"› 抓取: {name}", file=sys.stderr)
412
+ page = ctx.new_page()
413
+ page.goto(url, wait_until="domcontentloaded", timeout=60000)
414
+ _sleep(*SLEEP_NAV)
415
+ humanize(page)
416
+ _sleep(*SLEEP_AFTER_CLICK)
417
+
418
+ jd = _extract_jd_md(page)
419
+
420
+ path = Path(out_dir) / f"{name}.md"
421
+ path.parent.mkdir(parents=True, exist_ok=True)
422
+ path.write_text(
423
+ f"# {name}\n\n{jd}\n\n---\n"
424
+ f"_source: {url}\n"
425
+ f"_fetched: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
426
+ encoding="utf-8",
427
+ )
428
+ print(f"✓ 已保存: {path}", file=sys.stderr)
429
+ print(f"--- {name} ---\n{jd}\n")
430
+ finally:
431
+ ctx.close()
432
+
433
+
434
+ def cmd_batch_jds(out_dir: str, entries: list[str]):
435
+ for e in entries:
436
+ if ":" not in e:
437
+ print(f"✗ 跳过: {e}(格式 name:job_id)", file=sys.stderr)
438
+ continue
439
+ name, job_id = e.split(":", 1)
440
+ cmd_fetch_jd(name, job_id, out_dir)
441
+ _sleep(*SLEEP_BATCH_GAP)
442
+
443
+
444
+ def cmd_search(keyword: str, city: str, job_type: str, salary: str,
445
+ exp: str, degree: str, scale: str, industry: str):
446
+ _ensure_state_or_login()
447
+
448
+ params = [f"query={keyword}"]
449
+ city_code = CITY.get(city, "")
450
+ if city_code:
451
+ params.append(f"city={city_code}")
452
+ if job_type:
453
+ params.append(f"jobType={JOB_TYPE.get(job_type, '')}")
454
+ if salary:
455
+ params.append(f"salary={SALARY.get(salary, '')}")
456
+ if exp:
457
+ params.append(f"experience={EXP.get(exp, '')}")
458
+ if degree:
459
+ params.append(f"degree={DEGREE.get(degree, '')}")
460
+ if scale:
461
+ params.append(f"scale={SCALE.get(scale, '')}")
462
+ if industry:
463
+ params.append(f"industry={industry}")
464
+
465
+ url = "https://www.zhipin.com/web/geek/jobs?" + "&".join(params)
466
+ print(f"› {url}", file=sys.stderr)
467
+
468
+ ctx = launch_context(storage_state=str(STATE_FILE))
469
+ try:
470
+ page = ctx.new_page()
471
+ page.goto(url, wait_until="domcontentloaded", timeout=60000)
472
+ _sleep(*SLEEP_NAV)
473
+ humanize(page)
474
+
475
+ results = _extract_search_results(page)
476
+ if not results:
477
+ print("(无匹配岗位)", file=sys.stderr)
478
+ return
479
+ print(json.dumps(results, ensure_ascii=False, indent=2))
480
+ finally:
481
+ ctx.close()
482
+
483
+
484
+ def _handle_startchat_dialog(page):
485
+ """处理首次沟通弹窗:textarea + 发送按钮 + 右侧微信二维码。"""
486
+ dialog = page.query_selector(".dialog-container")
487
+ if not dialog or not dialog.is_visible():
488
+ return False
489
+
490
+ textarea = page.query_selector("textarea.input-area")
491
+ if not textarea:
492
+ return False
493
+
494
+ # 填入招呼语并触发 input 事件解除 disable
495
+ textarea.click()
496
+ page.evaluate("document.querySelector('textarea.input-area').value = ''")
497
+ for ch in GREETING:
498
+ page.evaluate(f"document.querySelector('textarea.input-area').value += {ch!r}")
499
+ page.evaluate("document.querySelector('textarea.input-area').dispatchEvent(new Event('input', {bubbles: true}))")
500
+ _sleep(*SLEEP_TYPE)
501
+ _sleep(*SLEEP_AFTER_TYPE)
502
+
503
+ # 点发送
504
+ send_btn = page.query_selector("div.send-message")
505
+ if send_btn and send_btn.is_visible():
506
+ send_btn.hover()
507
+ _sleep(0.2, 0.4)
508
+ send_btn.click()
509
+ _sleep(*SLEEP_AFTER_CLICK)
510
+ return True
511
+
512
+
513
+ def cmd_chat(job_id: str):
514
+ """打开岗位详情 → 点「立即沟通」/「继续沟通」→ 发送招呼语。"""
515
+ _ensure_state_or_login()
516
+
517
+ url = f"https://www.zhipin.com/job_detail/{job_id}/"
518
+ ctx = launch_context(storage_state=str(STATE_FILE), headless=False)
519
+ try:
520
+ page = ctx.new_page()
521
+ page.goto(url, wait_until="domcontentloaded", timeout=60000)
522
+ _sleep(*SLEEP_NAV)
523
+
524
+ # 浏览岗位描述(模拟阅读)
525
+ humanize(page)
526
+ _sleep(*SLEEP_BEFORE_CLICK)
527
+
528
+ # 找沟通按钮(首次:「立即沟通」;已沟通过:「继续沟通」)
529
+ for text in ("立即沟通", "继续沟通"):
530
+ btn = page.query_selector(f"text={text}")
531
+ if btn and btn.is_visible():
532
+ break
533
+ else:
534
+ die("页面上没找到「立即沟通」或「继续沟通」按钮。")
535
+
536
+ btn.hover()
537
+ _sleep(0.2, 0.5)
538
+ btn.click()
539
+ _sleep(*SLEEP_AFTER_CLICK)
540
+
541
+ # 检测是弹窗还是直开聊天面板
542
+ if not _handle_startchat_dialog(page):
543
+ # 直开面板:等待 .chat-input
544
+ chat_input = None
545
+ for _ in range(10):
546
+ chat_input = page.query_selector(".chat-input")
547
+ if chat_input and chat_input.is_visible():
548
+ break
549
+ _sleep(0.5, 1.5)
550
+ else:
551
+ die("聊天面板未出现。")
552
+
553
+ # 逐字键入招呼语
554
+ chat_input.click()
555
+ page.evaluate("document.querySelector('.chat-input').innerText = ''")
556
+ for ch in GREETING:
557
+ page.evaluate(f"document.querySelector('.chat-input').innerText += {ch!r}")
558
+ page.evaluate("document.querySelector('.chat-input').dispatchEvent(new Event('input', {bubbles: true}))")
559
+ _sleep(*SLEEP_TYPE)
560
+ _sleep(*SLEEP_AFTER_TYPE)
561
+
562
+ # 点「发送」按钮
563
+ send_btn = page.query_selector("button.btn-send")
564
+ if send_btn and send_btn.is_visible():
565
+ send_btn.hover()
566
+ _sleep(0.2, 0.4)
567
+ send_btn.click()
568
+ else:
569
+ die("没找到「发送」按钮。")
570
+
571
+ _sleep(*SLEEP_AFTER_CLICK)
572
+ print(f"✓ 招呼已发送至 {url}")
573
+
574
+ finally:
575
+ ctx.close()
576
+
577
+
578
+ # ── 主入口 ────────────────────────────────────────────────────────────────
579
+
580
+ def cmd_check_messages():
581
+ """检查消息列表,检测谁回复了。"""
582
+ _ensure_state_or_login()
583
+
584
+ ctx = launch_context(storage_state=str(STATE_FILE), headless=False)
585
+ try:
586
+ page = ctx.new_page()
587
+ page.goto("https://www.zhipin.com/web/geek/chat", wait_until="domcontentloaded", timeout=60000)
588
+ _sleep(*SLEEP_NAV)
589
+ humanize(page)
590
+ _sleep(*SLEEP_AFTER_CLICK)
591
+
592
+ conversations = page.evaluate("""
593
+ Array.from(document.querySelectorAll('.friend-content')).map(el => {
594
+ const nameEl = el.querySelector('.name-text');
595
+ const lastMsgText = el.querySelector('.last-msg-text');
596
+ const statusEl = el.querySelector('.message-status.status-delivery');
597
+ const vline = el.querySelector('.vline');
598
+ const timeEl = el.querySelector('.time');
599
+
600
+ let company = '';
601
+ if (vline && vline.nextSibling) {
602
+ company = (vline.nextSibling.textContent || '').trim();
603
+ }
604
+
605
+ return {
606
+ name: nameEl ? nameEl.innerText.trim() : '',
607
+ company: company,
608
+ lastMessage: lastMsgText ? lastMsgText.innerText.trim() : '',
609
+ hasReply: !statusEl,
610
+ time: timeEl ? timeEl.innerText.trim() : '',
611
+ };
612
+ })
613
+ """)
614
+
615
+ if not conversations:
616
+ print(json.dumps({"total": 0, "replied": [], "waiting": []}, ensure_ascii=False, indent=2), file=sys.stderr)
617
+ return
618
+
619
+ replied = [c for c in conversations if c.get("hasReply")]
620
+ sent = [c for c in conversations if not c.get("hasReply")]
621
+
622
+ result = {
623
+ "total": len(conversations),
624
+ "replied": replied,
625
+ "waiting": sent,
626
+ }
627
+ print(json.dumps(result, ensure_ascii=False, indent=2))
628
+ finally:
629
+ ctx.close()
630
+
631
+ def main():
632
+ ap = argparse.ArgumentParser(description="Boss直聘 × CloakBrowser")
633
+ sub = ap.add_subparsers(dest="cmd", required=True)
634
+
635
+ sub.add_parser("login")
636
+ sub.add_parser("resume-session")
637
+ sub.add_parser("state-path")
638
+ sub.add_parser("check-messages", help="检查消息列表,检测谁回复了")
639
+
640
+ p = sub.add_parser("chat")
641
+ p.add_argument("job_id")
642
+
643
+ p = sub.add_parser("fetch-jd")
644
+ p.add_argument("name")
645
+ p.add_argument("job_id")
646
+ p.add_argument("out_dir", nargs="?", default=".")
647
+
648
+ p = sub.add_parser("batch-jds")
649
+ p.add_argument("out_dir")
650
+ p.add_argument("entries", nargs="+")
651
+
652
+ p = sub.add_parser("search", help="搜索岗位,支持多条件筛选")
653
+ p.add_argument("keyword", help="搜索关键词")
654
+ p.add_argument("--city", default="深圳", help=f"城市: {' / '.join(CITY)}")
655
+ p.add_argument("--job-type", choices=list(JOB_TYPE), help=f"职位类型: {' / '.join(JOB_TYPE)}")
656
+ p.add_argument("--salary", choices=list(SALARY), help=f"薪资: {' / '.join(k for k in SALARY if k)}")
657
+ p.add_argument("--exp", choices=list(EXP), help=f"经验: {' / '.join(k for k in EXP if k)}")
658
+ p.add_argument("--degree", choices=list(DEGREE), help=f"学历: {' / '.join(k for k in DEGREE if k)}")
659
+ p.add_argument("--scale", choices=list(SCALE), help=f"公司规模: {' / '.join(k for k in SCALE if k)}")
660
+ p.add_argument("--industry", help="行业编码(如 10001 互联网)")
661
+
662
+ args = ap.parse_args()
663
+
664
+ {"login": cmd_login, "resume-session": cmd_resume_session,
665
+ "state-path": cmd_state_path,
666
+ "check-messages": cmd_check_messages,
667
+ "chat": lambda: cmd_chat(args.job_id),
668
+ "fetch-jd": lambda: cmd_fetch_jd(args.name, args.job_id, args.out_dir),
669
+ "batch-jds": lambda: cmd_batch_jds(args.out_dir, args.entries),
670
+ "search": lambda: cmd_search(args.keyword, args.city, args.job_type,
671
+ args.salary, args.exp, args.degree,
672
+ args.scale, args.industry)}[args.cmd]()
673
+
674
+
675
+ if __name__ == "__main__":
676
+ main()