diff --git a/README.md b/README.md index 8ed52f9..a756d78 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,141 @@ -# 🔥 自媒体平台爬虫🕷️MediaCrawler🔥 -NanmiCoder%2FMediaCrawler | Trendshift +# 小红书内容智能分析系统 -[![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/network/members) -[![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/issues) -[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/pulls) -[![License](https://img.shields.io/github/license/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE) +## 项目概述 +本项目旨在构建一个自动化的小红书内容采集和智能分析系统。通过爬虫采集、多模态内容处理和AI分析,将小红书的图文视频内容转化为结构化的知识输出。 -> **免责声明:** -> -> 大家请以学习为目的使用本仓库⚠️⚠️⚠️⚠️,[爬虫违法违规的案件](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China)
-> ->本仓库的所有内容仅供学习和参考之用,禁止用于商业用途。任何人或组织不得将本仓库的内容用于非法用途或侵犯他人合法权益。本仓库所涉及的爬虫技术仅用于学习和研究,不得用于对其他平台进行大规模爬虫或其他非法行为。对于因使用本仓库内容而引起的任何法律责任,本仓库不承担任何责任。使用本仓库的内容即表示您同意本免责声明的所有条款和条件。 -> -> 点击查看更为详细的免责声明。[点击跳转](#disclaimer) +## 系统架构 -# 仓库描述 +### 1. 数据采集层 +#### 1.1 内容爬取 +- 使用 MediaCrawler 爬虫框架 +- 根据指定关键词抓取小红书笔记 +- 将原始数据保存为 JSON 格式 +- 包含笔记文本、图片URL、视频URL等信息 -**小红书爬虫**,**抖音爬虫**, **快手爬虫**, **B站爬虫**, **微博爬虫**,**百度贴吧爬虫**,**知乎爬虫**...。 -目前能抓取小红书、抖音、快手、B站、微博、贴吧、知乎等平台的公开信息。 +#### 1.2 数据存储 +- 将 JSON 数据导入 MySQL 数据库 +- 建立规范的数据表结构 +- 实现数据的持久化存储和管理 -原理:利用[playwright](https://playwright.dev/)搭桥,保留登录成功后的上下文浏览器环境,通过执行JS表达式获取一些加密参数 -通过使用此方式,免去了复现核心加密JS代码,逆向难度大大降低 +#### 1.3 媒体文件下载 +- 从数据库读取媒体文件URL +- 下载笔记关联的图片和视频 +- 按笔记ID分类存储在本地文件系统 -# 功能列表 -| 平台 | 关键词搜索 | 指定帖子ID爬取 | 二级评论 | 指定创作者主页 | 登录态缓存 | IP代理池 | 生成评论词云图 | -| ------ | ---------- | -------------- | -------- | -------------- | ---------- | -------- | -------------- | -| 小红书 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| B 站 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 微博 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 贴吧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| 知乎 | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +### 2. 内容处理层 +#### 2.1 视频处理 +- 使用 Faster-Whisper 模型 +- 将视频音频转换为文字 +- 支持中文语音识别 +- 保存字幕文本 -### MediaCrawlerPro重磅发布啦!!! -> 主打学习成熟项目的架构设计,不仅仅是爬虫,Pro中的其他代码设计思路也是值得学习,欢迎大家关注!!! +#### 2.2 图像处理 +- 使用 ChatGPT-4-Vision 模型 +- 分析图片内容 +- 提取图片中的关键信息 +- 生成图片描述文本 -[MediaCrawlerPro](https://github.com/MediaCrawlerPro) 版本已经重构出来了,相较于开源版本的优势: -- 多账号+IP代理支持(重点!) -- 去除Playwright依赖,使用更加简单 -- 支持linux部署(Docker docker-compose) -- 代码重构优化,更加易读易维护(解耦JS签名逻辑) -- 代码质量更高,对于构建更大型的爬虫项目更加友好 -- 完美的架构设计,更加易扩展,源码学习的价值更大 +### 3. 智能分析层 +#### 3.1 内容理解 +- 使用 ChatGPT 处理文本内容 +- 整合视频字幕和图片描述 +- 生成内容摘要 +- 提取关键信息点 + +#### 3.2 知识图谱 +- 基于内容分析生成思维导图 +- 展示主题间的逻辑关系 +- 可视化知识结构 + +## 技术栈 +- 爬虫框架:MediaCrawler +- 数据库:MySQL +- 音频处理:Faster-Whisper +- 图像识别:ChatGPT-4-Vision +- 自然语言处理:ChatGPT +- 编程语言:Python + +## 工作流程图 +![工作流程图](docs/static/images/fig1.png) -# 安装部署方法 -> 开源不易,希望大家可以Star一下MediaCrawler仓库!!!!十分感谢!!!
+## 预期成果 +1. 自动化的内容采集系统 +2. 结构化的多模态数据存储 +3. 智能化的内容理解和分析 +4. 可视化的知识展示 -## 创建并激活 python 虚拟环境 -> 如果是爬取抖音和知乎,需要提前安装nodejs环境,版本大于等于:`16`即可
- ```shell - # 进入项目根目录 - cd MediaCrawler - - # 创建虚拟环境 - # 我的python版本是:3.9.6,requirements.txt中的库是基于这个版本的,如果是其他python版本,可能requirements.txt中的库不兼容,自行解决一下。 - python -m venv venv - - # macos & linux 激活虚拟环境 - source venv/bin/activate +## 应用场景 +- 内容创作参考 +- 市场趋势分析 +- 用户行为研究 +- 知识管理系统 - # windows 激活虚拟环境 - venv\Scripts\activate +## 后续优化方向 +1. 提高爬虫效率和稳定性 +2. 优化媒体文件存储结构 +3. 提升AI模型处理精度 +4. 增强可视化展示效果 +5. 添加用户交互界面 - ``` +## 风险分析 -## 安装依赖库 +### 1. 法律合规风险 - ```shell - pip install -r requirements.txt - ``` +#### 1.1 违反网络安全法风险 +- 根据《中华人民共和国网络安全法》第四十四条规定,任何个人和组织不得窃取或者以其他非法方式获取个人信息 +- 在爬取过程中必须避免收集用户个人隐私信息 +- 确保数据采集和使用符合相关法律法规 -## 安装 playwright浏览器驱动 +#### 1.2 侵犯知识产权风险 +- 需注意平台内容的版权问题 +- 避免大规模复制和传播他人原创内容 +- 不得将爬取的内容用于商业牟利 - ```shell - playwright install - ``` +#### 1.3 违反平台服务条款风险 +- 违反平台规则可能面临账号封禁 +- 过度爬取可能导致IP封锁 +- 严重违规可能引发平台法律诉讼 -## 运行爬虫程序 +### 2. 技术风险 - ```shell - ### 项目默认是没有开启评论爬取模式,如需评论请在config/base_config.py中的 ENABLE_GET_COMMENTS 变量修改 - ### 一些其他支持项,也可以在config/base_config.py查看功能,写的有中文注释 - - # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论 - python main.py --platform xhs --lt qrcode --type search - - # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息 - python main.py --platform xhs --lt qrcode --type detail - - # 打开对应APP扫二维码登录 - - # 其他平台爬虫使用示例,执行下面的命令查看 - python main.py --help - ``` +#### 2.1 反爬虫机制 +- 平台可能部署各种反爬虫措施 +- IP被封禁影响采集效率 +- 需要不断更新技术方案应对 -## 数据保存 -- 支持关系型数据库Mysql中保存(需要提前创建数据库) - - 执行 `python db.py` 初始化数据库数据库表结构(只在首次执行) -- 支持保存到csv中(data/目录下) -- 支持保存到json中(data/目录下) +#### 2.2 数据质量风险 +- 采集数据可能不完整或有误 +- 多媒体内容下载失败 +- 数据格式变化导致解析错误 +### 3. 使用建议 +#### 3.1 合规使用 +- 仅采集公开可见的内容 +- 避免采集用户个人信息 +- 采集频率保持合理范围 +- 遵守平台的robots.txt规则 -# 其他常见问题可以查看在线文档 -> -> 在线文档包含使用方法、常见问题、加入项目交流群等。 -> [MediaCrawler在线文档](https://nanmicoder.github.io/MediaCrawler/) -> +#### 3.2 技术防范 +- 使用代理IP分散请求 +- 控制请求频率和并发数 +- 做好异常处理和日志记录 +- 定期备份重要数据 -# 知识付费服务 -[作者的知识付费栏目介绍](https://nanmicoder.github.io/MediaCrawler/%E7%9F%A5%E8%AF%86%E4%BB%98%E8%B4%B9%E4%BB%8B%E7%BB%8D.html) +### 4. 案例警示 -# 项目微信交流群 +根据[GitHub上的中国爬虫违法违规案例汇总](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China),以下行为可能带来严重法律后果: -[加入微信交流群](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html) - -# 感谢下列Sponsors对本仓库赞助支持 -- 【Sider】全网最火的ChatGPT插件,我也免费薅羊毛用了快一年了,体验拉满。 +- 爬取和贩卖个人隐私数据 +- 破解验证码并提供服务 +- 未经授权爬取并复制商业数据 +- 大规模爬取导致目标网站服务中断 -成为赞助者,可以将您产品展示在这里,每天获得大量曝光,联系作者微信:yzglan 或 email:relakkes@gmail.com - - -# 爬虫入门课程 -我新开的爬虫教程Github仓库 [CrawlerTutorial](https://github.com/NanmiCoder/CrawlerTutorial) ,感兴趣的朋友可以关注一下,持续更新,主打一个免费. - -# star 趋势图 -- 如果该项目对你有帮助,帮忙 star一下 ❤️❤️❤️,让更多的人看到MediaCrawler这个项目 - -[![Star History Chart](https://api.star-history.com/svg?repos=NanmiCoder/MediaCrawler&type=Date)](https://star-history.com/#NanmiCoder/MediaCrawler&Date) - - -# 参考 - -- xhs客户端 [ReaJason的xhs仓库](https://github.com/ReaJason/xhs) -- 短信转发 [参考仓库](https://github.com/pppscn/SmsForwarder) -- 内网穿透工具 [ngrok](https://ngrok.com/docs/) - - -# 免责声明 -
- -## 1. 项目目的与性质 -本项目(以下简称“本项目”)是作为一个技术研究与学习工具而创建的,旨在探索和学习网络数据采集技术。本项目专注于自媒体平台的数据爬取技术研究,旨在提供给学习者和研究者作为技术交流之用。 - -## 2. 法律合规性声明 -本项目开发者(以下简称“开发者”)郑重提醒用户在下载、安装和使用本项目时,严格遵守中华人民共和国相关法律法规,包括但不限于《中华人民共和国网络安全法》、《中华人民共和国反间谍法》等所有适用的国家法律和政策。用户应自行承担一切因使用本项目而可能引起的法律责任。 - -## 3. 使用目的限制 -本项目严禁用于任何非法目的或非学习、非研究的商业行为。本项目不得用于任何形式的非法侵入他人计算机系统,不得用于任何侵犯他人知识产权或其他合法权益的行为。用户应保证其使用本项目的目的纯属个人学习和技术研究,不得用于任何形式的非法活动。 - -## 4. 免责声明 -开发者已尽最大努力确保本项目的正当性及安全性,但不对用户使用本项目可能引起的任何形式的直接或间接损失承担责任。包括但不限于由于使用本项目而导致的任何数据丢失、设备损坏、法律诉讼等。 - -## 5. 知识产权声明 -本项目的知识产权归开发者所有。本项目受到著作权法和国际著作权条约以及其他知识产权法律和条约的保护。用户在遵守本声明及相关法律法规的前提下,可以下载和使用本项目。 - -## 6. 最终解释权 -关于本项目的最终解释权归开发者所有。开发者保留随时更改或更新本免责声明的权利,恕不另行通知。 -
- - -## 感谢JetBrains提供的免费开源许可证支持 - - JetBrains - +### 5. 合规建议 +1. 项目启动前进行法律可行性评估 +2. 建立数据安全管理制度 +3. 保留完整的操作日志记录 +4. 定期进行合规性自查 +5. 如有必要可咨询法律专家 diff --git a/README_v1.md b/README_v1.md new file mode 100644 index 0000000..0b0deba --- /dev/null +++ b/README_v1.md @@ -0,0 +1,155 @@ +# 🔥 自媒体平台爬虫🕷️MediaCrawler🔥 +z +# 仓库描述 + +**小红书爬虫**,**抖音爬虫**, **快手爬虫**, **B站爬虫**, **微博爬虫**,**百度贴吧爬虫**,**知乎爬虫**...。 +目前能抓取小红书、抖音、快手、B站、微博、贴吧、知乎等平台的公开信息。 + +原理:利用[playwright](https://playwright.dev/)搭桥,保留登录成功后的上下文浏览器环境,通过执行JS表达式获取一些加密参数 +通过使用此方式,免去了复现核心加密JS代码,逆向难度大大降低 + +# 功能列表 +| 平台 | 关键词搜索 | 指定帖子ID爬取 | 二级评论 | 指定创作者主页 | 登录态缓存 | IP代理池 | 生成评论词云图 | +| ------ | ---------- | -------------- | -------- | -------------- | ---------- | -------- | -------------- | +| 小红书 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| B 站 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 微博 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 贴吧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 知乎 | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | + +### MediaCrawlerPro重磅发布啦!!! +> 主打学习成熟项目的架构设计,不仅仅是爬虫,Pro中的其他代码设计思路也是值得学习,欢迎大家关注!!! + +[MediaCrawlerPro](https://github.com/MediaCrawlerPro) 版本已经重构出来了,相较于开源版本的优势: +- 多账号+IP代理支持(重点!) +- 去除Playwright依赖,使用更加简单 +- 支持linux部署(Docker docker-compose) +- 代码重构优化,更加易读易维护(解耦JS签名逻辑) +- 代码质量更高,对于构建更大型的爬虫项目更加友好 +- 完美的架构设计,更加易扩展,源码学习的价值更大 + + +# 安装部署方法 +> 开源不易,希望大家可以Star一下MediaCrawler仓库!!!!十分感谢!!!
+ +## 创建并激活 python 虚拟环境 +> 如果是爬取抖音和知乎,需要提前安装nodejs环境,版本大于等于:`16`即可
+ ```shell + # 进入项目根目录 + cd MediaCrawler + + # 创建虚拟环境 + # 我的python版本是:3.9.6,requirements.txt中的库是基于这个版本的,如果是其他python版本,可能requirements.txt中的库不兼容,自行解决一下。 + python -m venv venv + + # macos & linux 激活虚拟环境 + source venv/bin/activate + + # windows 激活虚拟环境 + venv\Scripts\activate + + ``` + +## 安装依赖库 + + ```shell + pip install -r requirements.txt + ``` + +## 安装 playwright浏览器驱动 + + ```shell + playwright install + ``` + +## 运行爬虫程序 + + ```shell + ### 项目默认是没有开启评论爬取模式,如需评论请在config/base_config.py中的 ENABLE_GET_COMMENTS 变量修改 + ### 一些其他支持项,也可以在config/base_config.py查看功能,写的有中文注释 + + # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论 + python main.py --platform xhs --lt qrcode --type search + + # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息 + python main.py --platform xhs --lt qrcode --type detail + + # 打开对应APP扫二维码登录 + + # 其他平台爬虫使用示例,执行下面的命令查看 + python main.py --help + ``` + +## 数据保存 +- 支持关系型数据库Mysql中保存(需要提前创建数据库) + - 执行 `python db.py` 初始化数据库数据库表结构(只在首次执行) +- 支持保存到csv中(data/目录下) +- 支持保存到json中(data/目录下) + + + +# 其他常见问题可以查看在线文档 +> +> 在线文档包含使用方法、常见问题、加入项目交流群等。 +> [MediaCrawler在线文档](https://nanmicoder.github.io/MediaCrawler/) +> + +# 知识付费服务 +[作者的知识付费栏目介绍](https://nanmicoder.github.io/MediaCrawler/%E7%9F%A5%E8%AF%86%E4%BB%98%E8%B4%B9%E4%BB%8B%E7%BB%8D.html) + +# 项目微信交流群 + +[加入微信交流群](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html) + +# 感谢下列Sponsors对本仓库赞助支持 +- 【Sider】全网最火的ChatGPT插件,我也免费薅羊毛用了快一年了,体验拉满。 + +成为赞助者,可以将您产品展示在这里,每天获得大量曝光,联系作者微信:yzglan 或 email:relakkes@gmail.com + + +# 爬虫入门课程 +我新开的爬虫教程Github仓库 [CrawlerTutorial](https://github.com/NanmiCoder/CrawlerTutorial) ,感兴趣的朋友可以关注一下,持续更新,主打一个免费. + +# star 趋势图 +- 如果该项目对你有帮助,帮忙 star一下 ❤️❤️❤️,让更多的人看到MediaCrawler这个项目 + +[![Star History Chart](https://api.star-history.com/svg?repos=NanmiCoder/MediaCrawler&type=Date)](https://star-history.com/#NanmiCoder/MediaCrawler&Date) + + +# 参考 + +- xhs客户端 [ReaJason的xhs仓库](https://github.com/ReaJason/xhs) +- 短信转发 [参考仓库](https://github.com/pppscn/SmsForwarder) +- 内网穿透工具 [ngrok](https://ngrok.com/docs/) + + +# 免责声明 +
+ +## 1. 项目目的与性质 +本项目(以下简称“本项目”)是作为一个技术研究与学习工具而创建的,旨在探索和学习网络数据采集技术。本项目专注于自媒体平台的数据爬取技术研究,旨在提供给学习者和研究者作为技术交流之用。 + +## 2. 法律合规性声明 +本项目开发者(以下简称“开发者”)郑重提醒用户在下载、安装和使用本项目时,严格遵守中华人民共和国相关法律法规,包括但不限于《中华人民共和国网络安全法》、《中华人民共和国反间谍法》等所有适用的国家法律和政策。用户应自行承担一切因使用本项目而可能引起的法律责任。 + +## 3. 使用目的限制 +本项目严禁用于任何非法目的或非学习、非研究的商业行为。本项目不得用于任何形式的非法侵入他人计算机系统,不得用于任何侵犯他人知识产权或其他合法权益的行为。用户应保证其使用本项目的目的纯属个人学习和技术研究,不得用于任何形式的非法活动。 + +## 4. 免责声明 +开发者已尽最大努力确保本项目的正当性及安全性,但不对用户使用本项目可能引起的任何形式的直接或间接损失承担责任。包括但不限于由于使用本项目而导致的任何数据丢失、设备损坏、法律诉讼等。 + +## 5. 知识产权声明 +本项目的知识产权归开发者所有。本项目受到著作权法和国际著作权条约以及其他知识产权法律和条约的保护。用户在遵守本声明及相关法律法规的前提下,可以下载和使用本项目。 + +## 6. 最终解释权 +关于本项目的最终解释权归开发者所有。开发者保留随时更改或更新本免责声明的权利,恕不另行通知。 +
+ + +## 感谢JetBrains提供的免费开源许可证支持 + + JetBrains + + diff --git a/build.py b/build.py new file mode 100644 index 0000000..33437c7 --- /dev/null +++ b/build.py @@ -0,0 +1,52 @@ +import os +import shutil +import PyInstaller.__main__ + +def clean_build_dirs(): + """清理构建目录""" + dirs_to_clean = ['build', 'dist'] + for dir_name in dirs_to_clean: + if os.path.exists(dir_name): + shutil.rmtree(dir_name) + +def create_required_dirs(): + """创建必要的目录""" + os.makedirs('dist/data/xhs/json/media', exist_ok=True) + +def copy_required_files(): + """复制必要的文件""" + # 复制配置文件 + if os.path.exists('config'): + shutil.copytree('config', 'dist/config', dirs_exist_ok=True) + + # 复制其他必要文件 + files_to_copy = [ + 'main.py', + # 添加其他需要复制的文件 + ] + + for file in files_to_copy: + if os.path.exists(file): + shutil.copy2(file, 'dist/') + +def main(): + # 清理旧的构建文件 + clean_build_dirs() + + # 运行PyInstaller + PyInstaller.__main__.run([ + 'build_exe.spec', + '--clean', + '--noconfirm' + ]) + + # 创建必要的目录 + create_required_dirs() + + # 复制必要的文件 + copy_required_files() + + print("打包完成!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/check_downloads.py b/check_downloads.py new file mode 100644 index 0000000..cf51e18 --- /dev/null +++ b/check_downloads.py @@ -0,0 +1,159 @@ +import os +import mysql.connector +from urllib.parse import urlparse +import requests +import time + +# 数据库配置 +db_config = { + 'user': 'root', + 'password': 'zaq12wsx@9Xin', + 'host': '183.11.229.79', + 'port': 3316, + 'database': '9xin', + 'auth_plugin': 'mysql_native_password' +} + +def download_file(url, save_path): + """下载文件""" + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + response = requests.get(url, headers=headers, stream=True, timeout=10) + response.raise_for_status() + + os.makedirs(os.path.dirname(save_path), exist_ok=True) + + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + return True + except Exception as e: + print(f'下载文件失败 {url}: {str(e)}') + return False + +def retry_download(note_id, image_list, video_url): + """重试下载失败的媒体文件""" + media_dir = f'./data/xhs/json/media/{note_id}' + download_success = True + + # 重试下载图片 + if image_list: + image_urls = image_list.split(',') + for i, url in enumerate(image_urls): + url = url.strip() + if not url: + continue + + ext = os.path.splitext(urlparse(url).path)[1] or '.jpg' + image_path = os.path.join(media_dir, f'image_{i+1}{ext}') + + if not os.path.exists(image_path) or os.path.getsize(image_path) == 0: + print(f'重试下载图片 {note_id} - {i+1}') + if not download_file(url, image_path): + download_success = False + time.sleep(0.5) # 添加延时避免请求过快 + + # 重试下载视频 + if video_url and video_url.strip(): + video_url = video_url.strip() + ext = os.path.splitext(urlparse(video_url).path)[1] or '.mp4' + video_path = os.path.join(media_dir, f'video{ext}') + + if not os.path.exists(video_path) or os.path.getsize(video_path) == 0: + print(f'重试下载视频 {note_id}') + if not download_file(video_url, video_path): + download_success = False + + return download_success + +def check_media_files(): + """检查媒体文件下载状态并更新数据库,对失败的记录进行重试下载""" + try: + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + # 确保download_flag字段存在 + try: + cursor.execute(""" + ALTER TABLE xhs_notes + ADD COLUMN IF NOT EXISTS download_flag BOOLEAN DEFAULT FALSE + """) + conn.commit() + except Exception as e: + print(f"添加download_flag字段时出错: {e}") + + # 获取所有记录 + cursor.execute(""" + SELECT note_id, image_list, video_url, download_flag + FROM xhs_notes + """) + records = cursor.fetchall() + + update_query = """ + UPDATE xhs_notes + SET download_flag = %s + WHERE note_id = %s + """ + + total = len(records) + completed = 0 + + print(f"开始检查 {total} 条记录的下载状态...") + + for record in records: + note_id = record['note_id'] + is_complete = True + media_dir = f'./data/xhs/json/media/{note_id}' + + # 检查图片和视频是否完整 + if record['image_list']: + image_urls = record['image_list'].split(',') + for i, url in enumerate(image_urls): + if url.strip(): + ext = os.path.splitext(urlparse(url).path)[1] or '.jpg' + image_path = os.path.join(media_dir, f'image_{i+1}{ext}') + if not os.path.exists(image_path) or os.path.getsize(image_path) == 0: + is_complete = False + break + + if record['video_url'] and record['video_url'].strip(): + url = record['video_url'].strip() + ext = os.path.splitext(urlparse(url).path)[1] or '.mp4' + video_path = os.path.join(media_dir, f'video{ext}') + if not os.path.exists(video_path) or os.path.getsize(video_path) == 0: + is_complete = False + + # 如果下载不完整,尝试重新下载 + if not is_complete: + print(f"发现未完成下载的记录: {note_id},开始重试下载...") + is_complete = retry_download( + note_id, + record['image_list'], + record['video_url'] + ) + + # 更新数据库状态 + if is_complete != record['download_flag']: + cursor.execute(update_query, (is_complete, note_id)) + status = "完成" if is_complete else "未完成" + print(f"更新记录 {note_id} 的下载状态为: {status}") + + completed += 1 + if completed % 10 == 0: + print(f"进度: {completed}/{total}") + + conn.commit() + print("检查和重试下载完成!") + + except Exception as e: + print(f"发生错误: {e}") + finally: + if 'conn' in locals(): + conn.close() + +if __name__ == "__main__": + check_media_files() \ No newline at end of file diff --git a/config/base_config.py b/config/base_config.py index 6d6d8b8..897e0c7 100644 --- a/config/base_config.py +++ b/config/base_config.py @@ -11,7 +11,7 @@ # 基础配置 PLATFORM = "xhs" -KEYWORDS = "编程副业,编程兼职" # 关键词搜索配置,以英文逗号分隔 +KEYWORDS = """“宝宝你是一个油痘肌”""" # 关键词搜索配置,以英文逗号分隔 LOGIN_TYPE = "qrcode" # qrcode or phone or cookie COOKIES = "" # 具体值参见media_platform.xxx.field下的枚举值,暂时只支持小红书 @@ -35,7 +35,7 @@ IP_PROXY_PROVIDER_NAME = "kuaidaili" # 设置False会打开一个浏览器 # 小红书如果一直扫码登录不通过,打开浏览器手动过一下滑动验证码 # 抖音如果一直提示失败,打开浏览器看下是否扫码登录之后出现了手机号验证,如果出现了手动过一下再试。 -HEADLESS = False +HEADLESS = True # 是否保存登录状态 SAVE_LOGIN_STATE = True @@ -50,7 +50,7 @@ USER_DATA_DIR = "%s_user_data_dir" # %s will be replaced by platform name START_PAGE = 1 # 爬取视频/帖子的数量控制 -CRAWLER_MAX_NOTES_COUNT = 200 +CRAWLER_MAX_NOTES_COUNT = 1 # 并发爬虫数量控制 MAX_CONCURRENCY_NUM = 1 @@ -59,7 +59,7 @@ MAX_CONCURRENCY_NUM = 1 ENABLE_GET_IMAGES = False # 是否开启爬评论模式, 默认开启爬评论 -ENABLE_GET_COMMENTS = True +ENABLE_GET_COMMENTS = False # 爬取一级评论的数量控制(单视频/帖子) CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES = 10 diff --git a/config/xhs_config.py b/config/xhs_config.py new file mode 100644 index 0000000..d71d067 --- /dev/null +++ b/config/xhs_config.py @@ -0,0 +1,3 @@ +SEARCH_KEYWORDS = [ + "熬夜" +] \ No newline at end of file diff --git a/docs/project_workflow.md b/docs/project_workflow.md new file mode 100644 index 0000000..a2fbe08 --- /dev/null +++ b/docs/project_workflow.md @@ -0,0 +1,141 @@ +# 小红书内容智能分析系统 + +## 项目概述 +本项目旨在构建一个自动化的小红书内容采集和智能分析系统。通过爬虫采集、多模态内容处理和AI分析,将小红书的图文视频内容转化为结构化的知识输出。 + +## 系统架构 + +### 1. 数据采集层 +#### 1.1 内容爬取 +- 使用 MediaCrawler 爬虫框架 +- 根据指定关键词抓取小红书笔记 +- 将原始数据保存为 JSON 格式 +- 包含笔记文本、图片URL、视频URL等信息 + +#### 1.2 数据存储 +- 将 JSON 数据导入 MySQL 数据库 +- 建立规范的数据表结构 +- 实现数据的持久化存储和管理 + +#### 1.3 媒体文件下载 +- 从数据库读取媒体文件URL +- 下载笔记关联的图片和视频 +- 按笔记ID分类存储在本地文件系统 + +### 2. 内容处理层 +#### 2.1 视频处理 +- 使用 Faster-Whisper 模型 +- 将视频音频转换为文字 +- 支持中文语音识别 +- 保存字幕文本 + +#### 2.2 图像处理 +- 使用 ChatGPT-4-Vision 模型 +- 分析图片内容 +- 提取图片中的关键信息 +- 生成图片描述文本 + +### 3. 智能分析层 +#### 3.1 内容理解 +- 使用 ChatGPT 处理文本内容 +- 整合视频字幕和图片描述 +- 生成内容摘要 +- 提取关键信息点 + +#### 3.2 知识图谱 +- 基于内容分析生成思维导图 +- 展示主题间的逻辑关系 +- 可视化知识结构 + +## 技术栈 +- 爬虫框架:MediaCrawler +- 数据库:MySQL +- 音频处理:Faster-Whisper +- 图像识别:ChatGPT-4-Vision +- 自然语言处理:ChatGPT +- 编程语言:Python + +## 工作流程图 +![工作流程图](static/images/fig1.png) + + +## 预期成果 +1. 自动化的内容采集系统 +2. 结构化的多模态数据存储 +3. 智能化的内容理解和分析 +4. 可视化的知识展示 + +## 应用场景 +- 内容创作参考 +- 市场趋势分析 +- 用户行为研究 +- 知识管理系统 + +## 后续优化方向 +1. 提高爬虫效率和稳定性 +2. 优化媒体文件存储结构 +3. 提升AI模型处理精度 +4. 增强可视化展示效果 +5. 添加用户交互界面 + +## 风险分析 + +### 1. 法律合规风险 + +#### 1.1 违反网络安全法风险 +- 根据《中华人民共和国网络安全法》第四十四条规定,任何个人和组织不得窃取或者以其他非法方式获取个人信息 +- 在爬取过程中必须避免收集用户个人隐私信息 +- 确保数据采集和使用符合相关法律法规 + +#### 1.2 侵犯知识产权风险 +- 需注意平台内容的版权问题 +- 避免大规模复制和传播他人原创内容 +- 不得将爬取的内容用于商业牟利 + +#### 1.3 违反平台服务条款风险 +- 违反平台规则可能面临账号封禁 +- 过度爬取可能导致IP封锁 +- 严重违规可能引发平台法律诉讼 + +### 2. 技术风险 + +#### 2.1 反爬虫机制 +- 平台可能部署各种反爬虫措施 +- IP被封禁影响采集效率 +- 需要不断更新技术方案应对 + +#### 2.2 数据质量风险 +- 采集数据可能不完整或有误 +- 多媒体内容下载失败 +- 数据格式变化导致解析错误 + +### 3. 使用建议 + +#### 3.1 合规使用 +- 仅采集公开可见的内容 +- 避免采集用户个人信息 +- 采集频率保持合理范围 +- 遵守平台的robots.txt规则 + +#### 3.2 技术防范 +- 使用代理IP分散请求 +- 控制请求频率和并发数 +- 做好异常处理和日志记录 +- 定期备份重要数据 + +### 4. 案例警示 + +根据[GitHub上的中国爬虫违法违规案例汇总](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China),以下行为可能带来严重法律后果: + +- 爬取和贩卖个人隐私数据 +- 破解验证码并提供服务 +- 未经授权爬取并复制商业数据 +- 大规模爬取导致目标网站服务中断 + +### 5. 合规建议 + +1. 项目启动前进行法律可行性评估 +2. 建立数据安全管理制度 +3. 保留完整的操作日志记录 +4. 定期进行合规性自查 +5. 如有必要可咨询法律专家 diff --git a/docs/project_workflow.pdf b/docs/project_workflow.pdf new file mode 100644 index 0000000..17c1a18 Binary files /dev/null and b/docs/project_workflow.pdf differ diff --git a/docs/project_workflow_1.pdf b/docs/project_workflow_1.pdf new file mode 100644 index 0000000..c085427 Binary files /dev/null and b/docs/project_workflow_1.pdf differ diff --git a/docs/static/images/fig1.png b/docs/static/images/fig1.png new file mode 100644 index 0000000..fa42952 Binary files /dev/null and b/docs/static/images/fig1.png differ diff --git a/docs/static/images/新建 BMP 图像.bmp b/docs/static/images/新建 BMP 图像.bmp new file mode 100644 index 0000000..d40d632 Binary files /dev/null and b/docs/static/images/新建 BMP 图像.bmp differ diff --git a/flv.md b/flv.md new file mode 100644 index 0000000..d46448f --- /dev/null +++ b/flv.md @@ -0,0 +1,98 @@ +### CAAC无人机飞行执照申报指南 + +#### 1. 无人机执照分类 +根据中国民用航空局(CAAC)规定,无人机飞行执照主要分为以下几种类型: + +1. **超视距驾驶员执照(机长执照)** + - 允许持证人在视觉范围之外操作无人机。 + - 适用于远距离和高难度任务,作业半径通常大于500米或人机相对高度大于120米。 + +2. **视距内驾驶员执照** + - 允许在视觉范围内操作无人机。 + - 适用于短距离和低难度任务,作业半径通常小于或等于500米,人机相对高度小于或等于120米。 + +3. **教员证** + - 具备培训和指导他人学习无人机操作的能力。 + - 要求丰富的飞行经验和优秀的教学能力。 + +此外,根据无人机的类型,执照还可细分为多旋翼无人机执照和固定翼无人机执照等。 + +--- + +#### 2. 申报执照的基本条件 + +1. **年龄要求**:申请人需年满16周岁。 +2. **身体要求**:通过体检,确保身体健康,具备适应无人机飞行的基本身体条件。 +3. **背景审查**:无重大违法记录。 + +--- + +#### 3. 申报执照的具体步骤 + +1. **参加培训课程** + - 培训内容包括: + - **理论知识**:飞行法规、无人机飞行原理、航空气象等。 + - **实际飞行技能**:无人机起飞、降落、空中控制及应急操作。 + - 正规的培训课程是执照申报的必要条件。 + +2. **完成理论考试** + - 由CAAC组织,考试内容涵盖培训所学理论知识。 + +3. **完成实际操作考核** + - 包括无人机操控技能考核。 + - 要求达到规定标准。 + +4. **申请执照** + - 提交相关材料,包括身份证明、体检证明、考试合格证明等。 + - 通过审核后即可获得CAAC颁发的无人机飞行执照。 + +--- + +#### 4. 是否需要参加培训班? + +是的。申请人必须通过正规培训班,以学习必要的理论知识和操作技能。培训课程的内容通常包括: +- 无人机飞行操作 +- 安全措施与应急处理 +- 飞行法规与规则 + +--- + +#### 5. 机构推荐 +以下是提供CAAC无人机执照培训的知名机构: + +1. **翼飞鸿天** + - 拥有民航局和AOPA认证的教员,学员考试通过率高。 + +2. **鲲鹏堂** + - 成立于2012年,提供多种无人机类型的培训,实操场地广阔。 + +3. **通航无人机** + - 提供专业培训,设施先进,适合不同层次学员。 + +4. **能飞无人机学院** + - 中南地区指定考试中心,课程种类多。 + +其他机构包括撼动、华悦、新航道、三足、青华航宇等,均具备资质并提供系统化培训。 + +--- + +#### 6. 培训费用参考 + +- **超视距驾驶员执照**:10,000元–15,800元。 +- **视距内驾驶员执照**:8,500元–12,800元。 +- **固定翼与垂直起降固定翼执照**:16,000元–20,000元。 +- **直升机执照**:16,000元–20,000元。 + +> **建议**:选择培训机构时,重点考虑其师资力量、资质和成功案例,确保获得高质量培训。 + +--- + +#### 7. 注意事项 +- 确保申请材料真实、完整。 +- 提前安排体检,避免申请过程中的不必要延误。 +- 选择正规培训机构,以保证顺利通过考试并获取执照。 + +--- + +如有进一步问题,可咨询CAAC官网或直接联系相关培训机构获取详细信息。 + diff --git a/flv.pdf b/flv.pdf new file mode 100644 index 0000000..9d81592 Binary files /dev/null and b/flv.pdf differ diff --git a/input.xlsx b/input.xlsx new file mode 100644 index 0000000..26f5c87 Binary files /dev/null and b/input.xlsx differ diff --git a/integrate_xhs_crawler.py b/integrate_xhs_crawler.py new file mode 100644 index 0000000..67b2b13 --- /dev/null +++ b/integrate_xhs_crawler.py @@ -0,0 +1,231 @@ +import json +import os +from data.xhs.json.import_xhs_notes import connect_to_database, create_table, check_record_exists +# from check_downloads import check_media_files +from mysql.connector import Error +import time +import asyncio +import subprocess +from datetime import datetime +import random + +async def _run_crawler(keyword): + """运行爬虫的异步实现""" + try: + process = await asyncio.create_subprocess_exec( + 'python', 'main.py', + '--platform', 'xhs', + '--lt', 'qrcode', + '--keywords', keyword, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=1024*1024 + ) + + # 读取输出流 + async def read_stream(stream): + buffer = "" + while True: + chunk = await stream.read(8192) + if not chunk: + break + text = chunk.decode('utf-8', errors='ignore') + buffer += text + + # 处理输出 + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + if line.strip(): + print(f"爬虫进度: {line.strip()}") + + # 同时处理标准输出和错误输出 + await asyncio.gather( + read_stream(process.stdout), + read_stream(process.stderr) + ) + + await process.wait() + return process.returncode == 0 + + except Exception as e: + print(f"爬虫执行错误: {str(e)}") + return False + +def load_search_keywords(): + """从sheet_notes文件夹加载搜索关键词""" + keywords_dict = {} + json_dir = './data/xhs/json/sheet_notes' + + for json_file in os.listdir(json_dir): + if not json_file.endswith('.json'): + continue + + sheet_name = os.path.splitext(json_file)[0] + with open(os.path.join(json_dir, json_file), 'r', encoding='utf-8') as f: + keywords = json.load(f) + + # 修护.json从第12个元素开始 + # if sheet_name == '修护': + # keywords = keywords[11:] + + keywords_dict[sheet_name] = keywords + + return keywords_dict + +def insert_note_data(connection, data, sheet_name): + """插入笔记数据到数据库""" + insert_query = """ + INSERT INTO xhs_notes ( + note_id, type, title, description, video_url, time, + last_update_time, user_id, nickname, avatar, + liked_count, collected_count, comment_count, share_count, + ip_location, image_list, tag_list, last_modify_ts, + note_url, source_keyword, sheet_name, download_flag + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + """ + try: + cursor = connection.cursor() + inserted_count = 0 + skipped_count = 0 + + for item in data: + note_id = item.get('note_id') + + # 检查记录是否已存在 + if check_record_exists(cursor, note_id): + skipped_count += 1 + continue + + values = ( + note_id, + item.get('type'), + item.get('title'), + item.get('desc'), + item.get('video_url'), + item.get('time'), + item.get('last_update_time'), + item.get('user_id'), + item.get('nickname'), + item.get('avatar'), + item.get('liked_count'), + item.get('collected_count'), + item.get('comment_count'), + item.get('share_count'), + item.get('ip_location'), + item.get('image_list'), + item.get('tag_list'), + item.get('last_modify_ts'), + item.get('note_url'), + item.get('source_keyword'), + sheet_name, + False # download_flag 默认为False + ) + cursor.execute(insert_query, values) + inserted_count += 1 + + connection.commit() + print(f'成功插入 {inserted_count} 条新数据') + print(f'跳过 {skipped_count} 条已存在的数据') + + except Error as e: + print(f'插入数据时出错: {e}') + connection.rollback() + +def search_xhs_notes(keyword): + """搜索小红书笔记""" + try: + # 创建事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + # 运行爬虫 + success = loop.run_until_complete(_run_crawler(keyword)) + if not success: + print(f"爬虫执行失败: {keyword}") + return None + + # 读取爬虫结果 + json_path = f'./data/xhs/json/search_contents_{datetime.now().strftime("%Y-%m-%d")}.json' + if not os.path.exists(json_path): + print(f"找不到爬虫结果文��: {json_path}") + return None + + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 为每条记录添加来源关键词 + for item in data: + item['source_keyword'] = keyword + + return data + + finally: + loop.close() + + except Exception as e: + print(f"搜索过程发生错误: {str(e)}") + return None + +def keyword_exists_in_db(connection, keyword): + """检查关键词是否已存在于数据库中""" + query = "SELECT COUNT(*) FROM xhs_notes WHERE source_keyword = %s" + cursor = connection.cursor() + cursor.execute(query, (keyword,)) + result = cursor.fetchone() + return result[0] > 0 + +def main(): + # 连接数据库 + connection = connect_to_database() + if connection is None: + return + + try: + # 创建表格 + create_table(connection) + + # 加载搜索关键词 + keywords_dict = load_search_keywords() + + # 对每个sheet的关键词进行搜索 + for sheet_name, keywords in keywords_dict.items(): + print(f'开始处理 {sheet_name} 的关键词...') + + for keyword in keywords: + # 检查关键词是否已存在 + if keyword_exists_in_db(connection, keyword): + print(f'关键词已存在,跳过: {keyword}') + continue + + print(f'搜索关键词: {keyword}') + + # 搜索小红书笔记 + search_results = search_xhs_notes(keyword) + if search_results: + # 将搜索结果保存到数据库 + insert_note_data(connection, search_results, sheet_name) + else: + print(f"未获取到搜索结果: {keyword}") + + # 添加延时避免请求过快(随机延时10-30秒) + time.sleep(random.uniform(10, 30)) + + print(f'{sheet_name} 的关键词处理完成') + + # 下载所有媒体文件 + # print('开始下载媒体文件...') + # check_media_files() + + except Exception as e: + print(f'处理过程中出错: {e}') + finally: + if connection.is_connected(): + connection.close() + print('数据库连接已关闭') + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/launcher.bat b/launcher.bat new file mode 100644 index 0000000..bcc8640 --- /dev/null +++ b/launcher.bat @@ -0,0 +1,3 @@ +@echo off +cd /d %~dp0 +start "" "小红书内容采集器.exe" \ No newline at end of file diff --git a/playwright.md b/playwright.md new file mode 100644 index 0000000..ca9257c --- /dev/null +++ b/playwright.md @@ -0,0 +1,77 @@ +# Playwright 爬虫技术分析 + +## Playwright 简介 + +Playwright 是一个由微软开发的自动化浏览器测试工具,它可以: + +1. 自动控制 Chromium、Firefox 和 WebKit 浏览器 +2. 模拟真实用户操作 +3. 执行 JavaScript 代码 +4. 获取浏览器上下文和 Cookie + +## 使用 Playwright 爬取小红书的步骤 + +根据代码分析,主要步骤如下: + +1. **初始化浏览器环境** +```python +async with async_playwright() as playwright: + browser_context = await self.launch_browser( + playwright.chromium, + proxy=None, + headless=config.HEADLESS + ) +``` + +2. **登录获取 Cookie** +```python +# 扫码登录 +login_obj = XiaoHongShuLogin( + login_type=config.LOGIN_TYPE, + browser_context=self.browser_context, + context_page=self.context_page +) +await login_obj.begin() +``` + +3. **获取加密参数** +- 通过执行 JS 获取 X-s 等签名参数 +- 保留登录成功后的上下文环境 +- 避免了复杂的 JS 逆向过程 + +4. **发送请求获取数据** +```python +# 搜索笔记 +await self.xhs_client.search_notes( + keyword=keyword, + page=page, + sort=SearchSortType.GENERAL +) + +# 获取笔记详情 +await self.xhs_client.get_note_by_id(note_id) +``` + +5. **数据存储** +- 支持存储到 MySQL +- 支持导出为 CSV +- 支持导出为 JSON + +## 核心优势 + +1. 降低逆向难度 +- 不需要复现复杂的加密算法 +- 直接获取浏览器中的参数 + +2. 更真实的请求环境 +- 完整的浏览器环境 +- 真实的 Cookie 和请求头 + +3. 更稳定的爬取 +- 自动处理反爬验证 +- 支持 IP 代理池 +- 支持登录态缓存 + +这种方式通过 Playwright 模拟真实浏览器环境,大大降低了爬虫开发难度,是一种非常实用的爬虫技术方案。 + +需要注意的是,使用时要遵守平台规则,合理控制爬取频率,仅用于学习研究用途。 diff --git a/process_xhs_note.py b/process_xhs_note.py new file mode 100644 index 0000000..4c7ec03 --- /dev/null +++ b/process_xhs_note.py @@ -0,0 +1,40 @@ +import pandas as pd +import json +import os + +def excel_to_json(): + # 创建输出文件夹 + output_dir = 'sheet_notes' + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # 读取Excel文件 + excel_file = 'input.xlsx' + + # 获取所有sheet名称 + xl = pd.ExcelFile(excel_file) + sheet_names = xl.sheet_names + + # 处理每个sheet + for sheet_name in sheet_names: + # 读取当前sheet,跳过第一行,使用第二行作为列名 + df = pd.read_excel(excel_file, + sheet_name=sheet_name, + header=1) + + # 获取"笔记标题"列的内容 + if '笔记标题' in df.columns: + # 将笔记标题转换为列表,并去除空值 + notes = df['笔记标题'].dropna().tolist() + + # 保存为JSON文件 + output_file = os.path.join(output_dir, f'{sheet_name}.json') + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(notes, f, ensure_ascii=False, indent=4) + + print(f'已保存 {sheet_name} 的笔记标题到 {output_file}') + else: + print(f'警告: {sheet_name} 中没有找到"笔记标题"列') + +if __name__ == '__main__': + excel_to_json() \ No newline at end of file diff --git a/project.md b/project.md new file mode 100644 index 0000000..24d8013 --- /dev/null +++ b/project.md @@ -0,0 +1,81 @@ +# MediaCrawler 项目技术分析 + +## 总体架构 + +项目使用了 Playwright 作为核心技术来模拟浏览器行为,主要步骤包括: + +1. 使用 Playwright 启动浏览器并保持登录状态 +2. 通过执行 JS 获取加密参数 +3. 使用 httpx 发送请求获取数据 +4. 数据存储到 MySQL/CSV/JSON + +## 各平台实现细节 + +### 1. 抖音 + +核心技术: +- 使用 Playwright 模拟浏览器环境 +- 通过执行 JS 获取 X-Bogus 等加密参数 +- 支持搜索、指定视频、创作者主页爬取 +- 支持评论和二级评论获取 + +### 2. 小红书 + +核心技术: +- 使用 Playwright 获取登录态 +- 通过 JS 注入获取 X-s 等签名参数 +- 支持笔记搜索、指定笔记、用户主页爬取 +- 支持评论数据抓取 + +### 3. 快手 + +核心技术: +- 使用 Playwright 维持登录状态 +- 通过 JS 执行获取 _signature 等参数 +- 支持视频搜索和指定视频爬取 +- 支持评论数据抓取 + +### 4. B站 + +核心技术: +- Cookie 登录方式 +- wbi 签名参数构造 +- 支持视频搜索和指定视频爬取 +- 支持评论数据抓取 + +### 5. 微博 + +核心技术: +- 使用 Playwright 维持登录态 +- 通过 JS 获取加密参数 +- 支持微博搜索和指定微博爬取 +- 支持评论数据抓取 + +### 6. 贴吧 + +核心技术: +- Cookie 登录方式 +- 通过解析 HTML 获取数据 +- 支持帖子搜索和指定帖子爬取 +- 支持评论数据抓取 + +### 7. 知乎 + +核心技术: +- 使用 Playwright 维持登录态 +- 通过 JS 获取 x-zse-96 等参数 +- 支持回答搜索和指定回答爬取 +- 支持评论数据抓取 + +## 项目亮点 + +1. 使用抽象类设计,代码结构清晰 +2. 支持 IP 代理池 +3. 支持多账号登录 +4. 支持生成评论词云图 +5. 支持多种数据存储方式 +6. 使用 Playwright 降低逆向难度 + +这个项目通过巧妙使用 Playwright 来获取加密参数,大大降低了爬虫的开发难度,是一个非常实用的自媒体平台数据采集工具。 + +#63f85aee0000000013015174 \ No newline at end of file diff --git a/project.pdf b/project.pdf new file mode 100644 index 0000000..c4e6a6e Binary files /dev/null and b/project.pdf differ diff --git a/requirements.txt b/requirements.txt index 83a9c83..b3e1bd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,5 @@ -httpx==0.24.0 -Pillow==9.5.0 -playwright==1.42.0 -tenacity==8.2.2 -opencv-python -aiomysql==0.2.0 -redis~=4.6.0 -pydantic==2.5.2 -aiofiles~=23.2.1 -fastapi==0.110.2 -uvicorn==0.29.0 -python-dotenv==1.0.1 -jieba==0.42.1 -wordcloud==1.9.3 -matplotlib==3.9.0 -requests==2.32.3 -parsel==1.9.1 -pyexecjs==1.5.1 \ No newline at end of file +aiohttp +aiofiles +mysql-connector-python +PyQt6 +pyinstaller \ No newline at end of file diff --git a/xhs_crawler_gui.py b/xhs_crawler_gui.py new file mode 100644 index 0000000..807bdbd --- /dev/null +++ b/xhs_crawler_gui.py @@ -0,0 +1,1121 @@ +import sys +import os +import json +import asyncio +import subprocess +from datetime import datetime +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLineEdit, QPushButton, QProgressBar, + QScrollArea, QLabel, QFrame, QTabWidget, QComboBox) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl +from PyQt6.QtGui import QDesktopServices +import mysql.connector +import aiohttp +import aiofiles +from urllib.parse import urlparse + +# 数据库配置 +db_config = { + 'user': 'root', + 'password': 'zaq12wsx@9Xin', + 'host': '183.11.229.79', + 'port': 3316, + 'database': '9Xin', + 'auth_plugin': 'mysql_native_password' +} + +class NoteCard(QFrame): + """笔记卡片组件""" + def __init__(self, note_data, is_downloaded=False, parent=None): + super().__init__(parent) + self.note_data = note_data + self.is_downloaded = is_downloaded + self.setup_ui() + + def setup_ui(self): + self.setFrameStyle(QFrame.Shape.Box | QFrame.Shadow.Raised) + # 根据下载状态设置不同的背景色 + bg_color = "#f0f9ff" if self.is_downloaded else "white" + hover_color = "#e1f3ff" if self.is_downloaded else "#f0f0f0" + + self.setStyleSheet(f""" + NoteCard {{ + background-color: {bg_color}; + border-radius: 10px; + margin: 5px; + padding: 10px; + font-family: "Microsoft YaHei", Arial; + }} + NoteCard:hover {{ + background-color: {hover_color}; + }} + """) + + layout = QVBoxLayout(self) + + # 笔记类型和下载状态 + header_layout = QHBoxLayout() + type_label = QLabel("视频笔记" if self.note_data['type'] == 'video' else "图文笔记") + type_label.setStyleSheet("color: #666; font-size: 12px;") + header_layout.addWidget(type_label) + + # 添加下载状态标签 + status_label = QLabel("✓ 已下载" if self.is_downloaded else "⋯ 下载中") + status_label.setStyleSheet( + "color: #4CAF50;" if self.is_downloaded else "color: #FFA500;" + ) + header_layout.addWidget(status_label, alignment=Qt.AlignmentFlag.AlignRight) + layout.addLayout(header_layout) + + # 标题 + title = QLabel(self.note_data['title']) + title.setStyleSheet("font-size: 16px; font-weight: bold;") + title.setWordWrap(True) + layout.addWidget(title) + + # 描述 + desc = QLabel(self.note_data['description']) + desc.setWordWrap(True) + desc.setStyleSheet("color: #333;") + layout.addWidget(desc) + + # 统计信息 + stats_layout = QHBoxLayout() + stats = [ + f"❤️ {self.note_data['liked_count']}", + f"⭐ {self.note_data['collected_count']}", + f"💬 {self.note_data['comment_count']}", + f"↗️ {self.note_data['share_count']}" + ] + for stat in stats: + stat_label = QLabel(stat) + stat_label.setStyleSheet("color: #666; font-size: 12px;") + stats_layout.addWidget(stat_label) + layout.addLayout(stats_layout) + + # 标签 + if self.note_data['tag_list']: + tags = self.note_data['tag_list'].split(',') + tags_text = ' '.join([f'#{tag}' for tag in tags]) + tags_label = QLabel(tags_text) + tags_label.setStyleSheet("color: #0066cc; font-size: 12px;") + tags_label.setWordWrap(True) + layout.addWidget(tags_label) + + # 添加下载状态和进度区域 + self.status_area = QWidget() + status_layout = QHBoxLayout(self.status_area) + + # 下载进度标签 + self.progress_label = QLabel() + self.progress_label.setStyleSheet("color: #666; font-size: 12px;") + self.progress_label.setVisible(False) + status_layout.addWidget(self.progress_label) + + # 下载进度条 + self.progress_bar = QProgressBar() + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: 1px solid #ddd; + border-radius: 3px; + text-align: center; + height: 10px; + } + QProgressBar::chunk { + background-color: #1a73e8; + border-radius: 2px; + } + """) + self.progress_bar.setVisible(False) + status_layout.addWidget(self.progress_bar) + + layout.addWidget(self.status_area) + + # 重新下载按钮 + if not self.is_downloaded: + self.retry_button = QPushButton("重新下载") + self.retry_button.setStyleSheet(""" + QPushButton { + padding: 5px 10px; + background-color: #ff4d4f; + color: white; + border: none; + border-radius: 3px; + font-size: 12px; + } + QPushButton:hover { + background-color: #ff7875; + } + QPushButton:disabled { + background-color: #ffccc7; + } + """) + self.retry_button.clicked.connect(self.retry_download) + layout.addWidget(self.retry_button) + + def retry_download(self): + # 发送信号给主窗口处理下载 + parent = self.parent() + while parent and not isinstance(parent, MainWindow): + parent = parent.parent() + if parent: + parent.retry_download(self.note_data['note_id']) + + def update_progress(self, value, message): + """更新下载进度""" + self.progress_bar.setValue(value) + self.progress_label.setText(message) + self.progress_bar.setVisible(True) + self.progress_label.setVisible(True) + + def set_downloading(self, is_downloading): + """设置下载状态""" + if hasattr(self, 'retry_button'): + self.retry_button.setEnabled(not is_downloading) + self.retry_button.setText("下载中..." if is_downloading else "重新下载") + + def show_error(self, message): + """显示错误信息""" + self.progress_label.setText(f"错误: {message}") + self.progress_label.setStyleSheet("color: red;") + self.progress_label.setVisible(True) + self.progress_bar.setVisible(False) + + def download_complete(self): + """下载完成""" + self.progress_label.setText("下载完成") + self.progress_label.setStyleSheet("color: #4CAF50;") + self.progress_bar.setVisible(False) + self.is_downloaded = True + if hasattr(self, 'retry_button'): + self.retry_button.setVisible(False) + + def mousePressEvent(self, event): + """处理鼠标点击事件""" + if event.button() == Qt.MouseButton.LeftButton: + url = QUrl(self.note_data['note_url']) + QDesktopServices.openUrl(url) + +class WorkerThread(QThread): + """工作线程""" + progress = pyqtSignal(int, str) + status_update = pyqtSignal(str) # 新增:用于显示详细状态 + download_progress = pyqtSignal(str, bool) # 新增:用于更新下载状态 + finished = pyqtSignal(list) + error = pyqtSignal(str) + data_imported = pyqtSignal() + + # 添加类变量 + MAX_RETRIES = 5 # 最大重试次数 + DOWNLOAD_TIMEOUT = 300 # 下载超时时间(秒) + RETRY_DELAY = 2 # 重试延迟时间(秒) + CHUNK_SIZE = 8192 # 分块下载大小 + + def __init__(self, keywords): + super().__init__() + self.keywords = keywords + self.retry_note_id = None # 添加重试笔记ID属性 + + def run(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + if self.retry_note_id: + # 重新下载单个笔记 + records = loop.run_until_complete(self.download_single_note(self.retry_note_id)) + self.progress.emit(100, "处理完成") + self.finished.emit(records) + else: + # 原有的完整搜索流程 + if loop.run_until_complete(self.run_crawler()): + if loop.run_until_complete(self.import_to_db()): + self.data_imported.emit() + records = loop.run_until_complete(self.download_media()) + self.progress.emit(100, "处理完成") + self.finished.emit(records) + finally: + loop.close() + + async def download_single_note(self, note_id): + """下载单个笔记的媒体文件""" + try: + self.progress.emit(0, "开始下载...") + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT note_id, image_list, video_url + FROM xhs_notes + WHERE note_id = %s + """, (note_id,)) + record = cursor.fetchone() + + if record: + timeout = aiohttp.ClientTimeout(total=300) + conn_kwargs = {'timeout': timeout, 'ssl': False} + + async with aiohttp.ClientSession(**conn_kwargs) as session: + base_dir = f'./data/xhs/json/media/{record["note_id"]}' + os.makedirs(base_dir, exist_ok=True) + + total_files = 0 + completed_files = 0 + + # 计算总文件数 + if record['image_list']: + total_files += len([url for url in record['image_list'].split(',') if url.strip()]) + if record['video_url'] and record['video_url'].strip(): + total_files += 1 + + if total_files == 0: + self.progress.emit(100, "没有需要下载的文件") + return [record] + + # 下载图片 + if record['image_list']: + image_urls = record['image_list'].split(',') + for i, url in enumerate(image_urls): + if url.strip(): + ext = os.path.splitext(urlparse(url).path)[1] or '.jpg' + save_path = os.path.join(base_dir, f'image_{i+1}{ext}') + + self.status_update.emit(f"下载图片 {i+1}/{len(image_urls)}") + if await self.download_file_with_retry(session, url.strip(), save_path, note_id): + completed_files += 1 + progress = int(completed_files / total_files * 100) + self.progress.emit(progress, f"已完成: {completed_files}/{total_files}") + + # 下载视频 + if record['video_url'] and record['video_url'].strip(): + url = record['video_url'].strip() + ext = os.path.splitext(urlparse(url).path)[1] or '.mp4' + save_path = os.path.join(base_dir, f'video{ext}') + + self.status_update.emit("下载视频...") + if await self.download_file_with_retry(session, url, save_path, note_id, True): + completed_files += 1 + progress = int(completed_files / total_files * 100) + self.progress.emit(progress, f"已完成: {completed_files}/{total_files}") + + # 检查下载完成状态并更新数据库 + await self.check_download_complete(note_id) + + self.progress.emit(100, "下载完成") + return [record] + else: + self.error.emit("找不到指定的笔记") + return [] + + except Exception as e: + self.error.emit(str(e)) + return [] + finally: + if 'conn' in locals(): + conn.close() + + async def run_crawler(self): + """运行爬虫""" + try: + self.progress.emit(10, "启动爬虫...") + process = await asyncio.create_subprocess_exec( + 'python', 'main.py', '--platform', 'xhs', '--lt', 'qrcode', + '--keywords', self.keywords, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=1024*1024 # 增加缓冲区大小到1MB + ) + + # 实时读取爬虫输出 + async def read_stream(stream): + buffer = "" + while True: + try: + chunk = await stream.read(8192) # 每次读取8KB + if not chunk: + break + text = chunk.decode('utf-8', errors='ignore') + buffer += text + + # 按行处理缓冲区 + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if line: + self.status_update.emit(f"爬虫进度: {line}") + + except Exception as e: + print(f"读取爬虫输出错误: {str(e)}") + continue + + # 处理最后可能剩余的内容 + if buffer.strip(): + self.status_update.emit(f"爬虫进度: {buffer.strip()}") + + # 同时读取stdout和stderr + await asyncio.gather( + read_stream(process.stdout), + read_stream(process.stderr) + ) + + await process.wait() + self.progress.emit(30, "爬虫数据获取完成") + return True + except Exception as e: + self.error.emit(f"爬虫执行失败: {str(e)}") + return False + + async def import_to_db(self): + """导入数据到据库""" + try: + self.progress.emit(40, "导入数据到数据库...") + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + # 修改JSON文件路径 + json_path = f'./data/xhs/json/search_contents_{datetime.now().strftime("%Y-%m-%d")}.json' + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 插入数据 + for item in data: + # 检查记录是否存在 + cursor.execute("SELECT COUNT(*) FROM xhs_notes WHERE note_id = %s", (item['note_id'],)) + if cursor.fetchone()['COUNT(*)'] == 0: + # 插入新记录 + insert_query = """INSERT INTO xhs_notes ( + note_id, type, title, description, video_url, time, + last_update_time, user_id, nickname, avatar, + liked_count, collected_count, comment_count, share_count, + ip_location, image_list, tag_list, last_modify_ts, + note_url, source_keyword + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""" + values = ( + item.get('note_id'), item.get('type'), item.get('title'), + item.get('desc'), item.get('video_url'), item.get('time'), + item.get('last_update_time'), item.get('user_id'), + item.get('nickname'), item.get('avatar'), + item.get('liked_count'), item.get('collected_count'), + item.get('comment_count'), item.get('share_count'), + item.get('ip_location'), item.get('image_list'), + item.get('tag_list'), item.get('last_modify_ts'), + item.get('note_url'), self.keywords + ) + cursor.execute(insert_query, values) + + conn.commit() + self.progress.emit(60, "数导入完成") + return True + except Exception as e: + self.error.emit(f"数据导入失败: {str(e)}") + return False + finally: + if 'conn' in locals(): + conn.close() + + async def download_file_with_retry(self, session, url, save_path, note_id, is_video=False): + """带重试的文件下载""" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Referer': 'https://www.xiaohongshu.com/', + } + + success = False + for attempt in range(self.MAX_RETRIES): + try: + async with session.get(url, headers=headers, timeout=self.DOWNLOAD_TIMEOUT) as response: + if response.status == 200: + # 检查内容类型 + content_type = response.headers.get('content-type', '') + if 'video' not in content_type.lower() and url.endswith(('.mp4', '.m3u8')): + # 尝试处理重定向 + location = response.headers.get('location') + if location: + url = location + continue + + total_size = int(response.headers.get('content-length', 0)) + if total_size == 0: + print(f"警告:{url} 的内容长度为0") + return False + + # 分块下载 + async with aiofiles.open(save_path, 'wb') as f: + downloaded = 0 + async for chunk in response.content.iter_chunked(self.CHUNK_SIZE): + await f.write(chunk) + downloaded += len(chunk) + + # 验证文件大小 + if os.path.getsize(save_path) > 0: + success = True + break + else: + print(f"下载的文件大小为0: {url}") + if os.path.exists(save_path): + os.remove(save_path) + + elif response.status in [301, 302, 303, 307, 308]: + # 处理重定向 + url = str(response.url) + continue + else: + print(f"下载失败,状态码: {response.status}, URL: {url}") + + except asyncio.TimeoutError: + print(f"下载超时 {url}, 尝试次数 {attempt + 1}/{self.MAX_RETRIES}") + if attempt < self.MAX_RETRIES - 1: + await asyncio.sleep(self.RETRY_DELAY) + continue + except Exception as e: + print(f"下载出错 {url}: {str(e)}, 尝试次数 {attempt + 1}/{self.MAX_RETRIES}") + if attempt < self.MAX_RETRIES - 1: + await asyncio.sleep(self.RETRY_DELAY) + continue + + if success: + # 只有在成功下载后才检查是否所有文件都下载完成 + await self.check_download_complete(note_id) + + return success + + async def check_download_complete(self, note_id): + """检查笔记的所有媒体是否下载完成""" + try: + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + # 获取笔记信息 + cursor.execute(""" + SELECT image_list, video_url + FROM xhs_notes + WHERE note_id = %s + """, (note_id,)) + record = cursor.fetchone() + + if not record: + return + + is_complete = True + media_dir = f'./data/xhs/json/media/{note_id}' + + # 检查图片 + if record['image_list']: + image_urls = record['image_list'].split(',') + for i, url in enumerate(image_urls): + if url.strip(): + ext = os.path.splitext(urlparse(url).path)[1] or '.jpg' + image_path = os.path.join(media_dir, f'image_{i+1}{ext}') + if not os.path.exists(image_path) or os.path.getsize(image_path) == 0: + is_complete = False + break + + # 检查视频 + if record['video_url'] and record['video_url'].strip(): + url = record['video_url'].strip() + ext = os.path.splitext(urlparse(url).path)[1] or '.mp4' + video_path = os.path.join(media_dir, f'video{ext}') + if not os.path.exists(video_path) or os.path.getsize(video_path) == 0: + is_complete = False + + # 更新数据库 + cursor.execute(""" + UPDATE xhs_notes + SET download_flag = %s + WHERE note_id = %s + """, (is_complete, note_id)) + + conn.commit() + + if is_complete: + self.download_progress.emit(note_id, True) + + except Exception as e: + print(f"检查下载状态时出错: {e}") + finally: + if 'conn' in locals(): + conn.close() + + async def download_media(self): + """下载媒体文件""" + try: + self.progress.emit(70, "开始下载媒体文件...") + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT note_id, image_list, video_url + FROM xhs_notes + WHERE source_keyword = %s + """, (self.keywords,)) + records = cursor.fetchall() + + # 创建下载任务列表 + download_tasks = [] + timeout = aiohttp.ClientTimeout(total=300) # 增加超时时间到5分钟 + + conn_kwargs = { + 'timeout': timeout, + 'ssl': False, # 禁用SSL验证 + } + + async with aiohttp.ClientSession(**conn_kwargs) as session: + for record in records: + self.status_update.emit(f"处理笔记: {record['note_id']}") + base_dir = f'./data/xhs/json/media/{record["note_id"]}' + os.makedirs(base_dir, exist_ok=True) + + # 处理图下载任务 + if record['image_list']: + image_urls = record['image_list'].split(',') + for i, url in enumerate(image_urls): + if url.strip(): + ext = os.path.splitext(urlparse(url).path)[1] or '.jpg' + save_path = os.path.join(base_dir, f'image_{i+1}{ext}') + if not os.path.exists(save_path): # 避免重复下载 + task = self.download_file_with_retry( + session, url.strip(), save_path, record['note_id'], False + ) + download_tasks.append(task) + + # 处理视频下载任务 + if record['video_url'] and record['video_url'].strip(): + url = record['video_url'].strip() + ext = os.path.splitext(urlparse(url).path)[1] or '.mp4' + save_path = os.path.join(base_dir, f'video{ext}') + if not os.path.exists(save_path): # 避免重复下载 + task = self.download_file_with_retry( + session, url, save_path, record['note_id'], True + ) + download_tasks.append(task) + + # 分批执行下载任务 + batch_size = 3 # 少并发数 + total_tasks = len(download_tasks) + completed = 0 + + for i in range(0, len(download_tasks), batch_size): + batch = download_tasks[i:i + batch_size] + results = await asyncio.gather(*batch, return_exceptions=True) + completed += len(batch) + success_count = sum(1 for r in results if r is True) + + progress = int(70 + (completed / total_tasks) * 20) + self.progress.emit(progress, f"已下载: {completed}/{total_tasks}") + self.status_update.emit( + f"下载进度: {completed}/{total_tasks} (成功: {success_count})" + ) + await asyncio.sleep(1) + + self.progress.emit(90, "媒体文件下载完成") + return records + + except Exception as e: + self.error.emit(f"媒体下载失败: {str(e)}") + return [] + finally: + if 'conn' in locals(): + conn.close() + +class BatchDownloadWorker(QThread): + progress = pyqtSignal(int, int, int, list) # current, total, success_count, failed_ids + error = pyqtSignal(str) + finished = pyqtSignal() + + def __init__(self, note_ids): + super().__init__() + self.note_ids = note_ids + + def run(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + failed_ids = [] + success_count = 0 + total = len(self.note_ids) + + for i, note_id in enumerate(self.note_ids, 1): + try: + # 下载单个笔记的媒体文件 + worker = WorkerThread(None) + worker.retry_note_id = note_id + success = loop.run_until_complete(worker.download_single_note(note_id)) + + if success: + success_count += 1 + else: + failed_ids.append(note_id) + + except Exception as e: + print(f"下载记录 {note_id} 失败: {str(e)}") + failed_ids.append(note_id) + + self.progress.emit(i, total, success_count, failed_ids) + + self.finished.emit() + + except Exception as e: + self.error.emit(str(e)) + finally: + loop.close() + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("小红书内容采集器") + self.setMinimumSize(1200, 800) + + os.makedirs('./data/xhs/json/media', exist_ok=True) + self.setup_ui() + + # 初始化时加载所有记录 + self.filter_records() # 这里会加载所有记录,因为搜索框为空且类型过滤为"全部笔记" + + def setup_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + + # 创建标签页 + self.tab_widget = QTabWidget() + layout.addWidget(self.tab_widget) + + # 据展示标签页 + self.setup_data_tab() + + # 搜索标签页 + self.setup_search_tab() + + def setup_data_tab(self): + data_tab = QWidget() + layout = QVBoxLayout(data_tab) + + # 搜索和过滤区域 + filter_layout = QHBoxLayout() + + # 搜索输入框 + self.db_search_input = QLineEdit() + self.db_search_input.setPlaceholderText("搜索标题或描述...") + self.db_search_input.setStyleSheet(self.get_input_style()) + self.db_search_input.returnPressed.connect(self.filter_records) + filter_layout.addWidget(self.db_search_input) + + # 类型过滤下拉框 + self.type_filter = QComboBox() + self.type_filter.addItems(["全部笔记", "仅看��文", "仅看视频"]) + self.type_filter.setStyleSheet(""" + QComboBox { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + font-family: "Microsoft YaHei", Arial; + } + """) + self.type_filter.currentIndexChanged.connect(self.filter_records) + filter_layout.addWidget(self.type_filter) + + # 搜索按钮 + db_search_button = QPushButton("搜索") + db_search_button.setStyleSheet(self.get_button_style()) + db_search_button.clicked.connect(self.filter_records) + filter_layout.addWidget(db_search_button) + + # AI处理按钮 + ai_process_button = QPushButton("AI处理") + ai_process_button.setStyleSheet(""" + QPushButton { + padding: 8px 16px; + background-color: #52c41a; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-family: "Microsoft YaHei", Arial; + } + QPushButton:hover { + background-color: #73d13d; + } + """) + ai_process_button.clicked.connect(self.start_ai_process) # 预留AI处理功能 + filter_layout.addWidget(ai_process_button) + + layout.addLayout(filter_layout) + + # 记录统计 + self.total_count_label = QLabel() + self.total_count_label.setStyleSheet("color: #666; margin: 5px 0;") + layout.addWidget(self.total_count_label) + + # 记录列表 + self.records_scroll = QScrollArea() + self.records_scroll.setWidgetResizable(True) + self.records_content = QWidget() + self.records_layout = QVBoxLayout(self.records_content) + self.records_scroll.setWidget(self.records_content) + layout.addWidget(self.records_scroll) + + self.tab_widget.addTab(data_tab, "数据展示") + + def setup_search_tab(self): + search_tab = QWidget() + layout = QVBoxLayout(search_tab) + + # 搜索区域 + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("输入搜索关键词") + self.search_input.setStyleSheet(self.get_input_style()) + self.search_button = QPushButton("搜索") + self.search_button.setStyleSheet(self.get_button_style()) + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.search_button) + layout.addLayout(search_layout) + + # 进度信息 + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.status_label = QLabel() + self.status_label.setVisible(False) + layout.addWidget(self.status_label) + + self.detail_status_label = QLabel() + self.detail_status_label.setStyleSheet("color: #666; font-size: 12px;") + self.detail_status_label.setVisible(False) + layout.addWidget(self.detail_status_label) + + # 搜索结果 + self.result_count_label = QLabel() + self.result_count_label.setStyleSheet("color: #666; margin: 5px 0;") + self.result_count_label.setVisible(False) + layout.addWidget(self.result_count_label) + + self.search_scroll = QScrollArea() + self.search_scroll.setWidgetResizable(True) + self.search_content = QWidget() + self.search_layout = QVBoxLayout(self.search_content) + self.search_scroll.setWidget(self.search_content) + layout.addWidget(self.search_scroll) + + self.tab_widget.addTab(search_tab, "搜索笔记") + + # 连接信号 + self.search_button.clicked.connect(self.start_search) + + def filter_records(self): + """根据搜索条件过滤记录""" + try: + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + # 构建查询条件 + search_text = self.db_search_input.text().strip() + type_filter = self.type_filter.currentText() + + query = """ + SELECT *, + COALESCE(download_flag, FALSE) as is_downloaded + FROM xhs_notes + WHERE download_flag = TRUE # 只显示已下载完成的记录 + """ + params = [] + + if search_text: + query += """ AND (title LIKE %s OR description LIKE %s)""" + params.extend([f'%{search_text}%', f'%{search_text}%']) + + if type_filter == "仅看图文": + query += """ AND type = 'normal'""" + elif type_filter == "仅看视频": + query += """ AND type = 'video'""" + + query += " ORDER BY time DESC" + + cursor.execute(query, params) + records = cursor.fetchall() + + # 更新显示 + self.total_count_label.setText(f'找到 {len(records)} 条已下载记录') + self.clear_records() + + for record in records: + card = NoteCard(record, True) # 所有显示的记录都是已下载的 + self.records_layout.addWidget(card) + + except Exception as e: + self.show_error(f"过滤记录失败: {str(e)}") + finally: + if 'conn' in locals(): + conn.close() + + def retry_download(self, note_id): + """重新下载指定笔记的媒体文件""" + # 到对应的卡片 + card = self.find_card_by_note_id(note_id) + if not card: + return + + # 更新卡片状态 + card.set_downloading(True) + card.update_progress(0, "准备下载...") + + # 创建工作线程 + self.worker = WorkerThread(None) + self.worker.progress.connect(lambda v, m: card.update_progress(v, m)) + self.worker.finished.connect(lambda: self.on_retry_complete(note_id)) + self.worker.error.connect(lambda e: self.on_retry_error(note_id, e)) + self.worker.status_update.connect(lambda m: card.update_progress(-1, m)) + + # 启动单个笔记的下载 + self.worker.retry_note_id = note_id + self.worker.start() + + def find_card_by_note_id(self, note_id): + """在两个标签页中查找指定note_id的卡片""" + # 在数据展示页查找 + for i in range(self.records_layout.count()): + card = self.records_layout.itemAt(i).widget() + if isinstance(card, NoteCard) and card.note_data['note_id'] == note_id: + return card + + # 在搜索结果页查找 + for i in range(self.search_layout.count()): + card = self.search_layout.itemAt(i).widget() + if isinstance(card, NoteCard) and card.note_data['note_id'] == note_id: + return card + + return None + + def on_retry_complete(self, note_id): + """重新下载完成的处理""" + card = self.find_card_by_note_id(note_id) + if card: + card.download_complete() + # 刷新显示 + self.filter_records() + current_keyword = self.search_input.text().strip() + if current_keyword: + self.show_search_results(current_keyword) + + def on_retry_error(self, note_id, error_message): + """重新下载出错的处理""" + card = self.find_card_by_note_id(note_id) + if card: + card.set_downloading(False) + card.show_error(error_message) + + def get_button_style(self): + """获取按钮样式""" + return """ + QPushButton { + padding: 8px 16px; + background-color: #1a73e8; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-family: "Microsoft YaHei", Arial; + } + QPushButton:hover { + background-color: #1557b0; + } + """ + + def get_input_style(self): + """获取输入框样式""" + return """ + QLineEdit { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + font-family: "Microsoft YaHei", Arial; + } + """ + + def get_label_style(self): + """获取标签样式""" + return """ + QLabel { + font-size: 14px; + color: #333; + font-family: "Microsoft YaHei", Arial; + } + """ + + def start_search(self): + """开始搜索""" + keywords = self.search_input.text().strip() + if not keywords: + return + + # 清空搜索结果 + self.clear_search_results() + + # 显示进度条 + self.progress_bar.setVisible(True) + self.status_label.setVisible(True) + self.search_button.setEnabled(False) + + # 创建工作线程 + self.worker = WorkerThread(keywords) + self.worker.progress.connect(self.update_progress) + self.worker.finished.connect(self.on_download_complete) + self.worker.error.connect(self.show_error) + self.worker.status_update.connect(self.update_status_detail) + self.worker.download_progress.connect(self.update_download_status) + self.worker.data_imported.connect(self.on_data_imported) + self.worker.start() + + def clear_search_results(self): + """清空搜索结果区域""" + while self.search_layout.count(): + item = self.search_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self.result_count_label.setVisible(False) + + def clear_records(self): + """清空记录区域""" + while self.records_layout.count(): + item = self.records_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def update_progress(self, value, message): + """更新进度条和状态信息""" + self.progress_bar.setValue(value) + self.status_label.setText(message) + self.status_label.setVisible(True) + + def update_status_detail(self, message): + """更新详细状态信息""" + self.detail_status_label.setText(message) + self.detail_status_label.setVisible(True) + + def update_download_status(self, note_id, is_complete): + """更新下载状态""" + if is_complete: + # 刷新显示 + self.filter_records() # 刷新数据展示页 + current_keyword = self.search_input.text().strip() + if current_keyword: # 如果有搜索关键词,也刷新搜索结果 + self.show_search_results(current_keyword) + + def on_data_imported(self): + """数据导入完成后的处理""" + current_keyword = self.search_input.text().strip() + self.show_search_results(current_keyword) + self.filter_records() # 同时刷新数据展示页 + + def on_download_complete(self, records): + """下载完成后的处理""" + self.search_button.setEnabled(True) + self.progress_bar.setVisible(False) + self.status_label.setVisible(False) + self.filter_records() # 刷新数据展示页 + + def show_search_results(self, keyword): + """显示搜索结果""" + try: + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT *, + COALESCE(download_flag, FALSE) as is_downloaded + FROM xhs_notes + WHERE source_keyword = %s + ORDER BY time DESC + """, (keyword,)) + records = cursor.fetchall() + + # 显示搜索结果统计 + self.result_count_label.setText(f'关键词 "{keyword}" 的搜索结果:{len(records)} 条') + self.result_count_label.setVisible(True) + + # 清空搜索结果区域 + self.clear_search_results() + + # 创建笔记卡片 + for record in records: + card = NoteCard(record, record['is_downloaded']) + self.search_layout.addWidget(card) + + except Exception as e: + self.show_error(f"获取数据失败: {str(e)}") + finally: + if 'conn' in locals(): + conn.close() + + def show_error(self, message): + """显示错误信息""" + self.status_label.setText(f"错误: {message}") + self.status_label.setStyleSheet("color: red;") + self.status_label.setVisible(True) + self.search_button.setEnabled(True) + + def start_batch_download(self): + """开始批量下载未完成的记录""" + try: + conn = mysql.connector.connect(**db_config) + cursor = conn.cursor(dictionary=True) + + # 获取未下载完成的记录 + cursor.execute(""" + SELECT note_id + FROM xhs_notes + WHERE COALESCE(download_flag, FALSE) = FALSE + """) + unfinished_records = cursor.fetchall() + + if not unfinished_records: + self.show_status_message("没有需要下载的记录") + return + + # 显示开始下载的消息 + self.show_status_message(f"开始下载 {len(unfinished_records)} 条未完成记录...") + + # 创建批量下载工作线程 + self.batch_worker = BatchDownloadWorker([r['note_id'] for r in unfinished_records]) + self.batch_worker.progress.connect(self.update_batch_progress) + self.batch_worker.error.connect(self.show_error) + self.batch_worker.finished.connect(self.on_batch_download_complete) + self.batch_worker.start() + + except Exception as e: + self.show_error(f"获取未完成记录失败: {str(e)}") + finally: + if 'conn' in locals(): + conn.close() + + def update_batch_progress(self, current, total, success_count, failed_ids): + """更新批量下载进度""" + self.show_status_message(f"正在下载: {current}/{total} (成功: {success_count})") + if failed_ids: + failed_text = "下载失败的记录:\n" + "\n".join(failed_ids) + self.failed_records_label.setText(failed_text) + self.failed_records_label.setVisible(True) + + def on_batch_download_complete(self): + """批量下载完成的处理""" + self.show_status_message("批量下载完成") + self.filter_records() # 刷新显示 + + def show_status_message(self, message): + """显示状态消息""" + self.total_count_label.setText(message) + + def start_ai_process(self): + """AI处理功能(预留)""" + # TODO: 实现AI处理功能 + self.show_status_message("AI处理功能开发中...") + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/向量化小红书笔记流程图.md b/向量化小红书笔记流程图.md new file mode 100644 index 0000000..527dd1c --- /dev/null +++ b/向量化小红书笔记流程图.md @@ -0,0 +1,116 @@ +# 向量化小红书笔记流程及注意事项 + +## 正向过程(笔记内容向量化) +```mermaid +flowchart TD + A[开始] --> B[连接MySQL数据库] + B --> C[查询xhs_notes表
type='normal'的笔记] + C --> D[初始化FAISS向量存储] + + subgraph 笔记处理循环 + E[获取单条笔记数据
note_id, title, description] --> F[合并标题和描述] + F --> G[文本分割
chunk_size=120
overlap=20] + G --> H[创建新的向量存储new_vs] + end + + subgraph FAISS向量存储内部结构 + H --> I1[FAISS索引
存储向量数据] + H --> I2[DocStore内部字典
key: vector_id自动生成
value: Document对象] + end + + subgraph 向量存储处理 + I2 --> J[遍历DocStore字典
获取vector_id和文档内容] + J --> K[生成content_hash] + K --> L{检查content_hash
是否存在} + L -->|不存在| M[存入vector_store表
关联字段:
note_id
vector_id
content
content_hash] + L -->|存在| N[跳过] + M --> O[合并到主向量存储] + N --> O + end + + O --> P[保存最终向量存储
到本地文件] + P --> Q[结束] + + subgraph 数据关系说明 + R[xhs_notes表] -.->|note_id关联| S[vector_store表] + S -.->|vector_id关联| T[FAISS DocStore] + T -.->|自动生成| U[UUID格式的vector_id] + end +``` + + + +1. vector_id 的生成过程: +- 当使用 FAISS.from_texts() 创建新的向量存储时,FAISS 会自动为每个文本片段生成一个 UUID 格式的 vector_id +这个 vector_id 存储在 FAISS 的 DocStore 内部字典中作为 key +代码中通过 new_vs.docstore._dict.items() 可以获取这些 vector_id + +2. 数据结构关系: +```json +new_vs.docstore._dict = { + "vector_id_1": Document(page_content="文本内容1"), + "vector_id_2": Document(page_content="文本内容2"), + ... +} +``` + +3. 存储过程: +- FAISS 索引存储实际的向量数据 +- DocStore 存储 vector_id 和文本内容的映射 +MySQL 中的 vector_store 表存储 vector_id、note_id 和文本内容的关联关系 + +这样,通过 vector_id 就可以: +- 在 FAISS 中找到对应的向量 +- 在 DocStore 中找到原始文本 +- 在数据库中找到对应的笔记 ID + + +## 反向过程(通过用户输入的文本,检索出对应的笔记) + +```mermaid +flowchart TD + A[开始] --> B[加载FAISS向量存储] + B --> C[向量化用户查询] + C --> D[执行相似度搜索
k=10条结果] + + subgraph 向量搜索结果处理 + D --> E[遍历搜索结果
doc, score] + E --> F[计算文本内容的
content_hash] + F --> G[查询vector_store表
通过content_hash
获取note_id] + end + + subgraph 笔记数据获取 + G --> H{note_id是否
已处理?} + H -->|否| I[查询xhs_notes表
获取笔记详情] + H -->|是| J[跳过] + I --> K[获取清洗后的
笔记内容] + K --> L[构建完整笔记数据] + end + + subgraph 数据整合 + L --> M[收集上下文内容
用于GPT] + L --> N[收集完整笔记数据
用于前端展示] + end + + M --> O[调用GPT生成回答] + N --> P[返回GPT回答和笔记数据] + P --> Q[结束] + + subgraph 数据结构关系 + R[FAISS向量库] -.->|相似度搜索| S[文本内容] + S -.->|content_hash| T[vector_store表] + T -.->|note_id| U[xhs_notes表] + end + + subgraph 笔记数据结构 + V[完整笔记数据包含:] + V --> V1[note_id] + V --> V2[标题] + V --> V3[描述] + V --> V4[收藏数] + V --> V5[评论数] + V --> V6[分享数] + V --> V7[清洗后内容] + end +``` +