最近有个老哥在群里问,说他想用Go语言写个小工具,专门采集pp365小视频m.tzcwn.cn上的视频信息,我一听就乐了,这不是我上个月折腾过的事儿嘛,当时我也是心血来潮,想研究一下这个站点的数据结构,顺便练练Go的并发抓取,折腾了几天,踩了不少坑,今天就把这些经验掰开了揉碎了讲给你听。
先别急着写代码,你得懂这个站的脾气
pp365小视频m.tzcwn.cn这个站点,说实话,它的页面结构有点“任性”,我第一次用net/http直接去GET首页的时候,返回的HTML让我一度怀疑是不是拿错了数据,它的视频信息不是老老实实写在静态页面里的,而是通过一堆JavaScript动态加载的,你要是直接抓静态HTML,只能拿到一个空壳子。
那怎么办呢?两个思路:
- 分析它的XHR请求,找到真正返回视频JSON数据的那个接口;
- 用无头浏览器(比如chromedp),模拟真人操作,等JS跑完再抓取。
我当时图省事,选了第一条路,打开浏览器的开发者工具,切到Network面板,刷新页面,看着几十个请求哗哗地往下滚,我一个个点开看,终于在一个叫/api/video/list的接口里发现了视频数据的JSON,里面整整齐齐地排列着视频标题、封面图URL、播放地址、时长、点赞数这些字段。
这一步的坑在于:它的接口有签名校验,你在请求头里得带上一个动态生成的X-Sign字段,这个字段是用当前时间戳加一个固定盐值,再经过MD5加密得到的,搞错一次,它就直接返回403 Forbidden,干净利落,不带一点犹豫。
用Go搭建基础的抓取框架
确定了目标,接下来就可以动手写Go代码了,我习惯先把整个流程拆解成几个清晰的步骤,每步一个函数,这样后期维护起来不头疼。
第一步:获取接口数据
Go标准库的net/http就够用了,但为了效率,我一般会封装一个带超时控制的Client,毕竟对方服务器又不是只伺候你一个人,得讲点武德,设置个合理的超时时间,比如10秒。
package main
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// buildSign 模拟前端的签名生成逻辑
func buildSign(path string, timestamp int64) string {
// 这里用伪代码示意,真实的盐值你得自己从它的JS文件里扒拉出来
rawString := fmt.Sprintf("%s:%d:%s", path, timestamp, "your_salt_here")
hash := md5.Sum([]byte(rawString))
return hex.EncodeToString(hash[:])
}
// fetchVideoList 抓取指定页码的视频列表
func fetchVideoList(page int) ([]VideoItem, error) {
baseURL := "https://m.tzcwn.cn/api/video/list"
fullURL := fmt.Sprintf("%s?page=%d&type=hot", baseURL, page)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
// 模拟浏览器的请求头,防止被简单拦截
req.Header.Set("User-Agent", "Mozilla/5.0 ... Chrome/120.0")
req.Header.Set("Referer", "https://m.tzcwn.cn/index.html")
timestamp := time.Now().Unix()
req.Header.Set("X-Sign", buildSign("/api/video/list", timestamp))
req.Header.Set("X-Timestamp", strconv.FormatInt(timestamp, 10))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body))
}
var result ApiResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("JSON解析失败: %v", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("业务错误,代码: %d,消息: %s", result.Code, result.Msg)
}
return result.Data.List, nil
}
注意了,这个
ApiResponse结构体里,Data.List才是我们真正想要的视频元信息,你从JSON里能看到play_url、cover_url这些字段,但别忘了还有like_count和comment_count,分析用户喜好时用得上。
第二步:解决“懒加载”和分页
这个站的视频列表采用了无限滚动,你向下滚动,它自动加载下一页,对应的API就是?page=2、?page=3,但你别傻乎乎地一次发十几个并发请求,它的服务端做了限流,一秒内超过5次,直接封你IP半小时,我当时就吃过这个亏,写完代码一跑,整个宿舍的IP段都被封了,连手机流量都跟着遭殃。
我的建议是:用time.Ticker控制节奏,每抓一页,睡个300毫秒,别用sync.WaitGroup一股脑全上,要优雅,懂吧?
数据存储到本地,用什么格式好?
抓下来的视频信息,保存成JSON文件自然是最简单的,但如果你打算长期维护这个数据,用SQLite比较合适,Go语言这边我用的是modernc.org/sqlite这个驱动,不需要CGO编译,特别省心。
我建的表结构大致是下面这个样子:
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vid TEXT UNIQUE NOT NULL,TEXT,
cover_url TEXT,
play_url TEXT,
duration INTEGER,
like_count INTEGER DEFAULT 0,
comment_count INTEGER DEFAULT 0,
create_time INTEGER
);
把vid设成唯一索引,防止重复数据。play_url存的是原始M3U8地址,如果你要下载视频,还得用ffmpeg转封装,这个不在今天的讨论范围,但可以提一嘴。

