428 lines
18 KiB
Markdown
428 lines
18 KiB
Markdown
---
|
||
name: captcha-auto-login
|
||
title: ERP带验证码自动登录
|
||
description: 使用DrissionPage+ddddocr自动识别图形验证码并完成ERP系统登录。支持店小秘、妙手、通途等常见跨境ERP。
|
||
category: browser-automation
|
||
tags: [drissionpage, ddddocr, captcha, login, automation]
|
||
---
|
||
|
||
# 带验证码自动登录
|
||
|
||
## 依赖安装
|
||
|
||
```bash
|
||
pip install DrissionPage ddddocr requests
|
||
```
|
||
|
||
## 核心代码
|
||
|
||
### 1. 获取随机端口(防止BrowserConnectError)
|
||
|
||
```python
|
||
import socket
|
||
|
||
def get_free_port():
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
s.bind(('', 0))
|
||
return s.getsockname()[1]
|
||
```
|
||
|
||
### 2. 创建浏览器(headless模式)
|
||
|
||
```python
|
||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||
|
||
def create_browser(headless=True):
|
||
co = ChromiumOptions()
|
||
# 注意:根据环境查找正确的Chromium路径
|
||
co.set_browser_path('/home/ubuntu/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome')
|
||
co.headless(headless)
|
||
co.set_argument('--headless=new')
|
||
co.set_argument('--no-sandbox')
|
||
co.set_argument('--disable-dev-shm-usage')
|
||
co.set_argument('--disable-gpu')
|
||
co.set_argument('--ignore-certificate-errors')
|
||
co.set_argument('--window-size=1440,900')
|
||
co.set_user_agent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
|
||
co.set_local_port(get_free_port())
|
||
return ChromiumPage(addr_or_opts=co)
|
||
```
|
||
|
||
### 3. OCR识别验证码
|
||
|
||
```python
|
||
import ddddocr
|
||
|
||
def recognize_captcha(image_path):
|
||
ocr = ddddocr.DdddOcr(show_ad=False)
|
||
with open(image_path, 'rb') as f:
|
||
image_bytes = f.read()
|
||
return ocr.classification(image_bytes)
|
||
|
||
def correct_ocr(raw_code):
|
||
"""修正ddddocr常见误识别——数字验证码场景"""
|
||
t = raw_code.strip().replace(' ', '').replace('\n', '')
|
||
t = t.translate(str.maketrans(
|
||
'oliOSsZzBbGgQq',
|
||
'01105522669999'
|
||
))
|
||
return t
|
||
```
|
||
|
||
### 4. 获取验证码图片(三种方式)
|
||
|
||
```python
|
||
import base64
|
||
|
||
# 方式A:URL图片通过requests下载(共享cookie)
|
||
def get_captcha_by_requests(page, captcha_url, save_path):
|
||
cookies = {c['name']: c['value'] for c in page.cookies()}
|
||
resp = requests.get(captcha_url, cookies=cookies, timeout=10, verify=False)
|
||
if resp.status_code == 200:
|
||
with open(save_path, 'wb') as f:
|
||
f.write(resp.content)
|
||
return True
|
||
return False
|
||
|
||
# 方式B:Base64内联图片(如妙手ERP)
|
||
def get_captcha_base64(page, img_selector, save_path):
|
||
captcha_src = page.run_js(f'''var img = document.querySelector('{img_selector}'); return img ? img.src : null;''')
|
||
if captcha_src and captcha_src.startswith('data:image'):
|
||
header, data = captcha_src.split(',', 1)
|
||
image_bytes = base64.b64decode(data)
|
||
with open(save_path, 'wb') as f:
|
||
f.write(image_bytes)
|
||
return True
|
||
return False
|
||
|
||
# 方式C:浏览器截图(确保session完全一致,适用于店小秘等严格校验session的站点)
|
||
def get_captcha_by_screenshot(page, img_selector, save_path):
|
||
img_info = page.run_js(f'''
|
||
var img = document.querySelector('{img_selector}');
|
||
if (!img) return null;
|
||
var rect = img.getBoundingClientRect();
|
||
return {{x: rect.left, y: rect.top, width: rect.width, height: rect.height}};
|
||
''')
|
||
if not img_info:
|
||
return False
|
||
result = page.driver.run('Page.captureScreenshot', format='png', clip={
|
||
'x': img_info['x'], 'y': img_info['y'],
|
||
'width': img_info['width'], 'height': img_info['height'], 'scale': 1
|
||
})
|
||
image_data = base64.b64decode(result['data'])
|
||
with open(save_path, 'wb') as f:
|
||
f.write(image_data)
|
||
return True
|
||
```
|
||
|
||
### 5. 填充表单
|
||
|
||
某些表单(如店小秘)需要使用DrissionPage的 `ele().input()` 方法,直接JS设置value可能不生效:
|
||
|
||
```python
|
||
# 方式A:使用ele API(更稳定)
|
||
user_ele = page.ele('css:input[name="account"]', timeout=3)
|
||
if user_ele:
|
||
user_ele.clear()
|
||
user_ele.input('your_account')
|
||
|
||
# 方式B:JS直接设置(并触发事件)
|
||
page.run_js('''
|
||
function setValue(el, value) {
|
||
if (!el) return;
|
||
el.focus();
|
||
el.value = value;
|
||
el.dispatchEvent(new Event('focus', { bubbles: true }));
|
||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
|
||
el.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true }));
|
||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
||
}
|
||
setValue(document.querySelector('input[name="account"]'), 'your_account');
|
||
''')
|
||
```
|
||
|
||
### 4. 通用自动登录函数
|
||
|
||
```python
|
||
import time
|
||
import requests
|
||
|
||
def auto_login_with_captcha(login_url, username, password,
|
||
username_selector='input[name="account"],input[name="mobile"],input[name="username"]',
|
||
password_selector='input[name="password"]',
|
||
captcha_img_selector='img[src*="captcha"],img[src*="verify"],#verifyImgCode',
|
||
captcha_input_selector='input[name="verifyCode"],input[name="validateCode"],input[name="captcha"]',
|
||
login_btn_text='登录',
|
||
screenshot_path='/tmp/captcha.png',
|
||
wait_time=5):
|
||
page = create_browser(f'/tmp/dp_captcha_{time.time()}')
|
||
result = {'success': False, 'url': '', 'title': '', 'captcha': ''}
|
||
|
||
try:
|
||
page.get(login_url)
|
||
time.sleep(wait_time)
|
||
|
||
# 获取验证码图片src
|
||
captcha_src = page.run_js(f'''
|
||
var img = document.querySelector('{captcha_img_selector.replace("'", "\\'")}');
|
||
if (img) return img.src;
|
||
return null;
|
||
''')
|
||
|
||
if captcha_src:
|
||
# 使用requests下载验证码(共享cookie)
|
||
cookies = {c['name']: c['value'] for c in page.cookies()}
|
||
resp = requests.get(captcha_src, cookies=cookies, timeout=10)
|
||
if resp.status_code == 200:
|
||
with open(screenshot_path, 'wb') as f:
|
||
f.write(resp.content)
|
||
captcha_code = recognize_captcha(screenshot_path)
|
||
result['captcha'] = captcha_code
|
||
|
||
# 填充表单
|
||
page.run_js(f'''
|
||
var userInp = document.querySelector('{username_selector.replace("'", "\\'")}');
|
||
var pwdInp = document.querySelector('{password_selector.replace("'", "\\'")}');
|
||
var codeInp = document.querySelector('{captcha_input_selector.replace("'", "\\'")}');
|
||
|
||
if (userInp) {{
|
||
userInp.value = '{username}';
|
||
userInp.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||
userInp.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||
}}
|
||
if (pwdInp) {{
|
||
pwdInp.value = '{password}';
|
||
pwdInp.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||
pwdInp.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||
}}
|
||
if (codeInp && '{captcha_code}') {{
|
||
codeInp.value = '{captcha_code}';
|
||
codeInp.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||
codeInp.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||
}}
|
||
''')
|
||
time.sleep(1)
|
||
|
||
# 点击登录按钮
|
||
page.run_js(f'''
|
||
var btns = document.querySelectorAll('button');
|
||
for (var i = 0; i < btns.length; i++) {{
|
||
if (btns[i].textContent.includes('{login_btn_text}')) {{
|
||
btns[i].click();
|
||
return true;
|
||
}}
|
||
}}
|
||
var form = document.querySelector('form');
|
||
if (form) {{ form.submit(); return true; }}
|
||
return false;
|
||
''')
|
||
|
||
time.sleep(8)
|
||
result['url'] = page.url
|
||
result['title'] = page.title
|
||
result['success'] = 'login' not in page.url.lower()
|
||
|
||
except Exception as e:
|
||
result['error'] = str(e)
|
||
finally:
|
||
page.quit()
|
||
|
||
return result
|
||
```
|
||
|
||
## 使用示例
|
||
|
||
### 妙手ERP (erp.91miaoshou.com)
|
||
|
||
特点:
|
||
- 登录 URL:`https://erp.91miaoshou.com/`(主页,已登录自动跳转 `/welcome`)
|
||
- ⚠️ `/auth/login` 返回 404「页面未找到」— 不要使用此路径
|
||
- 验证码是base64内联图片(`.captcha-img`)
|
||
- 登录成功后跳转到 `/welcome`
|
||
- **多表单陷阱**:页面同时存在登录、注册、忘记密码、设备验证多个表单,每个都有独立的 `captcha-img`。如果直接用 `.captcha-text` 填写可能填到隐藏表单。
|
||
- **正确定位策略**:先通过 `input.J_captchaInput` 找到主登录表单的验证码输入框,再向上遍历 parent 查找关联的 `captcha-img`。
|
||
|
||
```python
|
||
import re
|
||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||
|
||
def login_miaoshou(username, password, max_retry=5):
|
||
co = ChromiumOptions()
|
||
co.set_browser_path('/home/ubuntu/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome')
|
||
co.set_argument('--no-sandbox')
|
||
co.set_argument('--disable-dev-shm-usage')
|
||
co.set_argument('--disable-gpu')
|
||
co.headless()
|
||
co.auto_port()
|
||
|
||
page = ChromiumPage(co)
|
||
try:
|
||
for attempt in range(max_retry):
|
||
page.get('https://erp.91miaoshou.com/')
|
||
time.sleep(3)
|
||
|
||
# 填充账号密码
|
||
page.ele('css:.account-input', timeout=5).clear().input(username)
|
||
page.ele('css:.password-input', timeout=5).clear().input(password)
|
||
|
||
# 找到主登录表单的验证码输入框
|
||
cap_input = page.ele('css:input.J_captchaInput', timeout=5)
|
||
|
||
# 通过 parent 查找关联的验证码图片(避免填到隐藏表单)
|
||
parent = cap_input.parent()
|
||
cap_img = None
|
||
for _ in range(5):
|
||
cap_img = parent.ele('css:img.captcha-img', timeout=1)
|
||
if cap_img:
|
||
break
|
||
parent = parent.parent()
|
||
if not parent:
|
||
break
|
||
if not cap_img:
|
||
# fallback:找第一个带 data URI 的验证码图
|
||
for img in page.eles('css:img.captcha-img'):
|
||
if (img.attr('src') or '').startswith('data:'):
|
||
cap_img = img
|
||
break
|
||
|
||
# 解码验证码
|
||
src = cap_img.attr('src') or '' if cap_img else ''
|
||
code = None
|
||
if src.startswith('data:image/'):
|
||
b64 = src.split(',', 1)[1]
|
||
ocr = ddddocr.DdddOcr(show_ad=False)
|
||
raw = ocr.classification(base64.b64decode(b64))
|
||
code = correct_ocr(raw)
|
||
else:
|
||
path = f'/tmp/ms_captcha_{attempt}.jpg'
|
||
cap_img.get_screenshot(path=path)
|
||
with open(path, 'rb') as f:
|
||
code = correct_ocr(ddddocr.DdddOcr(show_ad=False).classification(f.read()))
|
||
|
||
if not code or len(code) < 3:
|
||
continue
|
||
|
||
cap_input.clear().input(code)
|
||
page.ele('css:.login.login-button', timeout=5).click()
|
||
time.sleep(4)
|
||
|
||
body = page('tag:body').text
|
||
if '图形验证码不正确' in body:
|
||
continue # 重试
|
||
elif '登录' in body and '账号' in body:
|
||
continue
|
||
|
||
# 成功检查:妙手跳转到 /welcome
|
||
if '/welcome' in page.url or '工作台' in body or '实时数据' in body:
|
||
cookies = page.cookies(all_domains=True)
|
||
with open('/home/ubuntu/.hermes/cookies/miaoshou_cookies_new.json', 'w') as f:
|
||
json.dump(cookies, f, indent=2)
|
||
return True
|
||
return False
|
||
finally:
|
||
page.quit()
|
||
```
|
||
|
||
### 店小秘 (dianxiaomi.com)
|
||
|
||
关键点:使用浏览器截图获取验证码以确保session一致。
|
||
|
||
**登录页 URL**:`https://www.dianxiaomi.com/`(不是 `/index.htm`,虽然两个都可以跳转)
|
||
|
||
```python
|
||
def login_dianxiaomi(username, password):
|
||
page = create_browser()
|
||
page.get('https://www.dianxiaomi.com/')
|
||
time.sleep(8)
|
||
|
||
# 使用浏览器截图获取验证码
|
||
get_captcha_by_screenshot(page, '#verifyImgCode', '/tmp/dxm_captcha.png')
|
||
raw_code = recognize_captcha('/tmp/dxm_captcha.png')
|
||
captcha_code = correct_ocr(raw_code)
|
||
|
||
# 填写表单
|
||
account_ele = page.ele('css:#exampleInputName', timeout=3)
|
||
pwd_ele = page.ele('css:#exampleInputPassword', timeout=3)
|
||
code_ele = page.ele('css:#verifyCode', timeout=3)
|
||
|
||
if account_ele: account_ele.clear(); account_ele.input(username)
|
||
if pwd_ele: pwd_ele.clear(); pwd_ele.input(password)
|
||
if code_ele and captcha_code: code_ele.clear(); code_ele.input(captcha_code)
|
||
|
||
# 点击登录
|
||
login_btn = page.ele('css:.loginbnt', timeout=3)
|
||
if login_btn: login_btn.click()
|
||
|
||
time.sleep(10)
|
||
success = not page.run_js('return !!document.querySelector("#exampleInputName");')
|
||
page.quit()
|
||
return success
|
||
```
|
||
|
||
⚠️ **ddddocr 对店小秘验证码可靠性低**:多次实测(2026-06-08,含 CDP screenshot scale=2/3),ddddocr 持续误识别:`ncy`、`nCye`、`国x`、`三bnGM` 等均不正确。店小秘验证码字符风格与 ddddocr 训练数据不匹配。**建议**:
|
||
- 优先用 Bridge CDP 操控桌面浏览器,让用户肉眼识别验证码后手动输入
|
||
- 如必须自动识别,考虑 `onnxocr` 或 `paddleocr` 作为备选引擎
|
||
- 验证码失败时页面**无明确错误提示**,登录表单仍然可见 —— 需通过 `document.querySelector("#exampleInputName")` 是否存在来判断成功与否
|
||
- 9Commerce店小秘账号:9Commerce / Qq123456(**用户名登录,非邮箱**)。已登录。用于跨境电商ERP管理及CDP自动化演示。
|
||
|
||
### 妙手ERP Bridge CDP 登录(替代 headless 方案)
|
||
|
||
当桌面 CDP 可用时,可直接通过 Bridge 操控用户桌面 Chrome 完成登录,无需 headless:
|
||
|
||
```python
|
||
# 1. attach -> navigate -> evaluate
|
||
# 2. 提取 base64 captcha (img.captcha-img src="data:image/png;base64,...")
|
||
# 3. ddddocr 解码(妙手的数字验证码 ddddocr 识别准确,如 '6343' 一次成功)
|
||
# 4. fill: input[name="mobile"].account-input + input[name="password"].password-input
|
||
# + input[name="captcha"].captcha-text
|
||
# 5. click: button text="立即登录"
|
||
# 6. verify: URL 跳转到 /welcome 即成功
|
||
```
|
||
|
||
- 账号:17762501033,密码 `Tt123456!`
|
||
- 验证码:base64 内联图片,ddddocr 对妙手纯数字验证码识别较准确
|
||
- 登录成功标志:URL 包含 `/welcome`,标题 "妙手-欢迎使用"
|
||
|
||
## 关键要点
|
||
|
||
1. **端口管理**:必须使用随机端口,避免`BrowserConnectError`
|
||
2. **关联元素定位**:当页面存在多个同类表单(如登录/注册/忘记密码)时,不能直接用全局CSS选择器。应先定位到目标表单内的**特征输入框**(如 `input.J_captchaInput`),再向上遍历 `parent()` 查找关联的验证码图片,确保 captchaUuid 与验证码图片配对。
|
||
3. **验证码获取**:headless模式下直接截图可能失败,应通过JS获取`img.src`再用requests下载
|
||
3. **Cookie共享**:下载验证码时必须传递当前浏览器的cookies,否则验证码会刷新
|
||
4. **Session一致性**:部分站点(如店小秘)即使传递cookies,用requests下载验证码仍可能导致session不匹配。建议使用浏览器截图(CDP) 或浏览器内fetch获取验证码
|
||
5. **Base64内联图片**:如妙手ERP的验证码是`data:image/png;base64,...`格式,需split+解码
|
||
6. **事件触发**:仅设置`value`不够,必须触发`input`、`change`、`blur`等事件。某些表单需使用`ele().input()`替代JS设置
|
||
7. **OCR引擎**:`ddddocr`是本地模型,无需联网,支持中英文验证码。需注意常见误识别修正(如o→0, l→1)
|
||
8. **多验证码字段**:部分页面有多个验证码输入框(如妙手同时有`captcha`和`validateCode`),需确认填写正确的字段
|
||
|
||
## 常见平台特殊处理
|
||
|
||
### 妙手ERP
|
||
- 验证码:base64内联图片(`.captcha-img`)
|
||
- 登录字段:`mobile`(账号)、`password`、`captcha`(图形验证码)、`captchaUuid`(hidden)
|
||
- 登录按钮:`.login-button`(文字"立即登录")
|
||
|
||
### 店小秘
|
||
- 验证码:URL加载(`#verifyImgCode`,src为`/verify/code.htm?t=TIMESTAMP`)
|
||
- 建议用浏览器截图获取,避免session不一致
|
||
- 登录字段:`account`(#exampleInputName,**用户名非邮箱**)、`password`(#exampleInputPassword)、`verifyCode`(#verifyCode)
|
||
- 登录按钮:`.loginbnt`
|
||
- **防止设备验证**:在非常用设备/环境登录时,店小秘会触发二次验证(邮箱或手机短信),无法通过纯自动化绕过。需先在常用设备上手动登录并勾选"信任此设备"
|
||
|
||
## 故障排查
|
||
|
||
| 问题 | 解决方案 |
|
||
|------|----------|
|
||
| BrowserConnectError | 更换端口或重启浏览器进程 |
|
||
| 验证码识别失败 | 检查图片是否下载完整,尝试调整页面等待时间,使用浏览器截图替代requests下载 |
|
||
| "验证码填写错误" | 检查session是否一致(用浏览器截图/fetch替代requests),检查OCR结果是否需修正(o→0, l→1) |
|
||
| "图形验证码不正确" 连续出现 | 检查是否填到了隐藏/错误表单的验证码框。使用 `input.J_captchaInput` 定位主登录表单,向上遍历 parent 查找关联的 captcha-img。 |
|
||
| "手机号/子账号/邮箱不能为空" | 表单填写未生效,尝试使用`ele().input()`替代JS设置value |
|
||
| 登录后仍跳转login页 | 检查账号密码是否正确,或增加等待时间 |
|
||
| 元素找不到 | 使用浏览器开发者工具确认selector是否正确 |
|
||
| 店小秘触发二次验证 | 在常用设备上手动登录一次并勾选"信任此设备" |
|
||
| ddddocr 持续识别错误(店小秘) | ddddocr 对店小秘验证码可靠性低。改用 Bridge CDP + 用户肉眼识别,或尝试 onnxocr/paddleocr 备选引擎 |
|