基于 Python 的电影数据爬取:从 DOM 解析到正则清洗

7 阅读11分钟

💡 导读:我们今天在干嘛?

很多人以为写代码抓数据是高级黑客的特权。其实不然!今天咱们用大白话来拆解:其实就是写一张任务清单,派出一个“网络跑腿小哥”(也就是我们的机器人),让他帮我们去电影网站(TMDB),把前 60 部高分电影的信息全部抄下来,最后整整齐齐地写进一个 Excel 表格里。

只要你会一点点 Python,今天你就能成功雇佣这个小哥!


🛑 第一步:机器人的“法律与道德准则”(robots.txt 协议)

在派小哥出门之前,我们必须教他学会尊重别人。互联网世界有一条默认的“君子协定”,叫做 robots.txt 协议。

1. 大白话科普:什么是 robots.txt?

这就好比每一个公开的商业大楼(网站),都会在门口贴一张《外来跑腿机器人谢绝入内区域清单》。
只要你在任何网站的域名后面加上 /robots.txt(比如:https://themoviedb.org),就能看到官方给所有机器人设定的红线。

image.png

2. 怎么看懂这份协议?

  • User-agent: *:意思是“不管是哪家公司的跑腿小哥,都得听好了”。
  • Disallow: /search:意思是“里面正在热火朝天搞搜索,小哥请勿入内,否则会把服务器挤爆”。
  • Allow: /:意思是“普通的公开展示页面,欢迎正常查阅”。

💡 作者寄语:我们的机器人今天只去访问公开的高分电影榜单www.themoviedb.org/movie/top-r… 和详情页,完全符合官方的合规要求。做爬虫第一步,一定要先看眼门口的告示牌!


🏃‍♂️ 第二步:搭建主控制中枢(解决动态翻页难题)

我们要让小哥跑 3 趟(翻 3 页),每页拿 20 部电影,先把这 60 部电影的“详情页小地址”全部翻出来。

1. 网页结构与抓包真相

很多新手以为,翻到第二页,只要把网址(URL)后面的数字改成 page=2 就行了。但这个电影网站很贼,你点“下一页”的时候,浏览器顶部的网址根本没变!如果小哥只盯着网址看,他就会在第一页原地打转。

📸 建议配图 1:翻页抓包现场

第一页内容:

image.png

第二页内容:

image.png

image.png

2. 核心大白话原理

我们在代码里给小哥装上一个“智能判断开关”:

  • 第一趟(第1页):直接走正门看公开网页(用 GET 请求)。
  • 后面几趟(第2、3页):带着一长串参数做成一张“秘密申请表”(Payload),去敲后门(用 POST 请求)。
  • 💡 避坑处理:因为我们还没教小哥怎么去单部电影抄数据,所以我们先用 print(f"发现了电影链接: {movie}") 代替。这段代码你直接复制运行,完全能成功跑起来!

💻 核心代码

from lxml import html
import requests

# 跑腿小哥要去的大本营网址
ROOT_URL = "https://themoviedb.org"


def main():
    # 循环 3 次,代表让小哥跑 3 趟,拿前 60 部电影
    for i in range(3):
        print(f"🏃‍♂️ 小哥报告:我正在跑第 {i+1} 趟...")

        if i == 0:
            # 状态 A:第一趟,走正门直接看(GET请求)
            response = requests.get(f"{ROOT_URL}/movie/top-rated")
        else:
            # 状态 B:后面几趟,带着“秘密表单”(POST请求)去敲后门
            payload = f"air_date.gte=&air_date.lte=&certification=&certification_country=HK&debug=&first_air_date.gte=&first_air_date.lte=&include_adult=false&include_softcore=false&latest_ceremony.gte=&latest_ceremony.lte=&page={i+1}&primary_release_date.gte=&primary_release_date.lte=&region=&release_date.gte=&release_date.lte=2027-02-07&show_me=everything&sort_by=vote_average.desc&vote_average.gte=0&vote_average.lte=10&vote_count.gte=300&watch_region=HK&with_genres=&with_keywords=&with_networks=&with_origin_country=&with_original_language=&with_watch_monetization_types=&with_watch_providers=&with_release_type=&with_runtime.gte=0&with_runtime.lte=400"

            response = requests.post(
                f"{ROOT_URL}/discover/movie/items", data=payload
            )

        # 小哥把看到的网页打包成一棵方便查找的“菜单树”
        data = html.fromstring(response.text)

        # 仙人指路!在这页网页里,把 20 部电影的详情页“小地址”全部翻出来
        movies = data.xpath(
            "//*[@class='media-list-results contents']/div/div/div/a/@href"
        )

        # 拿着这 20 个小地址,我们先让小哥在控制台打印出来看看
        for movie in movies:
            print(f"📌 跑腿小哥:我发现了电影详情页链接: {movie}")


if __name__ == "__main__":
    main()

🧠 语法知识点科普

  • requests.get():就像你直接去柜台看菜单,菜单挂在墙上(网址里),大家都能看得见。
  • requests.post(url, data=...):就像你填了一张保密申请表塞给柜员,柜员在后台悄悄把你要的第二页数据递给你。

🎯 第三步:精准轰炸(手写核心抓取逻辑与高级 XPath)

现在小哥能拿到电影的小地址了。接下来我们要写一个新的函数 get_movie_detail(url),正式教他点进单部电影的房间,抄下 11 个维度的数据。

📸 建议配图 2:寻找电影语言的“文本锚定”

image.png

1. 核心大白话原理

很多教程会教你“在浏览器里右键复制 XPath”,那拿到的叫“绝对路径”(类似:某某路某某小区某栋某单元)。万一明天网站多加了个广告,这个地址就彻底错了。

image.png

咱们今天手写“环境不变量”指路牌:

  • 锁定大路标:抓电影名时,告诉小哥:“你先找到一个挂着 id='original_header' 牌子的大房间,在这个房间里,去找只要名字里包含 title 的区域(用 contains),里面写着的字就是电影名!”
  • 绝活:按字找地方:我们告诉小哥:“你在墙上找找,哪一行写着‘默认语言’这四个字,那这一行里写的内容就是我们要的语言!”网页随便改,只要“默认语言”这四个字还在,小哥就绝对不会抄错!

💻 核心代码

为了让这段代码直接能跑,我们先在函数里把抓到的原始信息原封不动地打包成字典返回。

from lxml import html
import requests

ROOT_URL = "https://themoviedb.org"


def get_movie_detail(url):
    # 让小哥点进这部电影的完整链接,并打包成“菜单树”
    details = requests.get(f"{ROOT_URL}/{url}")
    data = html.fromstring(details.text)

    # 1. 电影名
    movie_name = data.xpath(
        "//*[@id='original_header']/div[contains(@class, 'header_poster_wrapper')]//div[contains(@class, 'title')]//a/text()"
    )
    # 2. 年份
    movie_year = data.xpath(
        "//*[@id='original_header']/div[contains(@class, 'header_poster_wrapper')]//div[contains(@class, 'title')]//span/text()"
    )
    # 3. 上映时间
    movie_release_date = data.xpath(
        "//*[@id='original_header']//span[@class='release']/text()"
    )
    # 4. 类型(多标签,用逗号拼接)
    movie_type = ",".join(
        data.xpath(
            "//*[@id='original_header']//span[@class='genres']/a/text()"
        )
    )
    # 5. 时长
    movie_duration = data.xpath(
        "//*[@id='original_header']//span[@class='runtime']/text()"
    )
    # 6. 评分(拿隐藏属性的值)
    movie_rating = data.xpath(
        "//*[@id='consensus_pill']//div[@class='user_score_chart' and @data-percent]/@data-percent"
    )
    # 7. 语言【按字找地方的绝活】
    movie_language = data.xpath(
        "//*[contains(@class, 'facts')]//p[.//*[contains(text(), '默认语言')]]/text()"
    )
    # 8. 导演
    movie_director = data.xpath(
        "//*[@id='original_header']//li[p[contains(text(), 'Director')]]/p/a/text()"
    )
    # 9. 主演(多演员,用逗号拼接)
    movie_actor = ",".join(
        data.xpath(
            "//*[@id='cast_scroller']/ol/li/p/a[contains(@href, '/person')]/text()"
        )
    )
    # 10. slogan(宣传语)
    movie_slogan = data.xpath(
        "//*[@id='original_header']/div/section/div/h3/text()"
    )
    # 11. 简介
    movie_synopsis = data.xpath(
        "//*[@id='original_header']/div/section/div/div/p/text()"
    )

    # 把抄回来的东西做成一个精美的“小档案”(字典)返回
    # .strip() 用于拍掉两边的空格。如果没捞到,就给个空白字符串 ""
    return {
        "电影名": movie_name[0].strip() if movie_name else "",
        "年份": movie_year[0].strip() if movie_year else "",
        "上映时间": movie_release_date[0].strip() if movie_release_date else "",
        "类型": movie_type.strip() if movie_type else "",
        "时长": movie_duration[0].strip() if movie_duration else "",
        "评分": movie_rating[0].strip() if movie_rating else "",
        "语言": movie_language[0].strip() if movie_language else "",
        "导演": movie_director[0].strip() if movie_director else "",
        "主演": movie_actor.strip() if movie_actor else "",
        "slogan": movie_slogan[0].strip() if movie_slogan else "",
        "简介": movie_synopsis[0].strip() if movie_synopsis else "",
    }


# 单独测试一下这个小哥抄数据的能力
if __name__ == "__main__":
    # 我们随便拿个电影小地址测试一下,看看能不能成功吐出小档案
    test_data = get_movie_detail("/movie/278")
    print(test_data)

🧠 语法知识点科普

网页上的信息是不确定的。比如有些老电影根本没有写 slogan。如果小哥强行去摘列表第一项,代码就会报错崩溃。
movie_slogan[0].strip() if movie_slogan else "" 这句话的意思就是:“如果小哥抓到了这个宣传语,就取第 0 项并清洗交出来;如果根本没捞到(是个空列表),那就给个空白字符串,千万别死机!”


🧼 第四步:洗数据中心(用正则驯服脏数据)

小哥把刚才的测试数据拿回来了,但是里面年份带着括号 (1994),上映时间塞着地区 1994-10-14 (US),时长更是混乱的 2h 22m
我们需要写三个“清洁洗涤剂”函数,专门把这些脏数据洗白。这段代码导入了 re 库,可以直接运行测试!

💻 核心代码

import re


def format_movie_year(year):
    # 【洗涤剂 A】用 strip("()") 剥掉两边的括号,把 "(1994)" 变成 "1994"
    result = year[0].strip("()") if year else ""
    return result


def format_movie_release_data(release_date):
    # 【洗涤剂 B】利用正则,强行提取 YYYY-MM-DD 格式,丢弃括号里的地区噪声
    pattern = r"(\d{4}-\d{2}-\d{2})\s*(.*?)"
    result = re.search(pattern, release_date[0].strip() if release_date else "")
    return result.group(1) if result else ""


def format_movie_duration(duration):
    # 【洗涤剂 C】把 "2h 22m" 统一换算成纯分钟数字
    if not duration:
        return "0m"

    pattern = r"(?:(\d+)h)?\s*(?:(\d+)m)?"
    match = re.match(pattern, duration[0].strip())

    if match:
        hours = int(match.group(1) or 0)
        minutes = int(match.group(2) or 0)
        return f"{hours * 60 + minutes}m"
    else:
        return "0m"


# 测试一下洗涤效果
if __name__ == "__main__":
    print(format_movie_year(["(1994)"]))  # 应该输出 1994
    print(format_movie_release_data(["1994-10-14 (US)"]))  # 应该输出 1994-10-14
    print(format_movie_duration(["2h 22m"]))  # 应该输出 142m

🧠 语法知识点科普

你可以把 re(正则表达式)理解为一种带有超强过滤网的漏斗。我们通过定制特定的“网眼形状”(如 \d{4} 代表四个数字),就能把混在泥沙里的纯金子(纯日期、纯数字)精准地过滤出来。


💾 第五步:完整版大合体!(完美落盘为 CSV)

现在所有的拼图都齐了!我们把前四步的函数全部拼在一起,并加上最后的数据保存功能。这就是我们的终极完整版代码。

📸 建议配图 3:最终生成的表格成果

image.png

💻 核心完整代码

import csv
from lxml import html
import re
import requests

ROOT_URL = "https://themoviedb.org"


# --- 数据清洗模块 ---
def format_movie_year(year):
    return year[0].strip("()") if year else ""


def format_movie_release_data(release_date):
    pattern = r"(\d{4}-\d{2}-\d{2})\s*(.*?)"
    result = re.search(pattern, release_date[0].strip() if release_date else "")
    return result.group(1) if result else ""


def format_movie_duration(duration):
    if not duration:
        return "0m"
    pattern = r"(?:(\d+)h)?\s*(?:(\d+)m)?"
    match = re.match(pattern, duration[0].strip())
    if match:
        hours = int(match.group(1) or 0)
        minutes = int(match.group(2) or 0)
        return f"{hours * 60 + minutes}m"
    return "0m"


# --- 详情页抓取模块 ---
def get_movie_detail(url):
    print(f"🔍 小哥正在深入查看电影:{url} 的详细信息...")
    details = requests.get(f"{ROOT_URL}/{url}")
    data = html.fromstring(details.text)

    movie_name = data.xpath(
        "//*[@id='original_header']/div[contains(@class, 'header_poster_wrapper')]//div[contains(@class, 'title')]//a/text()"
    )
    movie_year = data.xpath(
        "//*[@id='original_header']/div[contains(@class, 'header_poster_wrapper')]//div[contains(@class, 'title')]//span/text()"
    )
    movie_release_date = data.xpath(
        "//*[@id='original_header']//span[@class='release']/text()"
    )
    movie_type = ",".join(
        data.xpath(
            "//*[@id='original_header']//span[@class='genres']/a/text()"
        )
    )
    movie_duration = data.xpath(
        "//*[@id='original_header']//span[@class='runtime']/text()"
    )
    movie_rating = data.xpath(
        "//*[@id='consensus_pill']//div[@class='user_score_chart' and @data-percent]/@data-percent"
    )
    movie_language = data.xpath(
        "//*[contains(@class, 'facts')]//p[.//*[contains(text(), '默认语言')]]/text()"
    )
    movie_director = data.xpath(
        "//*[@id='original_header']//li[p[contains(text(), 'Director')]]/p/a/text()"
    )
    movie_actor = ",".join(
        data.xpath(
            "//*[@id='cast_scroller']/ol/li/p/a[contains(@href, '/person')]/text()"
        )
    )
    movie_slogan = data.xpath(
        "//*[@id='original_header']/div/section/div/h3/text()"
    )
    movie_synopsis = data.xpath(
        "//*[@id='original_header']/div/section/div/div/p/text()"
    )

    # 包装并调用清洗函数
    return {
        "电影名": movie_name[0].strip() if movie_name else "",
        "年份": format_movie_year(movie_year),
        "上映时间": format_movie_release_data(movie_release_date),
        "类型": movie_type.strip() if movie_type else "",
        "时长": format_movie_duration(movie_duration),
        "评分": movie_rating[0].strip() if movie_rating else "",
        "语言": movie_language[0].strip() if movie_language else "",
        "导演": movie_director[0].strip() if movie_director else "",
        "主演": movie_actor.strip() if movie_actor else "",
        "slogan": movie_slogan[0].strip() if movie_slogan else "",
        "简介": movie_synopsis[0].strip() if movie_synopsis else "",
    }


# --- 数据保存模块 ---
def save_movie_file(movie_list):
    print("💾 正在把小哥抄来的小档案整整齐齐地塞进文件...")
    with open("data/movie.csv", "w", newline="", encoding="utf-8") as file:
        headers = [
            "电影名",
            "年份",
            "上映时间",
            "类型",
            "时长",
            "评分",
            "语言",
            "导演",
            "主演",
            "slogan",
            "简介",
        ]
        writer = csv.DictWriter(file, fieldnames=headers)
        writer.writeheader()
        writer.writerows(movie_list)
    print("🎉 保存数据成功!任务圆满完成!")


# --- 控制中枢模块 ---
def main():
    top_movies = []
    for i in range(3):
        print(f"跑腿小哥:正在切入第 {i+1} 层网络空间...")
        if i == 0:
            response = requests.get(f"{ROOT_URL}/movie/top-rated")
        else:
            payload = f"air_date.gte=&air_date.lte=&certification=&certification_country=HK&debug=&first_air_date.gte=&first_air_date.lte=&include_adult=false&include_softcore=false&latest_ceremony.gte=&latest_ceremony.lte=&page={i+1}&primary_release_date.gte=&primary_release_date.lte=&region=&release_date.gte=&release_date.lte=2027-02-07&show_me=everything&sort_by=vote_average.desc&vote_average.gte=0&vote_average.lte=10&vote_count.gte=300&watch_region=HK&with_genres=&with_keywords=&with_networks=&with_origin_country=&with_original_language=&with_watch_monetization_types=&with_watch_providers=&with_release_type=&with_runtime.gte=0&with_runtime.lte=400"
            response = requests.post(
                f"{ROOT_URL}/discover/movie/items", data=payload
            )

        data = html.fromstring(response.text)
        movies = data.xpath(
            "//*[@class='media-list-results contents']/div/div/div/a[1]/@href"
        )

        for movie in movies:
            movie_details = get_movie_detail(movie)
            top_movies.append(movie_details)

    save_movie_file(top_movies)


if __name__ == "__main__":
    main()

🧠 语法知识点科普:为什么推荐用 csv.DictWriter

如果用普通的 csv.writer,你是按照顺序去塞数据的。万一有一部电影缺了“导演”,你后面所有的信息都会往前挪一格,整个表格就全乱套了。
csv.DictWriter 认的是名字标签(字典的 Key)。只要小档案里写着 {'导演': '肖申克导演'},不管前面缺了啥,它都能精准塞进“导演”那一列,绝对不会发生错位。


🛠️ 结语:代码日后失效了?小白现场自救绝学!

如果明天网站改版,小哥罢工了,报了 IndexError: list index out of range,不要慌!

  1. 去控制台(Console)原地给小哥探路:
    在你要抓取的网页上按 F12,把面板切换到 Console(控制台)。

  2. 直接在控制台里输入 $x("你的指路牌路径") 敲回车。

    • 例如你输入:$x("//*[@id='original_header']//span[@class='release']/text()")
    • 如果回车后能吐出数组,说明路径是亮的,可以用!
    • 如果吐出来是空的 [],说明网站把 class='release' 给改名了。你可以直接在这个控制台里一遍遍修改测试,直到它能吐出数据为止,然后把调通的路径复制回 Python 代码,小哥立刻复活!