Add browser-automation/captcha-auto-login

This commit is contained in:
2026-07-10 16:11:56 +08:00
parent bba72cd7b7
commit 7549d08b01
@@ -0,0 +1,427 @@
---
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
# 方式AURL图片通过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')
# 方式BJS直接设置(并触发事件)
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 备选引擎 |