ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Laravel+QueryList影视站源码拆解:采集、队列与自适应实战

Laravel+QueryList影视站源码拆解:采集、队列与自适应实战

简介:这份资源是一套基于 Laravel 与 QueryList 框架搭建的影视站点源码,面向希望快速上线影视内容站点的开发者与站长,尤其适合具备一定 PHP 基础、想省去从零开发采集与适配环节的中级用户。源码主打自动采集影视资源,并支持电脑端与手机端自适应展示,同时预留了自定义尝鲜设置入口,可对接微信自定义回复图片,方便在移动端做内容分发与互动。压缩包为 rar 格式,整体约 20.74MB,文件包内附有安装说明,便于按文档完成部署与调试。目前已有 550 人学习下载,说明该方案在影视建站场景中具备一定参考价值。读者可从中获得一套可直接运行的站点骨架、采集逻辑与前后端适配思路,并借助微信回复图片的对接示例,理解影视内容在移动端的触达方式,适合作为二次开发或功能扩展的起点。

1. 一套 Laravel 打底的影视站源码,为什么值得花时间拆一遍

手里这套电影站源码-laravel+querylist框架-自适应.rar,第一眼看名字就知道技术栈没藏着掖着:后端 Laravel,采集靠 QueryList,前端走自适应。对做影视聚合站的人来说,这三个词基本把核心痛点全点到了——数据从哪来、怎么入库、用户拿手机还是电脑看。

我拆这套源码的起因很直接:之前接过一个需求,要快速搭一个能自动采集、能同时扛住 PC 和移动端访问的影视站。市面上现成的 CMS 要么采集规则写死不好改,要么模板和采集耦合太深,改一处崩一片。这套源码把采集层和展示层做了分离,QueryList 负责抓,Laravel 负责管,前端自适应负责呈现,结构上比那种一锅炖的写法清爽不少。

适合谁看?如果你正在找一个能跑通「自动采集影视资源 → 入库 → 多端展示」完整链路的起点,这套源码能省掉你从零搭框架的时间。如果你只是想找个模板套壳,那它可能不是最优解,因为它的价值在采集逻辑和框架结构,不在皮肤。下面我按实际拆解顺序,把能复现的部分一块块摊开讲。

2. 环境搭建与 Laravel 骨架:从解压到跑通第一个路由

2.1 运行环境与依赖清单

这套源码基于 Laravel,所以 PHP 版本和扩展是第一个门槛。我实测下来,PHP 7.4 到 8.1 之间比较稳,再高有些老扩展会报兼容问题。数据库用 MySQL 5.7 或 8.0 都行,但字符集建议统一utf8mb4,影视数据里经常出现特殊符号和 emoji,utf8会直接截断。

必备扩展清单如下,缺一个都可能卡在安装阶段:

扩展名用途缺失时的典型报错
fileinfo文件类型检测上传图片时提示无法识别 MIME
redis缓存与队列采集任务队列无法异步执行
pdo_mysql数据库连接安装向导第一步就卡住
curlQueryList 底层抓取采集任务返回空数据
mbstring多字节字符串处理中文标题乱码或截断
openssl加密与 HTTPS 请求部分采集源无法访问

安装依赖用 Composer,这是 Laravel 项目的标准动作。解压后进入项目根目录,先别急着composer install,看一眼composer.json里的 Laravel 版本和 PHP 版本约束,确认和你本地环境对得上。

# 进入项目根目录 cd movie-laravel-querylist # 查看 composer.json 中的版本约束 cat composer.json | grep -E '"php"|"laravel/framework"' # 安装依赖,生产环境加 --no-dev composer install --no-dev --optimize-autoloader # 复制环境配置文件 cp .env.example .env # 生成应用密钥 php artisan key:generate

--no-dev会跳过开发依赖,线上部署时能减少攻击面。--optimize-autoloader会生成类映射文件,加快自动加载速度。key:generate是 Laravel 加密的基础,不做这步所有加密相关功能都会抛异常。

2.2 数据库配置与迁移

.env文件里改数据库连接信息,这是 Laravel 的标准操作。重点注意DB_DATABASE要提前建好空库,Laravel 不会自动创建数据库本身。

# .env 中需要修改的段落 DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=movie_site DB_USERNAME=your_user DB_PASSWORD=your_password # 执行迁移,建表 php artisan migrate # 如果有种子数据,一并填充 php artisan db:seed

迁移文件在database/migrations目录下,我翻了一遍,核心表大概有这些:movies存影片主信息,movie_sources存采集来源和播放地址,categories分类表,users用户表,settings系统配置表。表结构设计得比较常规,没有过度范式化,查询时 join 不多,对影视站这种读多写少的场景够用。

