Initial commit: environment config and automation scripts
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
5
browser_login/.env.example
Normal file
5
browser_login/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# ERP 登录配置
|
||||||
|
ERP_URL=https://yunmes.tftykj.cn/#
|
||||||
|
ERP_TENANT=gddtsk
|
||||||
|
ERP_USERNAME=
|
||||||
|
ERP_PASSWORD=
|
||||||
4
browser_login/.gitignore
vendored
Normal file
4
browser_login/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
output/
|
||||||
235
browser_login/README.md
Normal file
235
browser_login/README.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# ERP 自动化数据采集 — 技术说明
|
||||||
|
|
||||||
|
> 目标系统:[腾一工业互联网平台](https://yunmes.tftykj.cn/#)
|
||||||
|
> 框架:DrissionPage(Python)
|
||||||
|
> 更新:2026-04-16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、ERP 接口如何鉴权
|
||||||
|
|
||||||
|
### 1.1 基础机制
|
||||||
|
|
||||||
|
该 ERP 基于 **ASP.NET Boilerplate (ABP) 框架**,接口鉴权通过以下两种凭据组合完成:
|
||||||
|
|
||||||
|
| 凭据 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `auth_token` | Cookie / JWT | 登录后由服务端颁发,携带用户ID、角色、租户ID等声明 |
|
||||||
|
| `ASP.NET_SessionId` | Cookie | 服务端会话标识 |
|
||||||
|
| `Abp.TenantId` / `Abp.TenantCode` | Cookie | 多租户标识 |
|
||||||
|
| `x-sig` | 请求Header | 客户端动态生成的请求签名,每次请求不同 |
|
||||||
|
|
||||||
|
### 1.2 `auth_token` 解析(JWT)
|
||||||
|
|
||||||
|
登录成功后的 JWT 结构(Base64 解码 Payload):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nameid": "103",
|
||||||
|
"unique_name": ["DTCS", "DTCS"],
|
||||||
|
"role": "0099",
|
||||||
|
"http://www.aspnetboilerplate.com/identity/claims/tenantId": "18",
|
||||||
|
"iss": "gdtykj",
|
||||||
|
"aud": "TfTechApi",
|
||||||
|
"exp": 1776330877,
|
||||||
|
"nbf": 1776323677
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该 Token 有效期约 **2 小时**(`exp - nbf = 7200s`)。
|
||||||
|
|
||||||
|
### 1.3 `x-sig` 签名
|
||||||
|
|
||||||
|
每次 POST 请求都携带一个动态值,例:
|
||||||
|
```
|
||||||
|
x-sig: QpE4yJ/TNqPJvIkYjww5QM.w/cn1wtipR2zQZSWlrF9L8
|
||||||
|
```
|
||||||
|
|
||||||
|
该签名由**浏览器端 JavaScript 在运行时生成**,算法未公开,无法离线复现。
|
||||||
|
⚠️ 这意味着**无法绕过浏览器直接用 `requests` 调用 API**。
|
||||||
|
|
||||||
|
### 1.4 典型 API 请求示例(BOM 查询)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://yunmes.tftykj.cn/api/services/TfTechApi/Material/MaterialBom_SearchList_Proxy
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
x-sig: <动态签名>
|
||||||
|
Cookie: auth_token=<JWT>; Abp.TenantId=18; ...
|
||||||
|
|
||||||
|
page=1&rows=50&MaterialCode=&MaterialName=&...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、我们的程序如何绕过 `x-sig` 获取数据
|
||||||
|
|
||||||
|
### 核心思路:让真实浏览器替我们发请求
|
||||||
|
|
||||||
|
由于 `x-sig` 无法离线生成,我们**不直接调用 API**,而是:
|
||||||
|
|
||||||
|
```
|
||||||
|
Python 控制真实 Chrome 浏览器
|
||||||
|
→ 浏览器完成登录(cookies 自动注入)
|
||||||
|
→ 浏览器跳转到目标页面(页面自动发起 API 请求)
|
||||||
|
→ Python 拦截浏览器网络层的原始响应
|
||||||
|
→ 提取响应 JSON,保存到本地
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Python 程序 │
|
||||||
|
│ │
|
||||||
|
│ DrissionPage.listen.start() ←──────┐ │
|
||||||
|
│ ↓ │ │
|
||||||
|
│ page.refresh() → Chrome 发出请求 │ │
|
||||||
|
│ ↓ │ │
|
||||||
|
│ 服务器响应(含数据)│ │
|
||||||
|
│ ↓ │ │
|
||||||
|
│ packet = listen.wait() ────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ packet.response.body → JSON 数据 │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue 表单填写的特殊处理
|
||||||
|
|
||||||
|
该 ERP 前端使用 **Vue.js 双向绑定**。普通的 `clear() + input()` 会留下残值导致登录失败。
|
||||||
|
我们使用 JS 原生 setter 强制清空后再模拟键盘输入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用 JS 原生 setter 清空(触发 Vue 响应式)
|
||||||
|
page.run_js("""
|
||||||
|
var el = arguments[0];
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype, 'value'
|
||||||
|
).set.call(el, '');
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
""", ele)
|
||||||
|
time.sleep(0.05) # 等 Vue 处理 clear 事件
|
||||||
|
ele.input(value) # 模拟键盘输入,触发 keydown/change
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、使用方式
|
||||||
|
|
||||||
|
### 3.1 环境准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pip install DrissionPage python-dotenv
|
||||||
|
|
||||||
|
# 配置账号(自动模式使用,手动模式不需要)
|
||||||
|
cp .env.example .env # 或直接编辑 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 文件内容:
|
||||||
|
```env
|
||||||
|
ERP_URL=https://yunmes.tftykj.cn/#
|
||||||
|
ERP_TENANT=gddtsk
|
||||||
|
ERP_USERNAME=DTCS
|
||||||
|
ERP_PASSWORD=123456
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `.env` 已加入 `.gitignore`,不会被提交到代码仓库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 自动模式(适合生产部署)
|
||||||
|
|
||||||
|
从 `.env` 读取账号自动完成登录,无需人工干预。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bom_query.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行流程:**
|
||||||
|
1. 打开 Chrome,访问 ERP 登录页
|
||||||
|
2. 自动填写租户代码 / 账号 / 密码
|
||||||
|
3. 点击登录按钮,等待跳转(约 2s)
|
||||||
|
4. 导航到 BOM 表查询页,触发数据加载
|
||||||
|
5. 拦截 API 响应,保存 JSON 到 `output/` 目录
|
||||||
|
|
||||||
|
**典型日志:**
|
||||||
|
```
|
||||||
|
[15:58:21] ℹ️ BOM 查询启动 [自动登录]
|
||||||
|
[15:58:23] ℹ️ 填写登录信息...
|
||||||
|
[15:58:25] ✅ 登录成功(2s)
|
||||||
|
[15:58:43] ✅ 拦截成功 → HTTP 200
|
||||||
|
[15:58:43] ℹ️ 本页记录: 50 条 | 总计: 1494 条
|
||||||
|
[15:58:43] ✅ 已保存: output/bom_page1_20260416_155843.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 手动模式(适合涉密账号,不留存密码)
|
||||||
|
|
||||||
|
程序只负责打开浏览器,由**人工在浏览器中完成登录**,程序检测到登录成功后自动继续执行数据采集。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python bom_query.py --manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行流程:**
|
||||||
|
1. 打开 Chrome,访问 ERP 登录页(字段为空,不填写任何内容)
|
||||||
|
2. 终端提示:`请在浏览器中手动输入账号并登录`
|
||||||
|
3. 用户在浏览器中自行输入账号、密码并点击登录
|
||||||
|
4. 程序检测到登录成功后,自动导航到 BOM 页面并采集数据
|
||||||
|
5. 采集完成,保存 JSON 到 `output/` 目录
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- 密码不出现在任何配置文件或代码中
|
||||||
|
- 程序等待最长 **120 秒**供人工操作
|
||||||
|
- 每 10 秒打印一次进度提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 输出文件
|
||||||
|
|
||||||
|
数据保存在 `output/` 目录,文件名格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
output/bom_page1_YYYYMMDD_HHMMSS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 结构(ABP 标准响应):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"totalCount": 1494,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"materialCode": "1ADAA00001",
|
||||||
|
"materialName": "DA400大锥度中走丝",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
browser_login/
|
||||||
|
├── login.py # 核心登录模块(自动 / 手动 / 共用等待逻辑)
|
||||||
|
├── bom_query.py # BOM 数据采集(支持 --manual 参数)
|
||||||
|
├── .env # 账号配置(自动模式,不提交 git)
|
||||||
|
├── .gitignore # 忽略 .env 等敏感文件
|
||||||
|
├── output/ # 采集结果 JSON 文件
|
||||||
|
└── README.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、已知限制
|
||||||
|
|
||||||
|
| 限制 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 必须使用真实浏览器 | `x-sig` 由 JS 动态生成,无法离线复现 |
|
||||||
|
| 每次只获取 50 条 | 当前固定取第 1 页,翻页逻辑待开发 |
|
||||||
|
| JWT 有效期 ~2h | 长时间运行需重新登录 |
|
||||||
|
| 表格等待逻辑待优化 | 当前用刷新触发请求,偶发 WARN 提示 |
|
||||||
119
browser_login/bom_query.py
Normal file
119
browser_login/bom_query.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
BOM 表查询
|
||||||
|
用法:
|
||||||
|
python bom_query.py # 自动登录(从 .env 读取账号)
|
||||||
|
python bom_query.py --manual # 手动登录(浏览器由人工操作)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from login import get_page, login, login_manual, log, dump_page_state
|
||||||
|
|
||||||
|
BOM_PAGE_URL = "https://yunmes.tftykj.cn/MaterialBom"
|
||||||
|
BOM_API_PATH = "MaterialBom_SearchList_Proxy"
|
||||||
|
OUTPUT_DIR = Path(__file__).parent / "output"
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 导航到 BOM 页面 ───────────────────────────────────────────────────────────
|
||||||
|
def navigate_to_bom(page):
|
||||||
|
log("INFO", f"导航到 BOM 表查询...")
|
||||||
|
page.get(BOM_PAGE_URL)
|
||||||
|
# 等待数据表格区域出现(比 title XPath 更可靠)
|
||||||
|
table = page.ele("xpath://table | .el-table__body", timeout=15)
|
||||||
|
if table:
|
||||||
|
log("OK", "BOM 页面已加载")
|
||||||
|
else:
|
||||||
|
log("WARN", "表格元素未找到,继续执行")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 拦截并获取第 1 页 BOM 数据 ───────────────────────────────────────────────
|
||||||
|
def fetch_bom_page1(page) -> dict | None:
|
||||||
|
log("INFO", "开启网络监听...")
|
||||||
|
page.listen.start(BOM_API_PATH)
|
||||||
|
|
||||||
|
# 刷新页面触发自动加载请求
|
||||||
|
page.refresh()
|
||||||
|
|
||||||
|
log("INFO", "等待 API 响应...")
|
||||||
|
packet = page.listen.wait(timeout=30)
|
||||||
|
page.listen.stop()
|
||||||
|
|
||||||
|
if not packet:
|
||||||
|
log("ERR", "超时未收到 BOM 响应")
|
||||||
|
dump_page_state(page, "监听超时")
|
||||||
|
return None
|
||||||
|
|
||||||
|
log("OK", f"拦截成功 → HTTP {packet.response.status}")
|
||||||
|
|
||||||
|
# DrissionPage 已自动反序列化 JSON
|
||||||
|
body = packet.response.body
|
||||||
|
data = body if isinstance(body, (dict, list)) else json.loads(body)
|
||||||
|
|
||||||
|
# 摘要
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = data.get("result", {})
|
||||||
|
items = result.get("items", [])
|
||||||
|
total = result.get("totalCount", "?")
|
||||||
|
log("INFO", f"本页记录: {len(items)} 条 | 总计: {total} 条")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── 保存为 JSON ───────────────────────────────────────────────────────────────
|
||||||
|
def save_json(data, prefix: str = "bom") -> Path:
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
path = OUTPUT_DIR / f"{prefix}_{ts}.json"
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
log("OK", f"已保存: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── 主流程 ────────────────────────────────────────────────────────────────────
|
||||||
|
def run(manual: bool = False):
|
||||||
|
mode = "手动登录" if manual else "自动登录"
|
||||||
|
log("INFO", f"BOM 查询启动 [{mode}]")
|
||||||
|
|
||||||
|
page = get_page(port=9333)
|
||||||
|
try:
|
||||||
|
# Step 1: 登录
|
||||||
|
ok = login_manual(page) if manual else login(page)
|
||||||
|
if not ok:
|
||||||
|
log("ERR", "登录失败,退出")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: 进入 BOM 页面
|
||||||
|
navigate_to_bom(page)
|
||||||
|
|
||||||
|
# Step 3: 获取第 1 页数据
|
||||||
|
data = fetch_bom_page1(page)
|
||||||
|
if data is None:
|
||||||
|
log("ERR", "数据获取失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 4: 保存 JSON
|
||||||
|
save_json(data, prefix="bom_page1")
|
||||||
|
log("OK", "完成!文件保存在 output/")
|
||||||
|
|
||||||
|
input("\n按 Enter 关闭浏览器...\n")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("INFO", "用户中断 (Ctrl+C)")
|
||||||
|
except Exception as e:
|
||||||
|
log("ERR", f"异常: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
page.quit()
|
||||||
|
log("INFO", "浏览器已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(manual="--manual" in sys.argv)
|
||||||
175
browser_login/login.py
Normal file
175
browser_login/login.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
ERP 登录模块 - DrissionPage
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||||
|
|
||||||
|
# ── 加载 .env ─────────────────────────────────────────────────────────────────
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
ERP_URL = os.getenv("ERP_URL", "https://yunmes.tftykj.cn/#")
|
||||||
|
ERP_TENANT = os.getenv("ERP_TENANT", "")
|
||||||
|
ERP_USERNAME = os.getenv("ERP_USERNAME", "")
|
||||||
|
ERP_PASSWORD = os.getenv("ERP_PASSWORD", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 日志 ──────────────────────────────────────────────────────────────────────
|
||||||
|
def log(level: str, msg: str):
|
||||||
|
icons = {"INFO": "ℹ️ ", "OK": "✅", "WARN": "⚠️ ", "ERR": "❌"}
|
||||||
|
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
|
print(f"[{ts}] {icons.get(level, ' ')} [{level}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_page_state(page: ChromiumPage, label: str = ""):
|
||||||
|
"""仅在出错时调用,打印页面快照用于排查。"""
|
||||||
|
tag = f" ({label})" if label else ""
|
||||||
|
log("WARN", f"─── 页面快照{tag} ───")
|
||||||
|
log("WARN", f" URL : {page.url}")
|
||||||
|
log("WARN", f" 标题 : {page.title}")
|
||||||
|
# 错误提示(timeout=0 不阻塞)
|
||||||
|
for sel in [".el-message--error", ".error-msg"]:
|
||||||
|
el = page.ele(sel, timeout=0)
|
||||||
|
if el and el.text.strip():
|
||||||
|
log("WARN", f" 页面提示: {el.text.strip()}")
|
||||||
|
log("WARN", "─────────────────────────────")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 浏览器 ────────────────────────────────────────────────────────────────────
|
||||||
|
def get_page(headless: bool = False, port: int = 9222) -> ChromiumPage:
|
||||||
|
co = ChromiumOptions()
|
||||||
|
if headless:
|
||||||
|
co.set_argument("--headless=new")
|
||||||
|
co.set_argument("--disable-blink-features=AutomationControlled")
|
||||||
|
co.set_argument("--no-sandbox")
|
||||||
|
co.set_argument("--window-size=1440,900")
|
||||||
|
co.set_local_port(port)
|
||||||
|
return ChromiumPage(co)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vue 表单专用输入(JS setter + 模拟键盘) ──────────────────────────────────
|
||||||
|
def set_input_value(page: ChromiumPage, ele, value: str):
|
||||||
|
"""
|
||||||
|
Vue 双向绑定下 clear()+input() 会留残值,必须用 JS 原生 setter 清空
|
||||||
|
再通过模拟键盘输入触发 keydown/change 事件。
|
||||||
|
"""
|
||||||
|
page.run_js("""
|
||||||
|
var el = arguments[0];
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype, 'value'
|
||||||
|
).set.call(el, '');
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
""", ele)
|
||||||
|
time.sleep(0.05) # 等 Vue 处理 clear 事件
|
||||||
|
ele.input(value)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 自动登录(使用 .env 账号) ────────────────────────────────────────────────
|
||||||
|
def login(page: ChromiumPage) -> bool:
|
||||||
|
"""自动填写 .env 中的账号信息并登录。"""
|
||||||
|
log("INFO", f"打开登录页: {ERP_URL}")
|
||||||
|
page.get(ERP_URL)
|
||||||
|
|
||||||
|
# 先用较短 timeout 等登录表单;若不出现则检测是否已处于登录状态
|
||||||
|
tenant_input = page.ele("@placeholder=租户代码", timeout=5)
|
||||||
|
if not tenant_input:
|
||||||
|
# 没有登录表单 → 判断是否已登录(主界面无密码框)
|
||||||
|
has_pwd = bool(page.ele("@placeholder=密码", timeout=0))
|
||||||
|
if not has_pwd:
|
||||||
|
log("OK", f"检测到已登录状态,跳过登录 → {page.url}")
|
||||||
|
return True
|
||||||
|
log("ERR", "未找到登录表单,页面可能未加载")
|
||||||
|
dump_page_state(page, "找不到表单")
|
||||||
|
return False
|
||||||
|
|
||||||
|
log("INFO", "填写登录信息...")
|
||||||
|
set_input_value(page, tenant_input, ERP_TENANT)
|
||||||
|
set_input_value(page, page.ele("@placeholder=账号"), ERP_USERNAME)
|
||||||
|
set_input_value(page, page.ele("@placeholder=密码"), ERP_PASSWORD)
|
||||||
|
time.sleep(0.2) # 等所有字段的 change/blur 事件结算
|
||||||
|
|
||||||
|
# 点击登录按钮
|
||||||
|
login_btn = page.ele(
|
||||||
|
"xpath://*[@id='app']/div/div[3]/div/div[2]/div[2]/div[5]/button",
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if not login_btn:
|
||||||
|
log("ERR", "未找到登录按钮(XPath 失效,请更新选择器)")
|
||||||
|
dump_page_state(page, "找不到登录按钮")
|
||||||
|
return False
|
||||||
|
|
||||||
|
log("INFO", "点击登录...")
|
||||||
|
login_btn.click()
|
||||||
|
|
||||||
|
return _wait_for_login(page)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 手动登录(等待用户在浏览器操作) ─────────────────────────────────────────
|
||||||
|
def login_manual(page: ChromiumPage) -> bool:
|
||||||
|
"""打开登录页,等待用户手动完成登录操作。"""
|
||||||
|
log("INFO", f"打开登录页(手动模式): {ERP_URL}")
|
||||||
|
page.get(ERP_URL)
|
||||||
|
|
||||||
|
# 确认登录页已加载
|
||||||
|
tenant_input = page.ele("@placeholder=租户代码", timeout=15)
|
||||||
|
if not tenant_input:
|
||||||
|
log("ERR", "未找到登录表单")
|
||||||
|
dump_page_state(page, "找不到表单")
|
||||||
|
return False
|
||||||
|
|
||||||
|
log("INFO", "═" * 50)
|
||||||
|
log("INFO", " 请在浏览器中手动输入账号并登录")
|
||||||
|
log("INFO", " 程序将在检测到登录成功后自动继续...")
|
||||||
|
log("INFO", "═" * 50)
|
||||||
|
|
||||||
|
return _wait_for_login(page, timeout=120) # 手动模式等待 2 分钟
|
||||||
|
|
||||||
|
|
||||||
|
# ── 等待登录结果(自动/手动共用) ────────────────────────────────────────────
|
||||||
|
def _wait_for_login(page: ChromiumPage, timeout: int = 15) -> bool:
|
||||||
|
"""轮询检测登录结果:表单消失 → 成功;error 提示 → 失败。"""
|
||||||
|
for elapsed in range(1, timeout + 1):
|
||||||
|
time.sleep(1)
|
||||||
|
has_form = bool(page.ele("@placeholder=密码", timeout=0))
|
||||||
|
err_el = page.ele(".el-message--error", timeout=0)
|
||||||
|
err_text = err_el.text.strip() if err_el else ""
|
||||||
|
|
||||||
|
if err_text:
|
||||||
|
log("ERR", f"登录失败: {err_text}")
|
||||||
|
dump_page_state(page)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not has_form:
|
||||||
|
log("OK", f"登录成功({elapsed}s)→ {page.url}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if elapsed % 10 == 0: # 每 10 秒提示一次进度(手动模式有用)
|
||||||
|
log("INFO", f" 等待登录中... ({elapsed}s)")
|
||||||
|
|
||||||
|
log("WARN", f"超过 {timeout}s 未检测到登录成功")
|
||||||
|
dump_page_state(page, "登录超时")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 单独运行时的入口 ──────────────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
manual = "--manual" in sys.argv
|
||||||
|
page = get_page()
|
||||||
|
try:
|
||||||
|
ok = login_manual(page) if manual else login(page)
|
||||||
|
if ok:
|
||||||
|
log("OK", "登录完成,按 Enter 关闭浏览器")
|
||||||
|
input()
|
||||||
|
else:
|
||||||
|
log("ERR", "登录失败")
|
||||||
|
time.sleep(3)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("INFO", "用户中断")
|
||||||
|
finally:
|
||||||
|
page.quit()
|
||||||
|
log("INFO", "浏览器已关闭")
|
||||||
19
browser_login/run.sh
Executable file
19
browser_login/run.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# ERP 自动化登录启动脚本
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VENV="$SCRIPT_DIR/../venv"
|
||||||
|
|
||||||
|
# 激活虚拟环境
|
||||||
|
source "$VENV/bin/activate"
|
||||||
|
|
||||||
|
echo "===== 依赖检查 ====="
|
||||||
|
pip install -q DrissionPage python-dotenv
|
||||||
|
|
||||||
|
echo "===== 启动登录脚本 ====="
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
python login.py
|
||||||
Reference in New Issue
Block a user