lp-auto 0.1.0__py3-none-any.whl
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.
- lp_auto/__init__.py +41 -0
- lp_auto/__main__.py +6 -0
- lp_auto/_version.py +3 -0
- lp_auto/cli.py +215 -0
- lp_auto/config.py +96 -0
- lp_auto/constants.py +88 -0
- lp_auto/core/__init__.py +29 -0
- lp_auto/core/browser.py +119 -0
- lp_auto/core/collector.py +188 -0
- lp_auto/core/login.py +65 -0
- lp_auto/core/pagination.py +126 -0
- lp_auto/core/search.py +164 -0
- lp_auto/core/tabs.py +42 -0
- lp_auto/exceptions.py +33 -0
- lp_auto/py.typed +0 -0
- lp_auto-0.1.0.dist-info/METADATA +658 -0
- lp_auto-0.1.0.dist-info/RECORD +21 -0
- lp_auto-0.1.0.dist-info/WHEEL +5 -0
- lp_auto-0.1.0.dist-info/entry_points.txt +2 -0
- lp_auto-0.1.0.dist-info/licenses/LICENSE +21 -0
- lp_auto-0.1.0.dist-info/top_level.txt +1 -0
lp_auto/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""lp_auto:基于 DrissionPage 的 liepin 自动化工具包。
|
|
2
|
+
|
|
3
|
+
提供浏览器复用、登录、搜索筛选、翻页与简历信息导出能力,
|
|
4
|
+
既可作为命令行工具使用,也可作为 Python 库导入。
|
|
5
|
+
|
|
6
|
+
典型用法:
|
|
7
|
+
|
|
8
|
+
# 命令行
|
|
9
|
+
$ lp-auto login
|
|
10
|
+
$ lp-auto run --save-dir ./resumes
|
|
11
|
+
|
|
12
|
+
# Python 函数
|
|
13
|
+
from lp_auto import AppConfig, BrowserManager, LoginFlow
|
|
14
|
+
|
|
15
|
+
cfg = AppConfig(chrome_exe=r"D:\\Chrome\\chrome.exe")
|
|
16
|
+
page = BrowserManager(cfg).get_page(open_url="https://lpt.liepin.com/login")
|
|
17
|
+
LoginFlow().verify(page)
|
|
18
|
+
"""
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .config import AppConfig
|
|
21
|
+
from .core import (
|
|
22
|
+
BrowserManager,
|
|
23
|
+
Collector,
|
|
24
|
+
LoginFlow,
|
|
25
|
+
Pagination,
|
|
26
|
+
SearchController,
|
|
27
|
+
TabManager,
|
|
28
|
+
)
|
|
29
|
+
from . import exceptions
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
"AppConfig",
|
|
34
|
+
"BrowserManager",
|
|
35
|
+
"LoginFlow",
|
|
36
|
+
"SearchController",
|
|
37
|
+
"Pagination",
|
|
38
|
+
"Collector",
|
|
39
|
+
"TabManager",
|
|
40
|
+
"exceptions",
|
|
41
|
+
]
|
lp_auto/__main__.py
ADDED
lp_auto/_version.py
ADDED
lp_auto/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Typer 命令行入口。
|
|
2
|
+
|
|
3
|
+
命令一览:
|
|
4
|
+
lp-auto login 启动/接管浏览器并手动登录
|
|
5
|
+
lp-auto verify 校验当前登录态是否有效
|
|
6
|
+
lp-auto identify 识别搜索页的输入控件(调试用)
|
|
7
|
+
lp-auto search 按配置文件执行搜索筛选
|
|
8
|
+
lp-auto collect 导出当前页的简历卡片
|
|
9
|
+
lp-auto paginate 翻页(可配合 collect 逐页导出)
|
|
10
|
+
lp-auto run 组合:搜索 -> 逐页导出(一键任务)
|
|
11
|
+
|
|
12
|
+
所有命令均可通过 --help 查看详细用法,例如:
|
|
13
|
+
lp-auto collect --help
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from .config import AppConfig
|
|
23
|
+
from .core import (
|
|
24
|
+
BrowserManager,
|
|
25
|
+
Collector,
|
|
26
|
+
LoginFlow,
|
|
27
|
+
Pagination,
|
|
28
|
+
SearchController,
|
|
29
|
+
TabManager,
|
|
30
|
+
)
|
|
31
|
+
from . import constants as C
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="lp-auto",
|
|
35
|
+
help="基于 DrissionPage 的 liepin 自动化工具包。",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- 通用配置选项
|
|
40
|
+
_PORT = typer.Option(None, "--port", help="Chrome 调试端口(默认 46517)")
|
|
41
|
+
_CHROME = typer.Option(None, "--chrome", help="Chrome 可执行文件路径")
|
|
42
|
+
_USER_DATA = typer.Option(None, "--user-data", help="Chrome 用户数据目录")
|
|
43
|
+
_SAVE_DIR = typer.Option(None, "--save-dir", help="简历保存目录")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_config(
|
|
47
|
+
port: Optional[int] = None,
|
|
48
|
+
chrome: Optional[str] = None,
|
|
49
|
+
user_data: Optional[str] = None,
|
|
50
|
+
save_dir: Optional[str] = None,
|
|
51
|
+
) -> AppConfig:
|
|
52
|
+
"""从环境变量加载,再用命令行参数覆盖 None 之外的值。"""
|
|
53
|
+
cfg = AppConfig.from_env()
|
|
54
|
+
overrides = {}
|
|
55
|
+
if port is not None:
|
|
56
|
+
overrides["debug_port"] = port
|
|
57
|
+
if chrome is not None:
|
|
58
|
+
overrides["chrome_exe"] = chrome
|
|
59
|
+
if user_data is not None:
|
|
60
|
+
overrides["user_data"] = user_data
|
|
61
|
+
if save_dir is not None:
|
|
62
|
+
overrides["save_dir"] = save_dir
|
|
63
|
+
return cfg.update(**overrides) if overrides else cfg
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _manager(cfg: AppConfig) -> BrowserManager:
|
|
67
|
+
return BrowserManager(cfg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_search_dict(
|
|
71
|
+
controller: SearchController,
|
|
72
|
+
config_path: Optional[str],
|
|
73
|
+
keyword: Optional[str],
|
|
74
|
+
job: Optional[str],
|
|
75
|
+
company: Optional[str],
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""解析搜索词来源:--config 优先,其次命令行参数,最后默认配置文件。"""
|
|
78
|
+
if config_path:
|
|
79
|
+
return controller.load_config(config_path)
|
|
80
|
+
query = controller.build_query(keyword=keyword, job=job, company=company)
|
|
81
|
+
return query or controller.load_config()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------- 命令
|
|
85
|
+
@app.command()
|
|
86
|
+
def login(
|
|
87
|
+
port: Optional[int] = _PORT,
|
|
88
|
+
chrome: Optional[str] = _CHROME,
|
|
89
|
+
user_data: Optional[str] = _USER_DATA,
|
|
90
|
+
verify: bool = typer.Option(True, help="登录后是否自动校验登录态"),
|
|
91
|
+
):
|
|
92
|
+
"""启动/接管浏览器,打开登录页,等待人工登录。"""
|
|
93
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data)
|
|
94
|
+
flow = LoginFlow()
|
|
95
|
+
ok = flow.login(_manager(cfg), url=C.LOGIN_URL, verify_after=verify)
|
|
96
|
+
raise typer.Exit(0 if ok else 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def verify(
|
|
101
|
+
port: Optional[int] = _PORT,
|
|
102
|
+
chrome: Optional[str] = _CHROME,
|
|
103
|
+
user_data: Optional[str] = _USER_DATA,
|
|
104
|
+
):
|
|
105
|
+
"""校验当前登录态是否有效。"""
|
|
106
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data)
|
|
107
|
+
page = _manager(cfg).get_page()
|
|
108
|
+
ok = LoginFlow().verify(page)
|
|
109
|
+
raise typer.Exit(0 if ok else 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def identify(
|
|
114
|
+
port: Optional[int] = _PORT,
|
|
115
|
+
chrome: Optional[str] = _CHROME,
|
|
116
|
+
user_data: Optional[str] = _USER_DATA,
|
|
117
|
+
):
|
|
118
|
+
"""识别搜索页上的可见输入控件,帮助定位 placeholder。"""
|
|
119
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data)
|
|
120
|
+
page = _manager(cfg).get_page()
|
|
121
|
+
TabManager().open_search_tab(page)
|
|
122
|
+
SearchController(cfg).identify_inputs(page)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def search(
|
|
127
|
+
port: Optional[int] = _PORT,
|
|
128
|
+
chrome: Optional[str] = _CHROME,
|
|
129
|
+
user_data: Optional[str] = _USER_DATA,
|
|
130
|
+
config_path: Optional[str] = typer.Option(None, "--config", help="搜索词 JSON 路径"),
|
|
131
|
+
keyword: Optional[str] = typer.Option(None, "--keyword", help="主关键词"),
|
|
132
|
+
job: Optional[str] = typer.Option(None, "--job", help="职位"),
|
|
133
|
+
company: Optional[str] = typer.Option(None, "--company", help="公司"),
|
|
134
|
+
):
|
|
135
|
+
"""打开搜索页并按搜索词执行筛选搜索(JSON 配置或直接传参均可)。"""
|
|
136
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data)
|
|
137
|
+
page = _manager(cfg).get_page()
|
|
138
|
+
TabManager().open_search_tab(page)
|
|
139
|
+
|
|
140
|
+
controller = SearchController(cfg)
|
|
141
|
+
search_dict = _resolve_search_dict(controller, config_path, keyword, job, company)
|
|
142
|
+
controller.search(page, search_dict)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def collect(
|
|
147
|
+
port: Optional[int] = _PORT,
|
|
148
|
+
chrome: Optional[str] = _CHROME,
|
|
149
|
+
user_data: Optional[str] = _USER_DATA,
|
|
150
|
+
save_dir: Optional[str] = _SAVE_DIR,
|
|
151
|
+
start: int = typer.Option(0, help="起始卡片索引"),
|
|
152
|
+
step: int = typer.Option(1, help="遍历步长"),
|
|
153
|
+
limit: Optional[int] = typer.Option(None, help="最多导出数量"),
|
|
154
|
+
delay: float = typer.Option(1.0, help="卡片间休眠秒数"),
|
|
155
|
+
):
|
|
156
|
+
"""导出当前页面的简历卡片并保存为文本。"""
|
|
157
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data, save_dir=save_dir)
|
|
158
|
+
page = _manager(cfg).get_page()
|
|
159
|
+
print(f"当前页: {page.url}")
|
|
160
|
+
Collector(cfg).collect_page(page, start=start, step=step, limit=limit, delay=delay)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def paginate(
|
|
165
|
+
port: Optional[int] = _PORT,
|
|
166
|
+
chrome: Optional[str] = _CHROME,
|
|
167
|
+
user_data: Optional[str] = _USER_DATA,
|
|
168
|
+
max_pages: int = typer.Option(100, help="最大翻页数"),
|
|
169
|
+
delay: float = typer.Option(2.0, help="翻页间隔秒数"),
|
|
170
|
+
):
|
|
171
|
+
"""在搜索页循环翻页(常用于调试或配合导出)。"""
|
|
172
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data)
|
|
173
|
+
page = _manager(cfg).get_page()
|
|
174
|
+
print(f"当前页: {page.url}")
|
|
175
|
+
Pagination().loop_pages(page, max_pages=max_pages, delay=delay)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def run(
|
|
180
|
+
port: Optional[int] = _PORT,
|
|
181
|
+
chrome: Optional[str] = _CHROME,
|
|
182
|
+
user_data: Optional[str] = _USER_DATA,
|
|
183
|
+
save_dir: Optional[str] = _SAVE_DIR,
|
|
184
|
+
config_path: Optional[str] = typer.Option(None, "--config", help="搜索词 JSON 路径"),
|
|
185
|
+
keyword: Optional[str] = typer.Option(None, "--keyword", help="主关键词"),
|
|
186
|
+
job: Optional[str] = typer.Option(None, "--job", help="职位"),
|
|
187
|
+
company: Optional[str] = typer.Option(None, "--company", help="公司"),
|
|
188
|
+
max_pages: int = typer.Option(100, help="最大翻页数"),
|
|
189
|
+
step: int = typer.Option(1, help="每页导出步长"),
|
|
190
|
+
):
|
|
191
|
+
"""一键任务:搜索 -> 逐页导出简历,直到最后一页。"""
|
|
192
|
+
cfg = _build_config(port=port, chrome=chrome, user_data=user_data, save_dir=save_dir)
|
|
193
|
+
manager = _manager(cfg)
|
|
194
|
+
page = manager.get_page()
|
|
195
|
+
|
|
196
|
+
tab_mgr = TabManager()
|
|
197
|
+
tab_mgr.open_search_tab(page)
|
|
198
|
+
|
|
199
|
+
search_ctrl = SearchController(cfg)
|
|
200
|
+
search_ctrl.search(page, _resolve_search_dict(search_ctrl, config_path, keyword, job, company))
|
|
201
|
+
|
|
202
|
+
pagination = Pagination()
|
|
203
|
+
collector = Collector(cfg)
|
|
204
|
+
total = 0
|
|
205
|
+
for _ in range(max_pages):
|
|
206
|
+
total += collector.collect_page(page, step=step)
|
|
207
|
+
if pagination.is_last_page(page):
|
|
208
|
+
print("🛑 已到最后一页")
|
|
209
|
+
break
|
|
210
|
+
pagination.go_next_page(page)
|
|
211
|
+
print(f"✅ 任务完成,共导出 {total} 份简历")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
app()
|
lp_auto/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""运行时配置。
|
|
2
|
+
|
|
3
|
+
所有可调参数集中到 AppConfig 这一个数据类里:
|
|
4
|
+
- 命令行与 Python 函数都能通过它统一控制行为;
|
|
5
|
+
- 提供 from_env() / to_dict() / update(),方便从多种来源加载与合并;
|
|
6
|
+
- 默认数据目录为 ~/.lp_auto/,会话、搜索词、导出结果都放在里面。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from . import constants as C
|
|
16
|
+
from .exceptions import ConfigError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_data_dir() -> Path:
|
|
20
|
+
return Path.home() / ".lp_auto"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AppConfig:
|
|
25
|
+
"""应用级配置。
|
|
26
|
+
|
|
27
|
+
可通过关键字参数在构造时覆盖任意字段,例如:
|
|
28
|
+
AppConfig(debug_port=47100, chrome_exe=r"D:\\Chrome\\chrome.exe")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# ---- 浏览器 ----
|
|
32
|
+
debug_port: int = C.DEFAULT_DEBUG_PORT
|
|
33
|
+
user_data: str = C.DEFAULT_USER_DATA
|
|
34
|
+
chrome_exe: str = C.DEFAULT_CHROME_EXE
|
|
35
|
+
browser_launch_timeout: int = C.BROWSER_LAUNCH_TIMEOUT
|
|
36
|
+
|
|
37
|
+
# ---- 数据目录 ----
|
|
38
|
+
data_dir: Path = field(default_factory=_default_data_dir)
|
|
39
|
+
|
|
40
|
+
# ---- 导出 ----
|
|
41
|
+
save_dir: Optional[Path] = None # None 时使用 data_dir / "resumes"
|
|
42
|
+
min_card_width: int = C.MIN_CARD_WIDTH
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.data_dir = Path(self.data_dir)
|
|
46
|
+
if isinstance(self.user_data, str) and not self.user_data:
|
|
47
|
+
raise ConfigError("user_data 不能为空")
|
|
48
|
+
|
|
49
|
+
# ---- 派生路径 ----
|
|
50
|
+
@property
|
|
51
|
+
def session_file(self) -> Path:
|
|
52
|
+
return self.data_dir / "lp_session.json"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def search_config_file(self) -> Path:
|
|
56
|
+
return self.data_dir / "search_config.json"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def resumes_dir(self) -> Path:
|
|
60
|
+
return Path(self.save_dir) if self.save_dir else self.data_dir / "resumes"
|
|
61
|
+
|
|
62
|
+
# ---- 序列化 ----
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
d = asdict(self)
|
|
65
|
+
d["data_dir"] = str(self.data_dir)
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
def update(self, **kwargs: Any) -> "AppConfig":
|
|
69
|
+
"""合并式更新,返回新实例(不改动原对象)。"""
|
|
70
|
+
data = self.to_dict()
|
|
71
|
+
data.update(kwargs)
|
|
72
|
+
return AppConfig(**data)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_env(cls, prefix: str = "LP_AUTO_") -> "AppConfig":
|
|
76
|
+
"""从环境变量读取覆盖项,例如 LP_AUTO_DEBUG_PORT=47100。"""
|
|
77
|
+
kwargs: Dict[str, Any] = {}
|
|
78
|
+
mapping = {
|
|
79
|
+
"DEBUG_PORT": ("debug_port", int),
|
|
80
|
+
"USER_DATA": ("user_data", str),
|
|
81
|
+
"CHROME_EXE": ("chrome_exe", str),
|
|
82
|
+
"DATA_DIR": ("data_dir", str),
|
|
83
|
+
"SAVE_DIR": ("save_dir", str),
|
|
84
|
+
"MIN_CARD_WIDTH": ("min_card_width", int),
|
|
85
|
+
}
|
|
86
|
+
for env_name, (attr, cast) in mapping.items():
|
|
87
|
+
value = os.environ.get(prefix + env_name)
|
|
88
|
+
if value is not None and value != "":
|
|
89
|
+
kwargs[attr] = cast(value)
|
|
90
|
+
return cls(**kwargs)
|
|
91
|
+
|
|
92
|
+
def ensure_dirs(self) -> "AppConfig":
|
|
93
|
+
"""确保数据目录与导出目录存在,返回自身便于链式调用。"""
|
|
94
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
self.resumes_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
return self
|
lp_auto/constants.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""全局常量与默认选择器。
|
|
2
|
+
|
|
3
|
+
页面 DOM 结构一旦变化,优先修改本文件即可,无需改动 core 里的业务逻辑。
|
|
4
|
+
这也是「高内聚低耦合」的关键:把易变内容(URL、CSS 选择器、超时)集中到一处。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# ============ URL ============
|
|
8
|
+
LOGIN_URL = "https://lpt.liepin.com/login"
|
|
9
|
+
HOME_URL = "https://lpt.liepin.com/"
|
|
10
|
+
SEARCH_URL = "https://lpt.liepin.com/search"
|
|
11
|
+
|
|
12
|
+
# ============ 浏览器默认参数 ============
|
|
13
|
+
DEFAULT_DEBUG_PORT = 46517 # 冷门端口,避免与常见端口冲突
|
|
14
|
+
DEFAULT_USER_DATA = r"D:\selenium_profile" # Chrome 用户目录(建议用真实 profile 副本)
|
|
15
|
+
DEFAULT_CHROME_EXE = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
|
16
|
+
BROWSER_LAUNCH_TIMEOUT = 30 # 等待浏览器就绪的最长时间(秒)
|
|
17
|
+
|
|
18
|
+
# ============ 简历信息导出 ============
|
|
19
|
+
CARD_SELECTOR = "css:div[class*='card']" # 卡片候选选择器(可改精确 class)
|
|
20
|
+
MIN_CARD_WIDTH = 50 # 过滤嵌套容器/隐藏元素的最小宽度阈值
|
|
21
|
+
|
|
22
|
+
# 模态框是否可见的判断选择器
|
|
23
|
+
MODAL_VISIBLE_SELECTORS = [
|
|
24
|
+
"css:.ant-modal-wrap",
|
|
25
|
+
"css:[class*='modal-wrap']",
|
|
26
|
+
"css:[role='dialog']",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# 模态框内容容器选择器(按优先级尝试)
|
|
30
|
+
MODAL_CONTENT_SELECTORS = [
|
|
31
|
+
"css:.ant-modal",
|
|
32
|
+
"css:[class*='modal-content']",
|
|
33
|
+
"css:[role='dialog']",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# 模态框关闭按钮候选
|
|
37
|
+
CLOSE_SELECTORS = [
|
|
38
|
+
"css:.ant-modal-close",
|
|
39
|
+
"css:[class*='modal'] [class*='close']",
|
|
40
|
+
"css:[class*='dialog'] [class*='close']",
|
|
41
|
+
"css:[aria-label='Close']",
|
|
42
|
+
"css:button[class*='close']",
|
|
43
|
+
"css:svg[class*='close']",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# ============ 搜索 ============
|
|
47
|
+
# 字段 -> 匹配 placeholder 的关键字
|
|
48
|
+
# keyword 用「搜职位/公司」避免与「搜索职位/公司」冲突
|
|
49
|
+
FIELD_HINTS = {
|
|
50
|
+
"keyword": ["搜职位/公司"], # 主搜索框
|
|
51
|
+
"job": ["搜索职位"], # 职位下拉
|
|
52
|
+
"company": ["搜索公司"], # 公司下拉
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
SELECT_OPTION_SELECTORS = [
|
|
56
|
+
"css:.ant-select-item-option",
|
|
57
|
+
"css:.ant-select-item",
|
|
58
|
+
"css:[class*='select-item']",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
SEARCH_BTN_SELECTORS = [
|
|
62
|
+
"css:button[class*='search']",
|
|
63
|
+
"css:button[class*='Search']",
|
|
64
|
+
"css:button[class*='btn-search']",
|
|
65
|
+
"css:[class*='search-btn']",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
SEARCH_BTN_TEXTS = ["搜索", "搜简历", "查找"]
|
|
69
|
+
|
|
70
|
+
# ============ 翻页 ============
|
|
71
|
+
NEXT_SELECTORS = [
|
|
72
|
+
"css:.ant-pagination-next",
|
|
73
|
+
"css:li[class*='pagination'][class*='next']",
|
|
74
|
+
"css:[class*='pagination'] [class*='next']",
|
|
75
|
+
"css:li[title='Next Page']",
|
|
76
|
+
"css:a[rel='next']",
|
|
77
|
+
"css:button[class*='next']",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
CURRENT_PAGE_SELECTORS = [
|
|
81
|
+
"css:.ant-pagination-item-active",
|
|
82
|
+
"css:li[class*='pagination-item-active']",
|
|
83
|
+
"css:[class*='pagination'] [class*='active']",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# ============ 键盘/特殊键 ============
|
|
87
|
+
ESCAPE_KEY = "\ue00c"
|
|
88
|
+
ENTER_KEY = "\ue007"
|
lp_auto/core/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""core 子包:把业务能力拆成职责单一的模块。
|
|
2
|
+
|
|
3
|
+
模块职责一览:
|
|
4
|
+
- browser 浏览器复用管理器(启动 / 接管 Chrome)
|
|
5
|
+
- login 登录与登录态校验
|
|
6
|
+
- search 搜索页筛选
|
|
7
|
+
- pagination 翻页
|
|
8
|
+
- collector 简历信息导出
|
|
9
|
+
- tabs 标签页管理
|
|
10
|
+
|
|
11
|
+
各模块只依赖 config + constants + 上层传入的 page 对象,
|
|
12
|
+
彼此不直接引用,实现「高内聚低耦合」。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .browser import BrowserManager
|
|
16
|
+
from .login import LoginFlow
|
|
17
|
+
from .search import SearchController
|
|
18
|
+
from .pagination import Pagination
|
|
19
|
+
from .collector import Collector
|
|
20
|
+
from .tabs import TabManager
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BrowserManager",
|
|
24
|
+
"LoginFlow",
|
|
25
|
+
"SearchController",
|
|
26
|
+
"Pagination",
|
|
27
|
+
"Collector",
|
|
28
|
+
"TabManager",
|
|
29
|
+
]
|
lp_auto/core/browser.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""浏览器复用管理器。
|
|
2
|
+
|
|
3
|
+
核心能力:
|
|
4
|
+
- 首次运行:独立启动 Chrome(固定端口 + 真实 profile),并保存会话;
|
|
5
|
+
- 再次运行:探测端口存活,直接接管(登录态、标签页全部保留);
|
|
6
|
+
- 会话复用:连接已启动的浏览器实例,保留登录态与页面状态。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
from lp_auto import BrowserManager, AppConfig
|
|
10
|
+
|
|
11
|
+
cfg = AppConfig(chrome_exe=r"D:\\Chrome\\chrome.exe")
|
|
12
|
+
mgr = BrowserManager(cfg)
|
|
13
|
+
page = mgr.get_page(open_url="https://lpt.liepin.com/login")
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
from DrissionPage import ChromiumOptions, ChromiumPage
|
|
25
|
+
|
|
26
|
+
from ..config import AppConfig
|
|
27
|
+
from ..exceptions import BrowserLaunchError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BrowserManager:
|
|
31
|
+
"""封装 Chrome 的启动、复用与 page 对象的获取。"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: AppConfig) -> None:
|
|
34
|
+
self.config = config
|
|
35
|
+
self.config.ensure_dirs()
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- 会话
|
|
38
|
+
def _load_session(self) -> Optional[dict]:
|
|
39
|
+
path = self.config.session_file
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def _save_session(self, port: int) -> None:
|
|
49
|
+
with open(self.config.session_file, "w", encoding="utf-8") as f:
|
|
50
|
+
json.dump({"port": port}, f)
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------- 探测
|
|
53
|
+
def is_browser_alive(self, port: Optional[int] = None) -> bool:
|
|
54
|
+
"""通过 CDP 端口探测浏览器是否存活。"""
|
|
55
|
+
port = port or self.config.debug_port
|
|
56
|
+
try:
|
|
57
|
+
resp = requests.get(f"http://localhost:{port}/json/version", timeout=2)
|
|
58
|
+
return resp.status_code == 200
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def _launch_chrome(self, port: int, url: Optional[str] = None) -> bool:
|
|
63
|
+
"""独立启动 Chrome。URL 作为启动参数加载,避免页面加载阶段出现自动化痕迹。"""
|
|
64
|
+
if not self.config.chrome_exe or not self.config.user_data:
|
|
65
|
+
raise BrowserLaunchError("请先配置 chrome_exe 与 user_data")
|
|
66
|
+
|
|
67
|
+
args = [
|
|
68
|
+
self.config.chrome_exe,
|
|
69
|
+
f"--remote-debugging-port={port}",
|
|
70
|
+
f"--user-data-dir={self.config.user_data}",
|
|
71
|
+
"--no-first-run",
|
|
72
|
+
"--no-default-browser-check",
|
|
73
|
+
"--start-maximized",
|
|
74
|
+
]
|
|
75
|
+
if url:
|
|
76
|
+
args.append(url)
|
|
77
|
+
subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
78
|
+
|
|
79
|
+
deadline = time.time() + self.config.browser_launch_timeout
|
|
80
|
+
while time.time() < deadline:
|
|
81
|
+
if self.is_browser_alive(port):
|
|
82
|
+
return True
|
|
83
|
+
time.sleep(0.5)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------- 对外
|
|
87
|
+
def get_page(self, open_url: Optional[str] = None):
|
|
88
|
+
"""获取已接管的 ChromiumPage 对象。
|
|
89
|
+
|
|
90
|
+
open_url 仅在首次启动浏览器时打开;复用接管时不会重复导航。
|
|
91
|
+
"""
|
|
92
|
+
session = self._load_session()
|
|
93
|
+
|
|
94
|
+
if session and self.is_browser_alive(session.get("port")):
|
|
95
|
+
port = session["port"]
|
|
96
|
+
print(f"[复用] 接管已有浏览器 (端口 {port})")
|
|
97
|
+
else:
|
|
98
|
+
if session:
|
|
99
|
+
print("[提示] 旧浏览器已关闭,重新启动...")
|
|
100
|
+
print(f"[启动] 首次运行,启动 Chrome (端口 {self.config.debug_port})")
|
|
101
|
+
if not self._launch_chrome(self.config.debug_port, url=open_url):
|
|
102
|
+
raise BrowserLaunchError(
|
|
103
|
+
"Chrome 启动失败,请检查 chrome_exe / user_data 路径"
|
|
104
|
+
)
|
|
105
|
+
self._save_session(self.config.debug_port)
|
|
106
|
+
port = self.config.debug_port
|
|
107
|
+
|
|
108
|
+
co = ChromiumOptions()
|
|
109
|
+
co.set_local_port(port) # 连接到已有浏览器,不新建
|
|
110
|
+
page = ChromiumPage(co)
|
|
111
|
+
|
|
112
|
+
# 健康检查:确保至少有一个可用标签页
|
|
113
|
+
try:
|
|
114
|
+
_ = page.url
|
|
115
|
+
except Exception:
|
|
116
|
+
print("[健康检查] 无可用标签页,新建一个")
|
|
117
|
+
page.new_tab()
|
|
118
|
+
|
|
119
|
+
return page
|