feat: add zero-argument douyin target detection
This commit is contained in:
parent
d910d6f6b9
commit
84bcc4ac71
215
Douyin.py
215
Douyin.py
@ -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
97
README.md
Normal 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 文件
|
||||||
@ -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.
|
||||||
@ -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 落地
|
||||||
@ -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,并有对应自动化测试覆盖
|
||||||
|
|||||||
@ -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. 手册包含关键截图、预期结果和常见错误处理
|
||||||
BIN
externaldocs/assets/chrome-window.png
Normal file
BIN
externaldocs/assets/chrome-window.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
externaldocs/assets/terminal-steps.png
Normal file
BIN
externaldocs/assets/terminal-steps.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 432 KiB |
BIN
externaldocs/assets/terminal-window.png
Normal file
BIN
externaldocs/assets/terminal-window.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
externaldocs/assets/video-folder.png
Normal file
BIN
externaldocs/assets/video-folder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 621 KiB |
329
externaldocs/beginner-guide.md
Normal file
329
externaldocs/beginner-guide.md
Normal 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)
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
296
test_douyin.py
296
test_douyin.py
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user