提示:迁移之前先确认 MySQL 的sql_mode没有开启ONLY_FULL_GROUP_BY,否则部分聚合查询会报错。可以在config/database.php里单独给这个连接关掉。

2.3 跑通第一个路由验证骨架

环境配好后,用内置服务器快速验证一下 Laravel 骨架是否正常。

# 启动开发服务器 php artisan serve --host=0.0.0.0 --port=8000 # 另开终端,测试首页路由 curl -I http://127.0.0.1:8000/

如果返回 200 或 302,说明 Laravel 核心已经跑起来了。如果返回 500,去storage/logs/laravel.log看具体报错,八成是权限问题——storage和bootstrap/cache目录需要写权限。

# 权限修复,Linux 下常用 chmod -R 775 storage bootstrap/cache chown -R www-data:www-data storage bootstrap/cache

这一步看着简单,但我见过太多人卡在这里。Laravel 的日志不会直接告诉你「权限不足」,而是抛一个模糊的异常,翻日志才能定位。先把权限给足,后面省很多事。

3. QueryList 采集层拆解:规则怎么写、任务怎么跑、数据怎么洗

3.1 QueryList 的采集原理与规则配置

QueryList 本质上是一个基于 phpQuery 的 DOM 解析器,它把 HTML 当成 jQuery 对象来操作。你给它一个 URL 和一组选择器规则,它返回结构化数组。这套源码里采集规则集中在app/Services/Collector目录下,每个采集源一个类,继承同一个基类。

基类里封装了通用的抓取逻辑:设置 User-Agent、超时时间、重试次数、代理开关(如果有的话)。子类只需要定义getRules()方法,返回选择器映射。

// app/Services/Collector/BaseCollector.php 核心片段 protected function buildQuery($url) { return QueryList::get($url, [], [ 'timeout' => 15, // 单次请求超时秒数 'user_agent' => 'Mozilla/5.0 ...', // 伪装浏览器 'retry' => 2, // 失败重试次数 'encoding' => 'UTF-8', // 目标页面编码 ]); } // 子类示例:某采集源 public function getRules(): array { return [ 'title' => ['.detail-title', 'text'], // 影片标题 'cover' => ['.detail-pic img', 'src'], // 封面图 'actor' => ['.actor-list a', 'text'], // 演员,多个 'play_url'=> ['.play-list a', 'href'], // 播放地址 'category'=> ['.breadcrumb a:eq(1)', 'text'], // 分类 ]; }

timeout设 15 秒是个折中值,太短容易误判失败,太长会拖慢整体采集速度。retry设 2 次,配合队列的失败重试,基本能覆盖网络抖动。encoding必须和目标站一致,有些老站是 GBK,不声明就会乱码。

选择器写法上,text取文本,src取属性,href取链接。多个元素用数组返回,QueryList 会自动收集成列表。eq(1)是索引选择,取第二个匹配项,因为面包屑第一个通常是「首页」。

3.2 采集任务的队列化与调度

同步采集在数据量大时就是灾难,一个源卡住整个进程都堵死。这套源码用 Laravel 的队列把采集任务异步化,每个采集源一个 Job,失败自动重试。

// app/Jobs/CollectMovieJob.php class CollectMovieJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 3; // 最多重试 3 次 public $timeout = 120; // 单个 Job 超时 2 分钟 public $backoff = [10, 30, 60]; // 重试间隔递增 protected $sourceUrl; public function __construct($sourceUrl) { $this->sourceUrl = $sourceUrl; } public function handle() { $collector = CollectorFactory::make($this->sourceUrl); $data = $collector->fetch(); $collector->save($data); } }

tries和backoff配合使用,第一次失败等 10 秒,第二次等 30 秒,第三次等 60 秒。这样设计是为了避开目标站的临时限流,立刻重试反而容易被封。timeout设 120 秒,因为有些详情页图片多、解析慢,设太短会误杀。

调度用 Laravel 的schedule方法,在app/Console/Kernel.php里配置。

// app/Console/Kernel.php protected function schedule(Schedule $schedule) { // 每 30 分钟跑一次采集 $schedule->job(new CollectMovieJob('https://example.com/list')) ->everyThirtyMinutes() ->withoutOverlapping(); // 防止任务堆叠 }

withoutOverlapping()是关键,如果上一次采集还没跑完,下一次直接跳过,避免重复入库和资源争抢。线上跑的时候配合 Supervisor 守护队列进程,崩了自动拉起。

3.3 数据清洗与入库去重

