Files
atomk-hermes-skills/skills/browser-automation/captcha-auto-login/SKILL.md
T

18 KiB
Raw Blame History

name, title, description, category, tags
name title description category tags
captcha-auto-login ERP带验证码自动登录 使用DrissionPage+ddddocr自动识别图形验证码并完成ERP系统登录。支持店小秘、妙手、通途等常见跨境ERP。 browser-automation
drissionpage
ddddocr
captcha
login
automation

带验证码自动登录

依赖安装

pip install DrissionPage ddddocr requests

核心代码

1. 获取随机端口(防止BrowserConnectError

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模式)

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识别验证码

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. 获取验证码图片(三种方式)

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可能不生效:

# 方式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. 通用自动登录函数

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)

特点:

  • 登录 URLhttps://erp.91miaoshou.com/(主页,已登录自动跳转 /welcome
  • ⚠️ /auth/login 返回 404「页面未找到」— 不要使用此路径
  • 验证码是base64内联图片(.captcha-img
  • 登录成功后跳转到 /welcome
  • 多表单陷阱:页面同时存在登录、注册、忘记密码、设备验证多个表单,每个都有独立的 captcha-img。如果直接用 .captcha-text 填写可能填到隐藏表单。
  • 正确定位策略:先通过 input.J_captchaInput 找到主登录表单的验证码输入框,再向上遍历 parent 查找关联的 captcha-img
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一致。

登录页 URLhttps://www.dianxiaomi.com/(不是 /index.htm,虽然两个都可以跳转)

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 持续误识别:ncynCye国x三bnGM 等均不正确。店小秘验证码字符风格与 ddddocr 训练数据不匹配。建议

  • 优先用 Bridge CDP 操控桌面浏览器,让用户肉眼识别验证码后手动输入
  • 如必须自动识别,考虑 onnxocrpaddleocr 作为备选引擎
  • 验证码失败时页面无明确错误提示,登录表单仍然可见 —— 需通过 document.querySelector("#exampleInputName") 是否存在来判断成功与否
  • 9Commerce店小秘账号:9Commerce / Qq123456用户名登录,非邮箱)。已登录。用于跨境电商ERP管理及CDP自动化演示。

妙手ERP Bridge CDP 登录(替代 headless 方案)

当桌面 CDP 可用时,可直接通过 Bridge 操控用户桌面 Chrome 完成登录,无需 headless

# 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下载
  4. Cookie共享:下载验证码时必须传递当前浏览器的cookies,否则验证码会刷新
  5. Session一致性:部分站点(如店小秘)即使传递cookies,用requests下载验证码仍可能导致session不匹配。建议使用浏览器截图(CDP) 或浏览器内fetch获取验证码
  6. Base64内联图片:如妙手ERP的验证码是data:image/png;base64,...格式,需split+解码
  7. 事件触发:仅设置value不够,必须触发inputchangeblur等事件。某些表单需使用ele().input()替代JS设置
  8. OCR引擎ddddocr是本地模型,无需联网,支持中英文验证码。需注意常见误识别修正(如o→0, l→1)
  9. 多验证码字段:部分页面有多个验证码输入框(如妙手同时有captchavalidateCode),需确认填写正确的字段

常见平台特殊处理

妙手ERP

  • 验证码:base64内联图片(.captcha-img
  • 登录字段:mobile(账号)、passwordcaptcha(图形验证码)、captchaUuidhidden
  • 登录按钮:.login-button(文字"立即登录"

店小秘

  • 验证码:URL加载(#verifyImgCodesrc为/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 备选引擎