[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
"""通过扫码和短信验证码登录抖音。
二维码由用户在抖音 App 中扫描,短信验证码由用户输入。脚本不会读取、绕过或自动
处理验证码/人脸验证,也不会默认保存会话。
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
class DouyinLoginError(RuntimeError):
"""抖音登录流程失败。"""
@dataclass(frozen=True)
class LoginOptions:
"""登录流程配置。"""
url: str = "https://www.douyin.com/"
browser_channel: str = "chrome"
executable_path: Optional[str] = None
headless: bool = False
timeout_ms: int = 30_000
qr_timeout_seconds: int = 600
storage_state: Optional[Path] = None
keep_open: bool = False
pause_on_error: bool = False
def _is_visible(locator, timeout_ms: int = 500) -> bool:
"""判断元素是否可见,并把超时转换为 False。"""
try:
return locator.is_visible(timeout=timeout_ms)
except Exception:
return False
def _wait_for(locator, timeout_seconds: float) -> bool:
"""轮询等待元素出现。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if _is_visible(locator, timeout_ms=300):
return True
locator.page.wait_for_timeout(300)
return False
def _has_visible_login_entry(page) -> bool:
"""判断页面顶部是否仍显示未登录入口。"""
login = page.get_by_text("登录", exact=True)
for index in range(login.count()):
if _is_visible(login.nth(index), timeout_ms=300):
return True
return False
def _wait_for_login_success(page, verification_article, timeout_seconds: float) -> bool:
"""等待验证弹窗消失或页面顶部切换为已登录状态。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if not _is_visible(verification_article, timeout_ms=300):
return True
if not _has_visible_login_entry(page):
return True
page.wait_for_timeout(300)
return False
def _verification_article(page):
"""找到身份验证弹窗对应的 article,避免命中后面的登录表单。"""
articles = page.locator("article")
for index in range(articles.count()):
article = articles.nth(index)
if not _is_visible(article, timeout_ms=300):
continue
try:
text = article.inner_text(timeout=500)
except Exception:
continue
if "身份验证" in text or "短信已发送至" in text:
return article
return None
def _wait_for_verification_article(page, timeout_seconds: float):
"""等待扫码后的身份验证弹窗出现。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
article = _verification_article(page)
if article is not None:
return article
page.wait_for_timeout(500)
return None
def _wait_for_sms_input(page, timeout_seconds: float):
"""只在身份验证弹窗内寻找验证码输入框。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
article = _verification_article(page)
if article is not None:
otp = article.get_by_role("textbox", name="请输入验证码").last
if _is_visible(otp, timeout_ms=300):
return otp
page.wait_for_timeout(300)
return None
def _ensure_login_dialog(page) -> None:
"""确保首页的登录弹窗已打开。"""
if _is_visible(page.get_by_text("扫码登录", exact=True)):
return
login = page.get_by_text("登录", exact=True)
if login.count() == 0:
raise DouyinLoginError("未找到抖音登录入口")
login.last.click()
if not _wait_for(page.get_by_text("扫码登录", exact=True), 10):
raise DouyinLoginError("抖音登录弹窗未出现")
def _wait_for_scan_or_login(page, timeout_seconds: int) -> None:
"""等待用户扫码后进入验证步骤。"""
error = page.get_by_text("错误次数过多或验证码过期,请稍后重试", exact=True)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if _is_visible(error):
raise DouyinLoginError("抖音提示验证码过期或错误次数过多,请稍后重试")
if _verification_article(page) is not None:
return
page.wait_for_timeout(500)
raise DouyinLoginError("等待扫码超时,请重新运行程序并扫描二维码")
def _select_sms_verification(page):
"""选择短信验证,并等待验证码输入框。"""
article = _wait_for_verification_article(page, 30)
if article is None:
raise DouyinLoginError("身份验证弹窗未出现")
sms_choice = article.get_by_text("接收短信验证码", exact=True)
if not _wait_for(sms_choice, 10):
raise DouyinLoginError("身份验证弹窗中未找到“接收短信验证码”选项")
sms_choice.click()
page.wait_for_timeout(500)
# 某些版本会在选择方式后再显示一个“发送短信验证”按钮。
article = _verification_article(page) or article
send_sms = article.get_by_text("发送短信验证", exact=True)
if _is_visible(send_sms):
send_sms.click()
otp = _wait_for_sms_input(page, 30)
if otp is None:
# 页面版本差异较大时保留人工兜底,但不再错误地读取后面的隐藏输入框。
print("请在浏览器中点击“接收短信验证码”,等待短信输入框出现。")
otp = _wait_for_sms_input(page, 120)
if otp is None:
raise DouyinLoginError("短信验证码输入框未出现,可能需要稍后重试")
verification_article = _verification_article(page) or article
return otp, verification_article
def _read_code() -> str:
"""从终端读取短信验证码,不记录到日志。"""
code = input("请输入抖音短信验证码(不会保存):").strip()
if not re.fullmatch(r"\d{4,8}", code):
raise DouyinLoginError("验证码应为 4 至 8 位数字")
return code
def login_douyin(options: LoginOptions = LoginOptions()):
"""打开隔离浏览器上下文并完成抖音登录。
返回 ``(playwright, browser, context, page)``,调用方负责关闭这些对象。
"""
try:
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise DouyinLoginError(
"缺少 Playwright,请先安装:pip install 'douyin-mcp-server[browser]'"
) from exc
pw = sync_playwright().start()
launch_args = {"headless": options.headless}
if options.browser_channel:
launch_args["channel"] = options.browser_channel
if options.executable_path:
launch_args["executable_path"] = options.executable_path
try:
browser = pw.chromium.launch(**launch_args)
context = browser.new_context()
context.set_default_timeout(options.timeout_ms)
page = context.new_page()
page.goto(options.url, wait_until="domcontentloaded")
_ensure_login_dialog(page)
print("请用抖音 App 扫描浏览器中的二维码。")
_wait_for_scan_or_login(page, options.qr_timeout_seconds)
otp, verification_article = _select_sms_verification(page)
code = _read_code()
otp.fill(code)
page.get_by_text("验证", exact=True).last.click()
error = page.get_by_text("错误次数过多或验证码过期,请稍后重试", exact=True)
if _wait_for(error, 3):
raise DouyinLoginError("验证码已过期或错误次数过多,请稍后重试")
# 抖音验证成功后会先执行动画和状态刷新,不能用“立即可见”判断失败。
if not _wait_for_login_success(page, verification_article, 15):
raise DouyinLoginError("验证码验证未完成,请检查抖音页面提示")
if options.storage_state:
options.storage_state.parent.mkdir(parents=True, exist_ok=True)
context.storage_state(path=str(options.storage_state))
print(f"登录会话已保存到:{options.storage_state}")
print("抖音登录成功。")
return pw, browser, context, page
except PlaywrightTimeoutError as exc:
if options.pause_on_error:
input("浏览器仍保持打开,按 Enter 后关闭……")
pw.stop()
raise DouyinLoginError("抖音页面操作超时,请检查网络或稍后重试") from exc
except Exception:
if options.pause_on_error:
input("浏览器仍保持打开,按 Enter 后关闭……")
pw.stop()
raise
def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="抖音扫码 + 短信验证码登录")
parser.add_argument("--url", default="https://www.douyin.com/", help="抖音入口 URL")
parser.add_argument(
"--browser-channel",
default="chrome",
help="Playwright 浏览器 channel,默认使用已安装的 Google Chrome",
)
parser.add_argument("--executable-path", help="自定义 Chrome/Chromium 可执行文件路径")
parser.add_argument("--qr-timeout", type=int, default=600, help="等待扫码秒数,默认 10 分钟")
parser.add_argument(
"--storage-state",
type=Path,
help="可选:保存登录 cookies 的 JSON 文件(请妥善保护)",
)
parser.add_argument(
"--keep-open",
action="store_true",
help="登录成功后保持浏览器窗口,按 Enter 后退出",
)
return parser.parse_args(argv)
def main(argv: Optional[list[str]] = None) -> int:
args = _parse_args(argv)
options = LoginOptions(
url=args.url,
browser_channel=args.browser_channel,
executable_path=args.executable_path,
qr_timeout_seconds=args.qr_timeout,
storage_state=args.storage_state,
keep_open=args.keep_open,
pause_on_error=True,
)
try:
pw, browser, context, _page = login_douyin(options)
except (DouyinLoginError, KeyboardInterrupt) as exc:
print(f"登录失败:{exc}", file=sys.stderr)
return 1
try:
if options.keep_open:
input("按 Enter 关闭浏览器并结束程序……")
finally:
context.close()
browser.close()
pw.stop()
return 0
if __name__ == "__main__":
raise SystemExit(main())