从豆瓣电影抓取评论:一份完整的 Python 爬虫实现

直接上源码,如果觉得可以点个关注再走。

目录

1. 环境准备

2. 项目结构

3. 核心功能拆解

3.1 配置区(全局常量)

3.2 数据库准备

3.3 Cookie & 登录

3.4 发起 HTTP 请求

3.5 解析与存储

3.6 进度管理

3.7 主流程

3.8 CSV 导出

4. 如何使用

5. 常见问题 & 调试技巧

6. 扩展思路

7. 小结




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. 环境准备

工具版本安装命令
Python3.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. 如何使用

  1. 克隆或下载代码

    git clone https://github.com/yourname/douban_comment_scraper.git
    cd douban_comment_scraper
    

  2. 准备代理(可选)

    • 在 proxies.txt 中每行写一个代理,例如:
      127.0.0.1:10809
      114.114.114.114:80

    • 如果不需要代理,直接删掉文件或留空。
  3. 登录并保存 Cookie

    python scraper.py --init-login
    
    • 浏览器会弹出,按提示完成豆瓣账号登录。
    • 登录成功后回到终端按回车即可完成 Cookie 保存。
  4. 开始抓取

     
    python scraper.py          # 默认每次抓 5 页
    python scraper.py --auto-continue  # 自动重置起点并继续抓取
    

  5. 查看结果

    • 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. 扩展思路

  1. 多电影批量抓取
    BASE_URL 换成循环列表,或从一个 CSV 中读取电影 ID。

  2. 分布式部署
    将 SQLite 换成 PostgreSQL 或 MySQL,然后用 Celery 分配任务。

  3. 数据清洗 & NLP
    jieba, snownlp 对评论文本做分词、情感分析,生成可视化报告。

  4. 监控与告警
    每次抓取完毕后发送邮件或推送到 Slack 以提醒任务完成。


7. 小结

这份脚本把豆瓣评论的爬取拆解成了请求、解析、存储、进度管理四大模块,并通过 Cookie + 代理轮转随机睡眠 等手段降低被封风险。
如果你想抓取自己的电影或其他站点的评论,只需改动 BASE_URL、适配 HTML 结构即可。

希望这篇博客能帮助你快速上手并扩展自己的数据采集项目!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值