
1. 项目概述从“宅舞”热潮到数据获取的自动化需求最近几年一种被称为“宅舞”的舞蹈视频内容在各大视频平台持续火爆。这类视频通常由创作者在特定场景下伴随着动漫、游戏或流行音乐进行舞蹈表演因其充满活力的表现形式和贴近特定文化圈层的审美吸引了海量观众。作为一个对流行文化和数据技术都感兴趣的从业者我观察到这些视频的更新频率极高每天都有大量新作品涌现。手动一个个去收藏、下载效率低下且容易遗漏精彩内容。于是一个很自然的需求就产生了能否通过技术手段自动化地、批量地将这些每日更新的“宅舞”视频采集下来构建一个属于自己的离线资源库方便随时、反复观看与研究这不仅仅是一个简单的“下载”动作其背后涉及对特定内容源的持续监控、结构化数据的提取、媒体文件的获取与存储以及整个流程的自动化调度。从技术角度看这是一个典型的网络爬虫与数据采集项目但其对象是动态更新的流媒体内容因此比爬取静态网页文本或图片要复杂一些。它考验的是对目标网站结构的逆向分析、网络请求的模拟处理、以及应对反爬策略的灵活能力。对于想学习网络数据采集尤其是涉及视频内容抓取的开发者来说这是一个非常具有实践价值的练手项目。当然一切操作的前提是严格遵守相关平台的服务条款仅将技术用于个人学习与研究并尊重内容创作者的版权。2. 核心思路与技术选型解析要实现“批量爬取每日更新的宅舞视频”这个目标我们需要将任务拆解成几个清晰的步骤并为每个步骤选择合适的技术工具。整个流程的核心思路是发现目标 - 解析列表 - 提取链接 - 下载媒体 - 组织存储。2.1 目标平台分析与请求模拟首先我们需要确定视频来源。国内主流视频平台是这类内容的主要集散地。我们的程序需要能模拟浏览器访问这些平台获取页面数据。这里不能使用简单的requests.get()因为现代网站大量依赖JavaScript动态渲染内容直接请求得到的HTML可能是空的或者不完整的。技术选型Selenium 或 Playwright为了获取完整渲染后的页面内容我们需要一个能控制真实浏览器的工具。早期常用Selenium它支持多种浏览器。但近年来微软开源的Playwright因其更快的速度、更强大的API和更好的异步支持成为了许多开发者的新宠。它能够可靠地录制和回放用户操作完美应对动态加载的页面。在本项目中我选择使用Playwright for Python因为它能更轻松地处理无限滚动加载的视频列表页。为什么不用简单的API有些平台提供了公开API但通常有严格的调用频率限制且不一定能获取到所有我们需要的字段如高清视频地址。而通过模拟浏览器访问我们获取的是和普通用户一样的页面数据虽然需要解析HTML但数据更全面也更稳定只要网站前端不改版。2.2 页面解析与数据提取策略获取到页面HTML后下一步是从中提取我们关心的信息视频标题、UP主名称、播放链接、封面图、以及最重要的——视频文件的真实下载地址。技术选型BeautifulSoup 或 lxmlBeautifulSoup是Python里最流行的HTML/XML解析库语法友好适合快速开发。lxml的解析速度更快但语法稍显复杂。对于视频平台这种结构相对规整的页面两者皆可。我习惯用BeautifulSoup配合css selector或find_all方法可以精准定位到包含视频信息的DOM节点。核心挑战视频地址的获取这是本项目最大的技术难点。网页上播放器中的视频地址src通常不是指向一个直接的.mp4或.flv文件而是一个经过加密或动态生成的m3u8索引文件。m3u8是一种基于HTTP Live Streaming (HLS) 协议的流媒体播放列表里面包含了视频分片ts文件的地址。我们需要从页面中找到m3u8文件的链接。下载并解析这个m3u8文件得到所有ts分片文件的地址列表。批量下载所有ts分片。使用工具如ffmpeg将这些ts分片合并成一个完整的视频文件。这个过程要求我们能分析网页的网络请求通过浏览器开发者工具的Network面板找到关键的媒体请求。2.3 下载与存储架构设计确定了视频的真实地址后就需要高效、稳定地下载。对于直接的文件链接可以用requests流式下载。对于m3u8则需要一个能处理并发下载分片的模块。技术选型aiohttp 与 asyncio由于需要下载大量文件无论是多个视频还是一个视频的多个ts分片同步下载会非常慢。Python的asyncio异步IO框架配合aiohttp库可以同时发起多个网络请求极大提升下载效率。我们需要构建一个异步下载器并合理控制并发连接数避免对目标服务器造成过大压力或被封禁IP。存储设计本地文件系统 元数据库下载的视频文件可以按日期或UP主分类存储在本地文件夹。但为了更好地管理我建议引入一个轻量级数据库如SQLite用来记录每条视频的元数据平台来源、视频ID、标题、UP主、发布时间、本地存储路径、下载状态等。这样我们可以方便地查询、去重以及实现断点续传的功能。2.4 自动化与调度“每日更新”意味着我们的爬虫需要定期运行。我们不可能一直手动启动脚本。技术选型系统任务调度器在Windows上可以使用“任务计划程序”在Linux/Mac上cron是标准选择。我们可以将爬虫脚本设置为每天在固定时间例如凌晨2点自动运行一次检查并下载过去24小时内发布的新视频。脚本本身应该具备良好的日志功能记录每次运行下载了哪些视频遇到了什么错误方便后续排查。注意法律与道德边界在开始任何爬取项目前必须仔细阅读目标网站的robots.txt文件和服务条款。批量下载视频可能违反其使用协议。本技术讨论仅限用于个人学习、研究及对公开数据的合法分析严禁用于商业用途、盗版传播或任何对网站正常运营造成干扰的行为。务必设置合理的请求间隔如每请求一次休眠1-2秒体现“善意爬虫”的原则。3. 实操步骤构建你的宅舞视频爬虫下面我将以模拟一个主流视频平台的宅舞频道为例详细拆解实现步骤。请注意以下代码为技术原理演示实际网站结构可能不同需要你根据实际情况调整选择器。3.1 环境准备与依赖安装首先确保你的Python环境在3.7以上。我们使用venv创建虚拟环境是一个好习惯。# 创建并激活虚拟环境Linux/Mac python3 -m venv dance_spider_env source dance_spider_env/bin/activate # 创建并激活虚拟环境Windows python -m venv dance_spider_env dance_spider_env\Scripts\activate安装必要的库pip install playwright beautifulsoup4 aiohttp aiosqlite # 安装Playwright所需的浏览器内核 playwright install chromium这里我们选择Chromium作为浏览器内核它足够轻量且兼容性好。aiosqlite是SQLite的异步版本配合我们的异步爬虫更高效。3.2 分析目标页面与编写解析器假设我们要爬取的频道列表页URL模式为https://example.com/channel/dance?page{page}。我们的第一步是使用Playwright打开页面等待内容加载完成。import asyncio from playwright.async_api import async_playwright from bs4 import BeautifulSoup import aiohttp import aiosqlite from urllib.parse import urljoin import re import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s: %(message)s) logger logging.getLogger(__name__) class DanceVideoSpider: def __init__(self, base_url, start_page1, max_page5): self.base_url base_url self.start_page start_page self.max_page max_page self.video_list [] # 用于存储解析到的视频信息字典 async def fetch_page(self, page_num): 使用Playwright获取指定页码的页面HTML url self.base_url.format(pagepage_num) async with async_playwright() as p: # 启动无头浏览器headlessFalse可以让你看到浏览器操作调试时有用 browser await p.chromium.launch(headlessTrue) context await browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... ) page await context.new_page() try: logger.info(f正在访问页面: {url}) await page.goto(url, wait_untilnetworkidle) # 等待网络空闲确保内容加载完 # 有些页面是滚动加载这里可以模拟滚动 for i in range(3): # 滚动3次每次等待1秒 await page.evaluate(window.scrollTo(0, document.body.scrollHeight)) await page.wait_for_timeout(1000) page_content await page.content() await browser.close() return page_content except Exception as e: logger.error(f获取页面 {url} 失败: {e}) await browser.close() return None获取到HTML后我们需要编写解析函数。打开浏览器开发者工具检查视频列表项的HTML结构。假设每个视频卡片都有一个类名为video-card的div里面包含了我们需要的信息。def parse_video_list(self, html): 从列表页HTML中解析视频基本信息 if not html: return [] soup BeautifulSoup(html, html.parser) video_items soup.find_all(div, class_video-card) # 根据实际结构修改选择器 videos [] for item in video_items: try: title_elem item.find(a, class_title) title title_elem.get(title) if title_elem else title_elem.text.strip() link urljoin(self.base_url, title_elem.get(href)) if title_elem else None up_elem item.find(span, class_up-name) up_name up_elem.text.strip() if up_elem else 未知 cover_elem item.find(img, class_cover) cover_url cover_elem.get(src) if cover_elem else None # 视频ID可以从链接中提取 video_id None if link: match re.search(r/video/([a-zA-Z0-9]), link) video_id match.group(1) if match else None if title and link and video_id: videos.append({ video_id: video_id, title: title, url: link, up_name: up_name, cover_url: cover_url, downloaded: False }) except Exception as e: logger.warning(f解析单个视频卡片时出错: {e}) continue logger.info(f本页解析到 {len(videos)} 个视频) return videos3.3 获取视频真实播放地址关键步骤这是最具挑战性的一步。我们需要进入每个视频的详情页找到m3u8文件地址。通常这个地址不会直接写在HTML里而是由JavaScript脚本动态加载。我们可以通过监听页面网络请求来捕获它。async def get_video_play_url(self, video_page_url): 访问视频详情页尝试获取m3u8播放地址 async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context() page await context.new_page() m3u8_url None # 监听所有网络响应寻找包含.m3u8的请求 def handle_response(response): nonlocal m3u8_url if .m3u8 in response.url: m3u8_url response.url logger.info(f捕获到m3u8地址: {m3u8_url}) page.on(response, handle_response) try: await page.goto(video_page_url, wait_untilnetworkidle) # 可能需要点击播放按钮触发视频加载 # await page.click(.play-button) # 根据实际页面调整 await page.wait_for_timeout(5000) # 等待足够时间让视频请求发出 except Exception as e: logger.error(f访问视频页 {video_page_url} 失败: {e}) finally: await browser.close() return m3u8_url实操心得请求过滤在开发者工具的Network面板中使用m3u8或ts关键字过滤请求能快速定位目标。多方案备用有些网站的视频地址可能藏在页面某个script标签的变量里需要用正则表达式去匹配。例如搜索play_url:(.*?\.m3u8)。所以一个健壮的get_video_play_url函数可能需要结合网络监听和页面脚本解析两种方式。清晰度选择m3u8文件可能有多个对应不同清晰度如720p.m3u8,1080p.m3u8。你需要分析捕获到的URL规律选择你想要的清晰度。3.4 下载m3u8并合并视频获取到m3u8地址后我们下载它并解析出所有ts分片链接。async def download_m3u8_video(self, m3u8_url, video_id, title): 下载m3u8流媒体视频并合并 import aiofiles import os from pathlib import Path # 创建临时目录存储ts文件 temp_dir Path(f./temp_{video_id}) temp_dir.mkdir(exist_okTrue) output_path Path(f./videos/{title}_{video_id}.mp4) output_path.parent.mkdir(parentsTrue, exist_okTrue) async with aiohttp.ClientSession() as session: try: # 1. 下载m3u8文件 async with session.get(m3u8_url) as resp: m3u8_content await resp.text() # 2. 解析出所有ts文件链接 ts_urls [] base_url m3u8_url[:m3u8_url.rfind(/)1] # 获取m3u8文件的基础路径 for line in m3u8_content.split(\n): line line.strip() if line and not line.startswith(#): if line.startswith(http): ts_urls.append(line) else: ts_urls.append(urljoin(base_url, line)) logger.info(f视频 {title} 共有 {len(ts_urls)} 个ts分片) # 3. 异步下载所有ts文件 semaphore asyncio.Semaphore(10) # 控制并发数避免被封 async def download_one_ts(index, ts_url): async with semaphore: ts_filename temp_dir / fsegment_{index:04d}.ts try: async with session.get(ts_url) as resp: if resp.status 200: content await resp.read() async with aiofiles.open(ts_filename, wb) as f: await f.write(content) logger.debug(f已下载分片 {index1}/{len(ts_urls)}) else: logger.error(f下载分片失败: {ts_url}, 状态码: {resp.status}) except Exception as e: logger.error(f下载分片 {ts_url} 时出错: {e}) tasks [download_one_ts(i, url) for i, url in enumerate(ts_urls)] await asyncio.gather(*tasks, return_exceptionsTrue) # 4. 合并ts文件为mp4 (使用ffmpeg) ts_file_list sorted(temp_dir.glob(*.ts)) if ts_file_list: list_file temp_dir / file_list.txt with open(list_file, w, encodingutf-8) as f: for ts in ts_file_list: f.write(ffile {ts.absolute()}\n) import subprocess # 调用ffmpeg合并 cmd [ ffmpeg, -f, concat, -safe, 0, -i, str(list_file.absolute()), -c, copy, str(output_path.absolute()) ] subprocess.run(cmd, capture_outputTrue, checkTrue) logger.info(f视频合并完成: {output_path}) else: logger.error(f未找到任何ts文件合并失败) except Exception as e: logger.error(f下载或合并视频 {title} 时发生错误: {e}) finally: # 5. 清理临时ts文件 import shutil if temp_dir.exists(): shutil.rmtree(temp_dir)重要提示ffmpeg依赖上述代码需要系统已安装ffmpeg命令行工具。你需要先去ffmpeg官网下载并安装并确保其路径已添加到系统环境变量中使得在命令行中可以直接调用ffmpeg命令。3.5 数据存储与任务调度我们将爬取到的视频元数据存入SQLite数据库便于管理和去重。async def init_database(self): 初始化数据库创建表 self.db await aiosqlite.connect(dance_videos.db) await self.db.execute( CREATE TABLE IF NOT EXISTS videos ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT UNIQUE, title TEXT, url TEXT, up_name TEXT, cover_url TEXT, m3u8_url TEXT, local_path TEXT, downloaded BOOLEAN DEFAULT 0, publish_date TEXT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) await self.db.commit() async def save_video_info(self, video_info): 保存或更新视频信息到数据库 try: await self.db.execute( INSERT OR IGNORE INTO videos (video_id, title, url, up_name, cover_url, downloaded) VALUES (?, ?, ?, ?, ?, ?) , (video_info[video_id], video_info[title], video_info[url], video_info[up_name], video_info[cover_url], video_info.get(downloaded, False))) await self.db.commit() except Exception as e: logger.error(f保存视频信息到数据库失败: {e}) async def mark_as_downloaded(self, video_id, m3u8_url, local_path): 标记视频已下载 try: await self.db.execute( UPDATE videos SET downloaded 1, m3u8_url ?, local_path ? WHERE video_id ? , (m3u8_url, local_path, video_id)) await self.db.commit() except Exception as e: logger.error(f更新视频下载状态失败: {e})最后编写主函数将所有步骤串联起来并加入简单的定时循环逻辑实际生产环境建议用系统cron。async def run(self): 主运行函数 await self.init_database() for page in range(self.start_page, self.start_page self.max_page): logger.info(f开始处理第 {page} 页) html await self.fetch_page(page) if not html: break videos self.parse_video_list(html) for video in videos: # 检查是否已下载过 cursor await self.db.execute(SELECT downloaded FROM videos WHERE video_id ?, (video[video_id],)) row await cursor.fetchone() if row and row[0]: logger.info(f视频 {video[title]} 已下载跳过) continue await self.save_video_info(video) logger.info(f开始处理视频: {video[title]}) m3u8_url await self.get_video_play_url(video[url]) if m3u8_url: output_path f./videos/{video[title]}_{video[video_id]}.mp4 await self.download_m3u8_video(m3u8_url, video[video_id], video[title]) await self.mark_as_downloaded(video[video_id], m3u8_url, output_path) else: logger.warning(f未能获取视频 {video[title]} 的播放地址) await asyncio.sleep(2) # 处理完一个视频后休眠避免请求过快 await asyncio.sleep(5) # 处理完一页后休眠更长时间 await self.db.close() async def main(): # 示例基础URL请替换为实际目标地址 base_url https://example.com/channel/dance?page{page} spider DanceVideoSpider(base_url, start_page1, max_page3) # 先爬3页测试 await spider.run() if __name__ __main__: asyncio.run(main())4. 常见问题、反爬策略与优化技巧在实际操作中你几乎一定会遇到各种问题。下面是我在类似项目中踩过的坑和总结的经验。4.1 常见问题排查表问题现象可能原因排查与解决方案Playwright无法打开页面或超时1. 网络问题2. 网站屏蔽自动化工具3. 页面加载元素过多过慢。1. 检查网络连接和代理设置2. 尝试添加--disable-blink-featuresAutomationControlled启动参数或使用stealth插件3. 增加wait_until的超时时间或改用wait_for_selector等待特定元素出现。解析不到视频列表video_items为空1. 页面结构已更新CSS选择器失效2. 内容由JS动态加载未完全渲染。1. 重新用开发者工具检查元素更新选择器2. 在page.goto后增加page.wait_for_selector(.video-card)确保元素加载完成。捕获不到m3u8请求1. 视频格式非HLS可能是MP4直链2. 视频地址加密或通过WebSocket传输3. 监听时机不对。1. 在Network面板搜索.mp4或.flv2. 分析页面源码搜索playUrl、video_url等关键字3. 尝试在点击播放按钮后再开始监听响应。下载的ts文件合并失败黑屏/音画不同步1. ts文件下载顺序或内容错误2. ffmpeg合并命令参数不对3. 部分ts文件下载失败。1. 确保按m3u8文件中的顺序下载和合并2. 尝试用-c copy直接流复制若不行则尝试重新编码-c:v libx264 -c:a aac3. 检查日志重试失败的ts分片下载。很快被网站封禁IP请求频率过高触发了反爬机制。1.必须在请求间加入随机延时如await asyncio.sleep(random.uniform(1, 3))2. 使用代理IP池轮换请求3. 降低并发数Semaphore值。数据库写入冲突或错误多任务异步写入时可能发生。使用aiosqlite并确保写操作是顺序的或者使用连接池。对于简单项目也可以将数据库操作放在主线程中。4.2 应对反爬策略的进阶技巧User-Agent与请求头伪装Playwright启动浏览器时已经带有真实的UA但通过aiohttp直接下载ts文件时需要手动设置UA等请求头模拟浏览器行为。headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Referer: video_page_url, # 关键很多服务器会校验Referer } async with session.get(ts_url, headersheaders) as resp: ...使用代理IP当单个IP被限制后代理是必须的。可以将代理列表存入文件或数据库在创建aiohttp.ClientSession或Playwright浏览器上下文时配置。# aiohttp 使用代理 connector aiohttp.TCPConnector(limit100) proxy http://your_proxy:port async with aiohttp.ClientSession(connectorconnector, headersheaders) as session: async with session.get(url, proxyproxy) as resp: ... # Playwright 使用代理 browser await p.chromium.launch(proxy{server: http://your_proxy:port})模拟人类行为在关键操作间增加随机延迟和鼠标移动。Playwright可以录制脚本生成包含点击、滚动等操作的代码让行为更像真人。await page.wait_for_timeout(random.randint(1000, 3000)) # 随机等待1-3秒 # 模拟鼠标移动到某个元素上 await page.hover(.some-element)处理验证码如果遇到验证码项目复杂度会急剧上升。可以考虑a) 手动处理不适用于全自动b) 使用付费的打码平台APIc) 尝试用机器学习库识别简单验证码成功率有限。4.3 项目优化与扩展方向增量爬取与去重我们的数据库设计了video_id唯一索引和downloaded字段就是为了实现增量爬取。每天运行脚本时只处理downloaded0的记录。还可以根据publish_date字段只爬取特定日期之后的视频。分布式爬虫如果视频量巨大可以考虑使用Scrapy框架结合Scrapy-Redis实现分布式爬取提升效率。但Scrapy与Playwright的集成需要一些额外配置。元数据丰富与分类除了下载视频还可以进一步解析视频的标签、分类、弹幕、评论等存入数据库。利用这些数据可以做简单的分析比如最受欢迎的UP主、热门标签等。图形化界面与通知使用PyQt或Tkinter为爬虫做一个简单的图形界面方便配置和启动。或者在爬虫完成下载后通过邮件、Server酱等工具发送一条通知给你。容器化部署使用Docker将整个爬虫环境包括Python、Chromium、ffmpeg打包成一个镜像可以方便地在任何支持Docker的服务器上部署和运行。最后一点个人体会这类项目最大的价值不在于最终下载了多少视频而在于解决问题的过程中你对网络协议、浏览器自动化、异步编程、数据解析和反爬对抗的理解会深入很多。每一个报错都是一个学习的机会。务必保持耐心从简单的目标开始逐步增加复杂度并始终将伦理和法律约束放在首位。