diff --git a/skills/browser-automation/captcha-auto-login/SKILL.md b/skills/browser-automation/captcha-auto-login/SKILL.md new file mode 100644 index 0000000..b5c14be --- /dev/null +++ b/skills/browser-automation/captcha-auto-login/SKILL.md @@ -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 + +# 方式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 备选引擎 |