From fc8f14b301525bb7a7ea8adc57a90efb67e625e6 Mon Sep 17 00:00:00 2001 From: seay Date: Fri, 17 Apr 2026 15:06:30 +0800 Subject: [PATCH] Initial commit: environment config and automation scripts --- .gitignore | 2 + browser_login/.env.example | 5 + browser_login/.gitignore | 4 + browser_login/README.md | 235 +++++++++++++++++++++++++++++++++++++ browser_login/bom_query.py | 119 +++++++++++++++++++ browser_login/login.py | 175 +++++++++++++++++++++++++++ browser_login/run.sh | 19 +++ 7 files changed, 559 insertions(+) create mode 100644 .gitignore create mode 100644 browser_login/.env.example create mode 100644 browser_login/.gitignore create mode 100644 browser_login/README.md create mode 100644 browser_login/bom_query.py create mode 100644 browser_login/login.py create mode 100755 browser_login/run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..769e5e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv/ +.DS_Store diff --git a/browser_login/.env.example b/browser_login/.env.example new file mode 100644 index 0000000..4d3598e --- /dev/null +++ b/browser_login/.env.example @@ -0,0 +1,5 @@ +# ERP 登录配置 +ERP_URL=https://yunmes.tftykj.cn/# +ERP_TENANT=gddtsk +ERP_USERNAME= +ERP_PASSWORD= diff --git a/browser_login/.gitignore b/browser_login/.gitignore new file mode 100644 index 0000000..651a985 --- /dev/null +++ b/browser_login/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +output/ diff --git a/browser_login/README.md b/browser_login/README.md new file mode 100644 index 0000000..bb5787d --- /dev/null +++ b/browser_login/README.md @@ -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=; 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 提示 | diff --git a/browser_login/bom_query.py b/browser_login/bom_query.py new file mode 100644 index 0000000..0c865ee --- /dev/null +++ b/browser_login/bom_query.py @@ -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) diff --git a/browser_login/login.py b/browser_login/login.py new file mode 100644 index 0000000..50b1555 --- /dev/null +++ b/browser_login/login.py @@ -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", "浏览器已关闭") diff --git a/browser_login/run.sh b/browser_login/run.sh new file mode 100755 index 0000000..38b1ef0 --- /dev/null +++ b/browser_login/run.sh @@ -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