采集回来的数据不能直接塞库,标题里带 HTML 标签、播放地址带多余参数、封面图是相对路径,这些都得洗。源码里在save()方法前做了一层清洗。

// 清洗逻辑示例 protected function cleanData(array $raw): array { return [ 'title' => trim(strip_tags($raw['title'])), 'cover' => $this->normalizeUrl($raw['cover']), 'actor' => implode(',', array_map('trim', $raw['actor'])), 'play_url' => $this->extractPlayUrl($raw['play_url']), 'category' => trim($raw['category']), 'hash' => md5($raw['title'] . $raw['category']), // 去重指纹 ]; }

去重指纹用title + category的 MD5,入库前先查这个 hash 是否存在。这里有个坑:不同采集源的标题可能有细微差异,比如多个空格、全角半角混用,直接 MD5 会漏判。我一般会先做一次preg_replace('/\s+/', '', $title)再去 MD5,能提高去重准确率。

入库用updateOrCreate,存在就更新播放地址,不存在就插入。这样同一个影片从多个源采集时,不会产生重复记录,而是把播放源合并到movie_sources表里。

4. 自适应前端与播放器对接:PC 和手机怎么共用一套模板

4.1 自适应布局的断点策略

这套源码的前端没有用 Bootstrap 那种重型框架,而是手写媒体查询,断点设在 768px 和 1200px。768px 以下走移动端布局,768 到 1200 走平板,1200 以上走 PC。这种轻量做法在影视站里挺常见,因为影视站页面结构不复杂,没必要引入整套 UI 框架。

核心 CSS 逻辑在public/static/css/main.css里,用 flex 做整体布局,网格用grid做影片列表。

/* 影片列表自适应 */ .movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; } @media (max-width: 768px) { .movie-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; } .detail-header { flex-direction: column; /* 详情页头部竖排 */ } }

auto-fill配合minmax的好处是列数自动算,不用写一堆断点。PC 上最小 160px 一列,手机上最小 110px,浏览器自己决定放几列。gap在移动端缩小,节省横向空间。

注意:影视封面图比例不统一,有的 3:4,有的 16:9。建议在 CSS 里给封面容器固定aspect-ratio,再用object-fit: cover裁切,否则列表会参差不齐。

4.2 播放器接入与多源切换

播放器部分源码用的是 DPlayer 加 HLS.js 的组合,支持 m3u8 格式。播放地址从movie_sources表读取,前端渲染成多线路切换按钮。

// 播放器初始化 const dp = new DPlayer({ container: document.getElementById('player'), video: { url: currentUrl, type: 'customHls', customType: { customHls: function (video, player) { const hls = new Hls(); hls.loadSource(video.src); hls.attachMedia(video); } } }, autoplay: false, }); // 切换线路 function switchSource(url) { dp.switchVideo({ url: url, type: 'customHls' }); }

customHls是 DPlayer 的自定义类型钩子,把 HLS.js 挂进去。switchVideo切换线路时不会销毁播放器实例,体验比重新创建好。多线路的数据结构是[{name: '线路一', url: '...'}, ...],后端返回 JSON,前端遍历渲染按钮。

这里有个实际踩过的坑:部分采集源的 m3u8 地址带防盗链校验,直接在前端播放会 403。常见做法是后端加一个代理接口,把 m3u8 和 ts 片段转发一遍,同时带上正确的 Referer。源码里预留了代理路由,但默认没开,需要自己配。

4.3 模板渲染与缓存策略

Laravel 的 Blade 模板在影视站里性能瓶颈主要在列表页和详情页。这套源码对详情页做了 Redis 缓存,缓存 key 是movie:detail:{id},过期时间 6 小时。

// 详情页缓存示例 public function show($id) { $cacheKey = "movie:detail:{$id}"; $movie = Cache::remember($cacheKey, 3600 * 6, function () use ($id) { return Movie::with(['sources', 'category'])->findOrFail($id); }); return view('movie.detail', compact('movie')); }

Cache::remember的逻辑是:缓存命中直接返回,未命中查库并写入缓存。6 小时过期是个平衡点,太短缓存命中率低,太长采集更新后前端看不到变化。采集任务完成后,记得主动删掉对应影片的缓存 key,否则用户要等过期才能看到新源。

列表页没做整页缓存,因为分页和筛选条件组合太多,缓存 key 不好管理。替代方案是用 Redis 缓存分类和筛选选项这种低频变化的数据,影片列表本身走数据库查询,配合索引优化。

5. 避坑与排查:采集站最容易翻车的五个地方

5.1 采集返回空数据但状态码 200

现象:Job 执行成功,日志显示 200,但入库数据为零。

