Python爬虫实战:影视站点每日增量链接抓取与定时采集
发布时间:2026/10/16 7:59:21 锦皓数字建站

先说结论这个案例做完你会拥有一套能每天自动运行的影视链接采集脚本。别被“py每日spider案例之某website之影视链接获取”这个描述吓到它说白了就是三件事从目标站点的列表页找到当天更新的影片条目进入详情页拿到真实播放地址再把数据存下来并且让脚本每天自己跑一遍。这次练习我用的目标站是一个仅供技术演示的影视站点域名我做了脱敏叫 demo-movie.example.com。它的页面结构在影视类网站里非常典型首页按分类展示海报和片名详情页里有播放地址、类型、地区、年份。演示站本身允许常规爬虫访问页面是服务端渲染非常适合拿来拆解爬虫的完整思路。如果你是刚接触爬虫没多久的新手或者想做个人影视片单数据源这个案例直接照着改就行。1. 案例拆解某website之影视链接获取的完整需求1.1 这个爬虫到底要抓什么我先不急着写代码把需求表列出来。目标站每天凌晨会更新一批影视条目每一个条目其实由三个层面组成列表页、详情页、播放链接。列表页展示影片标题、封面图和更新时间点进详情页之后能拿到影片的类型、地区、年份以及一个播放地址区块。播放地址区块里最常见的两种形式是 mp4 直链和 m3u8 流媒体地址。我们的爬虫就是要在这几个页面之间完成跳转和提取。注意我说的是“链接获取”不是“资源下载”。脚本只负责把链接和元数据抓回来不负责下载视频文件。这样做的好处是数据量小、执行快每天跑一趟也就几千个请求个人电脑完全没有压力。数据落地之后你可以拿来做数据分析比如统计某个分类下更新最频繁的类型趋势也可以给个人媒体库做自动化片单甚至把链接同步到播放器里。整个项目拆到最后就是一套“列表解析 详情解析 入库去重”的标准模板。1.2 为什么拿影视站当练习对象写爬虫练习案例可选的目标很多电商、新闻、天气、小说。我选影视站是反复比较过之后的结果。最大的原因是页面层级足够典型一个列表页对应 N 个详情页一个详情页对应 N 个播放链接这种一对多关系在各种数据采集场景里都是最常见的建模方式。另一个原因是“每日更新”这个属性特别好。爬虫项目最怕的不是不会写代码而是写完不知道该拿什么数据练手。影视站每天都有增量内容天然适合做“每日定时任务”你在增量抓取、去重入库这一块遇到的问题和真实生产场景非常一致。相比之下电商站的商品数据一两个月都不变练不出增量逻辑。而且影视站的页面结构相对规整反爬强度通常也介于“完全开放”和“严格保护”之间对新手而言既能碰到 Cookie、验证码这类问题又不至于一上来就被各种风控打到自闭。1.3 技术选型requests BeautifulSoup SQLite 的组合逻辑有同学可能会问都什么年代了为什么不直接用 Scrapy或者干脆上 Playwright我个人的判断是这个项目每天只需要处理几百条数据单线程同步请求完全够用。Scrapy 的异步引擎和中间件体系确实强大但学习成本也高而且对于这种轻量任务来说属于大炮打蚊子。Playwright 适合页面大量依赖 JS 渲染的场景但浏览器实例很吃内存还要单独维护驱动放在服务器上跑并不划算。先用 requests BeautifulSoup 把主流程跑通等数据量真的大到需要并发和分布式了再迁移到 Scrapy路径会很顺。存储方面用 SQLite 而不是 MySQL理由更直接零配置、单文件、Python 标准库直接支持定时任务跑完生成一个 movies.db 就能拿走。同时我也会把每一批原始结果保留一份 JSON 快照方便回溯和排查数据异常。这套组合不是最“高级”的但一定是最不容易翻车的。2. 目标站点分析先看懂页面结构再写爬虫2.1 列表页和详情页的结构拆解动手写代码之前我先花十来分钟把页面结构看了一遍。这里分享一个习惯不要急着翻源码先在浏览器里打开站点按 F12 查看 Element 面板用鼠标选中一个影片卡片看它的 CSS 类名。演示站的列表项长这样div classmovie-item a href/detail/1024img src/cover/1024.jpg alt示例电影/a div classtitle示例电影/div div classcategory动作 / 冒险/div div classupdate-time2025-01-08 03:12/div /div列表页的分页是/list/1、/list/2这种形式抓取的时候只需要改动页码参数判断终止条件是“当前页没有新的更新时间”或者“列表页为空”。注意有些分页器会把第 1 页写成/list而不是/list/1我第一次写的时候就因为这个少抓了第一页后来加了兼容逻辑才解决。进入详情页之后播放链接被放在一个独立的章节里。页面里可能同时存在多个播放源每个源有一个名字和一个链接要分别提取。详情页还会附带一些元信息我的建议是尽量在同一个请求里把详情页的正文 HTML 保存一份原始快照这样后面解析出错时可以离线排查。当然这会多花一点流量但排查问题时的效率提升远远抵得过这点成本。2.2 反爬识别三步法照着做就能定位问题很多新手一看到目标站就打怵总觉得要上各种复杂手段。实际上判断难度只需要做三个小实验。第一步关掉浏览器模拟直接用 requests 发一个最简单的 GET观察返回的页面里有没有完整数据。演示站对这种请求通常返回 200但内容可能是个验证页这时候就要检查响应里的标题和关键词。第二步把浏览器里复制出来的完整 User-Agent 加到请求头里再带上 Accept、Accept-Language、Upgrade-Insecure-Requests 这些常规头看看还出不出现验证页。这一步能解决一大半“抓不到内容”的问题。第三步打开开发者工具的 Network 面板刷新列表页如果数据直接出现在文档请求的 Response 里说明是服务端渲染用 BeautifulSoup 解析就好如果 Response 里看不到片名反而在某个 XHR 请求的 JSON 里看到数据说明是接口动态加载需要用另一套逻辑去模拟那个接口。我遇到过很多同学一上来就怀疑是加密参数、指纹检测结果做完三步发现只是少了 UA。反爬识别的核心原则是先排除低级问题再考虑高级策略。2.3 全量抓取还是每日增量爬虫设计里最容易被忽略的是“你要抓多久的数据”。第一次跑我的建议是全量抓一遍把演示站现存的所有列表页和详情页都过一遍建立完整的数据基础。之后进入每日任务模式只处理当天更新的条目。那怎么判断哪些是当天更新演示站列表页的每个卡片都有 update-time 字段这就是现成的增量依据。脚本启动后先读取数据库里最近一次成功抓取的时间戳然后只解析更新时间大于该时间戳的卡片再进详情页提取链接。如果没有时间戳也可以用详情页里的入库时间字段代替。增量逻辑的核心是状态位上一个批次的时间点必须持久化不能写在内存里否则服务重启就会重复抓全量。这里有个经验宁可多抓一点也不要漏抓。增量条件可以放宽到“前 40 分钟内更新的所有条目”入库时再利用唯一索引去重这样即便因为网络抖动漏掉某一条下一次也能从时间窗口内捞回来。3. 核心代码实现从请求到入库的完整流程3.1 请求模块封装会话、请求头、延时与重试先建一个 movie_spider.py 文件把请求基础打牢。我习惯用一个 requests.Session因为 Session 会自动维持 Cookie后续某几个页面需要登录态或校验码时同一个会话会更省事。请求头一定要给全最基本的四件套是 User-Agent、Accept、Accept-Language、Referer。Referer 在访问详情页的播放链接时特别有用很多站点会校验来源页。import time import random import requests from requests.adapters import HTTPAdapter BASE_URL https://demo-movie.example.com HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, Referer: BASE_URL /, } session requests.Session() session.headers.update(HEADERS) adapter HTTPAdapter(max_retries3) session.mount(http://, adapter) session.mount(https://, adapter) def fetch(url, *, retries3, delay(1, 3)): for attempt in range(retries): try: resp session.get(url, timeout12) resp.raise_for_status() return resp except Exception as exc: wait random.uniform(*delay) print(f[retry {attempt 1}] {url} - {exc}, wait {wait:.1f}s) time.sleep(wait) raise RuntimeError(ffetch failed: {url})这段代码里有两个细节值得说。一是 HTTPAdapter(max_retries3) 负责传输层重试fetch 函数里的 for 循环负责业务层重试两层重试的目的不一样前者处理连接重置这种瞬时问题后者处理超时或者 5xx。二是随机延时用 1 到 3 秒的均匀分布而不是固定 2 秒目的是让访问节奏更接近人工操作避免被规则匹配到周期性特征。3.2 列表页解析与翻页逻辑接下来写列表页解析。这里我直接使用 BeautifulSoup 配合 CSS 选择器因为 movie-item 这个类名在演示站里非常稳定。记得把详情页链接用 urljoin 转成绝对地址很多站点的 href 都是相对路径直接存下来后面会踩坑。from bs4 import BeautifulSoup from urllib.parse import urljoin def parse_list_items(html): soup BeautifulSoup(html, lxml) items [] for card in soup.select(.movie-item): link card.select_one(a[href]) if not link: continue title_tag card.select_one(.title) time_tag card.select_one(.update-time) cat_tag card.select_one(.category) items.append({ title: title_tag.get_text(stripTrue) if title_tag else , detail_url: urljoin(BASE_URL, link[href]), category: cat_tag.get_text(stripTrue) if cat_tag else , update_time: time_tag.get_text(stripTrue) if time_tag else , }) return items def crawl_list_pages(max_pages10): all_items [] for page in range(1, max_pages 1): url f{BASE_URL}/list/{page} resp fetch(url) items parse_list_items(resp.text) if not items: break all_items.extend(items) time.sleep(random.uniform(0.5, 1.2)) return all_items翻页逻辑的关键在 break 条件。演示站的页面如果没数据会返回空列表所以 if not items: break 是安全的。但有的站到了最后一页会循环回第一页或者返回重复内容所以更严谨一点的做法是记录最近几个页面的首条详情链接发现重复就停。另外crawl_list_pages 里的 sleep 不能省列表页连续请求很容易触发频率限制间隔设成 0.5 到 1.2 秒之间的随机数就够用。3.3 播放链接清洗与标准化从详情页拿到的 href 不一定是能直接访问的地址常见问题有三类。第一类是相对路径必须用 urljoin(page_url, raw_url) 补全。第二类是协议混杂比如//cdn.example.com/video.mp4这种必须以 http: 或 https: 补全。第三类是链接里带上了页面跳转参数比如/go?urlxxx需要先解析提取 query 里的真实地址再继续。清洗之后我还习惯给链接分类mp4、mkv 这类是直链m3u8 是流媒体地址再有就是普通网页地址。分类的意义在于后面做链接校验时不同协议的处理方式不一样。代码写出来是这样from urllib.parse import urlparse, urljoin, parse_qs def normalize_play_url(raw_url, page_url, referer): if not raw_url: return None parsed urlparse(raw_url) if parsed.scheme : raw_url urljoin(page_url, raw_url) elif parsed.scheme not in (http, https): return None # 部分站点的播放链接是跳转包裹 if raw_url.startswith(BASE_URL) and /go? in raw_url: qs parse_qs(urlparse(raw_url).query) target qs.get(url, [])[0] if target: raw_url target kind unknown path_lower urlparse(raw_url).path.lower() if m3u8 in path_lower: kind m3u8 elif path_lower.endswith((.mp4, .mkv, .avi, .webm)): kind video return {url: raw_url, kind: kind, referer: referer, page_url: page_url}有一点必须强调链接清洗不能只做一次。同一个详情页里可能存在多个播放源每个源的 Referer 可能是不同的页面保存时必须把来源页和 Referer 一起存下来否则后面拿链接去播放或校验服务器会直接拒绝。这个坑我实际踩过当时只存了 URL第二天链接全部打不开最后排查才发现是 Referer 丢了。标准化函数里把 referer 和 page_url 一起返回就是为了避免这种问题。3.4 SQLite 建表与 JSON 快照双写数据存两份。第一份是每次跑批的原始 JSON按日期归档第二份是合并后的 SQLite 数据库。JSON 的作用是审计SQLite 的作用是查询和去重。我习惯先把建表语句写完整字段类型、默认值、唯一约束一次到位避免后面数据多了再重建表。import sqlite3 import json from datetime import datetime def init_db(db_pathmovies.db): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, category TEXT DEFAULT , year TEXT DEFAULT , play_url TEXT UNIQUE, kind TEXT DEFAULT , referer TEXT DEFAULT , detail_url TEXT DEFAULT , created_at TEXT DEFAULT (datetime(now, localtime)) ) ) conn.commit() return connplay_url TEXT UNIQUE 是这里最关键的一行。UNIQUE 约束保证了同一部影片的同一个播放地址不会重复入库。插入时我用 INSERT OR IGNORE如果链接已存在就忽略避免每天跑批时把旧数据重插一遍。SQLite 的 UNIQUE 约束有个特性是你不管 NULL但我们的 play_url 非空所以可以放心用。写入时还可以顺便统计本次新增多少条、更新多少条。INSERT OR IGNORE 的 rowcount 在 SQLite 里有时候不准确更可靠的办法是插入前先 SELECT COUNT(*) 判断是否存在或者用 INSERT ... ON CONFLICT DO UPDATE 做真正的更新。个人项目数据量不大用 INSERT OR IGNORE 加前后总数对比就够了。4. 定时任务配置让爬虫每天早上自动跑爬虫本身写完只算一半另一半是让它每天自动跑起来。很多人写完成功跑一次就丢在一边第二天想用又得手动执行一遍。定时任务就是为了解决这个问题配置之前先想清楚脚本放哪、什么时候跑、日志写哪里。4.1 用 APScheduler 在脚本内实现每日调度如果脚本放在个人电脑或一台常驻服务器上最简单的方式是直接内置一个调度器。APScheduler 的 cron 触发器和 Linux 的 cron 语法基本一致而且还能在程序内拿到任务执行结果对个人项目足够友好。from apscheduler.schedulers.blocking import BlockingScheduler def daily_job(): spider MovieSpider(max_pages5, db_pathmovies.db) spider.run() scheduler BlockingScheduler() # 时区按服务器本地配置即可 scheduler.add_job(daily_job, cron, hour2, minute30) scheduler.start()为什么选凌晨 2 点半两个原因。一是目标站的内容更新发生在凌晨 1 到 3 点之间2 点半能抓到当天最新增量。二是这个时间段站点的访问压力小我们以较低的请求频率去抓不太会给对方服务器造成负担。如果你的服务器是 UTC 时区需要自己把 hour 参数按本地时间换算一下否则任务会在错误的时间点启动。4.2 用系统定时任务替代常驻进程常驻脚本的问题在于它得一直跑着万一崩了任务就断了。更轻量一点的做法是只让系统定时任务在指定时间拉起脚本脚本跑完自动退出。Linux 下直接写 crontab30 2 * * * cd /opt/movie_spider /usr/bin/python3 main.py logs/run.log 21Windows 用户可以用计划任务触发条件选“每日”启动程序填 python.exe 的绝对路径参数填 main.py 的绝对路径。这里我强烈建议脚本里加上 ifname main: 入口并且把所有路径都用 os.path.dirname(file) 拼出来避免“当前目录不在项目目录”导致的文件找不到问题。因为 crontab 执行时的工作目录不一定是项目目录我第一次部署时没注意结果每天生成一个空数据库在根目录查了半天才发现是路径问题。4.3 日志、重试与失败通知自动化最怕的不是报错而是报错了你不知道。我给脚本加了两个兜底机制。第一个是 logging 模块输出到独立文件同时控制台保留一份。日志格式里必须包含时间、级别、脚本的哪个阶段比如INFO 2025-01-08 02:31:02 list page 3 parsed 20 items。出了问题第一件事就是打开日志看最后几十行。import logging logging.basicConfig( filenamelogs/spider.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s )第二个是失败通知。日常运行里最常见的失败是目标站临时不可用脚本本身有重试逻辑能扛过去。但如果连续重试 3 次还失败脚本会 raise 异常系统定时任务会记录非零退出码。我在此基础上加了一个简单的告警通道把当天的错误摘要写入一个 alerts.json再由另一个巡检脚本扫描这个文件发现异常就推送消息到 IM 机器人。这样实现了“跑挂了知道你挂”也避免每次失败都轰炸手机。5. 常见问题与排坑实录这一部分全部来自我实际踩过的坑不废话直接上结论。5.1 动态加载的内容抓不到怎么办演示站我特意选了服务端渲染但真实项目中大概率会遇到数据是由 JS 异步加载的情况。你 GET 到的 HTML 里只有一个空壳容器真正的内容来自一个/api/list?page1的接口。解决办法是打开 Network 面板筛选 XHR 请求找到返回数据最完整的那个接口然后用 requests 去模拟接口请求。接口返回的通常是 JSON直接 resp.json() 取字段比解析 HTML 还方便。有个容易出错的地方接口请求头里往往会有一个 X-Requested-With: XMLHttpRequest甚至还有签名参数。前者直接加上就行后者需要仔细看 JS 源码在爬虫里复现签名逻辑。注意这类签名防护通常只针对注册或付费接口普通列表接口不会上所以先去试不带签名的请求别一开始就埋头去读混淆过的前端代码。5.2 翻页链接是相对路径拼接总是出错列表页第 2 页开始卡片里 href 可能是detail/1024这样不带域名的相对路径。如果你直接把这个字符串存进数据库后面请求一定失败。解决办法前面代码里已经出现所有链接一律经过 urljoin(BASE_URL, href)。如果站点用了base href...标签你还要优先读取 base 的地址再和相对路径拼接。另外还要警惕 hrefjavascript:void(0)。这种链接出现在“播放”按钮上的概率很高表面上是详情页跳转实际上是 JS 弹窗。遇到这种情况不要硬解析去 HTML 里搜索附近的 data 属性或者隐藏的 input 值播放链接往往就藏在里面。我之前在另一个站点上花了半个多小时解一个奇怪的跳转最后发现详情页底部有个 hidden input 直接保存了真实地址。5.3 播放链接带时效或防盗链参数影视站的播放链接很少是长期有效的。直链可能签了 CDN 防盗链m3u8 链接里可能带有 expiresxxx 和 signxxx过期后返回 403。这意味着你保存下来的链接可能在几个小时后就不能用。我的处理策略分两步。第一步入库前立即做一次轻量校验确认链接当前可访问把可访问的链接标记为有效。第二步在数据库里保存抓取时间和来源页 URL方便后续定期重新校验。如果业务方需要“始终可用的链接”就得在每个播放周期前重新打开详情页抓取一次最新链接而不是复用几天前的旧数据。这一点在需求阶段就要和上游说清楚否则次日发现链接失效你会以为脚本坏了。5.4 访问频率一高就触发验证码这种情况基本不是对方上了多强的风控而是你抓得太快。requests 的循环如果没加延时一秒几十个请求任何正常站点都会启动保护。我的经验值是列表页请求间隔不低于 0.8 秒详情页间隔不低于 1.5 秒并且加上 20% 的随机浮动。如果还触发验证码就把任务拆成每 5 分钟一小批分多次跑完。注意千万别用暴力方式去撞验证码也不要去批量注册账号绕过登录。爬虫练习的目标是理解网页数据流把这些技术用在正向上而不是挑战站点的安全边界。出现验证码时最正确的操作是停掉任务、降低频率、调整访问时间窗口让站点恢复正常访问再继续后续抓取。6. 扩展方向与个人经验6.1 从单站到多站抽象一个通用解析器模板单站跑通之后整个 main.py 里最脆弱的其实是解析函数。一个站点改版你的脚本就废了。我的建议是把“页面下载”和“内容解析”彻底分离定义统一的 Parser 接口每个站点自己实现一份解析器主流程只负责调度和数据落地。class BaseSiteParser: def parse_list_page(self, html): raise NotImplementedError def parse_detail_page(self, html): raise NotImplementedError class DemoMovieParser(BaseSiteParser): def parse_list_page(self, html): # 演示站解析逻辑 pass这样做的好处很多。新增站点只需要新增一个类不用动入库逻辑改版时只需要在对应类里修选择器风险隔离。后续想加搜索、加定时、加统计都是在主流程上叠功能不会越改越乱。我自己维护的小工具里现在挂了五个不同类型的站点主流程只有一份每个站点的解析器独立成一个文件非常好维护。6.2 增加链接可用性自动校验入库不代表万事大吉。链接会过期站点会改路径所以每过一段时间要对库里已有的播放链接做一轮体检。校验逻辑不复杂m3u8 链接用 GET 请求拿前几个字节直链用 HEAD 请求均设置超时时间超过 10 秒视为不可用。校验也要控制频率几百条链接在一个小时里跑完比较合理不要集中在几分钟内。跑完把不可用的链接单独导出一份 CSV标记上“最后可用时间”方便人工检查是整体失效还是单条失效。这个功能我一般用线程池做但并发数控制在 4 到 6避免又把自己送进验证码黑名单。6.3 爬虫学习中的几条个人原则说了这么多技术细节最后讲几条我自己的体会。第一能用静态解析解决的就不要上浏览器。自动化浏览器不是不能用而是每次启动一个浏览器实例的代价太高能省则省。第二一定要给目标站点留余量。爬虫的本质是访问公开页面但访问节奏过快就会从善意变成骚扰。我给自己定的标准是总请求速率低于每秒 1 个数据规模小的项目完全可以做到。第三抓下来的数据只用于个人学习和技术验证不参与任何传播和二次分发尤其不碰需要登录或者付费才能访问的内容。这是我这些年做数据采集一直守的底线也是我能安心把这些经验写出来的原因。项目做到这里一套每日定时影视链接获取的流程就跑通了。从页面分析、请求封装到解析入库、自动调度再到出问题之后的排查手段每一个环节都是独立知识点。你把它改成资讯站、商品站、图片站核心骨架不需要动换上对应解析器就能用。如果下一步想继续深入建议把重点放在链接校验和多源合并上这两块对实际项目的价值比炫技式的并发要高得多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。