处理反爬虫策略的“奇技淫巧”
前面提到了签名和限流,其实它还有个隐藏的坑:Cookie验证,有时候你光带请求头不够,它还会校验一个名为__session_id的Cookie,这个Cookie是怎么来的?你得先访问一次它的首页,获取到Set-Cookie,然后把这个Cookie存下来,后续的API请求都带上。
用Go实现就是,得先写个函数“预热”一下:
// warmUp 获取Cookie
func warmUp() (*http.Cookie, error) {
client := &http.Client{}
req, _ := http.NewRequest("GET", "https://m.tzcwn.cn/index.html", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 ...")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
for _, c := range resp.Cookies() {
if c.Name == "__session_id" {
return c, nil
}
}
return nil, fmt.Errorf("未找到session cookie")
}
拿到这个Cookie后,再丢到后面的请求里,如果不带这个,就算你签名算得再准,它也会返回一个空列表,看起来像是数据没加载出来,实际上是在跟你打哑谜。
并发技巧:Go程别乱开
说到Go就绕不开goroutine,但在这个场景下,我反而建议你谨慎使用并发,原因有三点:
- 服务器压力:人家是小站点,带宽有限,你10个goroutine同时打过去,等于直接把它服务器打崩,这不厚道。
- IP封禁风险:即使没崩,高频请求也会触发防火墙的自动封禁机制,封了IP,你只能换代理,狼狈得很。
- 数据一致性:如果你边抓边往数据库里写,并发情况下还得加锁,否则会重复插入,这笔账算下来,并发反而拖慢速度。
所以我的做法是:串行抓取,批量写入,每抓完5页,用一次事务插入到SQLite,这样既快又稳,写入时间也没增加多少。
下面是我用的一个简单的写入函数:
func batchInsert(videos []VideoItem) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, _ := tx.Prepare("INSERT INTO videos(vid,title,cover_url,play_url,duration,like_count) VALUES(?,?,?,?,?,?)")
defer stmt.Close()
for _, v := range videos {
if _, err := stmt.Exec(v.Id, v.Title, v.CoverUrl, v.PlayUrl, v.Duration, v.LikeCount); err != nil {
continue // 忽略重复的id
}
}
return tx.Commit()
}
注意看,我在Exec后面直接continue了,这是为了处理那些重复的vid,不让他们打断整个批次的操作。
怎么判断抓下来的数据是不是“好”数据?
抓完数据,你得给自己一个反馈,我一般会用几个维度来判断:
| 维度 | 检查项 | 合格标准 |
|---|---|---|
| 完整性 | title、play_url、cover_url是否为空 |
无空值,play_url以http开头 |
| 真实性 | 视频时长duration是否 > 0 |
80%以上的数据满足 |
| 时效性 | create_time是否在一个合理范围 |
不晚于当前时间,不早于2020年 |
| 去重率 | vid的唯一性 |
重复度低于0.1% |
如果某页数据的play_url大量为空,说明接口可能又改版了,这时候你得回去重新分析它的前端JS,看看请求参数是不是变了,这活儿有点像考古,一层一层挖。
稍等,我忘了说代理IP的使用
顺嘴提一句,如果你真的要大规模采集,千万别用自己的家庭IP,去弄个代理池,哪怕是最便宜的动态住宅代理,在Go里设置代理很简单:
proxyUrl, _ := url.Parse("http://user:pass@proxy_ip:port")
transport := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
测试代理是否可用,就发个请求给httpbin.org/ip,看看返回的IP是不是你设置的那个,能用就接着跑,不能用就换下一个,写个循环测试,这事儿不费劲,但能省你很多麻烦。
最后分享一个调试小技巧
你在写代码的时候,难免遇到返回数据不对劲的情况,别急着打fmt.Println看日志,我一般会这么干:把响应体的原始数据先存成一个.txt文件。
os.WriteFile("debug_response.txt", body, 0644)
然后直接用编辑器打开,看看JSON的层级结构,或者看看里面是不是混进去了什么HTML片段,有时候它错误提示不是JSON格式,而是个纯文本的“禁止访问”,如果不存下来看,你在终端里看着那一串乱码,问题不好定位。
这招对于这类“不太老实”的站点特别管用,写代码就是一个不断跟对方博弈的过程,别恼火,慢慢来,总能把数据掏出来。
好了,从分析接口到写Go代码,再到处理各种防爬细节,基本就是这么个流程,你手头那台电脑够跑这代码了,别浪费,试试看。
本文来自作者[kyadmin]投稿,不代表be365立场,如若转载,请注明出处:http://hljbesthome.com/qiche/2273.html
评论列表(4条)
我是be365的签约作者“kyadmin”!
希望本篇文章《用Go语言写一个视频站爬虫?聊聊pp365小视频m.tzcwn.cn那些事儿》能对你有所帮助!
本站[be365]内容主要涵盖:be365,be365网站,be365网址,ball365体育,Be365排名
本文概览:最近有个老哥在群里问,说他想用Go语言写个小工具,专门采集pp365小视频m.tzcwn.cn上的视频信息,我一听就乐了,这不是我上个月...