feat: add zero-argument douyin target detection

This commit is contained in:
wangshaoqing 2026-04-20 10:11:34 +08:00
parent d910d6f6b9
commit 84bcc4ac71
14 changed files with 1512 additions and 45 deletions

215
Douyin.py
View File

@ -16,6 +16,7 @@ import re
import socket import socket
import sys import sys
import time import time
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -24,8 +25,21 @@ DEFAULT_USER_URL = (
"MS4wLjABAAAAx7--dRYA0mPwhwvxNJ-35i6sB8d1Kv4Sj1WmugquqiHK19QYlB18Ikx6cECT1RVO" "MS4wLjABAAAAx7--dRYA0mPwhwvxNJ-35i6sB8d1Kv4Sj1WmugquqiHK19QYlB18Ikx6cECT1RVO"
"?from_tab_name=main" "?from_tab_name=main"
) )
DEFAULT_BROWSER_PORT = 9223
LISTEN_TARGET = "web/aweme/post/" LISTEN_TARGET = "web/aweme/post/"
SINGLE_VIDEO_LISTEN_TARGET = "web/aweme/detail/"
INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]') INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]')
CREATOR_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/user/[^/?#]+(?:\?.*)?$")
VIDEO_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/video/(?P<aweme_id>\d+)(?:[/?#].*)?$")
AWEME_ID_PATTERN = re.compile(r"^\d{5,}$")
@dataclass(frozen=True)
class ResolvedTarget:
kind: str
value: str
source: str
aweme_id: str | None = None
def sanitize_filename(value: str, fallback: str = "untitled") -> str: def sanitize_filename(value: str, fallback: str = "untitled") -> str:
@ -33,6 +47,81 @@ def sanitize_filename(value: str, fallback: str = "untitled") -> str:
return cleaned or fallback return cleaned or fallback
def is_creator_url(value: str) -> bool:
return bool(CREATOR_URL_PATTERN.match(value.strip()))
def is_video_url(value: str) -> bool:
return bool(VIDEO_URL_PATTERN.match(value.strip()))
def is_aweme_id(value: str) -> bool:
return bool(AWEME_ID_PATTERN.match(value.strip()))
def extract_aweme_id_from_video_url(value: str) -> str:
match = VIDEO_URL_PATTERN.match(value.strip())
if match is None:
raise ValueError("不是合法的抖音视频 URL。")
return match.group("aweme_id")
def build_video_page_url(aweme_id: str) -> str:
return f"https://www.douyin.com/video/{aweme_id}"
def parse_target_input(value: str, source: str) -> ResolvedTarget:
normalized = value.strip()
if is_creator_url(normalized):
return ResolvedTarget(kind="creator", value=normalized, source=source)
if is_video_url(normalized):
return ResolvedTarget(
kind="single-video",
value=normalized,
source=source,
aweme_id=extract_aweme_id_from_video_url(normalized),
)
if is_aweme_id(normalized):
return ResolvedTarget(
kind="single-video",
value=normalized,
source=source,
aweme_id=normalized,
)
raise ValueError(f"不支持的目标: {value}")
def get_active_page_url(page: Any) -> str:
return str(getattr(page, "url", "") or "").strip()
def resolve_target(page: Any, cli_target: str | None) -> ResolvedTarget:
if cli_target:
try:
return parse_target_input(cli_target, source="manual")
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
current_url = get_active_page_url(page)
try:
return parse_target_input(current_url, source="current-page")
except ValueError as exc:
raise RuntimeError(
"当前页面不是受支持的抖音博主页或单视频页,请切到目标页面后重试,或手动传入链接或 `aweme_id`。"
) from exc
def resolve_cli_target(cli_target: str | None, browser_port: int | None) -> ResolvedTarget:
if cli_target:
return parse_target_input(cli_target, source="manual")
_, chromium_page_cls, chromium_options_cls = import_runtime_dependencies()
if browser_port is not None:
ensure_browser_debug_port_ready(browser_port)
page = create_page(chromium_page_cls, chromium_options_cls, browser_port)
return resolve_target(page=page, cli_target=None)
def choose_video_url(url_list: list[str]) -> str: def choose_video_url(url_list: list[str]) -> str:
for url in url_list: for url in url_list:
if "douyinvod.com" in url: if "douyinvod.com" in url:
@ -114,6 +203,22 @@ def parse_aweme_items(body: Any) -> list[dict[str, str]]:
return items return items
def parse_single_aweme_item(body: Any) -> dict[str, str]:
if not isinstance(body, dict):
raise ValueError("接口响应不是字典,无法解析。")
if isinstance(body.get("aweme_detail"), dict):
items = parse_aweme_items({"aweme_list": [body["aweme_detail"]]})
if items:
return items[0]
items = parse_aweme_items(body)
if items:
return items[0]
raise ValueError("接口响应中缺少可下载的单视频数据。")
def build_headers(referer: str) -> dict[str, str]: def build_headers(referer: str) -> dict[str, str]:
return { return {
"referer": referer, "referer": referer,
@ -184,6 +289,7 @@ def collect_videos(
timeout: int, timeout: int,
output_dir: Path, output_dir: Path,
browser_port: int | None, browser_port: int | None,
auto_scroll: bool = False,
) -> int: ) -> int:
requests_module, chromium_page_cls, chromium_options_cls = import_runtime_dependencies() requests_module, chromium_page_cls, chromium_options_cls = import_runtime_dependencies()
headers = build_headers(user_url) headers = build_headers(user_url)
@ -203,19 +309,26 @@ def collect_videos(
print(f"[INFO] 正在处理第 {page_number}") print(f"[INFO] 正在处理第 {page_number}")
packet = wait_for_aweme_packet(page, timeout=timeout) packet = wait_for_aweme_packet(page, timeout=timeout)
if packet is None: if packet is None:
scroll_to_next_page(page) if auto_scroll:
continue scroll_to_next_page(page)
continue
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。")
try: try:
payload = extract_aweme_payload(packet.response) payload = extract_aweme_payload(packet.response)
items = parse_aweme_items(payload) items = parse_aweme_items(payload)
except Exception as exc: except Exception as exc:
print(f"[WARN] 解析接口数据失败: {exc}") print(f"[WARN] 解析接口数据失败: {exc}")
scroll_to_next_page(page) if auto_scroll:
continue scroll_to_next_page(page)
continue
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。") from exc
if not items: if not items:
print("[WARN] 这一页没有解析到视频。") if auto_scroll:
print("[WARN] 这一页没有解析到视频。")
else:
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。")
for item in items: for item in items:
if item["video_id"] in seen_ids: if item["video_id"] in seen_ids:
@ -242,15 +355,69 @@ def collect_videos(
downloaded += 1 downloaded += 1
print(f"[OK] 已保存: {output_path}") print(f"[OK] 已保存: {output_path}")
scroll_to_next_page(page) if auto_scroll:
scroll_to_next_page(page)
continue
break
return downloaded return downloaded
def collect_single_video(
target: ResolvedTarget,
timeout: int,
output_dir: Path,
browser_port: int | None,
) -> int:
requests_module, chromium_page_cls, chromium_options_cls = import_runtime_dependencies()
if browser_port is not None:
ensure_browser_debug_port_ready(browser_port)
page = create_page(chromium_page_cls, chromium_options_cls, browser_port)
page_url = target.value
if target.aweme_id is not None and not is_video_url(page_url):
page_url = build_video_page_url(target.aweme_id)
headers = build_headers(page_url)
page.listen.start(SINGLE_VIDEO_LISTEN_TARGET)
print("[INFO] 正在打开抖音视频页。若出现登录或验证码,请先在浏览器窗口里完成。")
page.get(page_url)
time.sleep(3)
packet = wait_for_aweme_packet(page, timeout=timeout)
if packet is None:
raise RuntimeError("当前视频页面未加载出可用视频数据,请先在浏览器中完成页面加载后重试。")
try:
payload = extract_aweme_payload(packet.response)
item = parse_single_aweme_item(payload)
except Exception as exc:
raise RuntimeError("当前视频页面未加载出可用视频数据,请先在浏览器中完成页面加载后重试。") from exc
output_path = build_output_path(
title=item["title"],
video_id=item["video_id"],
output_dir=output_dir,
)
download_video(
requests_module=requests_module,
headers=headers,
video_url=item["video_url"],
output_path=output_path,
)
print(f"[OK] 已保存: {output_path}")
return 1
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="监听抖音作品接口并下载视频") parser = argparse.ArgumentParser(description="附着抖音登录浏览器并下载当前页面或指定目标的视频")
parser.add_argument("user_url", nargs="?", default=DEFAULT_USER_URL, help="抖音博主主页 URL") parser.add_argument(
parser.add_argument("--pages", type=int, default=10, help="最多抓取多少页,默认 10") "target",
nargs="?",
default=None,
help="可选:博主主页 URL、单视频 URL 或 aweme_id不传则读取当前浏览器页面",
)
parser.add_argument("--pages", type=int, default=1, help="创作者抓取最多处理多少页;默认 1")
parser.add_argument("--timeout", type=int, default=10, help="单次等待接口响应秒数,默认 10") parser.add_argument("--timeout", type=int, default=10, help="单次等待接口响应秒数,默认 10")
parser.add_argument( parser.add_argument(
"--output-dir", "--output-dir",
@ -260,8 +427,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--browser-port", "--browser-port",
type=int, type=int,
default=None, default=DEFAULT_BROWSER_PORT,
help="附着到已启动 Chrome 的调试端口,例如 9223不传则由 DrissionPage 新开浏览器", help="附着到已启动 Chrome 的调试端口,默认 9223",
) )
return parser return parser
@ -278,13 +445,25 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--browser-port 必须大于 0") parser.error("--browser-port 必须大于 0")
try: try:
total = collect_videos( target = resolve_cli_target(args.target, browser_port=args.browser_port)
user_url=args.user_url, if target.kind == "creator":
max_pages=args.pages, total = collect_videos(
timeout=args.timeout, user_url=target.value,
output_dir=Path(args.output_dir), max_pages=args.pages,
browser_port=args.browser_port, timeout=args.timeout,
) output_dir=Path(args.output_dir),
browser_port=args.browser_port,
auto_scroll=args.pages > 1,
)
elif target.kind == "single-video":
total = collect_single_video(
target=target,
timeout=args.timeout,
output_dir=Path(args.output_dir),
browser_port=args.browser_port,
)
else:
raise RuntimeError(f"不支持的目标类型: {target.kind}")
except RuntimeError as exc: except RuntimeError as exc:
print(f"[ERROR] {exc}") print(f"[ERROR] {exc}")
return 1 return 1

97
README.md Normal file
View File

@ -0,0 +1,97 @@
# 抖音视频爬取工具
这是一个面向 macOS 的抖音视频下载项目。
它当前采用“两步式”方式工作:
1. 先启动一个可见的 Chrome 浏览器,让你手动登录抖音并完成验证码
2. 再让脚本附着到这个浏览器,抓取博主主页当前已加载的作品视频并下载到本地
这个项目已经完成过真实验证:在本机登录成功后,可以正常下载视频到 `video/` 目录。
## 适合谁使用
适合以下用户:
- 使用 Mac
- 项目已经在本地
- 想快速下载某个抖音博主主页当前可见的作品视频
## 当前能做什么
- 启动一个带调试端口的 Chrome 浏览器
- 手动登录抖音后附着到浏览器
- 自动识别当前浏览器页面是博主主页还是单视频页
- 抓取某个博主主页当前已加载的作品
- 下载当前单视频页对应的那一条视频
- 下载视频到本地 `video/` 目录
- 支持传入指定博主主页 URL、单视频 URL 或 `aweme_id`
## 当前不能做什么
- 不能自动帮你登录抖音
- 不能自动替你过验证码
- 不能默认抓完整个博主的全部历史作品
- 不能抓任意网页
- 不能自动筛选你想要的视频
## 快速开始
如果你已经把项目下载到本地,最快的使用方式是:
```bash
cd /你的项目目录/douyin-crawler-poc
python3 -m venv .venv
source .venv/bin/activate
pip install requests DrissionPage
./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py
```
说明:
- 第一个命令用于创建虚拟环境
- 第二个命令用于进入虚拟环境
- 第三个命令用于安装依赖
- 第四个命令会打开 Chrome让你登录抖音
- 第五个命令会读取你当前浏览器页面并自动开始抓取或下载
如果自动判断失败,也可以手动传入一个目标:
```bash
./.venv/bin/python Douyin.py "https://www.douyin.com/user/你的博主主页"
./.venv/bin/python Douyin.py "https://www.douyin.com/video/某个视频ID"
./.venv/bin/python Douyin.py "7619989983668240802"
```
## 下载结果在哪里
抓取成功后,视频会保存到项目根目录下的 `video/` 文件夹。
文件名格式一般是:
```text
视频标题-aweme_id.mp4
```
## 详细图文说明
详细操作步骤请看这份手册:
[小白图文操作手册](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/beginner-guide.md)
如果你完全不会代码,建议直接从这份手册开始照着做。
## 相关文档
- [当前抓取能力需求说明](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/2026-04-17-readme-and-beginner-guide-requirements.md)
- [后续定向抓取需求说明](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md)
## 当前验证状态
当前项目已验证:
- 单元测试通过
- 登录浏览器入口可用
- 抖音抓取脚本可附着到浏览器
- 成功下载出 mp4 文件

View File

@ -0,0 +1,154 @@
# Douyin Zero-Argument Target Detection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `Douyin.py` work with zero arguments by default, auto-detect the current browser page target, and keep a single manual fallback input for creator URLs, video URLs, or `aweme_id`.
**Architecture:** Add a target-resolution layer ahead of the existing crawl logic. Route resolved targets into either a visible-only creator flow or a single-video flow, keeping browser-attach checks and download primitives reusable.
**Tech Stack:** Python 3, `argparse`, `re`, `socket`, `pathlib`, `unittest`
---
### Task 1: Revise the requirements and freeze the contract
**Files:**
- Modify: `externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md`
- Create: `docs/superpowers/specs/2026-04-17-douyin-zero-arg-target-detection-design.md`
- [ ] **Step 1: Align the requirements doc with the approved UX**
Document that `./.venv/bin/python Douyin.py` is the primary command and that manual input is fallback-only.
- [ ] **Step 2: Save the approved design as a spec**
Write the validated design into `docs/superpowers/specs/2026-04-17-douyin-zero-arg-target-detection-design.md`.
- [ ] **Step 3: Review both docs locally**
Read both files and confirm the language matches the agreed zero-argument flow and visible-only scope.
### Task 2: Add failing tests for target parsing and target resolution
**Files:**
- Modify: `test_douyin.py`
- Modify: `Douyin.py`
- [ ] **Step 1: Write the failing tests**
Add tests for:
- `is_creator_url()` accepts supported creator URLs
- `is_video_url()` accepts supported video URLs
- `is_aweme_id()` accepts numeric IDs
- `parse_target_input()` classifies creator URLs, video URLs, and `aweme_id`
- `resolve_target()` uses the active browser page when CLI input is absent
- `resolve_target()` raises a readable error when neither the current page nor the manual input is supported
- [ ] **Step 2: Run the focused tests to verify RED**
Run: `python3 -m unittest test_douyin.py -q`
Expected: FAIL because the new target-resolution helpers do not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Implement the smallest set of pure helper functions and a compact parsed-target structure in `Douyin.py`.
- [ ] **Step 4: Run the focused tests to verify GREEN**
Run: `python3 -m unittest test_douyin.py -q`
Expected: PASS
### Task 3: Add failing tests for current-page behavior and visible-only creator flow
**Files:**
- Modify: `test_douyin.py`
- Modify: `Douyin.py`
- [ ] **Step 1: Write the failing tests**
Add tests for:
- current-page creator mode does not auto-scroll by default
- creator flow reports a clear error when no aweme items are available
- [ ] **Step 2: Run the focused tests to verify RED**
Run: `python3 -m unittest test_douyin.py -q`
Expected: FAIL because the current creator flow still scrolls automatically.
- [ ] **Step 3: Write the minimal implementation**
Split creator crawling so the default path only processes the currently loaded response set and does not call scroll helpers automatically.
- [ ] **Step 4: Run the focused tests to verify GREEN**
Run: `python3 -m unittest test_douyin.py -q`
Expected: PASS
### Task 4: Add failing tests for single-video flow
**Files:**
- Modify: `test_douyin.py`
- Modify: `Douyin.py`
- [ ] **Step 1: Write the failing tests**
Add tests for:
- resolving a video URL leads to a single-video target
- resolving an `aweme_id` leads to a single-video target
- single-video flow downloads exactly one file
- [ ] **Step 2: Run the focused tests to verify RED**
Run: `python3 -m unittest test_douyin.py -q`
Expected: FAIL because single-video execution path does not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Implement single-video resolution and a narrow download path that saves one mp4 file.
- [ ] **Step 4: Run the focused tests to verify GREEN**
Run: `python3 -m unittest test_douyin.py -q`
Expected: PASS
### Task 5: Update CLI entry behavior and verify end to end
**Files:**
- Modify: `Douyin.py`
- Modify: `test_douyin.py`
- Modify: `README.md`
- Modify: `externaldocs/beginner-guide.md`
- [ ] **Step 1: Write the failing tests**
Add tests for:
- default CLI invocation with no positional target chooses current-page resolution
- unsupported current page produces a fallback hint
- manual positional target overrides the current page
- [ ] **Step 2: Run the focused tests to verify RED**
Run: `python3 -m unittest test_douyin.py -q`
Expected: FAIL because the CLI still assumes a default hardcoded creator URL.
- [ ] **Step 3: Write the minimal implementation**
Update the parser and `main()` flow so zero-argument execution becomes the default, while keeping the manual positional target as fallback.
- [ ] **Step 4: Update user docs**
Revise `README.md` and `externaldocs/beginner-guide.md` to show the new default flow:
```bash
./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py
```
- [ ] **Step 5: Run full verification**
Run: `python3 -m unittest -q`
Expected: PASS for the full test suite.

View File

@ -0,0 +1,173 @@
# Douyin Zero-Argument Target Detection Design
## Goal
将当前抓取入口从“主要依赖手动传 URL 或页码参数”收敛为“默认零参数运行”,让用户在最常见场景下只需要:
```bash
./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py
```
脚本会优先附着已登录浏览器,并自动识别当前活动标签页是博主主页还是单视频页。
## Current Context
- `login_douyin.py` 已能启动带调试端口的可见 Chrome。
- `Douyin.py` 已能附着浏览器、监听博主作品列表接口并下载视频。
- 当前抓取入口仍偏向“显式传 URL + 可选翻页参数”。
- 当前实现默认会继续滚动翻页,这与“只按当前浏览器已加载状态工作”的目标不一致。
## User Experience
### Default Flow
1. 用户运行 `./.venv/bin/python login_douyin.py`
2. 用户在 Chrome 里完成抖音登录、验证码,并停留在目标页面
3. 用户运行 `./.venv/bin/python Douyin.py`
### Default Crawl Behavior
- 如果当前活动标签页是博主主页:
- 只抓当前页面已经加载出来的作品
- 不自动继续翻多页
- 如果当前活动标签页是单视频页:
- 只下载这一条视频
- 如果当前活动标签页不是受支持页面:
- 明确报错
- 提示用户手动传入链接或 `aweme_id`
### Manual Fallback
用户仅在自动判断失败或不想切换浏览器页面时,才传一个简单目标值:
```bash
./.venv/bin/python Douyin.py "https://www.douyin.com/user/..."
./.venv/bin/python Douyin.py "https://www.douyin.com/video/..."
./.venv/bin/python Douyin.py "7619989983668240802"
```
## Chosen Approach
采用“内部显式分类,外部最小参数”的方案:
- 对用户:
- 默认零参数
- 保留一个位置参数作为手动兜底入口
- 对代码:
- 先统一解析输入来源和目标类型
- 再分发到“博主页抓取”或“单视频下载”路径
这样既避免 `--mode --target` 的使用负担,也保留明确的内部边界,便于测试。
## Internal Model
建议在 `Douyin.py` 内新增一层目标解析:
- `creator`
- 来源可以是当前活动页 URL 或手动传入的博主页 URL
- `single-video`
- 来源可以是当前活动页 URL、手动传入的视频 URL 或 `aweme_id`
- `unsupported`
- 当前页或手动输入都不符合支持规则
建议新增的小函数边界:
- `parse_target_input(value: str) -> ParsedTarget`
- `resolve_target(page: Any, cli_target: str | None) -> ParsedTarget`
- `is_creator_url(url: str) -> bool`
- `is_video_url(url: str) -> bool`
- `is_aweme_id(value: str) -> bool`
- `get_active_page_url(page: Any) -> str`
## Crawl Paths
### Creator Path
- 复用当前 `web/aweme/post/` 监听和解析能力
- 默认只处理当前已加载内容
- 不再在默认主流程中自动继续滚动翻页
- 若页面未加载出可用作品数据,明确提示用户先在浏览器中完成加载或筛选后再重试
### Single Video Path
- 若目标是视频 URL先解析出 `aweme_id`
- 若目标本身就是 `aweme_id`,直接进入单视频下载
- 下载结果仍写入 `video/`
- 最终只落地一个 mp4 文件
## CLI Design
### Keep
- 保留 `--browser-port`
- 保留 `--output-dir`
- 保留 `--timeout`
### Change
- 位置参数从“默认博主页 URL”改为“可选手动目标值”
- 默认不再依赖硬编码博主页 URL
### De-Emphasize
- `--pages` 不再作为默认主流程的核心参数
- 如果保留兼容性也不应影响零参数主路径的“visible-only”语义
## Error Handling
- 浏览器端口不可用:提示先运行 `login_douyin.py` 并确认 Chrome 仍在运行
- 当前活动标签页不是受支持页面:提示切到目标页面,或手动传入链接/`aweme_id`
- 手动输入既不是博主页 URL、视频 URL也不是 `aweme_id`:明确报错
- 博主页没有加载出作品数据:提示用户先在浏览器页面完成滚动或等待加载
- 视频标识无法解析:明确报错
## Testing Strategy
### Unit Tests First
第一轮 TDD 先覆盖目标识别和错误路径:
1. 当前页是博主页 URL 时,识别为 `creator`
2. 当前页是单视频页 URL 时,识别为 `single-video`
3. 手动传入博主页 URL 时,识别为 `creator`
4. 手动传入视频 URL 时,识别为 `single-video`
5. 手动传入 `aweme_id` 时,识别为 `single-video`
6. 当前页不支持且没传参数时,抛出可读错误
7. 手动输入不支持时,抛出可读错误
第二轮 TDD 再覆盖执行路径:
1. 博主页默认不自动继续翻页
2. 单视频路径最终只下载一条
3. 端口不可用时提前失败
## Implementation Boundaries
本次实现只做:
- 零参数目标识别
- 当前活动标签页自动判断
- 单位置参数兜底输入
- 博主页 visible-only 默认行为
- 单视频下载路径
- 对应自动化测试
本次不做:
- 自动登录
- 自动搜索博主
- 自动筛选页面条件
- 抓完整个博主历史内容
- 任意网页抓取
## Success Criteria
满足以下条件即视为完成:
1. 用户默认执行 `./.venv/bin/python Douyin.py` 可以按当前活动标签页工作
2. 当前页为博主页时,只抓当前已加载内容
3. 当前页为单视频页时,只下载这一条
4. 当前页不支持时,提示用户手动传链接或 `aweme_id`
5. 手动传入博主页 URL、视频 URL 或 `aweme_id` 时可正常工作
6. 关键路径都有自动化测试覆盖,并按 TDD 落地

View File

@ -2,7 +2,7 @@
## Goal ## Goal
在现有“登录浏览器后附着抓取”的基础上,扩展为支持更明确的目标选择能力,使系统不仅能抓默认博主主页,还能: 在现有“登录浏览器后附着抓取”的基础上,扩展为支持更明确、但尽量少参数的目标选择能力,使系统不仅能抓默认博主主页,还能:
- 指定某个博主主页进行抓取 - 指定某个博主主页进行抓取
- 直接抓当前浏览器里正在查看的博主主页 - 直接抓当前浏览器里正在查看的博主主页
@ -24,15 +24,19 @@
## Target Modes ## Target Modes
新版本必须同时支持以下三种目标模式: 从实现视角看,新版本仍需同时支持以下三种目标模式;但默认用户交互应尽量自动判断,不要求用户每次显式传模式参数。
### 1. `creator-url` ### 1. `creator-url`
用户显式传入某个博主主页 URL系统以该博主主页为目标进行抓取。 用户显式传入某个博主主页 URL系统以该博主主页为目标进行抓取。
### 2. `current-creator` ### 2. `current-page`
系统直接读取当前已附着浏览器正在查看的页面。如果当前页面是博主主页,则以该页面为目标进行抓取。 系统直接读取当前已附着浏览器当前活动标签页正在查看的页面:
- 如果当前页面是博主主页,则以该页面为目标进行抓取
- 如果当前页面是单视频页,则按单视频方式抓取
- 如果当前页面不是支持的抖音页面,则提示用户手动传入链接或 `aweme_id`
### 3. `single-video` ### 3. `single-video`
@ -75,20 +79,33 @@
登录完成后,再运行抓取命令。 登录完成后,再运行抓取命令。
未来命令行接口应支持显式目标模式,例如 默认抓取命令应尽量零参数
```bash ```bash
./.venv/bin/python Douyin.py --mode creator-url --target "https://www.douyin.com/user/..." ./.venv/bin/python Douyin.py
./.venv/bin/python Douyin.py --mode current-creator
./.venv/bin/python Douyin.py --mode single-video --target "https://www.douyin.com/video/..."
./.venv/bin/python Douyin.py --mode single-video --target "7619989983668240802"
``` ```
上面只是推荐交互形态,具体参数名可在实现设计阶段微调,但必须满足以下原则 其推荐行为为:
- 模式必须显式可区分 - 默认附着已启动的登录浏览器
- “当前浏览器页面”与“传入 URL”不能混淆 - 默认读取当前活动标签页 URL
- 单视频目标与博主目标不能混淆 - 自动判断当前页是博主主页还是单视频页
- 若当前页不是支持页面,则报错并提示用户手动传入链接或 `aweme_id`
同时系统必须保留一个简单的手动兜底入口,例如单个位置参数:
```bash
./.venv/bin/python Douyin.py "https://www.douyin.com/user/..."
./.venv/bin/python Douyin.py "https://www.douyin.com/video/..."
./.venv/bin/python Douyin.py "7619989983668240802"
```
具体参数名可在实现设计阶段微调,但必须满足以下原则:
- 默认路径应尽量不需要复杂参数
- “当前浏览器页面自动判断”与“手动传入目标”不能混淆
- 单视频目标与博主目标在内部逻辑上不能混淆
- 用户一旦需要手动兜底,输入形式应尽量简单
## Functional Requirements ## Functional Requirements
@ -102,15 +119,16 @@
- 浏览器打开或切换到该 URL - 浏览器打开或切换到该 URL
- 系统只抓当前页面已加载的作品 - 系统只抓当前页面已加载的作品
### Requirement B: Current Browser Creator Crawling ### Requirement B: Current Browser Page Auto Detection
系统必须允许用户不手输目标 URL而是直接抓当前浏览器页面对应的博主主页 系统必须允许用户不手输目标 URL而是直接按当前已附着浏览器的活动标签页进行自动判断
完成条件: 完成条件:
- 系统能读取当前浏览器页 URL - 系统能读取当前浏览器活动标签页 URL
- 若当前页面是博主主页,则正常抓取 - 若当前页面是博主主页,则正常抓取
- 若当前页面不是博主主页,则明确报错并退出 - 若当前页面是单视频页,则按单视频逻辑抓取
- 若当前页面不是支持的抖音页面,则明确报错,并提示用户手动传链接或 `aweme_id`
### Requirement C: Single Video Download ### Requirement C: Single Video Download
@ -138,7 +156,7 @@
### Current Creator Errors ### Current Creator Errors
- 当前页面不是博主主页:报错并退出 - 当前页面不是受支持的抖音博主页或单视频页:报错并提示手动传链接或 `aweme_id`
- 当前页面虽然像博主页,但未加载出可用作品数据:提示用户先完成页面操作后重试 - 当前页面虽然像博主页,但未加载出可用作品数据:提示用户先完成页面操作后重试
### Single Video Errors ### Single Video Errors
@ -195,21 +213,23 @@
至少覆盖以下测试: 至少覆盖以下测试:
- `creator-url` 模式下,合法博主主页 URL 能被识别并生成正确抓取目标 - `creator-url` 模式下,合法博主主页 URL 能被识别并生成正确抓取目标
- `current-creator` 模式下,当前页面是博主主页时可抓取 - 默认零参数模式下,当前页面是博主主页时可抓取
- `current-creator` 模式下,当前页面不是博主主页时明确报错 - 默认零参数模式下,当前页面是单视频页时可抓取
- 默认零参数模式下,当前页面不是支持页面时明确报错并提示手动输入
- `single-video` 模式支持视频 URL - `single-video` 模式支持视频 URL
- `single-video` 模式支持 `aweme_id` - `single-video` 模式支持 `aweme_id`
- 创作者抓取默认只处理当前已加载内容,不自动继续翻页 - 创作者抓取默认只处理当前已加载内容,不自动继续翻页
- 目标模式错误时的报错路径 - 手动输入目标无法识别时的报错路径
- 浏览器端口不可用时的报错路径 - 浏览器端口不可用时的报错路径
## Acceptance Criteria ## Acceptance Criteria
需求完成后,应满足以下验收标准: 需求完成后,应满足以下验收标准:
1. 用户可以显式指定博主主页 URL 抓取 1. 用户在最常见场景下可以直接执行 `./.venv/bin/python Douyin.py`
2. 用户可以直接抓当前浏览器中的博主主 2. 系统可以自动识别当前浏览器活动标签页是博主主页还是单视频
3. 用户可以指定单视频 URL 或 `aweme_id` 下载单条视频 3. 用户可以手动指定博主主页 URL、单视频 URL 或 `aweme_id`
4. 当目标是博主时,默认只抓当前页面已加载作品 4. 当目标是博主时,默认只抓当前页面已加载作品
5. 关键失败场景都有明确报错 5. 当前页面不受支持时,系统会明确提示手动传入链接或 `aweme_id`
6. 实现过程遵循 TDD并有对应自动化测试覆盖 6. 关键失败场景都有明确报错
7. 实现过程遵循 TDD并有对应自动化测试覆盖

View File

@ -0,0 +1,196 @@
# README And Beginner Guide Requirements
## Goal
为当前抖音视频爬取项目补齐两层面向用户的文档,使一个不懂代码的 Mac 用户也能按照步骤执行,并最终成功下载出抖音视频。
本次文档需求包含两部分:
- 仓库根目录的 `README.md`
- `externaldocs/` 下的详细图文操作手册
本需求文档只定义文档目标、范围、结构、截图策略和约束,不直接修改实现代码。
## Target Audience
目标读者是:
- 使用 macOS 的用户
- 不懂代码
- 不熟悉 Python
- 不熟悉虚拟环境
- 但已经把项目放到了本地机器上
本次文档不负责指导用户如何 `git clone` 或下载项目源码到本地。
## Documentation Strategy
采用双层文档结构:
### 1. Root `README.md`
`README.md` 作为导航版首页,职责是:
- 简要说明项目是什么
- 说明项目能做什么
- 说明项目不能做什么
- 告诉用户从哪里开始
- 引导用户查看详细图文手册
`README.md` 不承担完整教学职责,不写成超长操作文档。
### 2. Detailed Guide In `externaldocs/`
详细图文手册作为主操作文档,职责是:
- 面向完全不会代码的用户
- 从环境准备开始写
- 一步一步带到成功下载出视频
- 给出执行命令、预期结果和失败时的处理方式
## Scope
### Included
详细图文手册必须覆盖以下内容:
1. 项目简介
2. 使用前准备
3. 如何确认本机已安装 Python
4. 如何打开终端
5. 如何进入项目目录
6. 如何创建或使用虚拟环境
7. 如何安装依赖
8. 如何启动登录浏览器
9. 如何在浏览器中完成抖音登录和验证码
10. 如何运行抓取命令
11. 如何确认抓取成功
12. 下载结果保存在哪里
13. 常见错误及处理办法
### Excluded
本次文档明确不包含以下内容:
- 如何从远端拉取项目代码到本地
- Git 基础教学
- 任意网页抓取教学
- 非 Mac 平台的安装说明
- 抖音接口原理深度分析
## README Requirements
`README.md` 必须满足以下要求:
1. 用非技术语言介绍项目用途
2. 明确说明这是一个“登录浏览器后附着抓取”的工具
3. 简短说明当前项目的主要能力
4. 简短说明当前项目的限制
5. 提供“最快开始”入口
6. 链接到 `externaldocs/` 下的详细图文手册
### Recommended README Sections
- 项目简介
- 适用人群
- 当前支持的能力
- 快速开始
- 详细使用说明
- 常见注意事项
## Detailed Guide Requirements
详细图文手册必须写成严格的步骤式说明,适合完全不会代码的读者照做。
### Writing Style
- 不使用不必要术语
- 每一步只做一件事
- 每一步都给出要执行的命令
- 每一步都给出“执行后应该看到什么”
- 若这一步常见失败,则紧跟“如果失败怎么办”
### Required Workflow Coverage
详细手册必须按以下顺序组织主流程:
1. 确认 Python 是否已安装
2. 打开终端
3. 进入项目目录
4. 创建或启用虚拟环境
5. 安装依赖
6. 启动登录浏览器
7. 在浏览器中完成抖音登录
8. 运行抓取命令
9. 查看终端成功提示
10. 在本地查看下载出来的 mp4 文件
## Screenshot Strategy
本次截图必须重新拍摄,不直接复用旧图。
### Principles
- 只截关键步骤
- 每张图只服务一个步骤
- 截图内容要与最终文档步骤严格一致
- 优先保证“能照着做”,而不是“图多”
### Required Screenshot Topics
至少应考虑覆盖以下截图:
1. 项目目录结构
2. 打开终端并进入项目目录
3. 安装依赖命令
4. 运行 `login_douyin.py`
5. 浏览器登录后的状态
6. 运行抓取命令
7. 成功下载后的 `video/` 目录
### Screenshot Placement
- `README.md` 只放少量必要截图或不放截图
- 主要截图集中放在详细图文手册中
## Success Signals In The Guide
文档里必须明确告诉用户如何判断是否成功,至少包括:
- 终端中会出现哪些关键信息
- 抓取完成后会看到哪些成功提示
- 本地哪个目录里能看到下载结果
- 下载出的文件是什么格式
## Common Error Handling
详细图文手册必须包含至少以下常见错误的处理建议:
1. 系统找不到 `python``.venv`
2. 依赖安装失败
3. Chrome 调试端口未就绪
4. 抖音登录未完成或验证码未通过
5. 运行抓取后没有下载出视频
6. 看不到 `video/` 目录或目录为空
## TDD Constraint
本次文档需求本身不要求用 TDD 编写文档。
但如果为了让文档步骤成立而需要修改代码,则后续代码改动必须遵循 TDD
1. 先写失败测试
2. 确认测试因功能未实现而失败
3. 再写最小实现让测试通过
4. 最后再重构
## Acceptance Criteria
该需求完成后,应满足以下标准:
1. 仓库首页有清晰的 `README.md`
2. `README.md` 能让用户快速理解项目并找到详细手册
3. `externaldocs/` 下存在一份完整的面向小白的图文操作手册
4. 一个不会代码的 Mac 用户可以按手册独立完成操作
5. 手册能从环境准备一路带到视频下载成功
6. 手册包含关键截图、预期结果和常见错误处理

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

View File

@ -0,0 +1,329 @@
# 抖音视频下载小白图文手册
> 适用人群Mac 用户、项目已经在本地、不会代码也可以照着做。
## 这份手册能帮你做什么
照着这份手册执行,你可以完成下面这件事:
- 打开项目
- 安装运行所需环境
- 登录抖音
- 启动抓取
- 在本地找到下载好的 mp4 视频
## 你开始前要知道的事
这个项目不是“自动登录”的。
你需要自己在浏览器里完成:
- 登录抖音账号
- 验证码
脚本负责做的是:
- 打开浏览器
- 连接到浏览器
- 读取当前浏览器页面里的作品数据
- 下载视频文件
## 第 1 步:确认 Mac 上已经安装了 Python
打开“终端”应用。
在终端里输入下面的命令:
```bash
python3 --version
```
你应该看到类似这样的结果:
```text
Python 3.11.0
```
如果你看到了版本号,说明可以继续。
如果提示找不到 `python3`
- 先安装 Python 3
- 安装完成后重新执行上面的命令
## 第 2 步:进入项目目录
假设你的项目放在桌面目录下,那么在终端里输入:
```bash
cd ~/Desktop/MiaoSi/Study/douyin-crawler-poc
```
你也可以把上面的路径换成你自己的项目路径。
然后输入:
```bash
pwd
```
如果输出结果里能看到 `douyin-crawler-poc`,说明你已经进入了正确目录。
## 第 3 步:创建虚拟环境
在项目目录里执行:
```bash
python3 -m venv .venv
```
这个命令的作用是:
- 给当前项目准备一个独立的 Python 运行环境
- 避免你的系统里其他 Python 包互相影响
如果这一步没有报错,就继续下一步。
## 第 4 步:激活虚拟环境
在终端里执行:
```bash
source .venv/bin/activate
```
成功后,你会看到终端左边通常多出一个类似 `.venv` 的提示。
如果你没有看到,也不用太紧张,只要命令没有报错,也通常可以继续。
## 第 5 步:安装依赖
在终端里执行:
```bash
pip install requests DrissionPage
```
如果安装成功,终端会显示若干 `Successfully installed` 或安装完成信息。
如果安装失败,常见处理方式:
- 检查网络是否可用
- 确认前一步虚拟环境已经激活
- 再执行一次命令
## 第 6 步:启动登录浏览器
在终端里执行:
```bash
./.venv/bin/python login_douyin.py
```
如果成功,你会看到类似提示:
```text
[INFO] Chrome 已启动。请在打开的浏览器中完成抖音登录和验证码。
```
这一步会做两件事:
- 打开一个新的 Chrome 浏览器窗口
- 给后面的抓取脚本准备调试端口
如果这一步失败,常见原因是:
- Chrome 没有安装
- 调试端口没能准备好
## 第 7 步:在浏览器里登录抖音
浏览器打开后,请你自己完成:
- 登录抖音账号
- 完成验证码
- 确认你已经进入想抓取的页面
这个页面可以是:
- 某个博主主页
- 某个单独视频页面
建议此时先不要关闭这个浏览器窗口。
## 第 8 步:运行抓取命令
回到终端,执行:
```bash
./.venv/bin/python Douyin.py
```
这条命令的意思是:
- 附着到刚才打开的浏览器
- 自动读取你当前打开的页面
- 如果当前页是博主主页,就下载当前已加载的视频
- 如果当前页是单视频页,就只下载这一条视频
如果自动判断失败,或者你不想切换浏览器页面,也可以手动传一个目标:
```bash
./.venv/bin/python Douyin.py "https://www.douyin.com/user/你的博主主页"
./.venv/bin/python Douyin.py "https://www.douyin.com/video/某个视频ID"
./.venv/bin/python Douyin.py "7619989983668240802"
```
## 第 9 步:判断是否抓取成功
抓取成功时,终端一般会看到类似输出:
```text
[INFO] 正在处理第 1 页
[OK] 已保存: video/某个视频标题-1234567890.mp4
[INFO] 处理结束,共下载 18 个视频。
```
如果你当前打开的是单视频页,也可能看到类似:
```text
[OK] 已保存: video/某个视频标题-1234567890.mp4
[INFO] 处理结束,共下载 1 个视频。
```
其中最关键的是:
- 出现 `[OK] 已保存`
- 最后一行出现 `处理结束`
如果看到这些内容,说明视频已经成功下载。
## 第 10 步:去本地查看下载好的视频
下载成功后,视频会保存在项目目录下的:
```text
video/
```
你可以用以下任一方式查看:
- 在 Finder 里打开项目目录,进入 `video/`
- 在 VS Code 左侧资源管理器中打开 `video/`
- 在终端里执行 `ls video`
视频文件格式是:
```text
.mp4
```
## 常见问题
### 1. 终端提示找不到 `python3`
说明你的 Mac 还没有安装可用的 Python 3。
先安装 Python 3再重新执行
```bash
python3 --version
```
### 2. 终端提示找不到 `.venv`
说明你还没有成功创建虚拟环境。
请先执行:
```bash
python3 -m venv .venv
```
然后再执行:
```bash
source .venv/bin/activate
```
### 3. 启动浏览器后提示调试端口未就绪
常见处理方法:
- 先确认 Chrome 已正常打开
- 不要立即关闭浏览器
- 重新运行 `login_douyin.py`
### 4. 运行抓取命令后提示无法连接浏览器
说明抓取脚本没有连上登录浏览器。
请按顺序重试:
1. 重新执行 `./.venv/bin/python login_douyin.py`
2. 确认浏览器保持打开
3. 再执行 `./.venv/bin/python Douyin.py`
### 5. 登录后还是没有下载出视频
可能原因:
- 你还没有真正登录成功
- 验证码还没完全通过
- 当前页面不是作品页
- 当前页面没有加载出作品数据
建议:
- 先确认浏览器里已经能正常看到博主主页和作品
- 再重新执行抓取命令
### 6. 我看不到 `video/` 文件夹
先在终端执行:
```bash
ls video
```
如果这里能看到很多 `.mp4` 文件,说明视频已经下载成功。
如果 VS Code 左侧看不到 `video/`,请刷新资源管理器。
## 建议你第一次就这样操作
把下面这几条命令按顺序执行:
```bash
cd ~/Desktop/MiaoSi/Study/douyin-crawler-poc
python3 -m venv .venv
source .venv/bin/activate
pip install requests DrissionPage
./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py
```
## 附:当前项目的实际工作方式
当前版本的逻辑是:
- 默认读取当前浏览器页面
- 如果当前页是博主主页,就监听该页面的作品列表接口
- 如果当前页是单视频页,就下载这一条视频
- 传入链接或 `aweme_id` 时,也可以按指定目标抓取
所以它更适合抓:
- 博主主页当前页面里已经能看到的作品
- 当前打开的单视频页面
而不是:
- 自动抓完整个博主所有历史视频
- 抓任意网页
## 进一步阅读
- [README 首页说明](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/README.md)
- [当前项目需求说明](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/2026-04-17-readme-and-beginner-guide-requirements.md)
- [定向抓取后续需求](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md)

View File

@ -114,7 +114,10 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
print("[INFO] Chrome 已启动。请在打开的浏览器中完成抖音登录和验证码。") print("[INFO] Chrome 已启动。请在打开的浏览器中完成抖音登录和验证码。")
print(f"[INFO] 登录完成后执行: ./.venv/bin/python Douyin.py --browser-port {args.browser_port}") next_command = "./.venv/bin/python Douyin.py"
if args.browser_port != DEFAULT_BROWSER_PORT:
next_command = f"{next_command} --browser-port {args.browser_port}"
print(f"[INFO] 登录完成后执行: {next_command}")
return 0 return 0

View File

@ -1,5 +1,7 @@
import importlib import importlib
import io
import unittest import unittest
from contextlib import redirect_stdout
from unittest import mock from unittest import mock
@ -9,6 +11,48 @@ class FakeResponse:
self.raw_body = raw_body self.raw_body = raw_body
class FakePage:
def __init__(self, url: str):
self.url = url
class FakePacketResponse:
def __init__(self, body):
self.body = body
self.raw_body = ""
class FakePacket:
def __init__(self, body):
self.response = FakePacketResponse(body)
class FakeListener:
def __init__(self, packet):
self.packet = packet
self.started_targets = []
def start(self, target):
self.started_targets.append(target)
def wait(self, timeout):
return self.packet
class FakeRuntimePage:
def __init__(self, url: str, packet):
self.url = url
self.listen = FakeListener(packet)
self.visited_urls = []
def get(self, url):
self.visited_urls.append(url)
self.url = url
def run_js(self, script):
raise AssertionError(f"unexpected scroll script: {script}")
class DouyinModuleTests(unittest.TestCase): class DouyinModuleTests(unittest.TestCase):
def test_module_can_import_without_optional_runtime_dependencies(self) -> None: def test_module_can_import_without_optional_runtime_dependencies(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
@ -71,6 +115,258 @@ class DouyinModuleTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "login_douyin.py"): with self.assertRaisesRegex(RuntimeError, "login_douyin.py"):
module.ensure_browser_debug_port_ready(9223) module.ensure_browser_debug_port_ready(9223)
def test_is_creator_url_accepts_supported_douyin_creator_url(self) -> None:
module = importlib.import_module("Douyin")
self.assertTrue(
module.is_creator_url(
"https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main"
)
)
self.assertFalse(module.is_creator_url("https://www.douyin.com/video/7619989983668240802"))
def test_is_video_url_accepts_supported_douyin_video_url(self) -> None:
module = importlib.import_module("Douyin")
self.assertTrue(module.is_video_url("https://www.douyin.com/video/7619989983668240802"))
self.assertFalse(
module.is_video_url("https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main")
)
def test_is_aweme_id_accepts_numeric_identifier(self) -> None:
module = importlib.import_module("Douyin")
self.assertTrue(module.is_aweme_id("7619989983668240802"))
self.assertFalse(module.is_aweme_id("not-an-aweme-id"))
def test_parse_target_input_classifies_creator_url(self) -> None:
module = importlib.import_module("Douyin")
target = module.parse_target_input(
"https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
source="manual",
)
self.assertEqual(target.kind, "creator")
self.assertEqual(
target.value,
"https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
)
self.assertEqual(target.source, "manual")
def test_parse_target_input_classifies_video_url(self) -> None:
module = importlib.import_module("Douyin")
target = module.parse_target_input(
"https://www.douyin.com/video/7619989983668240802",
source="manual",
)
self.assertEqual(target.kind, "single-video")
self.assertEqual(target.aweme_id, "7619989983668240802")
self.assertEqual(target.source, "manual")
def test_parse_target_input_classifies_aweme_id(self) -> None:
module = importlib.import_module("Douyin")
target = module.parse_target_input("7619989983668240802", source="manual")
self.assertEqual(target.kind, "single-video")
self.assertEqual(target.value, "7619989983668240802")
self.assertEqual(target.aweme_id, "7619989983668240802")
def test_resolve_target_uses_current_page_when_cli_target_is_absent(self) -> None:
module = importlib.import_module("Douyin")
target = module.resolve_target(
page=FakePage("https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main"),
cli_target=None,
)
self.assertEqual(target.kind, "creator")
self.assertEqual(target.source, "current-page")
def test_resolve_target_raises_readable_error_when_current_page_is_unsupported(self) -> None:
module = importlib.import_module("Douyin")
with self.assertRaisesRegex(RuntimeError, "手动传入链接或 `aweme_id`"):
module.resolve_target(page=FakePage("https://www.example.com/"), cli_target=None)
def test_resolve_target_raises_readable_error_when_manual_input_is_unsupported(self) -> None:
module = importlib.import_module("Douyin")
with self.assertRaisesRegex(RuntimeError, "不支持的目标"):
module.resolve_target(page=FakePage("https://www.douyin.com/video/7619989983668240802"), cli_target="abc")
def test_collect_videos_does_not_auto_scroll_when_processing_current_page_only(self) -> None:
module = importlib.import_module("Douyin")
packet = FakePacket(
{
"aweme_list": [
{
"aweme_id": "7619989983668240802",
"desc": "当前页视频",
"video": {
"play_addr": {
"url_list": ["https://v26-web.douyinvod.com/example/video.mp4"]
}
},
}
]
}
)
page = FakeRuntimePage(
"https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
packet,
)
with mock.patch.object(module, "import_runtime_dependencies", return_value=(object(), object(), object())):
with mock.patch.object(module, "create_page", return_value=page):
with mock.patch.object(module, "download_video"):
with mock.patch.object(module, "scroll_to_next_page") as mocked_scroll:
downloaded = module.collect_videos(
user_url="https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
max_pages=1,
timeout=10,
output_dir=module.Path("video"),
browser_port=None,
)
self.assertEqual(downloaded, 1)
mocked_scroll.assert_not_called()
def test_collect_videos_raises_readable_error_when_no_aweme_items_are_available(self) -> None:
module = importlib.import_module("Douyin")
packet = FakePacket({"aweme_list": []})
page = FakeRuntimePage(
"https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
packet,
)
with mock.patch.object(module, "import_runtime_dependencies", return_value=(object(), object(), object())):
with mock.patch.object(module, "create_page", return_value=page):
with mock.patch.object(module, "download_video"):
with self.assertRaisesRegex(RuntimeError, "当前页面未加载出可用作品数据"):
module.collect_videos(
user_url="https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
max_pages=1,
timeout=10,
output_dir=module.Path("video"),
browser_port=None,
)
def test_build_video_page_url_uses_aweme_id(self) -> None:
module = importlib.import_module("Douyin")
self.assertEqual(
module.build_video_page_url("7619989983668240802"),
"https://www.douyin.com/video/7619989983668240802",
)
def test_collect_single_video_downloads_exactly_one_file_for_video_url_target(self) -> None:
module = importlib.import_module("Douyin")
packet = FakePacket(
{
"aweme_detail": {
"aweme_id": "7619989983668240802",
"desc": "单视频页面",
"video": {
"play_addr": {
"url_list": ["https://v26-web.douyinvod.com/example/single.mp4"]
}
},
}
}
)
page = FakeRuntimePage("https://www.douyin.com/video/7619989983668240802", packet)
target = module.ResolvedTarget(
kind="single-video",
value="https://www.douyin.com/video/7619989983668240802",
source="manual",
aweme_id="7619989983668240802",
)
with mock.patch.object(module, "import_runtime_dependencies", return_value=(object(), object(), object())):
with mock.patch.object(module, "create_page", return_value=page):
with mock.patch.object(module, "download_video") as mocked_download:
downloaded = module.collect_single_video(
target=target,
timeout=10,
output_dir=module.Path("video"),
browser_port=None,
)
self.assertEqual(downloaded, 1)
self.assertEqual(page.visited_urls, ["https://www.douyin.com/video/7619989983668240802"])
mocked_download.assert_called_once()
def test_collect_single_video_downloads_exactly_one_file_for_aweme_id_target(self) -> None:
module = importlib.import_module("Douyin")
packet = FakePacket(
{
"aweme_detail": {
"aweme_id": "7619989983668240802",
"desc": "单视频页面",
"video": {
"play_addr": {
"url_list": ["https://v26-web.douyinvod.com/example/single.mp4"]
}
},
}
}
)
page = FakeRuntimePage("about:blank", packet)
target = module.ResolvedTarget(
kind="single-video",
value="7619989983668240802",
source="manual",
aweme_id="7619989983668240802",
)
with mock.patch.object(module, "import_runtime_dependencies", return_value=(object(), object(), object())):
with mock.patch.object(module, "create_page", return_value=page):
with mock.patch.object(module, "download_video") as mocked_download:
downloaded = module.collect_single_video(
target=target,
timeout=10,
output_dir=module.Path("video"),
browser_port=None,
)
self.assertEqual(downloaded, 1)
self.assertEqual(page.visited_urls, ["https://www.douyin.com/video/7619989983668240802"])
mocked_download.assert_called_once()
def test_build_parser_defaults_to_zero_argument_current_page_flow(self) -> None:
module = importlib.import_module("Douyin")
args = module.build_parser().parse_args([])
self.assertIsNone(args.target)
self.assertEqual(args.browser_port, 9223)
self.assertEqual(args.pages, 1)
def test_resolve_cli_target_prefers_manual_target_without_attaching_browser(self) -> None:
module = importlib.import_module("Douyin")
with mock.patch.object(module, "import_runtime_dependencies") as mocked_imports:
target = module.resolve_cli_target("7619989983668240802", browser_port=9223)
self.assertEqual(target.kind, "single-video")
self.assertEqual(target.aweme_id, "7619989983668240802")
mocked_imports.assert_not_called()
def test_main_without_target_dispatches_current_page_creator_flow(self) -> None:
module = importlib.import_module("Douyin")
stdout = io.StringIO()
creator_target = module.ResolvedTarget(
kind="creator",
value="https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
source="current-page",
)
with redirect_stdout(stdout):
with mock.patch.object(module, "resolve_cli_target", return_value=creator_target):
with mock.patch.object(module, "collect_videos", return_value=2) as mocked_collect:
exit_code = module.main([])
self.assertEqual(exit_code, 0)
mocked_collect.assert_called_once_with(
user_url="https://www.douyin.com/user/MS4wLjABAAAAexample?from_tab_name=main",
max_pages=1,
timeout=10,
output_dir=module.Path("video"),
browser_port=9223,
auto_scroll=False,
)
self.assertIn("处理结束,共下载 2 个视频", stdout.getvalue())
def test_main_returns_fallback_hint_when_current_page_is_unsupported(self) -> None:
module = importlib.import_module("Douyin")
stdout = io.StringIO()
with redirect_stdout(stdout):
with mock.patch.object(
module,
"resolve_cli_target",
side_effect=RuntimeError("请切到目标页面后重试,或手动传入链接或 `aweme_id`。"),
):
exit_code = module.main([])
self.assertEqual(exit_code, 1)
self.assertIn("手动传入链接或 `aweme_id`", stdout.getvalue())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -61,6 +61,26 @@ class LoginDouyinModuleTests(unittest.TestCase):
self.assertIn("9333", stdout.getvalue()) self.assertIn("9333", stdout.getvalue())
self.assertIn("./.venv/bin/python Douyin.py --browser-port 9333", stdout.getvalue()) self.assertIn("./.venv/bin/python Douyin.py --browser-port 9333", stdout.getvalue())
def test_main_uses_zero_argument_next_step_for_default_browser_port(self) -> None:
module = importlib.import_module("login_douyin")
with tempfile.TemporaryDirectory() as temp_dir:
profile_dir = Path(temp_dir) / "profile"
stdout = io.StringIO()
with redirect_stdout(stdout):
with mock.patch.object(module, "launch_browser"):
with mock.patch.object(module, "wait_for_browser_debug_port"):
exit_code = module.main(
[
"--chrome-path",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"--profile-dir",
str(profile_dir),
]
)
self.assertEqual(exit_code, 0)
self.assertIn("./.venv/bin/python Douyin.py", stdout.getvalue())
self.assertNotIn("--browser-port 9223", stdout.getvalue())
def test_main_returns_error_when_chrome_path_missing(self) -> None: def test_main_returns_error_when_chrome_path_missing(self) -> None:
module = importlib.import_module("login_douyin") module = importlib.import_module("login_douyin")
stdout = io.StringIO() stdout = io.StringIO()