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.
- package/.claude-plugin/plugin.json +16 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/package.json +35 -0
- package/skills/academic-ref-retrieval/SKILL.md +103 -0
- package/skills/academic-ref-retrieval/resources/arxiv-api-example.py +42 -0
- package/skills/academic-ref-retrieval/resources/cnki-scraper-example.py +58 -0
- package/skills/amlei-git-gh/SKILL.md +156 -0
- package/skills/amlei-resume/SKILL.md +204 -0
- package/skills/amlei-resume/assets/preview-shell.html +438 -0
- package/skills/amlei-resume/assets/sample-preview.html +361 -0
- package/skills/amlei-resume/assets/sample-resume.md +57 -0
- package/skills/amlei-resume/references/export.md +76 -0
- package/skills/amlei-resume/references/icons.md +35 -0
- package/skills/amlei-resume/references/profile-evaluator.md +44 -0
- package/skills/amlei-resume/references/profile.md +43 -0
- package/skills/amlei-resume/references/projects.md +76 -0
- package/skills/amlei-resume/references/resume-evaluator.md +121 -0
- package/skills/amlei-resume/references/revise.md +11 -0
- package/skills/amlei-resume/references/themes/theme-academic.md +238 -0
- package/skills/amlei-resume/references/themes/theme-content-green.md +138 -0
- package/skills/amlei-resume/references/themes/theme-english-mnc.md +127 -0
- package/skills/amlei-resume/references/themes/theme-index.md +46 -0
- package/skills/amlei-resume/references/themes/theme-sidebar-creative.md +163 -0
- package/skills/amlei-resume/references/themes/theme-soe-formal.md +153 -0
- package/skills/amlei-resume/references/themes/theme-tech-dense.md +157 -0
- package/skills/amlei-resume/references/write.md +14 -0
- package/skills/amlei-resume/scripts/boss_zhipin.py +676 -0
- package/skills/amlei-resume/scripts/extract_avatar.py +157 -0
- package/skills/amlei-resume/scripts/profile.py +465 -0
- package/skills/amlei-resume/scripts/validate_resume.py +195 -0
- package/skills/amlei-resume/scripts/wrap_preview.py +104 -0
- package/skills/amlei-story-generator/SKILL.md +67 -0
- package/skills/amlei-story-generator/resources/narrative-craft.md +74 -0
- package/skills/amlei-story-generator/resources/rubric.md +28 -0
- 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()
|