直接上源码,如果觉得可以点个关注再走。
目录
import os
import time
import random
import json
import sqlite3
import argparse
from bs4 import BeautifulSoup
import requests
import pandas as pd
# Selenium
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# ------------- 配置 -------------
BASE_URL = "https://movie.douban.com/subject/26647087/comments"
# 默认为最新评论(可选 'new_score' 或 'time')
SORT_TYPE = "time"
DB_PATH = "douban_comments.db"
COOKIE_SAVE = "cookies_saved.json"
# 每次脚本运行抓取的页数
PAGES_PER_RUN = 5
MAX_TOTAL_PAGES = 5000 # 最大页数(页数 * 20 = 最大起点上限)
SLEEP_BETWEEN_PAGE_MIN = 5
SLEEP_BETWEEN_PAGE_MAX = 10
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16 Safari/605.1.15",
]
COOKIE_LIST = []
PROXY_FILE = "proxies.txt"
# --------------------------------
def ensure_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# comments 表
c.execute("""CREATE TABLE IF NOT EXISTS comments
(
comment_id TEXT PRIMARY KEY,
user TEXT,
user_url TEXT,
rating INTEGER,
time TEXT,
votes INTEGER,
content TEXT
)""")
# progress 表:只保存一行配置(next_start, sort_type)
c.execute("""CREATE TABLE IF NOT EXISTS progress
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
next_start INTEGER UNIQUE,
sort_type TEXT
)""")
# 尝试兼容旧表结构(如果列丢失)
try:
c.execute("SELECT sort_type FROM progress LIMIT 1")
except sqlite3.OperationalError:
try:
c.execute("ALTER TABLE progress ADD COLUMN sort_type TEXT")
print("提示: 已更新数据库结构 (添加 sort_type 字段)")
except Exception:
pass
# 初始化一行进度(如果为空)
c.execute("SELECT COUNT(*) FROM progress")
if c.fetchone()[0] == 0:
c.execute("INSERT INTO progress(next_start, sort_type) VALUES(0, ?)", (SORT_TYPE,))
conn.commit()
conn.close()
def load_proxies():
if not os.path.exists(PROXY_FILE):
return []
with open(PROXY_FILE, "r", encoding="utf-8") as f:
return [l.strip() for l in f if l.strip()]
PROXIES = load_proxies()
def load_saved_cookie():
if os.path.exists(COOKIE_SAVE):
try:
j = json.load(open(COOKIE_SAVE, "r", encoding="utf-8"))
cookie = j.get("cookie")
if cookie:
return cookie
except Exception:
pass
return None
def save_cookie_from_selenium(cookies):
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
with open(COOKIE_SAVE, "w", encoding="utf-8") as f:
json.dump({"cookie": cookie_str, "raw": cookies}, f, ensure_ascii=False, indent=2)
print("已将 cookie 保存在", COOKIE_SAVE)
def start_selenium(headless=False, user_data_dir=None):
opts = Options()
if headless:
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
opts.add_argument("--window-size=1200,900")
if user_data_dir:
opts.add_argument(f"--user-data-dir={user_data_dir}")
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=opts)
return driver
def init_login_manual(profile_dir=None):
print("打开浏览器,请在弹出窗口里登录豆瓣。登录成功后,请回到这里按回车...")
driver = start_selenium(headless=False, user_data_dir=profile_dir)
try:
driver.get("https://accounts.douban.com/passport/login")
input("登录完成后,请按回车键继续...")
cookies = driver.get_cookies()
save_cookie_from_selenium(cookies)
finally:
driver.quit()
def headers_with(cookie_str=None):
h = {
"User-Agent": random.choice(USER_AGENTS),
"Referer": f"{BASE_URL}?status=P",
"Accept-Language": "zh-CN,zh;q=0.9",
}
if cookie_str:
h["Cookie"] = cookie_str
return h
def fetch_page_requests(start, cookie_str=None, proxy=None, timeout=15):
params = {
"start": start,
"limit": 20,
"status": "P",
"sort": SORT_TYPE
}
headers = headers_with(cookie_str)
proxies_dict = {"http": proxy, "https": proxy} if proxy else None
proxy_msg = f"[代理: {proxy}]" if proxy else "[直连]"
print(f"正在抓取: start={start}, sort={SORT_TYPE} {proxy_msg}")
try:
resp = requests.get(BASE_URL, params=params, headers=headers, timeout=timeout, proxies=proxies_dict)
return resp.status_code, resp.text
except (requests.exceptions.ProxyError, requests.exceptions.ConnectTimeout,
requests.exceptions.ConnectionError) as e:
# 如果使用代理失败,尝试直连
if proxy:
print(f" [警告] 代理 {proxy} 连接失败或拒绝,尝试切换本机直连重试...")
try:
resp = requests.get(BASE_URL, params=params, headers=headers, timeout=timeout, proxies=None)
print(f" [恢复] 本机直连成功。")
return resp.status_code, resp.text
except Exception as e2:
return None, f"直连也失败: {str(e2)}"
return None, str(e)
except Exception as e:
return None, str(e)
def parse_comments(html):
soup = BeautifulSoup(html, "lxml")
items = soup.find_all("div", class_="comment-item")
out = []
for it in items:
cid = it.get("data-cid") or it.get("id")
info = it.find("span", class_="comment-info")
user, user_url, rating, time_text = None, None, None, None
if info:
a = info.find("a")
if a:
user = a.get_text(strip=True)
user_url = a.get("href")
rs = info.find(
lambda tag: tag.name == "span" and tag.get("class") and any("allstar" in c for c in tag.get("class")))
if rs:
import re
m = re.search(r"allstar(\d+)", " ".join(rs.get("class")))
if m:
try:
rating = int(m.group(1)) // 10
except Exception:
rating = None
t = info.find("span", class_="comment-time")
if t:
time_text = t.get_text(strip=True)
content_tag = it.find("p", class_="comment-content")
if not content_tag:
content_tag = it.find("span", class_="short")
content = content_tag.get_text(strip=True) if content_tag else None
votes_tag = it.find("span", class_="votes")
votes = 0
if votes_tag:
tv = votes_tag.get_text(strip=True)
if tv.isdigit():
votes = int(tv)
if content:
out.append({
"comment_id": cid, "user": user, "user_url": user_url, "rating": rating,
"time": time_text, "votes": votes, "content": content
})
return out
def save_comments_to_db(rows):
if not rows:
return 0
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
inserted = 0
for r in rows:
try:
c.execute(
"INSERT INTO comments(comment_id, user, user_url, rating, time, votes, content) VALUES(?,?,?,?,?,?,?)",
(r["comment_id"], r["user"], r["user_url"], r["rating"], r["time"], r["votes"], r["content"]))
inserted += 1
except sqlite3.IntegrityError:
# 已存在,跳过
continue
except Exception:
continue
conn.commit()
conn.close()
return inserted
def get_next_start_and_increment():
"""
更稳健的获取并增加 next_start:
- 按 id 读取和更新 progress 的行(避免 WHERE next_start 精确匹配失败)
- 如果检测到 sort_type 变化,会把 next_start 重置为 0 并更新 sort_type
返回值:当前应抓取的 next_start(未增量)
"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
c.execute("SELECT id, next_start, sort_type FROM progress ORDER BY id LIMIT 1")
row = c.fetchone()
except sqlite3.OperationalError:
row = None
if not row:
# 初始化一行
c.execute("DELETE FROM progress")
c.execute("INSERT INTO progress(next_start, sort_type) VALUES(0, ?)", (SORT_TYPE,))
conn.commit()
c.execute("SELECT id, next_start, sort_type FROM progress ORDER BY id LIMIT 1")
row = c.fetchone()
row_id, next_start, current_db_sort = row[0], row[1], (row[2] if len(row) > 2 else None)
# 智能重置:如果配置的 sort_type 与 db 中不一致,重置为 0
if current_db_sort != SORT_TYPE:
print(f"\n>>> 检测到排序模式变更 ({current_db_sort} -> {SORT_TYPE})")
print(f">>> 自动将起点从 {next_start} 重置为 0,重新开始抓取...\n")
next_start = 0
c.execute("UPDATE progress SET sort_type = ?, next_start = 0 WHERE id = ?", (SORT_TYPE, row_id))
conn.commit()
# 计算并保存下一次的起点(按 id 更新)
new_start = next_start + PAGES_PER_RUN * 20
c.execute("UPDATE progress SET next_start = ? WHERE id = ?", (new_start, row_id))
conn.commit()
conn.close()
return next_start
def main_run(args):
ensure_db()
cookie_saved = load_saved_cookie()
if cookie_saved:
COOKIE_LIST.insert(0, cookie_saved)
if not COOKIE_LIST:
print("警告: 没有找到保存的 cookie,建议先运行 --init-login")
proxies = PROXIES
start = get_next_start_and_increment()
if start >= MAX_TOTAL_PAGES * 20:
print("已达到最大设定页数。")
return
pages_to_fetch = PAGES_PER_RUN
print(f"--- 任务开始: 抓取 {pages_to_fetch} 页, 起点 start={start} ---")
cookie_idx = 0
proxy_idx = 0
total_inserted = 0
page_idx = 0
# 使用 while 循环以便在需要时调整 start 并在同次运行内继续(当 args.auto_continue=True 时)
while page_idx < pages_to_fetch:
cur_start = start + page_idx * 20
cookie = COOKIE_LIST[cookie_idx % len(COOKIE_LIST)] if COOKIE_LIST else None
proxy = proxies[proxy_idx % len(proxies)] if proxies else None
status, html_or_err = fetch_page_requests(cur_start, cookie_str=cookie, proxy=proxy)
if status == 200:
parsed = parse_comments(html_or_err)
if parsed:
inserted = save_comments_to_db(parsed)
total_inserted += inserted
print(f" [成功] start={cur_start} | 获取: {len(parsed)} 条 | 新入库: {inserted} 条")
else:
print(f" [空数据] start={cur_start} 解析为空。")
print(" -> 提示: 如果是最新评论也爬完了,说明真的没有更多数据了。")
elif status == 403:
print(f" [403 禁止] start={cur_start} IP或Cookie被封。请暂停一段时间。")
break
elif status == 404:
print(f" [404 不存在] start={cur_start} 页面不存在 (到达列表末尾)。")
# 如果本次运行的第一个页(page_idx==0)就是 404,说明 DB 中的 next_start 可能超出了当前可用最大页
if page_idx == 0:
print(" -> 检测到首页就是 404,可能起点过高(进度指针超出)。")
print(" 自动将 progress.next_start 重置为 0。")
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT id FROM progress ORDER BY id LIMIT 1")
row = c.fetchone()
if row:
row_id = row[0]
c.execute("UPDATE progress SET next_start = 0 WHERE id = ?", (row_id,))
conn.commit()
print(" 已将 progress.next_start 置为 0。")
conn.close()
except Exception as e:
print(" 自动重置进度失败:", e)
if args.auto_continue:
# 在同次运行内从 start=0 继续抓取
print(" --auto-continue 已开启:将在本次运行中从 start=0 重新开始抓取。")
start = 0
cookie_idx = 0
proxy_idx = 0
page_idx = 0
# 注意:不要立即继续下一个循环迭代(本次循环结束后会重新从 page_idx 0 开始)
continue
else:
print(" 建议稍后再次运行脚本以从头抓取最新评论。")
break
# 如果不是第一个页,那说明后续页已经到尽头,继续或停止由策略决定(这里我们继续循环以尝试下一页)
else:
# 直接跳过该页并继续(不会改变 progress,因为我们已经在本次运行中)
print(" -> 跳过当前页并尝试下一页(如果有)。")
else:
print(f" [错误] start={cur_start} 状态码: {status} | 信息: {html_or_err}")
# 如果还要继续抓下一页,休眠一小段随机时间
page_idx += 1
cookie_idx += 1
proxy_idx += 1
if page_idx < pages_to_fetch:
t = random.uniform(SLEEP_BETWEEN_PAGE_MIN, SLEEP_BETWEEN_PAGE_MAX)
print(f" ...等待 {t:.1f} 秒...")
time.sleep(t)
print(f"--- 本次任务结束,共新增 {total_inserted} 条记录 ---")
# 导出到 CSV
try:
conn = sqlite3.connect(DB_PATH)
df = pd.read_sql_query("SELECT * FROM comments", conn)
df.to_csv("three_body_comments_fixed.csv", index=False, encoding="utf-8-sig")
conn.close()
print(f"当前数据库共 {len(df)} 条,已导出至 three_body_comments_fixed.csv")
except Exception as e:
print("导出 CSV 失败:", e)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--init-login", action="store_true", help="登录并保存cookie")
ap.add_argument("--profile-dir", type=str, default=None, help="selenium user-data-dir")
ap.add_argument("--auto-continue", action="store_true", help="当检测到首页 404 并重置进度后,在同次运行里从 start=0 继续抓取")
args = ap.parse_args()
if args.init_login:
init_login_manual(profile_dir=args.profile_dir)
else:
main_run(args)
本文基于 Python 3.11+,使用
requests+BeautifulSoup+SQLite+Selenium(可选)完成对单个电影页面下评论的批量抓取。
目标是:
- 能够在不被豆瓣反爬机制干扰的前提下持续抓取到最新/最高分评论;
- 自动记录进度,支持断点续跑;
- 支持代理与 Cookie 切换,以降低 IP/IP 段封禁风险。
1. 环境准备
| 工具 | 版本 | 安装命令 |
|---|---|---|
| Python | 3.11+ | python -m pip install --upgrade pip |
| requests, bs4, pandas, selenium, webdriver‑manager | 依赖 | pip install requests beautifulsoup4 pandas selenium webdriver-manager |
提示:若你在国内访问 Google Chrome 的官方驱动会超时,可改用
webdriver-manager[chromedriver]或手工下载对应版本的chromedriver.exe。
2. 项目结构
douban_comment_scraper/
├── proxies.txt # 可选,代理列表(格式:host:port)
├── cookies_saved.json # 存储登录后的 Cookie
├── douban_comments.db # SQLite 数据库
├── three_body_comments_fixed.csv # 导出的 CSV
└── scraper.py # 主程序(下文的完整代码)
为什么用 SQLite?
- 轻量、无需额外服务;
- 支持事务,防止半路退出导致数据重复或缺失。
3. 核心功能拆解
下面把脚本分块讲解,让你清晰知道每一行代码在做什么。
3.1 配置区(全局常量)
BASE_URL = "https://movie.douban.com/subject/26647087/comments"
SORT_TYPE = "time" # 'new_score' 或 'time'
DB_PATH = "douban_comments.db"
COOKIE_SAVE = "cookies_saved.json"
PAGES_PER_RUN = 5 # 每次脚本运行抓取多少页
MAX_TOTAL_PAGES = 5000 # 最大起点限制(防止无限循环)
SLEEP_BETWEEN_PAGE_MIN = 5 # 随机睡眠,降低被封概率
SLEEP_BETWEEN_PAGE_MAX = 10
USER_AGENTS = [...] # 常用浏览器 UA 列表
PROXY_FILE = "proxies.txt"
思路:
PAGES_PER_RUN控制一次抓取的粒度,便于后期手动中断。MAX_TOTAL_PAGES只在极端情况下才触发(比如页面已全部爬完)。
3.2 数据库准备
def ensure_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# comments 表
c.execute("""CREATE TABLE IF NOT EXISTS comments (... )""")
# progress 表:记录下一页的起点(next_start)和排序方式
c.execute("""CREATE TABLE IF NOT EXISTS progress (... )""")
...
进度表
只保留一行,用来记录next_start与当前使用的sort_type。
当你想改成另一种排序(如从最高分抓起)时,程序会自动重置起点为0。
3.3 Cookie & 登录
def init_login_manual(profile_dir=None):
print("打开浏览器,请在弹出窗口里登录豆瓣。登录成功后,请回到这里按回车...")
driver = start_selenium(headless=False, user_data_dir=profile_dir)
...
- 通过 Selenium 打开一个真实的 Chrome 实例,让你手动完成登录。
- 登录成功后,脚本会把 Cookie 保存为
cookies_saved.json,之后所有请求都带上该 Cookie。
为什么要用 Selenium?
- 豆瓣对无 Cookie 的请求会直接跳转到登录页;
- 通过 Selenium 可以完整模拟人类操作,获取
sessionid等隐藏字段。
3.4 发起 HTTP 请求
def fetch_page_requests(start, cookie_str=None, proxy=None, timeout=15):
params = {"start": start, "limit": 20, ...}
headers = headers_with(cookie_str)
proxies_dict = {"http": proxy, "https": proxy} if proxy else None
...
start表示评论列表的偏移量(每页 20 条)。- 代理失败时,自动回退到直连;若直连也失败,则返回错误信息。
3.5 解析与存储
def get_next_start_and_increment():
...
- 用
BeautifulSoup提取:评论 ID、用户昵称、评分、时间、投票数、正文。 - 结果列表随后通过
save_comments_to_db()写入 SQLite。
去重
comment_id是主键,SQLite 的INSERT OR IGNORE可以避免重复插入。
3.6 进度管理
def get_next_start_and_increment(): ...
- 从
progress表读取当前next_start。 - 若数据库中的
sort_type与脚本配置不一致,自动将起点重置为0并更新sort_type。 - 计算下一次的起点:
next_start + PAGES_PER_RUN * 20并写回。
好处
- 支持断点续跑;
- 防止因为手动更改
SORT_TYPE而导致抓取错误。
3.7 主流程
def main_run(args):
ensure_db()
cookie_saved = load_saved_cookie()
...
start = get_next_start_and_increment()
...
- 每次运行会根据
PAGES_PER_RUN抓取若干页。 - 支持多 Cookie 与多代理轮流使用(提高成功率)。
- 处理各种 HTTP 状态码:200、403、404 等。
- 在每页结束后随机休眠,模拟人类行为。
auto-continue
如果第一次请求返回 404(说明起点已超出实际页面),程序会把进度重置为0,并在同一次运行中继续抓取。这对于“从头开始”非常方便。
3.8 CSV 导出
conn = sqlite3.connect(DB_PATH)
df = pd.read_sql_query("SELECT * FROM comments", conn)
df.to_csv("three_body_comments_fixed.csv", index=False, encoding="utf-8-sig")
- 每次运行结束后,自动把数据库里的所有评论导出为 CSV。
- 方便后期分析(如情感倾向、评分分布等)。
4. 如何使用
-
克隆或下载代码
git clone https://github.com/yourname/douban_comment_scraper.git cd douban_comment_scraper -
准备代理(可选)
- 在
proxies.txt中每行写一个代理,例如:127.0.0.1:10809 114.114.114.114:80 - 如果不需要代理,直接删掉文件或留空。
- 在
-
登录并保存 Cookie
python scraper.py --init-login- 浏览器会弹出,按提示完成豆瓣账号登录。
- 登录成功后回到终端按回车即可完成 Cookie 保存。
-
开始抓取
python scraper.py # 默认每次抓 5 页 python scraper.py --auto-continue # 自动重置起点并继续抓取 -
查看结果
douban_comments.db:SQLite 数据库,直接用 DB Browser for SQLite 打开即可。three_body_comments_fixed.csv:完整 CSV 文件,可导入 Excel、Pandas 等工具分析。
5. 常见问题 & 调试技巧
| 场景 | 解决方案 |
|---|---|
| 403 Forbidden | 可能 Cookie 被封,或 IP 被限流。<br>尝试切换代理或重新登录获取新 Cookie。 |
| 404 Not Found | 起点超出了实际页面数量。<br>使用 --auto-continue 或手动将 next_start 重置为0(直接编辑 DB)。 |
| 请求被重定向到 login 页面 | 检查 Cookie 是否正确,或尝试手动登录后再次抓取。 |
| 解析不到评论 | 可能页面结构变更;检查 parse_comments() 中的选择器是否仍有效。 |
调试建议
- 在
fetch_page_requests中打印完整响应文本 (resp.text[:500]) 可快速定位是否被重定向。- 使用
sqlite3的事务模式(默认)可以避免数据不一致。
6. 扩展思路
-
多电影批量抓取
把BASE_URL换成循环列表,或从一个 CSV 中读取电影 ID。 -
分布式部署
将 SQLite 换成 PostgreSQL 或 MySQL,然后用 Celery 分配任务。 -
数据清洗 & NLP
用jieba,snownlp对评论文本做分词、情感分析,生成可视化报告。 -
监控与告警
每次抓取完毕后发送邮件或推送到 Slack 以提醒任务完成。
7. 小结
这份脚本把豆瓣评论的爬取拆解成了请求、解析、存储、进度管理四大模块,并通过 Cookie + 代理轮转 与 随机睡眠 等手段降低被封风险。
如果你想抓取自己的电影或其他站点的评论,只需改动 BASE_URL、适配 HTML 结构即可。
希望这篇博客能帮助你快速上手并扩展自己的数据采集项目!
2183

被折叠的 条评论
为什么被折叠?



