├── index.js ├── static └── videos.jpg ├── .gitignore ├── src ├── cofig.js ├── single.js ├── app.js └── method.js ├── package.json └── README.md /index.js: -------------------------------------------------------------------------------- 1 | let app = require('./src/app.js'); 2 | 3 | app.start(); 4 | -------------------------------------------------------------------------------- /static/videos.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibaiwan/spider-video/HEAD/static/videos.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # misc 5 | .DS_Store 6 | 7 | # screenshot 8 | /screenshot 9 | -------------------------------------------------------------------------------- /src/cofig.js: -------------------------------------------------------------------------------- 1 | // 批量下载配置相关 2 | module.exports = { 3 | originPath: 'https://www.ixigua.com', // 页面请求地址 4 | savePath: 'D:/videoZZ' // 存放路径 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spider-video", 3 | "version": "1.0.0", 4 | "description": "Node 下载西瓜视频", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "single": "node ./src/single.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/tibaiwan/spider-video.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/tibaiwan/spider-video/issues" 18 | }, 19 | "homepage": "https://github.com/tibaiwan/spider-video#readme", 20 | "devDependencies": { 21 | "axios": "^0.18.0", 22 | "puppeteer": "^1.8.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/single.js: -------------------------------------------------------------------------------- 1 | // 根据视频 url 下载单个视频 2 | 3 | const fs = require('fs'), 4 | method = require('./method') 5 | 6 | const folderName = 'D:/videoLOL' 7 | const fileName = 'S8预选赛TOP5:Haro李青无解操作支配战局「LOL七周年」' 8 | // 该视频链接可能已经失效了,请安排个正确的视频 src 9 | const videoSrc = 'http://v1-tt.ixigua.com/ad160a207f438d576d06f3cffa1ca52f/5ba06826/video/m/2203ce04dd18e0e426381abfe64ea44f19b115bbe0a000027c1f6e94a77/' 10 | 11 | // 初始化方法 12 | const start = async () => { 13 | method.mkdirSaveFolder(folderName) 14 | let video = { 15 | src: videoSrc, 16 | title: fileName 17 | } 18 | downloadVideo(video) 19 | } 20 | 21 | // 下载视频 22 | const downloadVideo = async video => { 23 | console.log(video) 24 | // 判断视频文件是否已经下载 25 | if (!fs.existsSync(`${folderName}/${video.title}.mp4`)) { 26 | await method.getVideoData(video.src, 'binary').then(fileData => { 27 | console.log('下载视频中:', video.title) 28 | method.savefileToPath(folderName, video.title, fileData).then(res => 29 | console.log(`${res}: ${video.title}`) 30 | ) 31 | }) 32 | } else { 33 | console.log(`视频文件已存在:${video.title}`) 34 | } 35 | } 36 | 37 | start() 38 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | /* 批量下载主程序 */ 2 | const fs = require('fs'), 3 | config = require('./cofig'), 4 | method = require('./method') 5 | 6 | // 初始化方法 7 | const start = async () => { 8 | method.mkdirSaveFolder(config.savePath) 9 | let toutiaoName = '维辰财经' 10 | let offset = 0 // offset: 0:第一页数据,20:第二页数据,依次类推 11 | let pageUrlList = await method.getVideoPageList(toutiaoName, offset) 12 | main(pageUrlList) 13 | } 14 | 15 | // 主方法 16 | const main = async pageUrlList => { 17 | console.log('视频所在页面列表:') 18 | console.log(pageUrlList) 19 | // 视频列表 20 | let pageVideoList = [] 21 | for (let i = 0; i < pageUrlList.length; i++) { 22 | let videoSrc = await method.getVideoSrc(pageUrlList[i].pathname) 23 | pageVideoList.push({ 24 | src: videoSrc, 25 | title: pageUrlList[i].title 26 | }) 27 | } 28 | console.log('视频列表:') 29 | console.log(pageVideoList) 30 | // 开始下载 31 | for (let j = 0; j < pageVideoList.length; j++) { 32 | await downloadVideo(pageVideoList[j]) 33 | } 34 | console.log('下载结束') 35 | process.exit(0) 36 | } 37 | 38 | // 下载视频 39 | const downloadVideo = async video => { 40 | // 判断视频文件是否已经下载 41 | if (!fs.existsSync(`${config.savePath}/${video.title}.mp4`)) { 42 | await method.getVideoData(video.src, 'binary').then(fileData => { 43 | console.log('下载视频中:', video.title) 44 | method.savefileToPath(config.savePath, video.title, fileData).then(res => 45 | console.log(`${res}: ${video.title}`) 46 | ) 47 | }) 48 | } else { 49 | console.log(`视频文件已存在:${video.title}`) 50 | } 51 | } 52 | 53 | module.exports = { 54 | start 55 | } 56 | -------------------------------------------------------------------------------- /src/method.js: -------------------------------------------------------------------------------- 1 | /* 方法集 */ 2 | const config = require('./cofig'), 3 | fs = require('fs'), 4 | axios = require('axios'), 5 | http = require('http'), 6 | https = require('https'), 7 | puppeteer = require('puppeteer'); 8 | 9 | module.exports = { 10 | // 新建保存视频的文件夹 11 | mkdirSaveFolder (savePath) { 12 | if (!fs.existsSync(savePath)) { 13 | fs.mkdirSync(savePath) 14 | console.log(`文件夹已生成:${savePath}`) 15 | } else { 16 | console.log(`文件夹已存在:${savePath}`) 17 | } 18 | // 生成保存截图的文件夹 19 | if (!fs.existsSync('./screenshot')) { 20 | fs.mkdirSync('./screenshot') 21 | } 22 | }, 23 | // 获取西瓜视频所在页面的地址 list 24 | // name:头条号名称 25 | // offset: 0:第一页数据,20:第二页数据,依次类推 26 | async getVideoPageList (name, offset = 0) { 27 | const headers = { 28 | Referer: `https://www.ixigua.com/search/?keyword=${encodeURI(name)}`, 29 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.92 Safari/537.36", 30 | cookie: "t_webid=6601999464352253444; WEATHER_CITY=%E5%8C%97%E4%BA%AC; UM_distinctid=165e525dafb1d9-0baaa42bbf8325-551e3f12-1fa400-165e525dafcb2a; __tasessionId=mnw1y8gjn1537147788480; tt_webid=6601999464352253444; csrftoken=c7f2776bbd139000288ce7cf6644df4b; CNZZDATA1259612802=1709968793-1537143142-https%253A%252F%252Fwww.baidu.com%252F%7C1537148542", 31 | "authority": "www.ixigua.com", 32 | "scheme": "https" 33 | } 34 | const result = await axios({ 35 | method: 'get', 36 | url: `${config.originPath}/search_content/?offset=${offset}&format=json&keyword=${encodeURI(name)}&autoload=true&count=20&cur_tab=1&from=search_tab`, 37 | headers 38 | }) 39 | const data = result.data.data 40 | const pageUrlList = [] 41 | // 过滤,只获取包含视频的页面 url 42 | data.forEach(item => { 43 | if (item.has_video) { 44 | pageUrlList.push({ 45 | pathname: item.seo_url, 46 | title: item.title 47 | }) 48 | } 49 | }) 50 | return pageUrlList 51 | }, 52 | // 根据页面地址获取页面内的视频地址 53 | async getVideoSrc (pathname) { 54 | const browser = await puppeteer.launch(); 55 | const page = await browser.newPage(); 56 | console.log('visit site:', `${config.originPath}${pathname}`) 57 | await page.goto(`${config.originPath}${pathname}`); 58 | await page.waitFor(5000); // 留给页面充分的加载时间 59 | let shotPicName = pathname.replace(/\//g, '') // 移除两头'/' 60 | await page.screenshot({path: `screenshot/${shotPicName}.png`}); 61 | await page.content() 62 | // 获取视频地址 63 | try { 64 | let src = await page.$eval('.vjs-tech', ele => ele.src) 65 | return src 66 | } catch (e) { 67 | console.log('异常图片截图:', `${shotPicName}.png`) 68 | console.log('e', e) 69 | } 70 | // await browser.close() 71 | return '' 72 | }, 73 | // 获取视频数据 74 | getVideoData (url, encoding) { 75 | return new Promise((resolve, reject) => { 76 | let req = http.get(url, function (res) { 77 | let result = '' 78 | encoding && res.setEncoding(encoding) 79 | res.on('data', function (d) { 80 | result += d 81 | }) 82 | res.on('end', function () { 83 | resolve(result) 84 | }) 85 | res.on('error', function (e) { 86 | reject(e) 87 | }) 88 | }) 89 | req.end() 90 | }) 91 | }, 92 | // 下载视频到本地 93 | savefileToPath (fileFolder, fileName, fileData) { 94 | let fileFullName = `${fileFolder}/${fileName}.mp4` 95 | return new Promise((resolve, reject) => { 96 | fs.writeFile(fileFullName, fileData, 'binary', function (err) { 97 | if (err) { 98 | console.log('savefileToPath error:', err) 99 | } 100 | resolve('已下载') 101 | }) 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 批量爬取头条视频并保存 2 | 3 | 目标网站:[西瓜视频](https://www.ixigua.com) 4 | 项目功能:下载头条号【维辰财经】下的最新20个视频 5 | 姊妹项目:[批量下载美女图集](https://github.com/tibaiwan/spider-picture) 6 | 7 | ## 简介 8 | 9 | 一般批量爬取视频或者图片的套路是,使用爬虫获得文件链接集合,然后通过 writeFile 等方法逐个保存文件。然而,头条的视频,在需要爬取的 html 文件(服务端渲染输出)中,无法捕捉视频链接。视频链接是页面在客户端渲染时,通过某些 js 文件内的算法或者解密方法,根据视频的已知 key 或者 hash 值,动态计算出来并添加到 video 标签的。这也是网站的一种反爬措施。 10 | 11 | 我们在浏览这些页面时,通过审核元素,可以看到计算后的文件地址。然而在批量下载时,逐个手动的获取视频链接显然不可取。开心的是,puppeteer 提供了模拟访问 Chrome 的功能,使我们可以爬取经过浏览器渲染出来的最终页面。 12 | 13 | 今日头条里有很多有意思的头条号玩家,他们发布了很多视频在里面。如果大家有批量下载某个头条号视频的需求,这个爬虫就派上用场了。当然,其他视频站也都大同小异,更改下部分代码设置就可以使用啦。 14 | 15 | ## 项目启动 16 | 17 | > 命令 18 | 19 | ```bash 20 | npm i 21 | npm start 22 | // 安装 puppeteer 的过程稍慢,耐心等待。 23 | ``` 24 | 25 | > 单个文件下载命令 26 | 27 | ```bash 28 | npm run single 29 | // 在文件 single.js 中设置视频名称和 src 即可。 30 | ``` 31 | 32 | > 配置文件 33 | 34 | ```js 35 | // 配置相关 36 | module.exports = { 37 | originPath: 'https://www.ixigua.com', // 页面请求地址 38 | savePath: 'D:/videoZZ' // 存放路径 39 | } 40 | ``` 41 | 42 | ```js 43 | // 单个视频下载设置 44 | const folderName = 'D:/videoLOL' 45 | const fileName = 'S8预选赛TOP5:Haro李青无解操作支配战局「LOL七周年」' 46 | const videoSrc = 'http://v11-tt.ixigua.com/e2b7cbd320031f6c19890001503a6ca0/5b9fd7bb/video/m/2203ce04dd18e0e426381abfe64ea44f19b115bbe0a000027c1f6e94a77/' 47 | 48 | // 初始化方法 49 | const start = async () => { 50 | method.mkdirSaveFolder(folderName) 51 | let video = { 52 | src: videoSrc, 53 | title: fileName 54 | } 55 | downloadVideo(video) 56 | } 57 | ``` 58 | 59 | ## 技术点 60 | 61 | > puppeteer 62 | 63 | [官方API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md) 64 | 65 | puppeteer 提供一个高级 API 来控制 Chrome 或者 Chromium。 66 | 67 | puppeteer 主要作用: 68 | 69 | - 利用网页生成 PDF、图片 70 | - 爬取SPA应用,并生成预渲染内容(即“SSR” 服务端渲染) 71 | - 可以从网站抓取内容 72 | - 自动化表单提交、UI测试、键盘输入等 73 | 74 | 使用到的 API: 75 | 76 | - puppeteer.launch() 启动浏览器实例 77 | - browser.newPage() 创建一个新页面 78 | - page.goto() 进入指定网页 79 | - page.screenshot() 截图 80 | - page.waitFor() 页面等待,可以是时间、某个元素、某个函数 81 | - page.$eval() 获取一个指定元素,相当于 document.querySelector 82 | - page.$$eval() 获取某类元素,相当于 document.querySelectorAll 83 | - page.$('#id .className') 获取文档中的某个元素,操作类似jQuery 84 | 85 | 代码示例 86 | 87 | ```js 88 | const puppeteer = require('puppeteer'); 89 | 90 | (async () => { 91 | const browser = await puppeteer.launch(); 92 | const page = await browser.newPage(); 93 | await page.goto('https://example.com'); 94 | await page.screenshot({path: 'example.png'}); 95 | 96 | await browser.close(); 97 | })(); 98 | ``` 99 | 100 | ## 视频文件下载方法 101 | 102 | * 下载视频主方法 103 | ```js 104 | const downloadVideo = async video => { 105 | // 判断视频文件是否已经下载 106 | if (!fs.existsSync(`${config.savePath}/${video.title}.mp4`)) { 107 | await getVideoData(video.src, 'binary').then(fileData => { 108 | console.log('下载视频中:', video.title) 109 | savefileToPath(video.title, fileData).then(res => 110 | console.log(`${res}: ${video.title}`) 111 | ) 112 | }) 113 | } else { 114 | console.log(`视频文件已存在:${video.title}`) 115 | } 116 | } 117 | ``` 118 | 119 | * 获取视频数据 120 | ```js 121 | getVideoData (url, encoding) { 122 | return new Promise((resolve, reject) => { 123 | let req = http.get(url, function (res) { 124 | let result = '' 125 | encoding && res.setEncoding(encoding) 126 | res.on('data', function (d) { 127 | result += d 128 | }) 129 | res.on('end', function () { 130 | resolve(result) 131 | }) 132 | res.on('error', function (e) { 133 | reject(e) 134 | }) 135 | }) 136 | req.end() 137 | }) 138 | } 139 | ``` 140 | 141 | * 将视频数据保存到本地 142 | ```js 143 | savefileToPath (fileName, fileData) { 144 | let fileFullName = `${config.savePath}/${fileName}.mp4` 145 | return new Promise((resolve, reject) => { 146 | fs.writeFile(fileFullName, fileData, 'binary', function (err) { 147 | if (err) { 148 | console.log('savefileToPath error:', err) 149 | } 150 | resolve('已下载') 151 | }) 152 | }) 153 | } 154 | ``` 155 | 156 | ## 爬取结果截图 157 | 158 | 视频截图 159 | 160 | ## 说明 161 | 162 | 此爬虫仅用于个人学习,如果侵权,即刻删除! 163 | --------------------------------------------------------------------------------