原因:目标站做了 UA 校验或 JS 渲染,QueryList 拿到的是空壳 HTML。有些站还会根据 Cookie 判断是否首次访问。

解决:先手动curl一下目标 URL,对比返回内容和浏览器看到的是否一致。如果 UA 被拦,换一个真实浏览器的 UA 字符串;如果是 JS 渲染,QueryList 搞不定,得换 Puppeteer 或 Playwright 这类无头浏览器方案。源码里预留了useBrowser开关,但默认关闭,因为无头浏览器资源消耗大。

5.2 中文标题乱码或截断

现象:入库的标题出现「」或半个汉字。

原因:目标页面编码是 GBK,但 QueryList 按 UTF-8 解析;或者 MySQL 字段长度不够,varchar(255)存不下长标题。

解决:在buildQuery里显式声明encoding,QueryList 会自动转码。数据库字段建议用varchar(500)或text,影视标题带副标题和年份,255 经常不够。另外检查 MySQL 连接字符集是否为utf8mb4。

5.3 队列任务堆积不执行

现象:jobs表里任务数一直涨,但failed_jobs没有记录,前端数据不更新。

原因:队列进程没启动,或者 Supervisor 配置的numprocs太少,消费速度跟不上生产速度。

解决:先php artisan queue:work --once手动跑一个看是否正常。如果正常,检查 Supervisor 配置,把进程数调到 4 到 8 个。同时确认QUEUE_CONNECTION在.env里设的是redis还是database,两者配置方式不同。

5.4 播放地址失效但数据库没更新

现象:用户反馈某影片播放不了,但后台看播放地址还在。

原因:采集任务只做插入和更新,没做失效检测。源站删了视频,本地数据库不知道。

解决:加一个定时任务,定期 HEAD 请求播放地址,返回 404 或 403 就标记该源为失效。源码里有个CheckSourceStatus命令,但默认没挂到调度里,需要手动加。检测频率别太高,一天一次足够,避免被源站封 IP。

5.5 移动端播放器全屏后无法退出

现象:手机上点全屏,再点退出没反应,页面卡死。

原因:DPlayer 在移动端全屏用的是浏览器 Fullscreen API,部分安卓 WebView 对exitFullscreen支持不完整。

解决:监听fullscreenchange事件,手动同步播放器状态。或者在移动端禁用原生全屏,改用 CSS 模拟全屏容器。源码里移动端默认走 CSS 全屏方案,但需要确认player-container的z-index足够高,否则会被导航栏盖住。

6. 二次开发与采集效率提升:几个我常用的改造点

这套源码跑通之后,真正花时间的是调优和扩展。分享几个我实际改过的点,能让采集效率和稳定性上一个台阶。

第一个是采集源的健康检查。我在BaseCollector里加了一个ping()方法,每次采集前先请求源站首页,响应时间超过 5 秒或状态码非 200 就跳过本轮,避免在死源上浪费时间。配合一个source_health表记录每个源的成功率和平均响应时间,调度器优先跑健康度高的源。

// 源健康检查示例 public function ping(): bool { $start = microtime(true); $code = QueryList::get($this->baseUrl, [], ['timeout' => 5]) ->getResponse() ->getStatusCode(); $cost = microtime(true) - $start; SourceHealth::record($this->name, $code === 200, $cost); return $code === 200 && $cost < 5; }

第二个是增量采集。全量采集一次动辄几千条,大部分是重复的。我在采集列表页时只取第一页到第三页,详情页入库前用 hash 比对,已存在且播放地址没变的直接跳过,省掉大量无谓请求。实测下来,增量模式下单次采集耗时从 20 分钟降到 3 分钟左右。

第三个是图片本地化。采集的封面图直接引用源站地址,源站挂了图就裂。我加了一个异步 Job,把封面图下载到storage/app/public/covers,数据库存本地路径。下载时做一次压缩,统一转成 WebP 格式,流量能省一半左右。

# 创建软链接,让 public 能访问 storage php artisan storage:link # 图片处理 Job 的核心逻辑 # 下载 -> 压缩 -> 转 WebP -> 存本地 -> 更新数据库路径

第四个是日志分级。采集日志量很大,全写文件很快就几百 MB。我把成功日志降到debug级别,只记录失败和异常到warning以上,日志文件按天切割,保留 7 天。排查问题时直接grep错误关键词,比翻全量日志快得多。

最后说个习惯:每次改完采集规则,先拿 10 条数据跑一遍完整流程,确认标题、封面、播放地址、分类都正确入库,再放全量任务。这个「小批量验证」的习惯帮我省了无数次回滚的麻烦。从那以后我每次调整采集规则都强制走一遍小批量验证,希望这个习惯也能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进