diff --git a/Douyin.py b/Douyin.py index 77c9e2f..13187fd 100644 --- a/Douyin.py +++ b/Douyin.py @@ -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\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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..61eebf0 --- /dev/null +++ b/README.md @@ -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 文件 diff --git a/docs/superpowers/plans/2026-04-17-douyin-zero-arg-target-detection.md b/docs/superpowers/plans/2026-04-17-douyin-zero-arg-target-detection.md new file mode 100644 index 0000000..c480df7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-douyin-zero-arg-target-detection.md @@ -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. diff --git a/docs/superpowers/specs/2026-04-17-douyin-zero-arg-target-detection-design.md b/docs/superpowers/specs/2026-04-17-douyin-zero-arg-target-detection-design.md new file mode 100644 index 0000000..26fbb45 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-douyin-zero-arg-target-detection-design.md @@ -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 落地 diff --git a/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md b/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md index f864233..f25c4a9 100644 --- a/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md +++ b/externaldocs/2026-04-17-douyin-targeted-crawling-requirements.md @@ -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,并有对应自动化测试覆盖 diff --git a/externaldocs/2026-04-17-readme-and-beginner-guide-requirements.md b/externaldocs/2026-04-17-readme-and-beginner-guide-requirements.md new file mode 100644 index 0000000..931173b --- /dev/null +++ b/externaldocs/2026-04-17-readme-and-beginner-guide-requirements.md @@ -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. 手册包含关键截图、预期结果和常见错误处理 diff --git a/externaldocs/assets/chrome-window.png b/externaldocs/assets/chrome-window.png new file mode 100644 index 0000000..eaec6f1 Binary files /dev/null and b/externaldocs/assets/chrome-window.png differ diff --git a/externaldocs/assets/terminal-steps.png b/externaldocs/assets/terminal-steps.png new file mode 100644 index 0000000..48e8887 Binary files /dev/null and b/externaldocs/assets/terminal-steps.png differ diff --git a/externaldocs/assets/terminal-window.png b/externaldocs/assets/terminal-window.png new file mode 100644 index 0000000..e5fd1f2 Binary files /dev/null and b/externaldocs/assets/terminal-window.png differ diff --git a/externaldocs/assets/video-folder.png b/externaldocs/assets/video-folder.png new file mode 100644 index 0000000..efbec87 Binary files /dev/null and b/externaldocs/assets/video-folder.png differ diff --git a/externaldocs/beginner-guide.md b/externaldocs/beginner-guide.md new file mode 100644 index 0000000..64d2ac1 --- /dev/null +++ b/externaldocs/beginner-guide.md @@ -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) diff --git a/login_douyin.py b/login_douyin.py index b42b7a2..a3ff06b 100644 --- a/login_douyin.py +++ b/login_douyin.py @@ -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 diff --git a/test_douyin.py b/test_douyin.py index 822e74d..b65177d 100644 --- a/test_douyin.py +++ b/test_douyin.py @@ -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() diff --git a/test_login_douyin.py b/test_login_douyin.py index 2800a39..8979867 100644 --- a/test_login_douyin.py +++ b/test_login_douyin.py @@ -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()