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 sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@ -24,8 +25,21 @@ DEFAULT_USER_URL = (
"MS4wLjABAAAAx7--dRYA0mPwhwvxNJ-35i6sB8d1Kv4Sj1WmugquqiHK19QYlB18Ikx6cECT1RVO"
"?from_tab_name=main"
)
DEFAULT_BROWSER_PORT = 9223
LISTEN_TARGET = "web/aweme/post/"
SINGLE_VIDEO_LISTEN_TARGET = "web/aweme/detail/"
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:
@ -33,6 +47,81 @@ def sanitize_filename(value: str, fallback: str = "untitled") -> str:
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:
for url in url_list:
if "douyinvod.com" in url:
@ -114,6 +203,22 @@ def parse_aweme_items(body: Any) -> list[dict[str, str]]:
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]:
return {
"referer": referer,
@ -184,6 +289,7 @@ def collect_videos(
timeout: int,
output_dir: Path,
browser_port: int | None,
auto_scroll: bool = False,
) -> int:
requests_module, chromium_page_cls, chromium_options_cls = import_runtime_dependencies()
headers = build_headers(user_url)
@ -203,19 +309,26 @@ def collect_videos(
print(f"[INFO] 正在处理第 {page_number}")
packet = wait_for_aweme_packet(page, timeout=timeout)
if packet is None:
scroll_to_next_page(page)
continue
if auto_scroll:
scroll_to_next_page(page)
continue
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。")
try:
payload = extract_aweme_payload(packet.response)
items = parse_aweme_items(payload)
except Exception as exc:
print(f"[WARN] 解析接口数据失败: {exc}")
scroll_to_next_page(page)
continue
if auto_scroll:
scroll_to_next_page(page)
continue
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。") from exc
if not items:
print("[WARN] 这一页没有解析到视频。")
if auto_scroll:
print("[WARN] 这一页没有解析到视频。")
else:
raise RuntimeError("当前页面未加载出可用作品数据,请先在浏览器中完成页面加载后重试。")
for item in items:
if item["video_id"] in seen_ids:
@ -242,15 +355,69 @@ def collect_videos(
downloaded += 1
print(f"[OK] 已保存: {output_path}")
scroll_to_next_page(page)
if auto_scroll:
scroll_to_next_page(page)
continue
break
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:
parser = argparse.ArgumentParser(description="监听抖音作品接口并下载视频")
parser.add_argument("user_url", nargs="?", default=DEFAULT_USER_URL, help="抖音博主主页 URL")
parser.add_argument("--pages", type=int, default=10, help="最多抓取多少页,默认 10")
parser = argparse.ArgumentParser(description="附着抖音登录浏览器并下载当前页面或指定目标的视频")
parser.add_argument(
"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(
"--output-dir",
@ -260,8 +427,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--browser-port",
type=int,
default=None,
help="附着到已启动 Chrome 的调试端口,例如 9223不传则由 DrissionPage 新开浏览器",
default=DEFAULT_BROWSER_PORT,
help="附着到已启动 Chrome 的调试端口,默认 9223",
)
return parser
@ -278,13 +445,25 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--browser-port 必须大于 0")
try:
total = collect_videos(
user_url=args.user_url,
max_pages=args.pages,
timeout=args.timeout,
output_dir=Path(args.output_dir),
browser_port=args.browser_port,
)
target = resolve_cli_target(args.target, browser_port=args.browser_port)
if target.kind == "creator":
total = collect_videos(
user_url=target.value,
max_pages=args.pages,
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:
print(f"[ERROR] {exc}")
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
在现有“登录浏览器后附着抓取”的基础上,扩展为支持更明确的目标选择能力,使系统不仅能抓默认博主主页,还能:
在现有“登录浏览器后附着抓取”的基础上,扩展为支持更明确、但尽量少参数的目标选择能力,使系统不仅能抓默认博主主页,还能:
- 指定某个博主主页进行抓取
- 直接抓当前浏览器里正在查看的博主主页
@ -24,15 +24,19 @@
## Target Modes
新版本必须同时支持以下三种目标模式:
从实现视角看,新版本仍需同时支持以下三种目标模式;但默认用户交互应尽量自动判断,不要求用户每次显式传模式参数。
### 1. `creator-url`
用户显式传入某个博主主页 URL系统以该博主主页为目标进行抓取。
### 2. `current-creator`
### 2. `current-page`
系统直接读取当前已附着浏览器正在查看的页面。如果当前页面是博主主页,则以该页面为目标进行抓取。
系统直接读取当前已附着浏览器当前活动标签页正在查看的页面:
- 如果当前页面是博主主页,则以该页面为目标进行抓取
- 如果当前页面是单视频页,则按单视频方式抓取
- 如果当前页面不是支持的抖音页面,则提示用户手动传入链接或 `aweme_id`
### 3. `single-video`
@ -75,20 +79,33 @@
登录完成后,再运行抓取命令。
未来命令行接口应支持显式目标模式,例如
默认抓取命令应尽量零参数
```bash
./.venv/bin/python Douyin.py --mode creator-url --target "https://www.douyin.com/user/..."
./.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"
./.venv/bin/python Douyin.py
```
上面只是推荐交互形态,具体参数名可在实现设计阶段微调,但必须满足以下原则
其推荐行为为:
- 模式必须显式可区分
- “当前浏览器页面”与“传入 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
@ -102,15 +119,16 @@
- 浏览器打开或切换到该 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
@ -138,7 +156,7 @@
### Current Creator Errors
- 当前页面不是博主主页:报错并退出
- 当前页面不是受支持的抖音博主页或单视频页:报错并提示手动传链接或 `aweme_id`
- 当前页面虽然像博主页,但未加载出可用作品数据:提示用户先完成页面操作后重试
### Single Video Errors
@ -195,21 +213,23 @@
至少覆盖以下测试:
- `creator-url` 模式下,合法博主主页 URL 能被识别并生成正确抓取目标
- `current-creator` 模式下,当前页面是博主主页时可抓取
- `current-creator` 模式下,当前页面不是博主主页时明确报错
- 默认零参数模式下,当前页面是博主主页时可抓取
- 默认零参数模式下,当前页面是单视频页时可抓取
- 默认零参数模式下,当前页面不是支持页面时明确报错并提示手动输入
- `single-video` 模式支持视频 URL
- `single-video` 模式支持 `aweme_id`
- 创作者抓取默认只处理当前已加载内容,不自动继续翻页
- 目标模式错误时的报错路径
- 手动输入目标无法识别时的报错路径
- 浏览器端口不可用时的报错路径
## Acceptance Criteria
需求完成后,应满足以下验收标准:
1. 用户可以显式指定博主主页 URL 抓取
2. 用户可以直接抓当前浏览器中的博主主
3. 用户可以指定单视频 URL 或 `aweme_id` 下载单条视频
1. 用户在最常见场景下可以直接执行 `./.venv/bin/python Douyin.py`
2. 系统可以自动识别当前浏览器活动标签页是博主主页还是单视频
3. 用户可以手动指定博主主页 URL、单视频 URL 或 `aweme_id`
4. 当目标是博主时,默认只抓当前页面已加载作品
5. 关键失败场景都有明确报错
6. 实现过程遵循 TDD并有对应自动化测试覆盖
5. 当前页面不受支持时,系统会明确提示手动传入链接或 `aweme_id`
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
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

View File

@ -1,5 +1,7 @@
import importlib
import io
import unittest
from contextlib import redirect_stdout
from unittest import mock
@ -9,6 +11,48 @@ class FakeResponse:
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):
def test_module_can_import_without_optional_runtime_dependencies(self) -> None:
module = importlib.import_module("Douyin")
@ -71,6 +115,258 @@ class DouyinModuleTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "login_douyin.py"):
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__":
unittest.main()

View File

@ -61,6 +61,26 @@ class LoginDouyinModuleTests(unittest.TestCase):
self.assertIn("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:
module = importlib.import_module("login_douyin")
stdout = io.StringIO()