Compare commits

...
8 Commits
5 changed files with 822 additions and 93 deletions
+379 -12
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import random
import re import re
import socket import socket
import sys import sys
@@ -19,6 +20,7 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
DEFAULT_USER_URL = ( DEFAULT_USER_URL = (
"https://www.douyin.com/user/" "https://www.douyin.com/user/"
@@ -29,8 +31,10 @@ DEFAULT_BROWSER_PORT = 9223
LISTEN_TARGET = "web/aweme/post/" LISTEN_TARGET = "web/aweme/post/"
RECOMMENDATION_LISTEN_TARGET = "aweme/v2/web/module/feed/" RECOMMENDATION_LISTEN_TARGET = "aweme/v2/web/module/feed/"
SINGLE_VIDEO_LISTEN_TARGET = "web/aweme/detail/" SINGLE_VIDEO_LISTEN_TARGET = "web/aweme/detail/"
SEARCH_LISTEN_TARGET = "aweme/v1/web/general/search/single"
MAX_FILENAME_BYTES = 240
INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]') INVALID_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|\r\n\t]')
RECOMMENDATION_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/?(?:\?.*)?$") RECOMMENDATION_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/?(?:jingxuan)?(?:\?.*)?$")
CREATOR_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/user/[^/?#]+(?:\?.*)?$") 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+)(?:[/?#].*)?$") VIDEO_URL_PATTERN = re.compile(r"^https?://www\.douyin\.com/video/(?P<aweme_id>\d+)(?:[/?#].*)?$")
AWEME_ID_PATTERN = re.compile(r"^\d{5,}$") AWEME_ID_PATTERN = re.compile(r"^\d{5,}$")
@@ -44,11 +48,48 @@ class ResolvedTarget:
aweme_id: str | None = None aweme_id: str | None = None
@dataclass(frozen=True)
class ScrollSettings:
mode: str = "human"
min_wait: float = 2.0
max_wait: float = 8.0
reverse_scroll_probability: float = 0.2
max_runtime: float = 600.0
min_scroll: int = 300
max_scroll: int = 900
min_reverse_scroll: int = 80
max_reverse_scroll: int = 250
@dataclass(frozen=True)
class HumanScrollPlan:
down_distance: int
down_wait: float
reverse_distance: int = 0
reverse_wait: float = 0.0
settle_wait: float = 0.0
def sanitize_filename(value: str, fallback: str = "untitled") -> str: def sanitize_filename(value: str, fallback: str = "untitled") -> str:
cleaned = INVALID_FILENAME_CHARS.sub("_", value).strip(" ._") cleaned = INVALID_FILENAME_CHARS.sub("_", value).strip(" ._")
return cleaned or fallback return cleaned or fallback
def truncate_utf8_bytes(value: str, max_bytes: int) -> str:
if len(value.encode("utf-8")) <= max_bytes:
return value
result = ""
used = 0
for character in value:
character_bytes = len(character.encode("utf-8"))
if used + character_bytes > max_bytes:
break
result += character
used += character_bytes
return result.rstrip(" ._")
def is_recommendation_url(value: str) -> bool: def is_recommendation_url(value: str) -> bool:
return bool(RECOMMENDATION_URL_PATTERN.match(value.strip())) return bool(RECOMMENDATION_URL_PATTERN.match(value.strip()))
@@ -76,6 +117,10 @@ def build_video_page_url(aweme_id: str) -> str:
return f"https://www.douyin.com/video/{aweme_id}" return f"https://www.douyin.com/video/{aweme_id}"
def build_search_page_url(keyword: str) -> str:
return f"https://www.douyin.com/search/{quote(keyword)}?type=general"
def parse_target_input(value: str, source: str) -> ResolvedTarget: def parse_target_input(value: str, source: str) -> ResolvedTarget:
normalized = value.strip() normalized = value.strip()
if is_recommendation_url(normalized): if is_recommendation_url(normalized):
@@ -139,6 +184,40 @@ def choose_video_url(url_list: list[str]) -> str:
raise ValueError("url_list 为空,无法选择视频地址。") raise ValueError("url_list 为空,无法选择视频地址。")
def extract_url_list_from_play_addr(play_addr: Any) -> list[str]:
if not isinstance(play_addr, dict):
return []
url_list = play_addr.get("url_list") or []
if not isinstance(url_list, list):
return []
return [str(url) for url in url_list if str(url).strip()]
def extract_video_url_list(video: Any) -> list[str]:
if not isinstance(video, dict):
return []
for address_key in ("play_addr", "play_addr_h264", "play_addr_lowbr"):
url_list = extract_url_list_from_play_addr(video.get(address_key))
if url_list:
return url_list
bit_rate_list = video.get("bit_rate") or []
if not isinstance(bit_rate_list, list):
return []
for bit_rate in bit_rate_list:
if not isinstance(bit_rate, dict):
continue
url_list = extract_url_list_from_play_addr(bit_rate.get("play_addr"))
if url_list:
return url_list
return []
def build_output_path( def build_output_path(
title: str, title: str,
video_id: str, video_id: str,
@@ -146,11 +225,20 @@ def build_output_path(
author_name: str | None = None, author_name: str | None = None,
) -> Path: ) -> Path:
safe_title = sanitize_filename(title, fallback="untitled") safe_title = sanitize_filename(title, fallback="untitled")
suffix = f"-{video_id}.mp4"
if author_name: if author_name:
safe_author = sanitize_filename(author_name, fallback="unknown") safe_author = sanitize_filename(author_name, fallback="unknown")
filename = f"[{safe_author}]{safe_title}-{video_id}.mp4" prefix = f"[{safe_author}]"
else: else:
filename = f"{safe_title}-{video_id}.mp4" prefix = ""
title_budget = MAX_FILENAME_BYTES - len(prefix.encode("utf-8")) - len(suffix.encode("utf-8"))
if title_budget < 1:
prefix_budget = MAX_FILENAME_BYTES - len(suffix.encode("utf-8")) - 1
prefix = truncate_utf8_bytes(prefix, max(1, prefix_budget))
title_budget = MAX_FILENAME_BYTES - len(prefix.encode("utf-8")) - len(suffix.encode("utf-8"))
filename = f"{prefix}{truncate_utf8_bytes(safe_title, max(1, title_budget))}{suffix}"
return output_dir / filename return output_dir / filename
@@ -200,8 +288,7 @@ def parse_aweme_items(body: Any) -> list[dict[str, str]]:
continue continue
video = aweme.get("video") or {} video = aweme.get("video") or {}
play_addr = video.get("play_addr") or {} url_list = extract_video_url_list(video)
url_list = play_addr.get("url_list") or []
if not url_list: if not url_list:
continue continue
@@ -219,7 +306,7 @@ def parse_aweme_items(body: Any) -> list[dict[str, str]]:
{ {
"title": title, "title": title,
"video_id": video_id, "video_id": video_id,
"video_url": choose_video_url([str(url) for url in url_list]), "video_url": choose_video_url(url_list),
"author_name": author_name, "author_name": author_name,
"author_id": author_id, "author_id": author_id,
} }
@@ -244,6 +331,25 @@ def parse_single_aweme_item(body: Any) -> dict[str, str]:
raise ValueError("接口响应中缺少可下载的单视频数据。") raise ValueError("接口响应中缺少可下载的单视频数据。")
def parse_search_items(body: Any) -> list[dict[str, str]]:
if not isinstance(body, dict):
raise ValueError("接口响应不是字典,无法解析。")
data = body.get("data")
if not isinstance(data, list):
raise ValueError("搜索接口响应中缺少 data。")
aweme_list = []
for entry in data:
if not isinstance(entry, dict):
continue
aweme_info = entry.get("aweme_info")
if isinstance(aweme_info, dict):
aweme_list.append(aweme_info)
return parse_aweme_items({"aweme_list": aweme_list})
def build_headers(referer: str) -> dict[str, str]: def build_headers(referer: str) -> dict[str, str]:
return { return {
"referer": referer, "referer": referer,
@@ -285,7 +391,8 @@ def create_page(chromium_page_cls: Any, chromium_options_cls: Any, browser_port:
def wait_for_aweme_packet(page: Any, timeout: int) -> Any | None: def wait_for_aweme_packet(page: Any, timeout: int) -> Any | None:
try: try:
return page.listen.wait(timeout=timeout) packet = page.listen.wait(timeout=timeout)
return packet if packet else None
except Exception as exc: except Exception as exc:
print(f"[WARN] 等待接口数据超时或失败: {exc}") print(f"[WARN] 等待接口数据超时或失败: {exc}")
return None return None
@@ -296,6 +403,95 @@ def scroll_to_next_page(page: Any) -> None:
time.sleep(2) time.sleep(2)
def create_human_scroll_plan(
settings: ScrollSettings,
random_module: Any = random,
) -> HumanScrollPlan:
down_distance = random_module.randint(settings.min_scroll, settings.max_scroll)
down_wait = random_module.uniform(settings.min_wait, settings.max_wait)
settle_wait = random_module.uniform(settings.min_wait, settings.max_wait)
reverse_distance = 0
reverse_wait = 0.0
if random_module.random() < settings.reverse_scroll_probability:
reverse_distance = random_module.randint(
settings.min_reverse_scroll,
settings.max_reverse_scroll,
)
reverse_wait = random_module.uniform(1.0, min(3.0, settings.max_wait))
return HumanScrollPlan(
down_distance=down_distance,
down_wait=down_wait,
reverse_distance=reverse_distance,
reverse_wait=reverse_wait,
settle_wait=settle_wait,
)
def run_scroll_step(page: Any, distance: int) -> bool:
script = f"""
const distance = {distance};
function findMainScrollContainer() {{
const preferredSelectors = ['.tKqwmYAX', '.route-scroll-container', '.semi-tabs-content'];
for (const selector of preferredSelectors) {{
const el = document.querySelector(selector);
if (el && el.scrollHeight > el.clientHeight + 20) {{
return el;
}}
}}
const candidates = Array.from(document.querySelectorAll('*'))
.filter((el) => {{
const rect = el.getBoundingClientRect();
return rect.width > 300
&& rect.height > 200
&& el.scrollHeight > el.clientHeight + 20;
}})
.sort((a, b) => {{
const areaA = a.getBoundingClientRect().width * a.getBoundingClientRect().height;
const areaB = b.getBoundingClientRect().width * b.getBoundingClientRect().height;
return areaB - areaA;
}});
return candidates[0] || null;
}}
const scrollTarget = findMainScrollContainer();
if (scrollTarget) {{
scrollTarget.scrollBy(0, distance);
return true;
}}
return false;
"""
scrolled_container = bool(page.run_js(script))
if not scrolled_container:
page.run_js(f"window.scrollBy(0, {distance});")
return scrolled_container
def run_human_scroll_sequence(page: Any, plan: HumanScrollPlan) -> None:
run_scroll_step(page, plan.down_distance)
print(f"[INFO] 向下滚动 {plan.down_distance}px,停留 {plan.down_wait:.1f}s")
time.sleep(plan.down_wait)
if plan.reverse_distance > 0:
run_scroll_step(page, -plan.reverse_distance)
print(f"[INFO] 小幅回滚 {plan.reverse_distance}px,停留 {plan.reverse_wait:.1f}s")
time.sleep(plan.reverse_wait)
forward_distance = plan.reverse_distance * 2
run_scroll_step(page, forward_distance)
if plan.settle_wait > 0:
print(f"[INFO] 继续停留 {plan.settle_wait:.1f}s")
time.sleep(plan.settle_wait)
def human_like_scroll(page: Any, settings: ScrollSettings | None = None) -> None:
scroll_settings = settings or ScrollSettings()
run_human_scroll_sequence(page, create_human_scroll_plan(scroll_settings))
def download_video( def download_video(
requests_module: Any, requests_module: Any,
headers: dict[str, str], headers: dict[str, str],
@@ -393,6 +589,7 @@ def collect_recommendations(
timeout: int, timeout: int,
output_dir: Path, output_dir: Path,
browser_port: int | None, browser_port: int | None,
scroll_settings: ScrollSettings | None = None,
) -> 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("https://www.douyin.com/") headers = build_headers("https://www.douyin.com/")
@@ -408,16 +605,22 @@ def collect_recommendations(
downloaded = 0 downloaded = 0
seen_ids: set[str] = set() seen_ids: set[str] = set()
consecutive_empty = 0 consecutive_empty = 0
max_consecutive_empty = 3 max_consecutive_empty = 6
settings = scroll_settings or ScrollSettings()
started_at = time.monotonic()
while downloaded < max_videos: while downloaded < max_videos:
if settings.max_runtime > 0 and time.monotonic() - started_at >= settings.max_runtime:
print("[INFO] 已达到最大运行时间,结束抓取。")
break
packet = wait_for_aweme_packet(page, timeout=timeout) packet = wait_for_aweme_packet(page, timeout=timeout)
if packet is None: if packet is None:
consecutive_empty += 1 consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty: if consecutive_empty >= max_consecutive_empty:
print("[INFO] 连续多次未获取到新数据,结束抓取。") print("[INFO] 连续多次未获取到新数据,结束抓取。")
break break
scroll_to_next_page(page) human_like_scroll(page, settings=settings)
continue continue
try: try:
@@ -428,14 +631,14 @@ def collect_recommendations(
consecutive_empty += 1 consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty: if consecutive_empty >= max_consecutive_empty:
break break
scroll_to_next_page(page) human_like_scroll(page, settings=settings)
continue continue
if not items: if not items:
consecutive_empty += 1 consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty: if consecutive_empty >= max_consecutive_empty:
break break
scroll_to_next_page(page) human_like_scroll(page, settings=settings)
continue continue
consecutive_empty = 0 consecutive_empty = 0
@@ -476,7 +679,109 @@ def collect_recommendations(
if consecutive_empty >= max_consecutive_empty: if consecutive_empty >= max_consecutive_empty:
break break
scroll_to_next_page(page) human_like_scroll(page, settings=settings)
return downloaded
def collect_search_results(
keyword: str,
max_videos: int,
timeout: int,
output_dir: Path,
browser_port: int | None,
scroll_settings: ScrollSettings | None = None,
) -> int:
requests_module, chromium_page_cls, chromium_options_cls = import_runtime_dependencies()
search_url = build_search_page_url(keyword)
headers = build_headers(search_url)
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.listen.start(SEARCH_LISTEN_TARGET)
print(f"[INFO] 正在打开抖音搜索页:{keyword}。若出现登录或验证码,请先在浏览器窗口里完成。")
page.get(search_url)
time.sleep(3)
downloaded = 0
seen_ids: set[str] = set()
consecutive_empty = 0
max_consecutive_empty = 6
settings = scroll_settings or ScrollSettings()
started_at = time.monotonic()
while downloaded < max_videos:
if settings.max_runtime > 0 and time.monotonic() - started_at >= settings.max_runtime:
print("[INFO] 已达到最大运行时间,结束抓取。")
break
packet = wait_for_aweme_packet(page, timeout=timeout)
if packet is None:
consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty:
print("[INFO] 连续多次未获取到新搜索数据,结束抓取。")
break
human_like_scroll(page, settings=settings)
continue
try:
payload = extract_aweme_payload(packet.response)
items = parse_search_items(payload)
except Exception as exc:
print(f"[WARN] 解析搜索接口数据失败: {exc}")
consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty:
break
human_like_scroll(page, settings=settings)
continue
if not items:
consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty:
break
human_like_scroll(page, settings=settings)
continue
consecutive_empty = 0
new_items_in_batch = 0
for item in items:
if item["video_id"] in seen_ids:
continue
if downloaded >= max_videos:
break
seen_ids.add(item["video_id"])
output_path = build_output_path(
title=item["title"],
video_id=item["video_id"],
output_dir=output_dir,
author_name=item.get("author_name"),
)
try:
download_video(
requests_module=requests_module,
headers=headers,
video_url=item["video_url"],
output_path=output_path,
)
except Exception as exc:
print(f"[WARN] 下载失败 {item['video_id']}: {exc}")
continue
downloaded += 1
new_items_in_batch += 1
print(f"[OK] 已保存: {output_path}")
if new_items_in_batch == 0:
consecutive_empty += 1
if consecutive_empty >= max_consecutive_empty:
break
human_like_scroll(page, settings=settings)
return downloaded return downloaded
@@ -554,6 +859,41 @@ def build_parser() -> argparse.ArgumentParser:
default=50, default=50,
help="推荐流最大抓取数量,默认 50", help="推荐流最大抓取数量,默认 50",
) )
parser.add_argument(
"--search-keyword",
default=None,
help="搜索关键词;提供后抓取搜索结果页视频",
)
parser.add_argument(
"--scroll-mode",
choices=["human"],
default="human",
help="推荐流滚动模式,默认 human",
)
parser.add_argument(
"--min-wait",
type=float,
default=2.0,
help="推荐流每次滚动后的最短等待秒数,默认 2",
)
parser.add_argument(
"--max-wait",
type=float,
default=8.0,
help="推荐流每次滚动后的最长等待秒数,默认 8",
)
parser.add_argument(
"--reverse-scroll-probability",
type=float,
default=0.2,
help="推荐流小幅回滚概率,取值 0 到 1,默认 0.2",
)
parser.add_argument(
"--max-runtime",
type=float,
default=600.0,
help="推荐流最大运行秒数,默认 600;设置为 0 表示不限制",
)
return parser return parser
@@ -569,8 +909,34 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--browser-port 必须大于 0") parser.error("--browser-port 必须大于 0")
if args.max_videos <= 0: if args.max_videos <= 0:
parser.error("--max-videos 必须大于 0") parser.error("--max-videos 必须大于 0")
if args.min_wait < 0:
parser.error("--min-wait 不能小于 0")
if args.max_wait < args.min_wait:
parser.error("--max-wait 必须大于或等于 --min-wait")
if not 0 <= args.reverse_scroll_probability <= 1:
parser.error("--reverse-scroll-probability 必须在 0 到 1 之间")
if args.max_runtime < 0:
parser.error("--max-runtime 不能小于 0")
scroll_settings = ScrollSettings(
mode=args.scroll_mode,
min_wait=args.min_wait,
max_wait=args.max_wait,
reverse_scroll_probability=args.reverse_scroll_probability,
max_runtime=args.max_runtime,
)
try: try:
if args.search_keyword:
total = collect_search_results(
keyword=args.search_keyword,
max_videos=args.max_videos,
timeout=args.timeout,
output_dir=Path(args.output_dir),
browser_port=args.browser_port,
scroll_settings=scroll_settings,
)
else:
target = resolve_cli_target(args.target, browser_port=args.browser_port) target = resolve_cli_target(args.target, browser_port=args.browser_port)
if target.kind == "creator": if target.kind == "creator":
total = collect_videos( total = collect_videos(
@@ -587,6 +953,7 @@ def main(argv: list[str] | None = None) -> int:
timeout=args.timeout, timeout=args.timeout,
output_dir=Path(args.output_dir), output_dir=Path(args.output_dir),
browser_port=args.browser_port, browser_port=args.browser_port,
scroll_settings=scroll_settings,
) )
elif target.kind == "single-video": elif target.kind == "single-video":
total = collect_single_video( total = collect_single_video(
+197 -58
View File
@@ -1,97 +1,236 @@
# 抖音视频爬取工具 # 抖音视频下载工具(中学生也能看懂版)
这是一个面向 macOS 的抖音视频下载项目。 ## 这个工具能做什么?
它当前采用“两步式”方式工作 帮你下载抖音上的视频!支持两种方式
1. 先启动一个可见的 Chrome 浏览器,让你手动登录抖音并完成验证码 1. **下载推荐页视频** - 就像你打开抖音看到的首页视频流
2. 再让脚本附着到这个浏览器,抓取博主主页当前已加载的作品视频并下载到本地 2. **下载某个博主主页视频** - 下载你喜欢的博主发布的视频
这个项目已经完成过真实验证:在本机登录成功后,可以正常下载视频到 `video/` 目录。 ## 你需要准备什么?
## 适合谁使用 - 一台 Mac 电脑
- 已经下载了这个项目到本地
- 一点点耐心(需要手动登录抖音)
适合以下用户: ## 重要提醒
- 使用 Mac - 不能自动登录抖音(需要你手动扫码或输入密码)
- 项目已经在本地 - 不能自动过验证码(遇到验证码需要你自己点)
- 想快速下载某个抖音博主主页当前可见的作品视频 - 只能下载当前页面上已经显示的视频(不会自动翻页下载全部历史视频)
## 当前能做什么 ---
- 启动一个带调试端口的 Chrome 浏览器 ## 第一次使用(安装环境)
- 手动登录抖音后附着到浏览器
- 自动识别当前浏览器页面是博主主页还是单视频页
- 抓取某个博主主页当前已加载的作品
- 下载当前单视频页对应的那一条视频
- 下载视频到本地 `video/` 目录
- 支持传入指定博主主页 URL、单视频 URL 或 `aweme_id`
## 当前不能做什么 打开终端(Terminal),依次输入以下命令:
- 不能自动帮你登录抖音
- 不能自动替你过验证码
- 不能默认抓完整个博主的全部历史作品
- 不能抓任意网页
- 不能自动筛选你想要的视频
## 快速开始
如果你已经把项目下载到本地,最快的使用方式是:
```bash ```bash
# 1. 进入项目文件夹
cd /你的项目目录/douyin-crawler-poc cd /你的项目目录/douyin-crawler-poc
# 2. 创建虚拟环境(隔离项目依赖)
python3 -m venv .venv python3 -m venv .venv
# 3. 激活虚拟环境
source .venv/bin/activate source .venv/bin/activate
# 4. 安装需要的库
pip install requests DrissionPage pip install requests DrissionPage
./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py
``` ```
说明: **什么是虚拟环境?** 就像给这个项目建了一个独立的房间,里面放的工具不会影响电脑其他地方。
- 第一个命令用于创建虚拟环境 ---
- 第二个命令用于进入虚拟环境
- 第三个命令用于安装依赖
- 第四个命令会打开 Chrome,让你登录抖音
- 第五个命令会读取你当前浏览器页面并自动开始抓取或下载
如果自动判断失败,也可以手动传入一个目标: ## 使用方法一:下载推荐页视频(首页视频流)
### 步骤 1:启动浏览器并登录抖音
```bash ```bash
./.venv/bin/python Douyin.py "https://www.douyin.com/user/你的博主主页" ./.venv/bin/python login_douyin.py
./.venv/bin/python Douyin.py "https://www.douyin.com/video/某个视频ID" ```
运行后会发生什么:
- 会自动打开 Chrome 浏览器
- 浏览器会显示抖音登录页面
- **你需要手动登录**(扫码或输入账号密码)
- 如果遇到验证码,也需要手动完成
### 步骤 2:下载视频
登录成功后,在终端输入:
```bash
# 下载默认数量(50个视频)
./.venv/bin/python Douyin.py
# 或者只下载10个视频
./.venv/bin/python Douyin.py --max-videos 10
# 或者只下载3个视频(测试用)
./.venv/bin/python Douyin.py --max-videos 3
```
**注意:** 下载前请确保浏览器显示的是抖音推荐页(就是打开抖音看到的第一个页面,有很多视频往下滚动的那种)。
---
## 使用方法二:下载某个博主的主页视频
### 步骤 1:同样先启动浏览器并登录
```bash
./.venv/bin/python login_douyin.py
```
### 步骤 2:进入博主主页
在浏览器中:
1. 搜索你想下载的博主(比如某个美食博主)
2. 点击进入他的主页
3. 等待页面加载完成(看到博主的视频列表)
### 步骤 3:下载视频
在终端输入:
```bash
# 下载当前页面显示的视频(默认只下载当前页)
./.venv/bin/python Douyin.py
# 或者下载多页(自动向下滚动加载)
./.venv/bin/python Douyin.py --pages 3
```
**注意:** `--pages 3` 表示滚动加载3页,但抖音可能会限制,不一定能下载到那么多。
---
## 方法三:直接下载单个视频
如果你只想下载某一个具体的视频:
```bash
# 方法 A:通过视频链接下载
./.venv/bin/python Douyin.py "https://www.douyin.com/video/视频ID"
# 方法 B:直接通过视频ID下载
./.venv/bin/python Douyin.py "7619989983668240802" ./.venv/bin/python Douyin.py "7619989983668240802"
``` ```
## 下载结果在哪里 ---
抓取成功后,视频会保存到项目根目录下的 `video/` 文件夹。 ## 下载的视频在哪里?
文件名格式一般是: 所有下载的视频都会保存在项目文件夹里的 `video/` 文件夹中。
```text 文件名格式:
视频标题-aweme_id.mp4
```
[博主昵称]视频标题-视频ID.mp4
``` ```
## 详细图文说明 例如:
```
[小张一人食]花2XXX在汕头喝白粥...-7633717884061725297.mp4
[相声老司机]春晚不好看?...-7606185972144901412.mp4
```
详细操作步骤请看这份手册: ---
## 常见问题
### Q1:运行时报错 "缺少 requests" 或 "缺少 DrissionPage"
**解决:** 没有安装依赖,运行:
```bash
pip install requests DrissionPage
```
### Q2:提示 "当前页面不是受支持的抖音页面"
**解决:**
- 如果要用推荐页:确保浏览器显示的是抖音首页(有视频流往下滚动的页面)
- 如果要用博主页:确保你在某个博主的主页
### Q3:下载了0个视频
**解决:**
- 检查是否已登录抖音
- 检查当前页面是否有视频显示
- 等待页面完全加载后再运行下载命令
### Q4Chrome 浏览器没打开
**解决:**
- 确保你用的是 Mac
- 确保已安装 Google Chrome
- 检查是否有其他 Chrome 正在运行,先关闭再试
### Q5:我想下载更多视频怎么办?
**推荐页:**
```bash
./.venv/bin/python Douyin.py --max-videos 100
```
**博主页:**
```bash
./.venv/bin/python Douyin.py --pages 5
```
---
## 所有可用命令汇总
```bash
# 查看所有参数说明
./.venv/bin/python Douyin.py --help
# 推荐页下载(默认50个)
./.venv/bin/python Douyin.py
# 推荐页下载(指定数量)
./.venv/bin/python Douyin.py --max-videos 20
# 博主页下载(默认当前页)
./.venv/bin/python Douyin.py
# 博主页下载(多页)
./.venv/bin/python Douyin.py --pages 3
# 单视频下载
./.venv/bin/python Douyin.py "https://www.douyin.com/video/xxx"
# 修改等待时间(如果网络慢)
./.venv/bin/python Douyin.py --timeout 20
# 修改保存位置
./.venv/bin/python Douyin.py --output-dir 我的视频
```
---
## 详细图文教程
如果你还是不太明白,可以查看这份更详细的图文教程:
[小白图文操作手册](/Users/wangshaoqing/Desktop/MiaoSi/Study/douyin-crawler-poc/externaldocs/beginner-guide.md) [小白图文操作手册](/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)
## 当前验证状态 1. **login_douyin.py** - 启动带调试端口的 Chrome,让你手动登录
2. **Douyin.py** - 附着到已登录的浏览器,监听抖音的 API 请求,提取视频地址并下载
当前项目已验证: 为什么需要手动登录?因为抖音有反爬虫机制,自动登录容易被封。
- 单元测试通过 ---
- 登录浏览器入口可用
- 抖音抓取脚本可附着到浏览器 ## 免责声明
- 成功下载出 mp4 文件
本工具仅供学习交流使用,请勿用于商业用途或侵犯他人权益。下载的视频版权归原博主所有。
+3 -2
View File
@@ -7,7 +7,8 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
from Douyin import DEFAULT_USER_URL DEFAULT_RECOMMENDATION_URL = "https://www.douyin.com/"
DEFAULT_USER_URL = DEFAULT_RECOMMENDATION_URL
DEFAULT_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" DEFAULT_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
DEFAULT_BROWSER_PORT = 9223 DEFAULT_BROWSER_PORT = 9223
@@ -54,7 +55,7 @@ def build_parser() -> argparse.ArgumentParser:
default=DEFAULT_BROWSER_PORT, default=DEFAULT_BROWSER_PORT,
help="Chrome 调试端口,默认 9223", help="Chrome 调试端口,默认 9223",
) )
parser.add_argument("--user-url", default=DEFAULT_USER_URL, help="启动后打开的抖音页 URL") parser.add_argument("--user-url", default=DEFAULT_RECOMMENDATION_URL, help="启动后打开的抖音页 URL,默认推荐流首页")
return parser return parser
+221 -1
View File
@@ -54,9 +54,32 @@ class FakeRuntimePage:
self.url = url self.url = url
def run_js(self, script): def run_js(self, script):
# Allow both old scroll_to_next_page and new human_like_scroll
if "window.scrollTo" in script or "window.scrollBy" in script:
return
raise AssertionError(f"unexpected scroll script: {script}") raise AssertionError(f"unexpected scroll script: {script}")
class FakeScrollPage:
def __init__(self):
self.scripts = []
def run_js(self, script):
self.scripts.append(script)
class FakeContainerScrollPage:
def __init__(self, container_found=True):
self.container_found = container_found
self.scripts = []
def run_js(self, script):
self.scripts.append(script)
if "findMainScrollContainer" in script:
return self.container_found
return None
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")
@@ -95,6 +118,16 @@ class DouyinModuleTests(unittest.TestCase):
) )
self.assertEqual(output_path.as_posix(), "video/[测试博主]测试标题-123456.mp4") self.assertEqual(output_path.as_posix(), "video/[测试博主]测试标题-123456.mp4")
def test_build_output_path_limits_long_filename(self) -> None:
module = importlib.import_module("Douyin")
output_path = module.build_output_path(
title="超长标题" * 100,
video_id="7619989983668240802",
author_name="超长博主名" * 20,
)
self.assertLessEqual(len(output_path.name.encode("utf-8")), 240)
self.assertTrue(output_path.name.endswith("-7619989983668240802.mp4"))
def test_extract_aweme_payload_uses_dict_body(self) -> None: def test_extract_aweme_payload_uses_dict_body(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
response = FakeResponse({"aweme_list": []}, "") response = FakeResponse({"aweme_list": []}, "")
@@ -108,11 +141,79 @@ class DouyinModuleTests(unittest.TestCase):
{"aweme_list": [{"aweme_id": "1"}]}, {"aweme_list": [{"aweme_id": "1"}]},
) )
def test_wait_for_aweme_packet_treats_false_listener_result_as_missing(self) -> None:
module = importlib.import_module("Douyin")
page = mock.MagicMock()
page.listen.wait.return_value = False
self.assertIsNone(module.wait_for_aweme_packet(page, timeout=10))
def test_build_browser_address_from_port(self) -> None: def test_build_browser_address_from_port(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
self.assertEqual(module.build_browser_address(9223), "127.0.0.1:9223") self.assertEqual(module.build_browser_address(9223), "127.0.0.1:9223")
self.assertIsNone(module.build_browser_address(None)) self.assertIsNone(module.build_browser_address(None))
def test_default_scroll_settings_uses_human_mode(self) -> None:
module = importlib.import_module("Douyin")
settings = module.ScrollSettings()
self.assertEqual(settings.mode, "human")
self.assertEqual(settings.min_wait, 2.0)
self.assertEqual(settings.max_wait, 8.0)
self.assertEqual(settings.reverse_scroll_probability, 0.2)
def test_create_human_scroll_plan_uses_configured_ranges(self) -> None:
module = importlib.import_module("Douyin")
settings = module.ScrollSettings(
min_wait=2.0,
max_wait=4.0,
min_scroll=300,
max_scroll=900,
reverse_scroll_probability=0.0,
)
plan = module.create_human_scroll_plan(settings, random_module=module.random.Random(7))
self.assertGreaterEqual(plan.down_distance, 300)
self.assertLessEqual(plan.down_distance, 900)
self.assertGreaterEqual(plan.down_wait, 2.0)
self.assertLessEqual(plan.down_wait, 4.0)
self.assertEqual(plan.reverse_distance, 0)
def test_create_human_scroll_plan_can_include_reverse_scroll(self) -> None:
module = importlib.import_module("Douyin")
settings = module.ScrollSettings(reverse_scroll_probability=1.0)
plan = module.create_human_scroll_plan(settings, random_module=module.random.Random(3))
self.assertGreaterEqual(plan.reverse_distance, 80)
self.assertLessEqual(plan.reverse_distance, 250)
self.assertGreater(plan.reverse_wait, 0)
def test_run_human_scroll_sequence_scrolls_down_and_optionally_back_up(self) -> None:
module = importlib.import_module("Douyin")
page = FakeScrollPage()
plan = module.HumanScrollPlan(
down_distance=500,
down_wait=2.5,
reverse_distance=120,
reverse_wait=1.0,
settle_wait=3.0,
)
with mock.patch.object(module.time, "sleep") as mocked_sleep:
module.run_human_scroll_sequence(page, plan)
self.assertIn("window.scrollBy(0, 500);", page.scripts)
self.assertIn("window.scrollBy(0, -120);", page.scripts)
self.assertIn("window.scrollBy(0, 240);", page.scripts)
mocked_sleep.assert_has_calls([mock.call(2.5), mock.call(1.0), mock.call(3.0)])
def test_run_scroll_step_prefers_main_scroll_container(self) -> None:
module = importlib.import_module("Douyin")
page = FakeContainerScrollPage(container_found=True)
self.assertTrue(module.run_scroll_step(page, 500))
self.assertIn("const distance = 500;", page.scripts[-1])
self.assertIn("scrollTarget.scrollBy(0, distance);", page.scripts[-1])
def test_run_scroll_step_falls_back_to_window_when_container_is_missing(self) -> None:
module = importlib.import_module("Douyin")
page = FakeContainerScrollPage(container_found=False)
self.assertFalse(module.run_scroll_step(page, 500))
self.assertEqual(page.scripts[-1], "window.scrollBy(0, 500);")
def test_ensure_browser_debug_port_ready_accepts_open_port(self) -> None: def test_ensure_browser_debug_port_ready_accepts_open_port(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
connection = mock.MagicMock() connection = mock.MagicMock()
@@ -291,6 +392,49 @@ class DouyinModuleTests(unittest.TestCase):
self.assertEqual(items[0]["author_name"], "测试博主") self.assertEqual(items[0]["author_name"], "测试博主")
self.assertEqual(items[0]["author_id"], "123456789") self.assertEqual(items[0]["author_id"], "123456789")
def test_parse_aweme_items_uses_play_addr_h264_when_play_addr_is_missing(self) -> None:
module = importlib.import_module("Douyin")
payload = {
"aweme_list": [
{
"aweme_id": "7619989983668240802",
"desc": "推荐视频",
"video": {
"play_addr_h264": {
"url_list": ["https://v26-web.douyinvod.com/example/h264.mp4"]
}
},
}
]
}
items = module.parse_aweme_items(payload)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["video_url"], "https://v26-web.douyinvod.com/example/h264.mp4")
def test_parse_aweme_items_uses_bit_rate_play_addr_when_top_level_addresses_are_missing(self) -> None:
module = importlib.import_module("Douyin")
payload = {
"aweme_list": [
{
"aweme_id": "7619989983668240802",
"desc": "推荐视频",
"video": {
"bit_rate": [
{
"format": "mp4",
"play_addr": {
"url_list": ["https://v11-weba.douyinvod.com/example/bitrate.mp4"]
},
}
]
},
}
]
}
items = module.parse_aweme_items(payload)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["video_url"], "https://v11-weba.douyinvod.com/example/bitrate.mp4")
def test_build_video_page_url_uses_aweme_id(self) -> None: def test_build_video_page_url_uses_aweme_id(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
self.assertEqual( self.assertEqual(
@@ -298,6 +442,38 @@ class DouyinModuleTests(unittest.TestCase):
"https://www.douyin.com/video/7619989983668240802", "https://www.douyin.com/video/7619989983668240802",
) )
def test_build_search_page_url_encodes_keyword(self) -> None:
module = importlib.import_module("Douyin")
self.assertEqual(
module.build_search_page_url("猫咪"),
"https://www.douyin.com/search/%E7%8C%AB%E5%92%AA?type=general",
)
def test_parse_search_items_extracts_aweme_info(self) -> None:
module = importlib.import_module("Douyin")
payload = {
"data": [
{
"type": 1,
"aweme_info": {
"aweme_id": "7319795133048769829",
"desc": "猫咪视频",
"author": {"nickname": "奶芙芙", "uid": "75478174642"},
"video": {
"play_addr_lowbr": {
"url_list": ["https://v26-web.douyinvod.com/example/search.mp4"]
}
},
},
}
]
}
items = module.parse_search_items(payload)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["video_id"], "7319795133048769829")
self.assertEqual(items[0]["author_name"], "奶芙芙")
self.assertEqual(items[0]["video_url"], "https://v26-web.douyinvod.com/example/search.mp4")
def test_collect_recommendations_downloads_videos_with_author_prefix(self) -> None: def test_collect_recommendations_downloads_videos_with_author_prefix(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
packet = FakePacket( packet = FakePacket(
@@ -321,7 +497,7 @@ class DouyinModuleTests(unittest.TestCase):
with mock.patch.object(module, "import_runtime_dependencies", return_value=(object(), object(), object())): 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, "create_page", return_value=page):
with mock.patch.object(module, "download_video") as mocked_download: with mock.patch.object(module, "download_video") as mocked_download:
with mock.patch.object(module, "scroll_to_next_page"): with mock.patch.object(module, "human_like_scroll"):
downloaded = module.collect_recommendations( downloaded = module.collect_recommendations(
max_videos=50, max_videos=50,
timeout=10, timeout=10,
@@ -409,6 +585,49 @@ class DouyinModuleTests(unittest.TestCase):
args = module.build_parser().parse_args(["--max-videos", "30"]) args = module.build_parser().parse_args(["--max-videos", "30"])
self.assertEqual(args.max_videos, 30) self.assertEqual(args.max_videos, 30)
def test_build_parser_has_human_scroll_arguments(self) -> None:
module = importlib.import_module("Douyin")
args = module.build_parser().parse_args(
[
"--scroll-mode",
"human",
"--min-wait",
"3",
"--max-wait",
"9",
"--reverse-scroll-probability",
"0.4",
"--max-runtime",
"600",
]
)
self.assertEqual(args.scroll_mode, "human")
self.assertEqual(args.min_wait, 3)
self.assertEqual(args.max_wait, 9)
self.assertEqual(args.reverse_scroll_probability, 0.4)
self.assertEqual(args.max_runtime, 600)
def test_build_parser_has_search_keyword_argument(self) -> None:
module = importlib.import_module("Douyin")
args = module.build_parser().parse_args(["--search-keyword", "猫咪"])
self.assertEqual(args.search_keyword, "猫咪")
def test_main_dispatches_search_flow_for_search_keyword(self) -> None:
module = importlib.import_module("Douyin")
stdout = io.StringIO()
with redirect_stdout(stdout):
with mock.patch.object(module, "collect_search_results", return_value=7) as mocked_collect:
exit_code = module.main(["--search-keyword", "猫咪"])
self.assertEqual(exit_code, 0)
mocked_collect.assert_called_once_with(
keyword="猫咪",
max_videos=50,
timeout=10,
output_dir=module.Path("video"),
browser_port=9223,
scroll_settings=module.ScrollSettings(),
)
def test_build_parser_defaults_to_zero_argument_current_page_flow(self) -> None: def test_build_parser_defaults_to_zero_argument_current_page_flow(self) -> None:
module = importlib.import_module("Douyin") module = importlib.import_module("Douyin")
args = module.build_parser().parse_args([]) args = module.build_parser().parse_args([])
@@ -442,6 +661,7 @@ class DouyinModuleTests(unittest.TestCase):
timeout=10, timeout=10,
output_dir=module.Path("video"), output_dir=module.Path("video"),
browser_port=9223, browser_port=9223,
scroll_settings=module.ScrollSettings(),
) )
def test_main_without_target_dispatches_current_page_creator_flow(self) -> None: def test_main_without_target_dispatches_current_page_creator_flow(self) -> None:
+2
View File
@@ -299,6 +299,8 @@ class PlaywrightLearningHelperTests(unittest.TestCase):
"title": "Playwright 示例", "title": "Playwright 示例",
"video_id": "7619989983668240802", "video_id": "7619989983668240802",
"video_url": "https://v26-web.douyinvod.com/example/single.mp4", "video_url": "https://v26-web.douyinvod.com/example/single.mp4",
"author_name": "unknown",
"author_id": "unknown",
}, },
) )