diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..05351bde --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,34 @@ +--- +name: Bug报修 +about: 向程序开发者申报bug +title: '' +labels: bug +assignees: '' + +--- + +感谢您申报bug,为了表示感谢,如果bug确实存在,您将出现在本项目的贡献者列表里;如果您不但发现了bug,还提供了很好的解决方案,我们会邀请您以pull request的方式成为本项目的代码贡献者(Contributor);如果您多次提供很好的pull request,我们将邀请您成为本项目的协助者(Collaborator)。当然,是否提供解决方按都是自愿的。不管是否是真正的bug、是否提供解决方案,我们都感谢您对本项目的帮助。 + +- 问:请您指明哪个版本出了bug(github版/PyPi版/全部)? + +答: + +- 问:您使用的是否是最新的程序(是/否)? + +答: + +- 问:爬取任意用户都会复现此bug吗(是/否)? + +答: + +- 问:若只有爬特定微博时才出bug,能否提供出错微博的weibo_id或url(非必填)? + +答: + +- 问:若您已提供出错微博的weibo_id或url,可忽略此内容,否则能否提供出错账号的**user_id**及您配置的**since_date**,方便我们定位出错微博(非必填)? + +答: + +- 问:如果方便,请您描述bug详情,如果代码报错,最好附上错误提示。 + +答: diff --git a/.github/ISSUE_TEMPLATE/failed.md b/.github/ISSUE_TEMPLATE/failed.md new file mode 100644 index 00000000..dfd47ac6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/failed.md @@ -0,0 +1,34 @@ +--- +name: 程序运行出错 +about: 运行出错,需要帮助 +title: '' +labels: failed +assignees: '' + +--- + +为了更好的解决问题,请认真回答下面的问题。等到问题解决,请及时关闭本issue。 + +- 问:请您指明哪个版本运行出错(github版/PyPi版/全部)? + +答: + +- 问:您使用的是否是最新的程序(是/否)? + +答: + +- 问:爬取任意用户都会运行出错吗(是/否)? + +答: + +- 问:若只有爬特定微博时才出错,能否提供出错微博的weibo_id或url(非必填)? + +答: + +- 问:若您已提供出错微博的weibo_id或url,可忽略此内容,否则能否提供出错账号的**user_id**及您配置的**since_date**,方便我们定位出错微博(非必填)? + +答: + +- 问:如果方便,请您描述出错详情,最好附上错误提示。 + +答: diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 00000000..60f26098 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,16 @@ +--- +name: 新需求或建议 +about: 建议开发新功能,或虽然没有新需求但对本项目有其它建议 +title: '' +labels: 'feature' +assignees: '' + +--- + +- 问:请说明需要什么新功能。 + +答: + +- 问:请说明添加该功能的意义。(非必填) + +答: diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md new file mode 100644 index 00000000..c8d4e001 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.md @@ -0,0 +1,10 @@ +--- +name: 其它问题 +about: 其它想讨论的问题 +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..9e5f5ca7 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,30 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 + +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - to do + +# Set to true to ignore issues with an assignee +exemptAssignees: true + +# Label to use when marking an issue as stale +staleLabel: wontfix + +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: > + Closing as stale, please reopen if you'd like to work on this further. + +# Limit to only `issues` or `pulls` +only: issues diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..c7f50674 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,36 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d4593149 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.vscode + +*.pyc +__pycache__ + +build/ +dist/ +*.egg-info + +config.json + +weibo/ +weibo.db +*.log + +.idea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c2077636 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# 为本项目做贡献 + +本项目使用**Python3**编写,感谢大家对项目的支持,也欢迎大家为开源项目做贡献。鉴于大家拥有不同的技能、经验、认知、时间等,每个人可以根据自身的情况为本项目贡献力量。我们不会因为贡献者写的代码少或者提的建议不好而失去感恩之心,每一个乐于奉献的人都值得并且应该被尊重。所以,如果您觉得自己的代码或建议不好,而不好意思去贡献,这样可能就让本项目失去了一次变得更好的机会。所以,如果您有好的想法、建议,或者发现了bug,欢迎通过issue提出来,这也是一种贡献方式。如果您想要为本项目贡献代码,我们也非常欢迎。最开始您可以通过pull request方式提交代码,如果我们发现您的代码质量非常高,或者非常有想法等,我们会邀请您请成为本项目的协作者([Collaborator](https://help.github.com/cn/github/setting-up-and-managing-your-github-user-account/permission-levels-for-a-user-account-repository#collaborator-access-on-a-repository-owned-by-a-user-account)),这样您就可以直接向本项目提交代码了。在您贡献代码之前,请先阅读下面的说明,这会让您更好的贡献代码。 + +## 贡献代码之前 + +如果要开发新功能或者其它需要大量编写代码的修改,在开发之前最好发Issue说明一下。比如,“我准备开发xx新功能”或者“我想修改xx功能”之类的。因为要开发的功能不一定适合本项目,所以提前说明讨论,判断新功能或修改是否有必要。否则,费时费力写了很多代码,结果最后没有被采纳,可能会做一些无用功。 + +## Python风格规范(建议Python新手阅读) + +参考[Python风格规范](https://zh-google-styleguide.readthedocs.io/en/latest/google-python-styleguide/python_style_rules/) +或者[Python风格规范](https://github.com/zh-google-styleguide/zh-google-styleguide/blob/master/google-python-styleguide/python_style_rules.rst), +二者内容是一样的。 + +## git提交规范 + +参考[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +或者[Git提交规范](https://zhuanlan.zhihu.com/p/67804026),commit描述中文英文皆可,只要符合规范就好。 + +## git提交建议(可选) + +本建议是可选的,如果你觉得不合理,可以按自己的方式编写代码。建议每次提交都是代码改动较少的提交,如果新功能需要大量修改代码,建议将新功能分成几个小模块,每个模块提交一次。原因是这样更容易管理代码。比如,一个新功能包含几个模块。其中大部分模块都写的很好,但是有一个模块有bug。分模块提交只需要单独处理出问题的模块,其他模块不受影响。 + +## Python之linter + +本项目使用flake8。 + +## Python之formatter + +本项目使用yapf。 + +## 引号的使用 + +代码中**建议使用单引号**,只有在特殊情况下使用双引号,如类、方法、函数等开头的注释使用6个双引号包裹(注释左边三个双引号,右边三个双引号),或者字符串中中已经包含单引号了,则要用双引号包裹。 + +## 避免过多的模块依赖 + +除非有必要,尽量少使用非内置的模块,因为会增加用户的安装成本,当然如果该模块能够为本项目或用户带来很多便利,则可以使用。 diff --git a/README.md b/README.md index d35ae525..7da707fa 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,274 @@ -# 功能 -爬取新浪微博信息:爬取微博信息,并写入文件,文件结果如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/weibotxt.png) +[![Build Status](https://github.com/dataabc/weiboSpider/workflows/Python%20application/badge.svg)](https://badge.fury.io/py/weibo-spider) +[![Python](https://img.shields.io/pypi/pyversions/weibo-spider)](https://badge.fury.io/py/weibo-spider) +[![PyPI](https://badge.fury.io/py/weibo-spider.svg)](https://badge.fury.io/py/weibo-spider) -# 输入 -用户id,例如新浪微博昵称为“Dear-迪丽热巴”的id为“1669879400” +# Weibo Spider -# 输出 -- 用户名:用户昵称,如"Dear-迪丽热巴" +本程序可以连续爬取**一个**或**多个**新浪微博用户(如[胡歌](https://weibo.cn/u/1223178222)、[迪丽热巴](https://weibo.cn/u/1669879400)、[郭碧婷](https://weibo.cn/u/1729370543))的数据,并将结果信息写入**文件**或**数据库**。写入信息几乎包括用户微博的所有数据,包括**用户信息**和**微博信息**两大类。因为内容太多,这里不再赘述,详细内容见[获取到的字段](#获取到的字段)。如果只需要用户信息,可以通过设置实现只爬取微博用户信息的功能。本程序需设置cookie来获取微博访问权限,后面会讲解[如何获取cookie](#如何获取cookie)。如果不想设置cookie,可以使用[免cookie版](https://github.com/dataabc/weibo-crawler),二者功能类似。 + +爬取结果可写入文件和数据库,具体的写入文件类型如下: + +- **txt文件**(默认) +- **csv文件**(默认) +- **json文件**(可选) +- **MySQL数据库**(可选) +- **MongoDB数据库**(可选) +- **SQLite数据库**(可选) + +同时支持下载微博中的图片和视频,具体的可下载文件如下: + +- **原创**微博中的原始**图片**(可选) +- **转发**微博中的原始**图片**(可选) +- **原创**微博中的**视频**(可选) +- **转发**微博中的**视频**(可选) +- **原创**微博**Live Photo**中的**视频**([免cookie版](https://github.com/dataabc/weibo-crawler)特有) +- **转发**微博**Live Photo**中的**视频**([免cookie版](https://github.com/dataabc/weibo-crawler)特有) + +## 内容列表 + +[TOC] + +- [Weibo Spider](#weibo-spider) + - [内容列表](#内容列表) + - [获取到的字段](#获取到的字段) + - [用户信息](#用户信息) + - [微博信息](#微博信息) + - [示例](#示例) + - [运行环境](#运行环境) + - [使用说明](#使用说明) + - [0.版本](#0版本) + - [1.安装程序](#1安装程序) + - [源码安装](#源码安装) + - [pip安装](#pip安装) + - [2.程序设置](#2程序设置) + - [3.运行程序](#3运行程序) + - [个性化定制程序(可选)](#个性化定制程序可选) + - [定期自动爬取微博(可选)](#定期自动爬取微博可选) + - [如何获取cookie](#如何获取cookie) + - [如何获取user_id](#如何获取user_id) + - [常见问题](#常见问题) + - [学术研究](#学术研究) + - [相关项目](#相关项目) + - [贡献](#贡献) + - [贡献者](#贡献者) + - [注意事项](#注意事项) + +## 获取到的字段 + +本部分为爬取到的字段信息说明,为了与[免cookie版](https://github.com/dataabc/weibo-crawler)区分,下面将两者爬取到的信息都列出来。如果是免cookie版所特有的信息,会有免cookie标注,没有标注的为二者共有的信息。 + +### 用户信息 + +- 用户id:微博用户id,如"1669879400",其实这个字段本来就是已知字段 +- 昵称:用户昵称,如"Dear-迪丽热巴" +- 性别:微博用户性别 +- 生日:用户出生日期 +- 所在地:用户所在地 +- 学习经历:用户上学时学校的名字和时间 +- 工作经历:用户所属公司名字和时间 +- 阳光信用(免cookie版):用户的阳光信用 +- 微博注册时间(免cookie版):用户微博注册日期 - 微博数:用户的全部微博数(转发微博+原创微博) -- 关注数:用户关注的微博账号数量 +- 关注数:用户关注的微博数量 - 粉丝数:用户的粉丝数 -- 微博内容:以list的形式存储了用户所有微博内容 -- 微博发布时间:以list的形式存储了用户所有微博的发布时间 -- 微博对应的点赞数:以list的形式存储了用户所有微博对应的点赞数 -- 微博对应的转发数:以list的形式存储了用户所有微博对应的转发数 -- 微博对应的评论数:以list的形式存储了用户所有微博对应的评论数 -- 结果文件:保存在当前目录的weibo文件夹里,名字为"user_id.txt"的形式 - -# 运行环境 -- 开发语言:python2.7 -- 系统: Windows/Linux - -# 使用说明 -1.下载脚本 +- 简介:用户简介 +- 主页地址(免cookie版):微博移动版主页url +- 头像url(免cookie版):用户头像url +- 高清头像url(免cookie版):用户高清头像url +- 微博等级(免cookie版):用户微博等级 +- 会员等级(免cookie版):微博会员用户等级,普通用户该等级为0 +- 是否认证(免cookie版):用户是否认证,为布尔类型 +- 认证类型(免cookie版):用户认证类型,如个人认证、企业认证、政府认证等 +- 认证信息:为认证用户特有,用户信息栏显示的认证信息 + +### 微博信息 + +- 微博id:微博唯一标志 +- 微博内容:微博正文 +- 头条文章url:微博中头条文章的url,若微博中不存在头条文章,则值为'' +- 原始图片url:原创微博图片和转发微博转发理由中图片的url,若某条微博存在多张图片,每个url以英文逗号分隔,若没有图片则值为"无" +- 视频url: 微博中的视频url,若微博中没有视频,则值为"无" +- 微博发布位置:位置微博中的发布位置 +- 微博发布时间:微博发布时的时间,精确到分 +- 点赞数:微博被赞的数量 +- 转发数:微博被转发的数量 +- 评论数:微博被评论的数量 +- 微博发布工具:微博的发布工具,如iPhone客户端、HUAWEI Mate 20 Pro等 +- 结果文件:保存在当前目录weibo文件夹下以用户昵称为名的文件夹里,名字为"user_id.csv"和"user_id.txt"的形式 +- 微博图片:原创微博中的图片和转发微博转发理由中的图片,保存在以用户昵称为名的文件夹下的img文件夹里 +- 微博视频:原创微博中的视频,保存在以用户昵称为名的文件夹下的video文件夹里 +- 微博bid(免cookie版):为[免cookie版](https://github.com/dataabc/weibo-crawler)所特有,与本程序中的微博id是同一个值 +- 话题(免cookie版):微博话题,即两个#中的内容,若存在多个话题,每个url以英文逗号分隔,若没有则值为'' +- @用户(免cookie版):微博@的用户,若存在多个@用户,每个url以英文逗号分隔,若没有则值为'' +- 原始微博(免cookie版):为转发微博所特有,是转发微博中那条被转发的微博,存储为字典形式,包含了上述微博信息中的所有内容,如微博id、微博内容等等 + +## 示例 + +如果想要知道程序的具体运行结果,可以查看[示例文档](https://github.com/dataabc/weiboSpider/blob/master/docs/example.md),该文档介绍了爬取[迪丽热巴微博](https://weibo.cn/u/1669879400)的例子,并附有部分结果文件截图。 + +## 运行环境 + +- 开发语言:python2/python3 +- 系统: Windows/Linux/macOS + +## 使用说明 + +### 0.版本 + +本程序有两个版本,你现在看到的是python3版,另一个是python2版,python2版位于[python2分支](https://github.com/dataabc/weiboSpider/tree/python2)。目前主力开发python3版,包括新功能开发和bug修复;python2版仅支持bug修复。推荐python3用户使用当前版本,推荐python2用户使用[python2版](https://github.com/dataabc/weiboSpider/tree/python2),本使用说明是python3版的使用说明。 + +### 1.安装程序 + +本程序提供两种安装方式,一种是**源码安装**,另一种是**pip安装**,二者功能完全相同。如果你需要修改源码,建议使用第一种方式,否则选哪种安装方式都可以。 + +#### 源码安装 + ```bash -$ git clone https://github.com/dataabc/weibospider.git +$ git clone https://github.com/dataabc/weiboSpider.git +$ cd weiboSpider +$ pip install -r requirements.txt ``` -运行上述命令,将本项目下载到当前目录,如果下载成功当前目录会出现一个名为"weibospider"的文件夹;
-2.用文本编辑器打开weibospider文件夹下的"weibospider.py"文件;
-3.将"weibospider.py"文件中的“your cookie”替换成爬虫微博的cookie,后面会详细讲解如何获取cookie;
-4.将"weibospider.py"文件中的user_id替换成想要爬取的微博的user_id,后面会详细讲解如何获取user_id;
-5.按需求调用脚本。本脚本是一个Weibo类,用户可以按照自己的需求调用Weibo类。 -例如用户可以直接在"weibospider.py"文件中调用Weibo类,具体调用代码示例如下: -```python -user_id = 1669879400 -filter = 1 -wb = Weibo(user_id,filter) #调用Weibo类,创建微博实例wb -wb.start() #爬取微博信息 + +#### pip安装 + +```bash +$ python3 -m pip install weibo-spider +``` + +### 2.程序设置 + +要了解程序设置,请查看[程序设置文档](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md)。 + +### 3.运行程序 + +**源码安装**的用户可以在weiboSpider目录运行如下命令,**pip安装**的用户可以在任意有写权限的目录运行如下命令 + +```bash +$ python3 -m weibo_spider +``` + +第一次执行,会自动在当前目录创建config.json配置文件,配置好后执行同样的命令就可以获取微博了。 + +如果你已经有config.json文件了,也可以通过config_path参数配置config.json路径,运行程序,命令行如下: + +```bash +$ python3 -m weibo_spider --config_path="config.json" ``` -user_id可以改成任意合法的用户id(爬虫的微博id除外);filter默认值为0,表示爬取所有微博信息(转发微博+原创微博),为1表示只爬取用户的所有原创微博;wb是Weibo类的一个实例,也可以是其它名字,只要符合python的命名规范即可;通过执行wb.start() 完成了微博的爬取工作。在上述代码之后,我们可以得到很多信息:
-**wb.username**:用户名;
-**wb.weibo_num**:微博数;
-**wb.following**:关注数;
-**wb.followers**:粉丝数;
-**wb.weibo_content**:存储用户的所有微博,为list形式,若filter=1, wb.weibo_content[0]为最新一条**原创**微博,filter=0为最新一条微博,wb.weibo_content[1]、wb.weibo_content[2]分别表示第二新和第三新的微博,以此类推。当然如果用户没有发过微博,wb.weibo_content则为[];
-**wb.publish_time**: 存储微博的发布时间,为list形式,如wb.publish_time[0]为最新一条微博的发布时间,与wb.weibo_content[0]对应,其它用法同wb.weibo_content;
-**wb.up_num**:存储微博获得的点赞数,为list形式,如wb.up_num[0]为最新一条微博获得的点赞数,与wb.weibo_content[0]对应,其它用法同wb.weibo_content;
-**wb.retweet_num**:存储微博获得的转发数,为list形式,如wb.retweet_num[0]为最新一条微博获得的转发数,与wb.weibo_content[0]对应,其它用法同wb.weibo_content;
-**wb.comment_num**:存储微博获得的评论数,为list形式,如wb.comment_num[0]为最新一条微博获得的评论数,与wb.weibo_content[0]对应,其它用法同wb.weibo_content。
-6.运行脚本。我的运行环境是IPython,通过 + +如果你想指定文件(csv、txt、json、图片、视频)保存路径,可以通过output_dir参数设定。假如你想把文件保存到/home/weibo/目录,可以运行如下命令: + ```bash -$ run filepath/weibospider.py +$ python3 -m weibo_spider --output_dir="/home/weibo/" ``` -即可运行脚本,大家可以根据自己的运行环境选择运行方式; -Linux可以通过 + +如果你想通过命令行输入user_id,可以使用参数u,可以输入一个或多个user_id,每个user_id以英文逗号分开,如果这些user_id中有重复的user_id,程序会自动去重。命令行如下: + ```bash -$ python filepath/weibospider.py +$ python3 -m weibo_spider --u="1669879400,1223178222" ``` -# 如何获取cookie -1.用Chrome打开
-2.按F12键打开Chrome开发者工具;
-3.点开“Network”,将“Preserve log”选中,输入微博的用户名、密码,登录,如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/cookie1.png) -4.点击Chrome开发者工具“Name"列表中的"m.weibo.cn",点击"Headers",其中"Request Headers"下,"Cookie"后的值即为我们要找的cookie值,复制即可,如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/cookie2.png) - -# 如何获取user_id -1.打开网址,搜索我们要找的人,如”郭碧婷“,进入她的主页;
-2.大部分情况下,在用户主页的地址栏里就包含了user_id,如”郭碧婷“的地址栏地址为"",其中的"1729370543"就是她的user_id。如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/userid1.png) -但是部分用户设置了个性域名,他们的地址栏地址就变成了""的形式,如柳岩主页的地址栏地址为""。如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/userid2.png) -事实上,如果仅仅爬取微博,用user_id或个性域名都可以,但是因为本脚本还要爬取用户昵称,而用个性域名表示的网页爬取有一些小问题,需要另外的网页。所以,如果遇到地址栏没有user_id的情况,大家可以点击”资料“,跳转到用户资料页面,如柳岩的资料页面地址为"",其中的"1644461042"即为柳岩微博的user_id。如图所示: -![](https://picture.cognize.me/cognize/github/weibospider/userid3.png) - -# 注意事项 -1.user_id不能为爬虫微博的user_id。因为要爬微博信息,必须先登录到某个微博账号,此账号我们姑且称为爬虫微博。爬虫微博访问自己的页面和访问其他用户的页面,得到的网页格式不同,所以无法爬取自己的微博信息;
-2.cookie有期限限制,大约有几天的有效期,超过有效期需重新更新cookie。 +程序会获取user_id分别为1669879400和1223178222的微博用户的微博,后面会讲[如何获取user_id](#如何获取user_id)。该方式的所有user_id使用config.json中的since_date和end_date设置,通过修改它们的值可以控制爬取的时间范围。若config.json中的user_id_list是文件路径,每个命令行中的user_id都会自动保存到该文件内,且自动更新since_date;若不是路径,user_id会保存在当前目录的user_id_list.txt内,且自动更新since_date,若当前目录下不存在user_id_list.txt,程序会自动创建它。 + +## 个性化定制程序(可选) + +本部分为可选部分,如果不需要个性化定制程序或添加新功能,可以忽略此部分。 + +本程序主体代码位于weibo_spider.py文件,程序主体是一个 Spider 类,上述所有功能都是通过在main函数调用 Spider 类实现的,默认的调用代码如下: + +```python + config = get_config() + wb = Spider(config) + wb.start() # 爬取微博信息 +``` + +用户可以按照自己的需求调用或修改 Spider 类。通过执行本程序,我们可以得到很多信息。 + +
+ +点击查看详情 + +- wb.user['nickname']:用户昵称; +- wb.user['gender']:用户性别; +- wb.user['location']:用户所在地; +- wb.user['birthday']:用户出生日期; +- wb.user['description']:用户简介; +- wb.user['verified_reason']:用户认证; +- wb.user['talent']:用户标签; +- wb.user['education']:用户学习经历; +- wb.user['work']:用户工作经历; +- wb.user['weibo_num']:微博数; +- wb.user['following']:关注数; +- wb.user['followers']:粉丝数; + +
+ +**wb.weibo**:除不包含上述信息外,wb.weibo包含爬取到的所有微博信息,如**微博id**、**微博正文**、**原始图片url**、**发布位置**、**发布时间**、**发布工具**、**点赞数**、**转发数**、**评论数**等。如果爬的是全部微博(原创+转发),除上述信息之外,还包含被**转发微博原始图片url**、**是否为原创微博**等。wb.weibo是一个列表,包含了爬取的所有微博信息。wb.weibo[0]为爬取的第一条微博,wb.weibo[1]为爬取的第二条微博,以此类推。当filter=1时,wb.weibo[0]为爬取的第一条**原创**微博,以此类推。wb.weibo[0]['id']为第一条微博的id,wb.weibo[0]['content']为第一条微博的正文,wb.weibo[0]['publish_time']为第一条微博的发布时间,还有其它很多信息不在赘述,大家可以点击下面的"详情"查看具体用法。 + +
+ +详情 + +若目标微博用户存在微博,则: + +- id:存储微博id。如wb.weibo[0]['id']为最新一条微博的id; +- content:存储微博正文。如wb.weibo[0]['content']为最新一条微博的正文; +- article_url:存储微博中头条文章的url。如wb.weibo[0]['article_url']为最新一条微博的头条文章url,若微博中不存在头条文章,则值为''; +- original_pictures:存储原创微博的原始图片url和转发微博转发理由中的图片url。如wb.weibo[0]['original_pictures']为最新一条微博的原始图片url,若该条微博有多张图片,则存储多个url,以英文逗号分割;若该微博没有图片,则值为"无"; +- retweet_pictures:存储被转发微博中的原始图片url。当最新微博为原创微博或者为没有图片的转发微博时,则值为"无",否则为被转发微博的图片url。若有多张图片,则存储多个url,以英文逗号分割; +- publish_place:存储微博的发布位置。如wb.weibo[0]['publish_place']为最新一条微博的发布位置,如果该条微博没有位置信息,则值为"无"; +- publish_time:存储微博的发布时间。如wb.weibo[0]['publish_time']为最新一条微博的发布时间; +- up_num:存储微博获得的点赞数。如wb.weibo[0]['up_num']为最新一条微博获得的点赞数; +- retweet_num:存储微博获得的转发数。如wb.weibo[0]['retweet_num']为最新一条微博获得的转发数; +- comment_num:存储微博获得的评论数。如wb.weibo[0]['comment_num']为最新一条微博获得的评论数; +- publish_tool:存储微博的发布工具。如wb.weibo[0]['publish_tool']为最新一条微博的发布工具。 + +
+ +## 定期自动爬取微博(可选) + +要想让程序每隔一段时间自动爬取,且爬取的内容为新增加的内容(不包括已经获取的微博),请查看[定期自动爬取微博](https://github.com/dataabc/weiboSpider/blob/master/docs/automation.md)。 + +## 如何获取cookie + +要了解获取cookie方法,请查看[cookie文档](https://github.com/dataabc/weiboSpider/blob/master/docs/cookie.md)。 + +## 如何获取user_id + +要了解获取user_id方法,请查看[user_id文档](https://github.com/dataabc/weiboSpider/blob/master/docs/userid.md),该文档介绍了如何获取一个及多个微博用户user_id的方法。 + +## 常见问题 + +如果运行程序的过程中出现错误,可以查看[常见问题](https://github.com/dataabc/weiboSpider/blob/master/docs/FAQ.md)页面,里面包含了最常见的问题及解决方法。如果出现的错误不在常见问题里,您可以通过[发issue](https://github.com/dataabc/weiboSpider/issues/new/choose)寻求帮助,我们会很乐意为您解答。 + +## 学术研究 + +本项目通过获取微博数据,为写论文、做研究等非商业项目提供所需数据。[学术研究文档](https://github.com/dataabc/weiboSpider/blob/master/docs/academic.md)是一些在论文或研究等方面使用过本程序的项目,这些项目展示已征得所有者同意。在一些涉及隐私的描述上,已与所有者做了沟通,描述中只介绍所有者允许展示的部分。如果部分信息所有者之前同意展示并且已经写在了文档中,现在又不想展示了,可以通过邮件(chillychen1991@gmail.com)或issue的方式告诉我,我会删除相关信息。同时,也欢迎使用本项目写论文或做其它学术研究的朋友,将自己的研究成果展示在[学术研究文档](https://github.com/dataabc/weiboSpider/blob/master/docs/academic.md)里,这完全是自愿的。 + +为方便大家引用,现提供本项目的 bibtex 条目如下: + +``` +@misc{weibospider2020, + author = {Lei Chen, Zhengyang Song, schaepher, minami9, bluerthanever, MKSP2015, moqimoqidea, windlively, eggachecat, mtuwei, codermino, duangan1}, + title = {{Weibo Spider}}, + howpublished = {\url{https://github.com/dataabc/weiboSpider}}, + year = {2020} +} +``` + +## 相关项目 + +- [weibo-crawler](https://github.com/dataabc/weibo-crawler) - 功能和本项目完全一样,可以不添加cookie,获取的微博属性更多; +- [weibo-search](https://github.com/dataabc/weibo-search) - 可以连续获取一个或多个**微博关键词搜索**结果,并将结果写入文件(可选)、数据库(可选)等。所谓微博关键词搜索即:**搜索正文中包含指定关键词的微博**,可以指定搜索的时间范围。对于非常热门的关键词,一天的时间范围,可以获得**1000万**以上的搜索结果,N天的时间范围就可以获得1000万 X N搜索结果。对于大多数关键词,一天产生的相应微博数量应该在1000万条以下,因此可以说该程序可以获得大部分关键词的全部或近似全部的搜索结果。而且该程序可以获得搜索结果的所有信息,本程序获得的微博信息该程序都能获得。 + +## 贡献 + +欢迎为本项目贡献力量。贡献可以是提交代码,可以是通过issue提建议(如新功能、改进方案等),也可以是通过issue告知我们项目存在哪些bug、缺点等,具体贡献方式见[为本项目做贡献](https://github.com/dataabc/weiboSpider/blob/master/CONTRIBUTING.md)。 + +## 贡献者 + +感谢所有为本项目贡献力量的朋友,贡献者详情见[贡献者](https://github.com/dataabc/weiboSpider/blob/master/docs/contributors.md)页面。 + +## 注意事项 + +1. user_id不能为爬虫微博的user_id。因为要爬微博信息,必须先登录到某个微博账号,此账号我们姑且称为爬虫微博。爬虫微博访问自己的页面和访问其他用户的页面,得到的网页格式不同,所以无法爬取自己的微博信息;如果想要爬取爬虫微博内容,可以参考[获取自身微博信息](https://github.com/dataabc/weiboSpider/issues/113); +2. cookie有期限限制,大约三个月。若提示cookie错误或已过期,需要重新更新cookie。 diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 00000000..14e41657 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,45 @@ +# 常见问题 + +## 1. 程序运行出错,错误提示中包含“ImportError: cannot import name 'config_util' from '__main__'”,如何解决? + +出现这种错误,说明使用者很可能是直接运行的.py文件,程序正确的运行方式是在weiboSpider目录下,运行如下命令: + +```bash +python3 -m weibo_spider +``` + +## 2. 程序运行出错,错误提示中包含“'NoneType' object”字样,如何解决? + +这是最常见的问题之一。出错原因是爬取速度太快,被暂时限制了,限制可能包含爬虫账号限制和ip限制。一般情况下,一段时间后限制会自动解除。可通过降低爬取速度避免被限制,具体修改config.json文件中的如下代码: + +```json + "random_wait_pages": [1, 5], + "random_wait_seconds": [6, 10], + "global_wait": [[1000, 3600], [500, 2000]], +``` + +前两行的意思是每爬取1到5页,随机等待6到10秒。可以通过加快暂停频率(减小random_wait_pages内的值)或增加等待时间(加大random_wait_seconds内的值)避免被限制。最后一行的意思是获取1000页微博,一次性等待3600秒;之后获取500页微博一次性等待2000秒。默认只有两个global_wait配置([1000, 3600]和[500, 2000]),可以添加更多个,也可以自定义。当配置使用完,如默认配置在获取1500(1000+500)页微博后就用完了,之后程序会从第一个配置开始循环使用(获取第1501页到2500页等待3600秒,获取第2501页到第3000页等待2000秒,以此类推)。 + +## 3. 如何获取微博评论? + +因为限制,只能获取一部分评论,无法获取全部,因此暂时没有添加获取评论功能的计划。 + +## 4. 有的长微博正文只能获取一部分内容,如何解决? + +程序是可以获取长微博全文的。程序首先在微博列表页获取微博,如果发现长微博(正文没有显示完整,以“全文”代替部分内容的微博),会先保存这个不全的内容,然后去该长微博的详情页尝试获取全文,如果获取成功,获取的内容就是微博文本;如果获取失败,等待若干秒重新获取;如果连续尝试5次都失败,就用上面不全的内容代替。这样做的原因是避免因部分长微博获取失败而卡住。如果想尝试更多次,可以修改comment_parser.py文件get_long_weibo方法内for循环的次数。 + +## 5. 如何按指定关键词获取微博? + +请使用[weibo-search](https://github.com/dataabc/weibo-search)。该程序可以连续获取一个或多个微博关键词搜索结果,并将结果写入文件(可选)、数据库(可选)等。所谓微博关键词搜索即:搜索正文中包含指定关键词的微博,可以指定搜索的时间范围。对于非常热门的关键词,一天的时间范围,可以获得1000万以上的搜索结果,N天的时间范围就可以获得1000万 X N搜索结果。对于大多数关键词,一天产生的相应微博数量应该在1000万条以下,因此可以说该程序可以获得大部分关键词的全部或近似全部的搜索结果。而且该程序可以获得搜索结果的所有信息,本程序获得的微博信息该程序都能获得。 + +## 6. 如何获取微博用户关注列表中用户的user_id? + +请使用[weibo-follow](https://github.com/dataabc/weibo-follow)。该程序可以利用一个user_id,获取该user_id微博用户关注人的user_id,一个user_id最多可以获得200个user_id,并写入user_id_list.txt文件。程序支持读文件,利用这200个user_id,可以获得最多200X200=40000个user_id。再利用这40000个user_id可以得到40000X200=8000000个user_id,如此反复,以此类推,可以获得大量user_id。本项目也支持读文件,将上述程序的结果文件user_id_list.txt路径赋值给本项目config.json的user_id_list参数,就可以获得这些user_id用户所发布的大量微博。 + +## 7. 如何获取自己的微博? + +修改page_parser.py中__init__方法,将self.url修改为: + +```python + self.url = "https://weibo.cn/%s/profile?page=%d" % (self.user_uri, page) +``` diff --git a/docs/academic.md b/docs/academic.md new file mode 100644 index 00000000..1f378eaa --- /dev/null +++ b/docs/academic.md @@ -0,0 +1,8 @@ +# 学术研究 + +本项目通过获取微博数据,为写论文、做研究等非商业项目提供所需数据。下面是一些在论文或研究等方面使用过本程序的项目。在一些涉及隐私的描述上,已与研究者做了沟通,在下面的描述中只介绍研究者 +允许展示的部分。如果部分信息研究者之前同意展示并且已经写在了本文档中,现在又不想展示了,可以通过邮件(chillychen1991@gmail.com)或issue的方式告诉我,我会删除相关信息。同时,使用本项目写论文或做其它学术研究的朋友,如果想把自己的研究成果展示在下面,也可以通过邮件或issue的方式告诉我。 + +*** + +- 英国伦敦国王学院[Mak-LokGay](https://github.com/Mak-LokGay)的[毕业论文](https://github.com/Mak-LokGay/KCL_Dissertation) diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 00000000..2d8970dc --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,58 @@ +# 定期自动爬取微博(可选) + +我们爬取了微博以后,很多微博账号又可能发了一些新微博,定期自动爬取微博就是每隔一段时间自动运行程序,自动爬取这段时间产生的新微博(忽略以前爬过的旧微博)。本部分为可选部分,如果不需要可以忽略。 + +思路是**利用第三方软件,如crontab,让程序每隔一段时间运行一次**。因为是要跳过以前爬过的旧微博,只爬新微博。所以需要**设置一个动态的since_date**。很多时候我们使用的since_date是固定的,比如since_date="2018-01-01",程序就会按照这个设置从最新的微博一直爬到发布时间为2018-01-01的微博(包括这个时间)。因为我们想追加新微博,跳过旧微博。第二次爬取时since_date值就应该是当前时间到上次爬取的时间。 +如果我们使用最原始的方式实现追加爬取,应该是这样: + +```text +假如程序第一次执行时间是2019-06-06,since_date假如为2018-01-01,那这一次就是爬取从2018-01-01到2019-06-06这段时间用户所发的微博; +第二次爬取,我们想要接着上次的爬,那since_date的值应该是上次程序执行的日期,即2019-06-06 +``` + +上面的方法太麻烦,因为每次都要手动设置since_date。因此我们需要动态设置since_date,即程序根据实际情况,自动生成since_date。 + +有两种方法实现动态更新since_date,**推荐使用方法二**。 + +## 方法一:将since_date设置成整数 + +将config.json文件中的since_date设置成整数,如: + +```json +"since_date": 10, +``` + +这个配置告诉程序爬取最近10天的微博,更准确说是爬取发布时间从**10天前到本程序开始执行时**之间的微博。这样since_date就是一个动态的变量,每次程序执行时,它的值就是当前日期减10。配合crontab每9天或10天执行一次,就实现了定期追加爬取。 + +## 方法二:将上次执行程序的时间写入文件(推荐) + +这个方法很简单,就是使用[程序设置](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md)中**设置user_id_list**的第二种方法设置user_id_list,这样设置就全部结束了。 + +说下这个方法的好处和原理,假如你的txt文件内容为: + +```text +1669879400 +1223178222 胡歌 +1729370543 郭碧婷 2019-01-01 19:28 +``` + +第一次执行时,因为第一行和第二行都没有写时间,程序会按照config.json文件中since_date的值爬取,第三行有时间“2019-01-01 19:28”,程序就会把这个时间当作since_date。每个用户爬取结束程序都会自动更新txt文件,每一行第一部分是user_id,第二部分是用户昵称,第三部分是程序**准备**爬取该用户第一条微博(最新微博)时的时间。爬完三个用户后,txt文件的内容自动更新为: + +```text +1669879400 Dear-迪丽热巴 2020-01-13 19:18 +1223178222 胡歌 2020-01-13 19:28 +1729370543 郭碧婷 2020-01-13 19:33 +``` + +下次再爬取微博的时候,程序会把每行的时间数据作为since_date。这样的好处一是不用修改since_date,程序自动更新;二是每一个用户都可以单独拥有只属于自己的since_date,每个用户的since_date相互独立,互不干扰。since_date既可以是“yyyy-mm-dd”格式,也可以是“yyyy-mm-dd hh:mm”格式。比如,现在又添加了一个新用户,例如杨紫,你想获取她从2018-01-23到现在的全部微博,只需要这样修改txt文件: + +```text +1669879400 Dear-迪丽热巴 2020-01-13 19:18 +1223178222 胡歌 2020-01-13 19:28 +1729370543 郭碧婷 2020-01-13 19:33 +1227368500 杨紫 2018-01-23 +``` + +注意每一行的用户配置参数以空格分隔,如果第一个参数全部由数字组成,程序就认为此行为一个用户的配置,否则程序会认为该行只是注释,跳过该行;第二个参数可以为任意格式,建议写用户昵称;第三个如果是日期格式(yyyy-mm-dd),程序就将该日期设置为用户自己的since_date,否则使用config.json中的since_date爬取该用户的微博,第二个参数和第三个参数也可以不填。 + +推荐第二种方法,本方法是[Evifly](https://github.com/Evifly)想出的,非常热心非常有想法的网友,在此感谢。 diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 00000000..a58eb5c9 --- /dev/null +++ b/docs/contributors.md @@ -0,0 +1,26 @@ +# 贡献者 + +感谢所有为本项目作出贡献和将要作出贡献的朋友,感谢对开源事业的支持。大家每贡献一行code都让项目功能更丰富,每提一个建议都让程序更完善,每发现一个bug都让代码更健壮。 + +本项目贡献者包含三部分:主要代码开发者、代码贡献者和优质issue提出者。以下按贡献者的用户名首字母排序,若某贡献者在多部分都有贡献,则以主要贡献为准。 + +## 主要代码开发者 + +| [dataabc](https://github.com/dataabc) | [songzy12](https://github.com/songzy12) | +| - | - | + +## 代码贡献者 + +| [codermino](https://github.com/codermino) | [duangan1](https://github.com/duangan1) | [MKSP2015](https://github.com/MKSP2015) | +| - | - | - | + +## 优质issue提出者 + +| | | | | | | +| - | - | - | - | - | - | +| [13531982270](https://github.com/13531982270) | [Archenemy61](https://github.com/Archenemy61) | [arctanx](https://github.com/arctanx) | [bossming](https://github.com/bossming) | [bubblesran](https://github.com/bubblesran) | [cangling](https://github.com/cangling)| +| [Ccccche](https://github.com/Ccccche) | [Evifly](https://github.com/Evifly) | [gudaost](https://github.com/gudaost) | [Hylan129](https://github.com/Hylan129) | [HZzzzy](https://github.com/HZzzzy) | [kur0mi](https://github.com/kur0mi) | +| [leonall](https://github.com/leonall) | [liu-song](https://github.com/liu-song) | [Issac110](https://github.com/Issac110) | [MengyingQian](https://github.com/MengyingQian) | [PandGnone](https://github.com/PandGnone) | [PLQin](https://github.com/PLQin) | +| [redMUSCLE](https://github.com/redMUSCLE) | [shengdade](https://github.com/shengdade) | [softrime](https://github.com/softrime) | [SugimitoYuuji](https://github.com/SugimitoYuuji) | [sunbat](https://github.com/sunbat) | [taichifox95](https://github.com/taichifox95) | +| [Twinklingcode](https://github.com/Twinklingcode) | [vincentlee5](https://github.com/vincentlee5) | [wiidi](https://github.com/wiidi) | [wwwpf](https://github.com/wwwpf) | [xiaomingdaily](https://github.com/xiaomingdaily) | [xiekeyi98](https://github.com/xiekeyi98) | +| [xnzmc](https://github.com/xnzmc) | [yangy9593](https://github.com/yangy9593) | [zhangjibao](https://github.com/zhangjibao) | diff --git a/docs/cookie.md b/docs/cookie.md new file mode 100644 index 00000000..57db59cf --- /dev/null +++ b/docs/cookie.md @@ -0,0 +1,10 @@ +# 如何获取cookie + +1. 用Chrome打开; +2. 输入微博的用户名、密码,登录,如图所示: +![weibo log in page](https://picture.cognize.me/cognize/github/weibospider/cookie1.png) +登录成功后会跳转到; +3. 按F12键打开Chrome开发者工具,在地址栏输入并跳转到,跳转后会显示如下类似界面: +![chrome debugger network tab](https://picture.cognize.me/cognize/github/weibospider/cookie2.png) +4. 依此点击Chrome开发者工具中的Network->Name中的weibo.cn->Headers->Request Headers,"Cookie:"后的值即为我们要找的cookie值,复制即可,如图所示: +![cookie in request headers section](https://picture.cognize.me/cognize/github/weibospider/cookie3.png) \ No newline at end of file diff --git a/docs/example.md b/docs/example.md new file mode 100644 index 00000000..d12e08bb --- /dev/null +++ b/docs/example.md @@ -0,0 +1,119 @@ +# 实例 + +以爬取迪丽热巴的微博为例,我们需要修改**config.json**文件,文件内容如下: + +```json +{ + "user_id_list": ["1669879400"], + "filter": 1, + "since_date": "1900-01-01", + "end_date": "now", + "write_mode": ["csv", "txt", "json"], + "pic_download": 1, + "video_download": 1, + "result_dir_name": 0, + "cookie": "your cookie" +} +``` + +对于上述参数的含义以及取值范围,这里仅作简单介绍,详细信息见[程序设置](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md)。 + +- **user_id_list**代表我们要爬取的微博用户的user_id,可以是一个或多个,也可以是文件路径,微博用户Dear-迪丽热巴的user_id为1669879400,具体如何获取user_id见[如何获取user_id](https://github.com/dataabc/weiboSpider/blob/master/docs/userid.md); +- **filter**的值为1代表爬取全部原创微博,值为0代表爬取全部微博(原创+转发); +- **since_date**代表我们要爬取since_date日期之后发布的微博,因为我要爬迪丽热巴的全部原创微博,所以since_date设置了一个非常早的值; +- **end_date**代表我们要爬取end_date日期之前发布的微博,since_date配合end_date,表示我们要爬取发布日期在since_date和end_date之间的微博,包含边界,如果end_date值为"now",表示爬取发布日期从since_date到现在的微博; +- **write_mode**代表结果文件的保存类型,我想要把结果写入txt文件、csv文件和json文件,所以它的值为["csv", "txt", "json"],如果你想写入数据库,具体设置见[设置数据库](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md#设置数据库可选); +- **pic_download**值为1代表下载微博中的图片,值为0代表不下载; +- **video_download**值为1代表下载微博中的视频,值为0代表不下载; +- **result_dir_name**控制结果文件夹名,值为1代表文件夹名是用户id,值为0代表文件夹名是用户昵称; +- **cookie**是爬虫微博的cookie,具体如何获取cookie见[cookie文档](https://github.com/dataabc/weiboSpider/blob/master/docs/cookie.md),获取cookie后把"your cookie"替换成真实的cookie值即可。 + +cookie修改完成后在weiboSpider目录下运行如下命令: + +```bash +$ python3 -m weibo_spider +``` + +程序会自动生成一个weibo文件夹,我们以后爬取的所有微博都被存储在这里。然后程序在该文件夹下生成一个名为"Dear-迪丽热巴"的文件夹,迪丽热巴的所有微博爬取结果都在这里。"Dear-迪丽热巴"文件夹里包含一个csv文件、一个txt文件、一个json文件、一个img文件夹和一个video文件夹,img文件夹用来存储下载到的图片,video文件夹用来存储下载到的视频。如果你设置了保存数据库功能,这些信息也会保存在数据库里,数据库设置见[设置数据库](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md#设置数据库可选)部分。 + +## csv结果文件如下所示 + +*1669879400.csv* + +![](https://picture.cognize.me/cognize/github/weibospider/weibo_csv.png) + +## txt结果文件如下所示 + +*1669879400.txt* + +![](https://picture.cognize.me/cognize/github/weibospider/weibo_txt.png) + +json文件包含迪丽热巴的用户信息和上千条微博信息,内容较多。为了表达清晰,这里仅展示两条微博。 + +## json结果文件如下所示 + +*1669879400.json* + +```json +{ + "user": { + "id": "1669879400", + "nickname": "Dear-迪丽热巴", + "gender": "女", + "location": "上海", + "birthday": "双子座", + "description": "一只喜欢默默表演的小透明。工作联系jaywalk@jaywalk.com.cn 🍒", + "verified_reason": "嘉行传媒签约演员", + "talent": "", + "education": "上海戏剧学院", + "work": "嘉行传媒 ", + "weibo_num": 1121, + "following": 250, + "followers": 66395910 + }, + "weibo": [ + { + "id": "IonM9ryMy", + "content": "2019#微博之夜#盛典即将开启,以微博之力,让世界更美。1月11日,不见不散@微博之夜  原图 ", + "original_pictures": "http://wx1.sinaimg.cn/large/63885668ly1gao0a01kfzj20ku112k98.jpg", + "video_url": "无", + "publish_place": "无", + "publish_time": "2020-01-07 14:59", + "publish_tool": "无", + "up_num": 239242, + "retweet_num": 71914, + "comment_num": 55916 + }, + { + "id": "InB4Df73X", + "content": "#happyNEOyear#都到了2020,还不换点新pose配新装[來] 穿上@adidasneo 迪士尼联名款,让#生来好动#的我们一起玩“新”大发、自拍不重样🤳http://t.cn/AiF7nREj adidasneo的微博视频  ", + "original_pictures": "无", + "video_url": "http://f.video.weibocdn.com/000pYrGmlx07zPTskBQQ010412008AOY0E010.mp4?label=mp4_hd&template=852x480.25.0&trans_finger=62b30a3f061b162e421008955c73f536&Expires=1578569162&ssig=IV3JEbh3Zu&KID=unistore,video", + "publish_place": "无", + "publish_time": "2020-01-02 11:00", + "publish_tool": "无", + "up_num": 275419, + "retweet_num": 376734, + "comment_num": 131069 + } + ] +} +``` + +## 下载的图片如下所示 + +*img文件夹* + +![](https://picture.cognize.me/cognize/github/weibospider/img.png) + +本次下载了793张图片,大小一共1.21GB,包括她原创微博中的图片和转发微博转发理由中的图片。图片名为yyyymmdd+微博id的形式,若某条微博存在多张图片,则图片名中还会包括它在微博图片中的序号。若某张图片因为网络等原因下载失败,程序则会以“weibo_id:pic_url”的形式将出错微博id和图片url写入同文件夹下的not_downloaded.txt里; + +## 下载的视频如下所示 + +*video文件夹* + +![](https://picture.cognize.me/cognize/github/weibospider/video.png) + +本次下载了70个视频,是她原创微博中的视频,视频名为yyyymmdd+微博id的形式。其中有一个视频因为网络原因下载失败,程序将它的微博id和视频url以“weibo_id:video_url”的形式写到了同文件夹下的not_downloaded.txt里。 + +因为我本地没有安装MySQL数据库和MongoDB数据库,所以暂时设置成不写入数据库。如果你想要将爬取结果写入数据库,只需要先安装数据库(MySQL或MongoDB),再安装对应包(pymysql或pymongo),然后将mysql_write或mongodb_write值设置为1即可。写入MySQL需要用户名、密码等配置信息,这些配置如何设置见[设置数据库](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md#设置数据库可选)部分。 diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 00000000..03733670 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,253 @@ +# 程序设置 + +**源码下载安装**的用户在weiboSpider目录下运行如下命令,**pip安装**的用户在任意有写权限的目录运行如下命令: + +```bash +$ python3 -m weibo_spider +``` + +第一次运行会生成**config.json**文件,请打开**config.json**文件,你会看到如下内容: + +```json +{ + "user_id_list": ["1669879400"], + "filter": 1, + "since_date": "2018-01-01", + "end_date": "now", + "random_wait_pages": [1, 5], + "random_wait_seconds": [6, 10], + "global_wait": [[1000, 3600], [500, 2000]], + "write_mode": ["csv", "txt"], + "pic_download": 1, + "video_download": 1, + "result_dir_name": 0, + "cookie": "your cookie", + "mysql_config": { + "host": "localhost", + "port": 3306, + "user": "root", + "password": "123456", + "charset": "utf8mb4" + }, + "sqlite_config": "weibo.db" +} +``` + +下面讲解每个参数的含义与设置方法。 + +## 设置user_id_list + +user_id_list是我们要爬取的微博的id,可以是一个,也可以是多个,例如: + +```json +"user_id_list": ["1223178222", "1669879400", "1729370543"], +``` + +上述代码代表我们要连续爬取user_id分别为“1223178222”、 “1669879400”、 “1729370543”的三个用户的微博,具体如何获取user_id见[如何获取user_id](https://github.com/dataabc/weiboSpider/blob/master/docs/userid.md)。 + +user_id_list的值也可以是文件路径,我们可以把要爬的所有微博用户的user_id都写到txt文件里,然后把文件的位置路径赋值给user_id_list,**推荐这种方式**。 + +在txt文件中,每个user_id占一行,也可以在user_id后面加注释(可选),如用户昵称等信息,user_id和注释之间必需要有空格,文件名任意,类型为txt,位置位于本程序的同目录下,文件内容示例如下: + +```text +1223178222 胡歌 +1669879400 迪丽热巴 +1729370543 郭碧婷 +``` + +假如文件叫user_id_list.txt,则user_id_list设置代码为: + +```json +"user_id_list": "user_id_list.txt", +``` + +## 设置filter + +filter控制爬取范围,值为1代表爬取全部原创微博,值为0代表爬取全部微博(原创+转发)。例如,如果要爬全部原创微博,请使用如下代码: + +```json +"filter": 1, +``` + +## 设置since_date + +since_date值可以是日期,也可以是整数。如果是日期,代表爬取该日期之后的微博,格式应为“yyyy-mm-dd”,如: + +```json +"since_date": "2018-01-01", +``` + +代表爬取从2018年1月1日到现在的微博。 + +如果是整数,代表爬取最近n天的微博,如: + +```json +"since_date": 10, +``` + +代表爬取最近10天的微博,这个说法不是特别准确,准确说是爬取发布时间从**10天前到本程序开始执行时**之间的微博。 + +**since_date是所有user的爬取起始时间,非常不灵活。如果你要爬多个用户,并且想单独为每个用户设置一个since_date,可以使用[定期自动爬取微博](https://github.com/dataabc/weiboSpider/blob/master/docs/automation.md)方法二中的方法,该方法可以为多个用户设置不同的since_date,非常灵活。** + +## 设置end_date + +end_date值可以是日期,也可以是"now"。如果是日期,代表爬取该日期之前的微博,格式应为“yyyy-mm-dd”;如果是"now",代表爬取发布日期从since_date到现在的微博。since_date配合end_date,表示爬取发布日期在since_date和end_date之间的微博,包含边界。since_date是起始日期,end_date是结束日期,因此end_date时间应晚于since_date。注意,since_date即可以通过config.json文件的since_date参数设置,也可以通过user_id_list.txt设置;而end_date只能通过config.json文件的end_date参数设置,是全局变量,所有user_id都使用同一个end_date。 + +**推荐使用"now"作为end_date值**,当值为"now"时,获取结果是正确和稳定的;当end_date值不是"now"时,在爬微博数非常多的账号时,程序可能不稳定,得到很多空微博页,并且此时无法获取微博中的视频,如果想要获取视频,请为end_date赋值为"now"。 + +## 设置random_wait_pages + +random_wait_pages值是一个长度为2的整数列表,代表每爬取x页微博暂停一次,x为整数,值在random_wait_pages列表两个整数之间随机获取。默认值为[1, 5],代表每爬取1到5页暂停一次,如果程序被限制,可以加快暂停频率,即适当减小random_wait_pages内的值。 + +## 设置random_wait_seconds + +random_wait_seconds值是一个长度为2的整数列表,代表每次暂停sleep x 秒,x为整数, 值在random_wait_seconds列表两个整数之间随机获取。默认值为[6, 10],代表每次暂停sleep 6到10秒,如果程序被限制,可以增加等待时间,即适当增大random_wait_seconds内的值。 + +## 设置global_wait + +global_wait控制全局等待时间,默认值为[[1000, 3600], [500, 2000]],代表获取1000页微博,程序一次性暂停3600秒;之后获取500页微博,程序再一次性暂停2000秒;之后如果再获取1000页微博,程序一次性暂停3600秒,以此类推。默认的只有前面的两个全局等待时间([1000, 3600]和[500, 2000]),可以设置多个,如值可以为[[1000, 3600], [500, 3000], [700, 3600]],程序会根据配置依次等待对应时间,如果配置全部被使用,程序会从第一个配置开始,依次使用,循环往复。 + +## 设置write_mode + +write_mode控制结果文件格式,取值范围是csv、txt、json、mongo、mysql和sqlite,分别代表将结果文件写入csv、txt、json、MongoDB、MySQL和SQLite数据库。write_mode可以同时包含这些取值中的一个或几个,如: + +```json +"write_mode": ["csv", "txt"], +``` + +代表将结果信息写入csv文件和txt文件。特别注意,如果你想写入数据库,除了在write_mode添加对应数据库的名字外,还应该安装相关数据库和对应python模块,具体操作见[设置数据库](https://github.com/dataabc/weiboSpider/blob/master/docs/settings.md#设置数据库可选)部分。 + +## 设置pic_download + +pic_download控制是否下载微博中的图片,值为1代表下载,值为0代表不下载,如 + +```json +"pic_download": 1, +``` + +代表下载微博中的图片。 + +## 设置video_download + +video_download控制是否下载微博中的视频,值为1代表下载,值为0代表不下载,如 + +```json +"video_download": 1, +``` + +代表下载微博中的视频。 + +## 设置result_dir_name + +result_dir_name控制结果目录的名字,可选值为0和1,默认值为0: + +```json +"result_dir_name": 0, +``` + +值为0表示将结果文件保存在以用户昵称为名的文件夹里,这样结果更清晰;值为1表示将结果保存在以用户id为名的文件夹里,这样更能保证多次爬取的一致性,因为用户昵称可以改变,用户id是不变的。 + +## 设置cookie + +请按照[如何获取cookie](https://github.com/dataabc/weiboSpider/blob/master/docs/cookie.md),获取cookie,然后将“your cookie”替换成真实的cookie值。 + +## 设置mysql_config(可选) + +mysql_config控制mysql参数配置。如果你不需要将结果信息写入mysql,这个参数可以忽略,即删除或保留都无所谓;如果你需要写入mysql且config.json文件中mysql_config的配置与你的mysql配置不一样,请将该值改成你自己mysql中的参数配置。 + +## 设置sqlite_config(可选) + +sqlite_config控制SQLite参数配置,代表SQLite数据库的保存路径,可根据自己需求修改。 + +## 设置数据库(可选) + +本部分是可选部分,如果不需要将爬取信息写入数据库,可跳过这一步。本程序目前支持MySQL数据库和MongoDB数据库,如果你需要写入其它数据库,可以参考这两个数据库的写法自己编写。 + +## MySQL数据库写入 + +要想将爬取信息写入MySQL,请根据自己的系统环境安装MySQL,然后命令行执行: + +```bash +$ pip install pymysql +``` + +## MongoDB数据库写入 + +要想将爬取信息写入MongoDB,请根据自己的系统环境安装MongoDB,然后命令行执行: + +```bash +$ pip install pymongo +``` +connection_string是MongoDB标准URI: +```text +mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] +``` + +dba_name和dba_password对应URI中的username和password。如果没有访问限制可不填。 +无访问限制的例子: +```json +"connection_string": "mongodb://localhost:27017/weibo", +``` +使用用户名和密码的例子: +```json +"connection_string": "mongodb://admin:password@localhost:27017/weibo", +"dba_name": "", +"dba_password": "", +``` +或 +```json +"connection_string": "mongodb://localhost:27017/weibo", +"dba_name": "admin", +"dba_password": "password", +``` + +MySQL和MongDB数据库的写入内容一样。程序首先会创建一个名为"weibo"的数据库,然后再创建"user"表和"weibo"表,包含爬取的所有内容。爬取到的微博**用户信息**或插入或更新,都会存储到user表里;爬取到的**微博信息**或插入或更新,都会存储到weibo表里,两个表通过user_id关联。如果想了解两个表的具体字段,请点击"详情"。 + +
+ +详情 + +- **user表** +- **id**:存储用户id,如"1669879400"; +- **nickname**:存储用户昵称,如"Dear-迪丽热巴"; +- **gender**:存储用户性别; +- **location**:存储用户所在地; +- **birthday**:存储用户出生日期; +- **description**:存储用户简介; +- **verified_reason**:存储用户认证; +- **talent**:存储用户标签; +- **education**:存储用户学习经历; +- **work**:存储用户工作经历; +- **weibo_num**:存储微博数; +- **following**:存储关注数; +- **followers**:存储粉丝数。 + +*** + +- **weibo表** +- **id**:存储微博id; +- **user_id**:存储微博发布者的用户id,如"1669879400"; +- **content**:存储微博正文; +- **article_url**:存储微博中头条文章的url,若微博中不存在头条文章,则值为''; +- **original_pictures**:存储原创微博的原始图片url和转发微博转发理由中的图片url。若某条微博有多张图片,则存储多个url,以英文逗号分割;若某微博没有图片,则值为"无"; +- **retweet_pictures**:存储被转发微博中的原始图片url。当最新微博为原创微博或者为没有图片的转发微博时,则值为"无",否则为被转发微博的图片url。若有多张图片,则存储多个url,以英文逗号分割; +- **publish_place**:存储微博的发布位置。如果某条微博没有位置信息,则值为"无"; +- **publish_time**:存储微博的发布时间; +- **up_num**:存储微博获得的点赞数; +- **retweet_num**:存储微博获得的转发数; +- **comment_num**:存储微博获得的评论数; +- **publish_tool**:存储微博的发布工具。 + +
+ +## 设置API接口POST联动(可选) + +本部分是可选部分,如果不需要将爬取信息通过POST请求发送到指定API接口,可跳过这一步 + +请求数据格式为 `content-type : application/json`,接口响应返回也需要是 `content-type : application/json`,HTTP状态码为 `200` + +数据主体与 `write_mode` 配置的 `json` 输出格式一致,是整页获取数据json,每页POST发送一次 + +`api_url` 为指定的API接口地址 + +`api_token` 为接口鉴权TOKEN,将在 Request Headers 中添加 `api-token` 字段,根据需要配置 \ No newline at end of file diff --git a/docs/userid.md b/docs/userid.md new file mode 100644 index 00000000..68d2e595 --- /dev/null +++ b/docs/userid.md @@ -0,0 +1,16 @@ +## 如何获取user_id + +1. 打开网址,搜索我们要找的人,如"迪丽热巴",进入她的主页; + ![user home](https://picture.cognize.me/cognize/github/weibospider/user_home.png) +2. 按照上图箭头所指,点击"资料"链接,跳转到用户资料页面; + ![user info](https://picture.cognize.me/cognize/github/weibospider/user_info.png) + +如上图所示,迪丽热巴微博资料页的地址为"",其中的"1669879400"即为此微博的user_id。 + +事实上,此微博的user_id也包含在用户主页()中,之所以我们还要点击主页中的"资料"来获取user_id,是因为很多用户的主页不是""的形式,而是""或""的形式。其中"微号"和user_id都是一串数字,如果仅仅通过主页地址提取user_id,很容易将"微号"误认为user_id。 + +上述可以获得一个user_id,如果想要获得**大量**微博,见[如何获取大量user_id](#如何获取大量user_id)部分。 + +## 如何获取大量user_id + +[如何获取user_id](#如何获取user_id)部分可以获得一个user_id,可以利用这一个user_id,获取该user_id微博用户关注人的user_id,一个user_id最多可以获得200个user_id,并写入user_id_list.txt文件。程序支持读文件,利用这200个user_id,可以获得最多200X200=40000个user_id。再利用这40000个user_id可以得到40000X200=8000000个user_id,如此反复,以此类推,可以获得大量user_id。本项目也支持读文件,将上述程序的结果文件user_id_list.txt路径赋值给本项目config.json的user_id_list参数,就可以获得这些user_id用户所发布的大量微博。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..23b93002 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +lxml==4.9.1 +requests==2.32.0 +tqdm==4.66.3 +absl-py==0.12.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..e76d2d0b --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +import setuptools + +with open('README.md', 'r', encoding='utf-8') as fh: + long_description = fh.read() + +setuptools.setup( + name='weibo-spider', + version='0.2.8', + author='Chen Lei', + author_email='chillychen1991@gmail.com', + description='新浪微博爬虫,用python爬取新浪微博数据。', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/dataabc/weiboSpider', + packages=setuptools.find_packages(), + package_data={'weibo_spider': ['config_sample.json', 'logging.conf']}, + classifiers=[ + 'Programming Language :: Python :: 3', + 'Operating System :: OS Independent', + ], + install_requires=[ + 'absl-py', + 'lxml', + 'requests', + 'tqdm', + ], + python_requires='>=3.6', +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_parser/__init__.py b/tests/test_parser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_parser/test_album_parser.py b/tests/test_parser/test_album_parser.py new file mode 100644 index 00000000..361949e4 --- /dev/null +++ b/tests/test_parser/test_album_parser.py @@ -0,0 +1,20 @@ +from unittest.mock import patch + +from .util import mock_request_get_content +from weibo_spider.parser.album_parser import AlbumParser + + +@patch('requests.get', mock_request_get_content) +def test_album_parser(): + album_parser = AlbumParser( + cookie="", + album_url="https://weibo.cn/album/166564740000001980768563?rl=1") + + pic_urls = album_parser.extract_pic_urls() + assert (len(pic_urls) == 4) + assert (pic_urls == [ + 'http://wx1.sinaimg.cn/wap180/76102133ly8ga961tpte6j20u00u0q65.jpg', + 'http://wx2.sinaimg.cn/wap180/76102133ly8fwr33wpn8fj20v90v9tbw.jpg', + 'http://wx4.sinaimg.cn/wap180/76102133ly8fvlyn5n52gj20v90v949a.jpg', + 'http://wx2.sinaimg.cn/wap180/76102133ly8fk0btnrn5zj20dp0e8q3t.jpg' + ]) diff --git a/tests/test_parser/test_comment_parser.py b/tests/test_parser/test_comment_parser.py new file mode 100644 index 00000000..51c79374 --- /dev/null +++ b/tests/test_parser/test_comment_parser.py @@ -0,0 +1,26 @@ +from unittest.mock import patch + +from .util import mock_request_get_content +from weibo_spider.parser.comment_parser import CommentParser + + +@patch('requests.get', mock_request_get_content) +def test_comment_parser(): + comment_parser = CommentParser(cookie="", weibo_id="J5cVGuUNq") + long_weibo = comment_parser.get_long_weibo() + long_retweet = comment_parser.get_long_retweet() + assert ( + long_retweet == """去年和亲善大使热巴@Dear-迪丽热巴 的特别回忆。""" + """我们在藏北羌塘一起爬山,探访藏羚羊、雪豹、黑颈鹤的栖息地,感受野生动物保护工作的点滴。""" + """此时此刻,我们比以往更加重视与自然相处的方式,我们也从未如此迫切需要将想法付诸行动。""" + """热巴已经和我们@北京绿色阳光 站在一起,希望看完视频的你们,也能获得同样感受与动力。""" + """We Stand for Wildlife. 明日朝阳68309的优酷视频 \xa0""") + assert ( + long_weibo == """去年和亲善大使热巴@Dear-迪丽热巴 的特别回忆。""" + """我们在藏北羌塘一起爬山,探访藏羚羊、雪豹、黑颈鹤的栖息地,感受野生动物保护工作的点滴。""" + """此时此刻,我们比以往更加重视与自然相处的方式,我们也从未如此迫切需要将想法付诸行动。""" + """热巴已经和我们@北京绿色阳光 站在一起,希望看完视频的你们,也能获得同样感受与动力。""" + """We Stand for Wildlife. 明日朝阳68309的优酷视频 \xa0""" + """原文转发[1000000] \xa0原文评论[38688] 转发理由: 在羌塘的美好回忆~""" + """第一次来到这片独特的荒野,看到野生动物自由生活,还有一群快乐可爱的人在守护着它们。""" + """把这些美好留存下来,关注野生动物保护,积极行动,我们每个人都能贡献力量。 \xa0 """) diff --git a/tests/test_parser/test_index_parser.py b/tests/test_parser/test_index_parser.py new file mode 100644 index 00000000..3c2ef26b --- /dev/null +++ b/tests/test_parser/test_index_parser.py @@ -0,0 +1,15 @@ +from unittest.mock import patch + +from .util import mock_request_get_content +from weibo_spider.parser.index_parser import IndexParser + + +@patch('requests.get', mock_request_get_content) +def test_index_parser(): + index_parser = IndexParser(cookie="", user_uri="1669879400") + assert (index_parser.get_page_num() == 117) + assert (str(index_parser.get_user()) == """用户昵称: Dear-迪丽热巴\n""" + """用户id: 1669879400\n""" + """微博数: 1159\n""" + """关注数: 253\n""" + """粉丝数: 70805574\n""") diff --git a/tests/test_parser/test_info_parser.py b/tests/test_parser/test_info_parser.py new file mode 100644 index 00000000..cc3aa9f3 --- /dev/null +++ b/tests/test_parser/test_info_parser.py @@ -0,0 +1,12 @@ +from unittest.mock import patch + +from .util import mock_request_get_content +from weibo_spider.parser.info_parser import InfoParser + + +@patch('requests.get', mock_request_get_content) +def test_info_parser(): + info_parser = InfoParser(cookie="", user_id="1669879400") + user = info_parser.extract_user_info() + # With info_parser, we can only get the nickname. + assert (user.nickname == "Dear-迪丽热巴") diff --git a/tests/test_parser/test_mblog_picAll_parser.py b/tests/test_parser/test_mblog_picAll_parser.py new file mode 100644 index 00000000..5dbdf530 --- /dev/null +++ b/tests/test_parser/test_mblog_picAll_parser.py @@ -0,0 +1,15 @@ +from unittest.mock import patch + +from .util import mock_request_get_content +from weibo_spider.parser.mblog_picAll_parser import MblogPicAllParser + + +@patch('requests.get', mock_request_get_content) +def test_mblog_picAll_parser(): + mblog_picAll_parser = MblogPicAllParser(cookie="", weibo_id="J5ZcSnCAg") + preview_picture_list = mblog_picAll_parser.extract_preview_picture_list() + # With info_parser, we can only get the nickname. + assert (len(preview_picture_list) == 18) + assert ( + preview_picture_list[0] == + 'http://ww3.sinaimg.cn/thumb180/63885668ly1gfn5qz5m1yj20u0140472.jpg') diff --git a/tests/test_parser/test_page_parser.py b/tests/test_parser/test_page_parser.py new file mode 100644 index 00000000..9de978c2 --- /dev/null +++ b/tests/test_parser/test_page_parser.py @@ -0,0 +1,38 @@ +from unittest.mock import patch + +from weibo_spider.parser.page_parser import PageParser + +from .util import mock_request_get_content + + +@patch('requests.get', mock_request_get_content) +def test_page_parser(): + user_config = { + 'user_uri': '1669879400', + 'since_date': '2020-06-01', + 'end_date': 'now' + } + page_parser = PageParser(cookie="", + user_config=user_config, + page=2, + filter=True) + weibos, weibo_id_list, to_continue = page_parser.get_one_page([]) + assert (weibo_id_list == ['J4PGk4yMw', 'J4EUStJKu']) + assert (len(weibos) == 2) + assert (str(weibos[0]) == """生日动态 \xa0\n""" + """微博发布位置:无\n""" + """发布时间:2020-06-03 00:00\n""" + """发布工具:生日动态\n""" + """点赞数:1499675\n""" + """转发数:1000000\n""" + """评论数:1000000\n""" + """url:https://weibo.cn/comment/J4PGk4yMw\n""") + assert (str(weibos[1]) == + """#微博剧场# #周放设计淡黄的长裙# 这是一幅有声音的手稿#幸福触手可及# 绿洲 \xa0原图\xa0\n""" + """微博发布位置:无\n""" + """发布时间:2020-06-01 20:35\n""" + """发布工具:绿洲APP\n""" + """点赞数:419181\n""" + """转发数:1000000\n""" + """评论数:1000000\n""" + """url:https://weibo.cn/comment/J4EUStJKu\n""") diff --git a/tests/test_parser/test_photo_parser.py b/tests/test_parser/test_photo_parser.py new file mode 100644 index 00000000..63cebe9b --- /dev/null +++ b/tests/test_parser/test_photo_parser.py @@ -0,0 +1,14 @@ +from unittest.mock import patch + +from weibo_spider.parser.photo_parser import PhotoParser + +from .util import mock_request_get_content + + +@patch('requests.get', mock_request_get_content) +def test_photo_parser(): + photo_parser = PhotoParser(cookie="", user_id=1980768563) + + avatar_album_url = photo_parser.extract_avatar_album_url() + assert (avatar_album_url == + "https://weibo.cn/album/166564740000001980768563?rl=1") diff --git a/tests/test_parser/util.py b/tests/test_parser/util.py new file mode 100644 index 00000000..959a1dca --- /dev/null +++ b/tests/test_parser/util.py @@ -0,0 +1,15 @@ +import json +import os +from unittest.mock import Mock + +from weibo_spider.parser.util import TEST_DATA_DIR, URL_MAP_FILE + + +def mock_request_get_content(url, headers): + with open(os.path.join(TEST_DATA_DIR, URL_MAP_FILE)) as f: + url_map = json.loads(f.read()) + resp_file = url_map[url] + mock = Mock() + with open(resp_file, "rb") as f: + mock.content = f.read() + return mock diff --git a/tests/testdata/2f62165fa3ca1e85e0d398d385c377a068b76eb95765f7020ffffd3e.html b/tests/testdata/2f62165fa3ca1e85e0d398d385c377a068b76eb95765f7020ffffd3e.html new file mode 100644 index 00000000..1101efb5 --- /dev/null +++ b/tests/testdata/2f62165fa3ca1e85e0d398d385c377a068b76eb95765f7020ffffd3e.html @@ -0,0 +1 @@ +Dear-迪丽热巴的微博
Dear-迪丽热巴的微博 加关注
 微博  相册 
生日动态  
赞[1499675] 转发[1000000] 评论[1000000] 收藏 2020-06-03 00:00 来自生日动态
炎炎夏日让每天的沐浴时光都变得尤其重要,精致的沙龙香相伴让沐浴也可以成为清新浪漫的享受!给大家@LUX力士 的沐浴小秘密分享,有力士植萃沐浴露,把沐浴变成“仪式感”!我的心选好物分享给你们啦 [笑而不语] LUX力士的微博视频  
赞[377578] 转发[1000000] 评论[1000000] 收藏 2020-05-31 10:59
#idoltube##周放vlog# 第二篇来啦!今天邀请大家走进生活,走进幸福的放放子一家~[喵喵]#幸福触手可及# Dear-迪丽热巴的微博视频  
赞[397970] 转发[1000000] 评论[1000000] 收藏 2020-05-30 19:02 来自国产剧集 · 视频社区
@法国娇韵诗 收到宠爱了~小娇的618#娇宠你有一套#,早晚护肤都靠它,超级喜欢这份宠爱!现在给全体爱丽丝们施法,希望你们都可以拥有这份让你变美的娇宠礼物哦~同款娇宠http://t.cn/A62cgDJp一起享用!  [组图共2张]
#微博剧场# 我为4A景区代言,酷飒周放的追剧邀请,你来吗? #4A景区触手可及#
@路易威登 PONT 9 手袋 陪你摩登一夏[嘻嘻]#LVPONT9#  [组图共3张]
#热巴手稿填色大赛#服装手稿填色游戏正式开启!图一出自迪迪子,图二出自放放子。迪迪子的面子就靠大家的后期填色了[微笑] 绿洲  [组图共2张]
图片 原图 
赞[733671] 转发[1000000] 评论[1000000] 收藏 2020-05-27 14:48 来自绿洲APP
转发了 护舒宝VM 的微博:还记得和宝宝陪着@Dear-迪丽热巴 走过的花路吗?谢谢阿丝们一直以来的陪伴[太开心][太开心]~为你甄选护舒宝天然纯棉卫生巾,给你透气亲肤的体验。现在上天猫超市购买,1套减25,第2套只要19.9。未来的花路,和宝宝一起用好物,守护热巴!#迪丽热巴[超话]#
图片 原图 赞[43521] 原文转发[1000000] 原文评论[13967]
转发理由:谢谢@护舒宝 和阿丝们的守护,每一刻都非常有意义。未来请继续指教啦~  
赞[418834] 转发[1000000] 评论[1000000] 收藏 2020-05-26 11:14
#idoltube##周放vlog# 放放子的第一支搞事业篇vlog已上线~约vlog的朋友们可以放下你们的号码牌了[可爱] #幸福触手可及# Dear-迪丽热巴的微博视频  
赞[450541] 转发[1000000] 评论[216934] 收藏 2020-05-25 20:53 来自影视剪辑 · 视频社区
下页 上页 首页  2/117页
TOP
彩版|触屏|语音
weibo.cn[06-19 00:47]
diff --git a/tests/testdata/4957814af5a123b82e974b5537dea736dfb34e48d8835203a45d2e67.html b/tests/testdata/4957814af5a123b82e974b5537dea736dfb34e48d8835203a45d2e67.html new file mode 100644 index 00000000..23803ea5 --- /dev/null +++ b/tests/testdata/4957814af5a123b82e974b5537dea736dfb34e48d8835203a45d2e67.html @@ -0,0 +1 @@ +Dear-迪丽热巴的微博
头像
Dear-迪丽热巴VM 女/上海   加关注
认证:嘉行传媒签约演员 
一只喜欢默默表演的小透明。工作联系jaywalk@jaywalk.com.cn ...
私信 资料 操作 特别关注 送Ta会员
 微博  相册 
#一起热爱就现在#给你们康康我眼前的画面[嘻嘻] 绿洲
图片 原图 
赞[523801] 转发[1000000] 评论[393530] 收藏 06月17日 18:12 来自绿洲APP
刚收到我定制的亓那眼镜,猜猜定制了什么[doge]好奇?没关系,你们也可以拥有自己的定制眼镜。关注@QINA亓那眼镜 解锁6月限定惊喜,#时髦寻宝计划# 线上线下都安排了[偷笑]QINA亓那眼镜的微博视频  
赞[415899] 转发[1000000] 评论[1000000] 收藏 06月15日 10:09
#idoltube##周放vlog# 什么?放放子还有两副面孔呢?[喵喵] #幸福触手可及# Dear-迪丽热巴的微博视频  
赞[318054] 转发[1000000] 评论[514546] 收藏 06月14日 20:09 来自影视剪辑 · 视频社区
图片 原图 
赞[1150265] 转发[1000000] 评论[1000000] 收藏 06月12日 19:11 来自绿洲APP
言出必行,说了18张就是18张,送给七千万的你们 ~  [组图共18张]
放放子缺个快板[偷笑] 绿洲
图片 原图 
赞[571755] 转发[1000000] 评论[1000000] 收藏 06月08日 15:17 来自绿洲APP
转发了 WCS野生生物保护学会V 的微博:去年和亲善大使热巴@Dear-迪丽热巴 的特别回忆[心]。我们在藏北羌塘一起爬山,探访藏羚羊、雪豹、黑颈鹤的栖息地,感受野生动物保护工作的点滴。此时此刻,我们比以往更加重视与自然相处的方式,我们也从未如此迫切需要将想法付诸行动。热巴已经和我们@北京绿色阳光 站在一起,希望看完视频的你们,也...全文 赞[119296] 原文转发[1000000] 原文评论[38688]
转发理由:在羌塘的美好回忆~第一次来到这片独特的荒野,看到野生动物自由生活,还有一群快乐可爱的人在守护着它们。把这些美好留存下来,关注野生动物保护,积极行动,我们每个人都能贡献力量。[心]  
赞[554415] 转发[1000000] 评论[1000000] 收藏 06月05日 11:11
要开心。要充实。
#微博live秀# 28岁的直播~@Dear-迪丽热巴 的一直播(下载App->http://t.cn/RDUuslr 
赞[435650] 转发[23584] 评论[1000000] 收藏 06月03日 19:00 来自一直播Yi
下页  1/117页
TOP
彩版|触屏|语音
weibo.cn[06-19 00:47]
diff --git a/tests/testdata/4d5ed0a3ebd0303cb45edd544dbc0ab5e86d43e103405f0c60515884.html b/tests/testdata/4d5ed0a3ebd0303cb45edd544dbc0ab5e86d43e103405f0c60515884.html new file mode 100644 index 00000000..9cb503c5 --- /dev/null +++ b/tests/testdata/4d5ed0a3ebd0303cb45edd544dbc0ab5e86d43e103405f0c60515884.html @@ -0,0 +1 @@ +评论列表
Dear-迪丽热巴VM  转发了 @WCS野生生物保护学会V 的微博:去年和亲善大使热巴@Dear-迪丽热巴 的特别回忆[心]。我们在藏北羌塘一起爬山,探访藏羚羊、雪豹、黑颈鹤的栖息地,感受野生动物保护工作的点滴。此时此刻,我们比以往更加重视与自然相处的方式,我们也从未如此迫切需要将想法付诸行动。热巴已经和我们@北京绿色阳光 站在一起,希望看完视频的你们,也能获得同样感受与动力。

We Stand for Wildlife.

明日朝阳68309的优酷视频  原文转发[1000000]  原文评论[38688]
转发理由: 在羌塘的美好回忆~第一次来到这片独特的荒野,看到野生动物自由生活,还有一群快乐可爱的人在守护着它们。把这些美好留存下来,关注野生动物保护,积极行动,我们每个人都能贡献力量。[心]   06月05日 11:11  关注她  举报 收藏 操作
 转发[1000000]  评论[1000000]  赞[554415] 
评论只显示前140字:

 
[热门]LUX力士VM:大家和迪迪一起保护动物[心] 举报 赞[18506] 回复 06月05日 11:12 来自网页
[热门]护舒宝VM:跟迪迪一起好好保护野生动物[心] 举报 赞[17193] 回复 06月05日 11:13 来自网页
[热门]Dear迪丽热巴后援会VM:一起保护野生动物[给你小心心] 举报 赞[15760] 回复 06月05日 11:12 来自网页
心动小巴 :姐姐你是我的榜样~  举报   赞[0]  回复   42分钟前 来自网页
给肖战热巴摘星星 :跟迪迪一起好好保护野生动物[心]  举报   赞[0]  回复   06月18日 23:22 来自网页
唯独爱你0603 :♥️♥️♥️  举报   赞[0]  回复   06月17日 23:08 来自网页
山野千里57383 :迪丽热巴  举报   赞[0]  回复   06月17日 22:19 来自网页
罗兰小幸福-1988 :嗯热巴姐姐说的对  举报   赞[1]  回复   06月17日 22:02 来自网页
ColumbiaYemenRussia :热巴,加油哦  举报   赞[0]  回复   06月17日 17:11 来自网页
江南很难 M :离谱  举报   赞[0]  回复   06月17日 14:56 来自网页
下页  1/100000页
\ No newline at end of file diff --git a/tests/testdata/63a98849ec82b2c87ec55bca03cbf5988f7eac233a23d86b4fdf5ffd.html b/tests/testdata/63a98849ec82b2c87ec55bca03cbf5988f7eac233a23d86b4fdf5ffd.html new file mode 100644 index 00000000..ea9bdf4c --- /dev/null +++ b/tests/testdata/63a98849ec82b2c87ec55bca03cbf5988f7eac233a23d86b4fdf5ffd.html @@ -0,0 +1 @@ +微博
图片加载中... 1/18 原图
图片加载中... 2/18 原图
图片加载中... 3/18 原图
图片加载中... 4/18 原图
图片加载中... 5/18 原图
图片加载中... 6/18 原图
图片加载中... 7/18 原图
图片加载中... 8/18 原图
图片加载中... 9/18 原图
图片加载中... 10/18 原图
图片加载中... 11/18 原图
图片加载中... 12/18 原图
图片加载中... 13/18 原图
图片加载中... 14/18 原图
图片加载中... 15/18 原图
图片加载中... 16/18 原图
图片加载中... 17/18 原图
图片加载中... 18/18 原图
\ No newline at end of file diff --git a/tests/testdata/76233b3f90394581aac6f19cfa5d674a610e8b442b1f83de7673ab49.html b/tests/testdata/76233b3f90394581aac6f19cfa5d674a610e8b442b1f83de7673ab49.html new file mode 100644 index 00000000..66883ef7 --- /dev/null +++ b/tests/testdata/76233b3f90394581aac6f19cfa5d674a610e8b442b1f83de7673ab49.html @@ -0,0 +1 @@ +微博
图片加载中... 1/2 原图
图片加载中... 2/2 原图
diff --git a/tests/testdata/a4437630f3bdfa2757bae1595186ac063fe5ec25cf2f98116ece83cb.html b/tests/testdata/a4437630f3bdfa2757bae1595186ac063fe5ec25cf2f98116ece83cb.html new file mode 100644 index 00000000..d10aba05 --- /dev/null +++ b/tests/testdata/a4437630f3bdfa2757bae1595186ac063fe5ec25cf2f98116ece83cb.html @@ -0,0 +1 @@ +Dear-迪丽热巴的微博
头像
Dear-迪丽热巴VM 女/上海   加关注
认证:嘉行传媒签约演员 
一只喜欢默默表演的小透明。工作联系jaywalk@jaywalk.com.cn ...
私信 资料 操作 特别关注 送Ta会员
 微博  相册 
#一起热爱就现在#给你们康康我眼前的画面[嘻嘻] 绿洲
图片 原图 
赞[523801] 转发[1000000] 评论[393530] 收藏 06月17日 18:12 来自绿洲APP
刚收到我定制的亓那眼镜,猜猜定制了什么[doge]好奇?没关系,你们也可以拥有自己的定制眼镜。关注@QINA亓那眼镜 解锁6月限定惊喜,#时髦寻宝计划# 线上线下都安排了[偷笑]QINA亓那眼镜的微博视频  
赞[415899] 转发[1000000] 评论[1000000] 收藏 06月15日 10:09
#idoltube##周放vlog# 什么?放放子还有两副面孔呢?[喵喵] #幸福触手可及# Dear-迪丽热巴的微博视频  
赞[318054] 转发[1000000] 评论[514546] 收藏 06月14日 20:09 来自影视剪辑 · 视频社区
图片 原图 
赞[1150265] 转发[1000000] 评论[1000000] 收藏 06月12日 19:11 来自绿洲APP
言出必行,说了18张就是18张,送给七千万的你们 ~  [组图共18张]
放放子缺个快板[偷笑] 绿洲
图片 原图 
赞[571755] 转发[1000000] 评论[1000000] 收藏 06月08日 15:17 来自绿洲APP
转发了 WCS野生生物保护学会V 的微博:去年和亲善大使热巴@Dear-迪丽热巴 的特别回忆[心]。我们在藏北羌塘一起爬山,探访藏羚羊、雪豹、黑颈鹤的栖息地,感受野生动物保护工作的点滴。此时此刻,我们比以往更加重视与自然相处的方式,我们也从未如此迫切需要将想法付诸行动。热巴已经和我们@北京绿色阳光 站在一起,希望看完视频的你们,也...全文 赞[119296] 原文转发[1000000] 原文评论[38688]
转发理由:在羌塘的美好回忆~第一次来到这片独特的荒野,看到野生动物自由生活,还有一群快乐可爱的人在守护着它们。把这些美好留存下来,关注野生动物保护,积极行动,我们每个人都能贡献力量。[心]  
赞[554415] 转发[1000000] 评论[1000000] 收藏 06月05日 11:11
要开心。要充实。
#微博live秀# 28岁的直播~@Dear-迪丽热巴 的一直播(下载App->http://t.cn/RDUuslr 
赞[435650] 转发[23584] 评论[1000000] 收藏 06月03日 19:00 来自一直播Yi
下页  1/117页
TOP
彩版|触屏|语音
weibo.cn[06-19 00:47]
diff --git a/tests/testdata/b541fd1751117498b6d6f40d3321686ddf871651237c4ac854a5c3eb.html b/tests/testdata/b541fd1751117498b6d6f40d3321686ddf871651237c4ac854a5c3eb.html new file mode 100644 index 00000000..30936fb8 --- /dev/null +++ b/tests/testdata/b541fd1751117498b6d6f40d3321686ddf871651237c4ac854a5c3eb.html @@ -0,0 +1 @@ +专辑:头像相册
专辑:头像相册
照片墙|传统列表
TOP
\ No newline at end of file diff --git a/tests/testdata/ca5f2a555e8d62f728c66fa90afb2d54d19f8c898e164204a61bdf03.html b/tests/testdata/ca5f2a555e8d62f728c66fa90afb2d54d19f8c898e164204a61bdf03.html new file mode 100644 index 00000000..ad189dec --- /dev/null +++ b/tests/testdata/ca5f2a555e8d62f728c66fa90afb2d54d19f8c898e164204a61bdf03.html @@ -0,0 +1 @@ +Dear-迪丽热巴的资料
头像
会员等级:7级 送Ta会员
微身份 语惊四座 七步成诗 谈笑风生 更多勋章
基本信息
昵称:Dear-迪丽热巴
认证:嘉行传媒签约演员 
性别:女
地区:上海
生日:双子座
认证信息:嘉行传媒签约演员 
简介:一只喜欢默默表演的小透明。工作联系jaywalk@jaywalk.com.cn 🍒
学习经历
·上海戏剧学院
工作经历
·嘉行传媒 
其他信息
互联网:http://weibo.com/u/1669879400
手机版:https://weibo.cn/u/1669879400
她的相册>>
TOP
彩版|触屏|语音
weibo.cn[06-19 00:47]
diff --git a/tests/testdata/d486235d4a17dd0accb0f2cc77b3648abfa03580b9e0cdb61f1e618f.html b/tests/testdata/d486235d4a17dd0accb0f2cc77b3648abfa03580b9e0cdb61f1e618f.html new file mode 100644 index 00000000..1f1b6a9c --- /dev/null +++ b/tests/testdata/d486235d4a17dd0accb0f2cc77b3648abfa03580b9e0cdb61f1e618f.html @@ -0,0 +1 @@ +Dear-迪丽热巴的微博
Dear-迪丽热巴的微博 加关注
 微博  相册 
粉色天空、闪耀夜色、浪漫爱意…我把我喜爱的元素和巴黎限定记忆全部定格在这一瓶#YSL反转巴黎#热爱限定中,第一次与YSL一起合作设计香水,在这#拦不住的夏天#把甜甜的曼陀罗花香送给你们,喜欢吗?💓  [组图共2张]
#幸福触手可及开播##幸福触手可及# 度量自身,方能修炼精彩人生。追梦不易,披荆斩棘。今晚八点@湖南卫视 和周放,一起守护梦想,书写初夏。
很高兴成为力士大中华区沐浴系列代言人,520就要到啦,大家快来接收告白福利哦!全新植萃泡泡沐浴露让每一位小仙女都能在浓密泡泡浴中拥有夏日嫩白肌,仙气香气都十足!关注@LUX力士 第一时间锁定新品哦!LUX力士的微博视频  
赞[741402] 转发[1000000] 评论[1000000] 收藏 05月19日 09:03
转发了 电视剧幸福触手可及VM 的微博:#幸福触手可及##幸福触手可及定档0519# 从没有一个时刻,幸福如此靠近,只因有你在身边[心]5月19日20:00锁定@湖南卫视 金鹰独播剧场,@优酷 @爱奇艺 @腾讯视频 24点同步更新,等你解锁初夏甜梦!
图片 原图 赞[129675] 原文转发[332651] 原文评论[6900]
转发理由:#幸福触手可及定档0519# 唯有热爱,不负韶华,为之全力以赴,才能成为更优秀的人。5月19日20:00锁定湖南卫视#幸福触手可及# ,愈挫愈勇的独立设计师周放来啦。  
赞[480418] 转发[57012] 评论[40966] 收藏 05月15日 20:23
哈哈哈哈哈哈👅
转发了 北京2022年冬奥会VM 的微博:【爱豆喊你来助力#北京2022#
花样滑冰,旋转跳跃 ,“迪丽”前行 @Dear-迪丽热巴 北京2022年冬奥会的微博视频
 赞[680201] 原文转发[1450782] 原文评论[50694]
转发理由:与我一起,关注花样滑冰,为中国健儿鼓劲加油[加油]  
赞[501777] 转发[1000000] 评论[1000000] 收藏 05月15日 10:20
转发了 央视网VM 的微博:【想看看战疫一线医护人员们的脸!#极限挑战致敬医护人员#】脱下防疫服,援鄂人员们原来是这个模样。八位医护人员集体分享支援一线的故事,是他们为后方的我们竖起了最坚实的屏障,感谢这群医护天使的负重前行,致敬!@央视网青年 @雷佳音 @岳云鹏 @演员王迅 @贾乃亮 @努力努力再努力x @Dear-迪丽热巴...全文 赞[364004] 原文转发[1056354] 原文评论[3645]
转发理由:#极限挑战# 感谢你们的守护,最美的逆行者们[心]  
赞[571256] 转发[1000000] 评论[362127] 收藏 05月10日 23:01
#极限挑战# 无奖填词竞答,今晚看👉登峰造_,不可_量,百里_一,南征北_~ Dear-迪丽热巴的微博视频  
赞[731516] 转发[1000000] 评论[1000000] 收藏 05月10日 16:50
转发了 中国青年报VM 的微博:#五四致敬战疫青年# #青春万岁#各地应急响应级别陆续下调,我们正在走向痊愈。回望这些年轻医务人员的脸,不应忘记,正是他们在危难之下,白衣执甲,毅然逆行,为我们筑起血肉长城。感恩提灯天使,致敬最可爱的人!春暖花开,等到疫情完全解除,无论你是从医还是就医,请记住医患之间的休戚与共、唇齿...全文 [组图共12张]
图片 原图 赞[32125] 原文转发[4801631] 原文评论[6975]
转发理由:#五四致敬战疫青年#五四青年节前夕,让我们说一声,#谢谢你保护了我们#!  
赞[721484] 转发[1000000] 评论[597487] 收藏 05月02日 16:40
转发了 东方卫视极限挑战VM 的微博:鸡条君目睹了vivo#极限挑战#第六季首发阵容@雷佳音 @岳云鹏 @演员王迅 @贾乃亮 @努力努力再努力x @Dear-迪丽热巴 @郭京飞 @邓伦 集结的整个过程,这就是欢迎新人的方式[疑问]说好要相亲相爱的呢😂东方卫视极限挑战的微博视频  赞[711013] 原文转发[1409505] 原文评论[14761]
转发理由:#极限挑战#举手之劳,岳岳哥别客气!//@岳云鹏:#极限挑战#谢谢热巴@Dear-迪丽热巴 给我p图,我这里还有好多库存 查看图片  
赞[983376] 转发[1000000] 评论[1000000] 收藏 04月30日 12:30
下页 上页 首页  3/117页
TOP
彩版|触屏|语音
weibo.cn[06-19 00:47]
diff --git a/tests/testdata/e4d541ecb02253c14abc1d52605fc00d91279df9ac4c1465c85b91b3.html b/tests/testdata/e4d541ecb02253c14abc1d52605fc00d91279df9ac4c1465c85b91b3.html new file mode 100644 index 00000000..161b4305 --- /dev/null +++ b/tests/testdata/e4d541ecb02253c14abc1d52605fc00d91279df9ac4c1465c85b91b3.html @@ -0,0 +1 @@ +微博
霜叶的相册
 微博  相册 
 
TOP
\ No newline at end of file diff --git a/tests/testdata/e97222acd5bc7d8d1bfbd3f352f8cad3e36fdd19e40b69e1c33fb3c3.html b/tests/testdata/e97222acd5bc7d8d1bfbd3f352f8cad3e36fdd19e40b69e1c33fb3c3.html new file mode 100644 index 00000000..9a4e494b --- /dev/null +++ b/tests/testdata/e97222acd5bc7d8d1bfbd3f352f8cad3e36fdd19e40b69e1c33fb3c3.html @@ -0,0 +1 @@ +微博
图片加载中... 1/2 原图
图片加载中... 2/2 原图
diff --git a/tests/testdata/url_map.json b/tests/testdata/url_map.json new file mode 100644 index 00000000..39cbd80e --- /dev/null +++ b/tests/testdata/url_map.json @@ -0,0 +1,13 @@ +{ + "https://weibo.cn/1669879400/profile": "tests/testdata/a4437630f3bdfa2757bae1595186ac063fe5ec25cf2f98116ece83cb.html", + "https://weibo.cn/1669879400/info": "tests/testdata/ca5f2a555e8d62f728c66fa90afb2d54d19f8c898e164204a61bdf03.html", + "https://weibo.cn/1669879400/profile?page=1": "tests/testdata/4957814af5a123b82e974b5537dea736dfb34e48d8835203a45d2e67.html", + "https://weibo.cn/mblog/picAll/J6k49kbTc?rl=1": "tests/testdata/e97222acd5bc7d8d1bfbd3f352f8cad3e36fdd19e40b69e1c33fb3c3.html", + "https://weibo.cn/mblog/picAll/J5ZcSnCAg?rl=1": "tests/testdata/63a98849ec82b2c87ec55bca03cbf5988f7eac233a23d86b4fdf5ffd.html", + "https://weibo.cn/1669879400/profile?page=2": "tests/testdata/2f62165fa3ca1e85e0d398d385c377a068b76eb95765f7020ffffd3e.html", + "https://weibo.cn/1669879400/profile?page=3": "tests/testdata/d486235d4a17dd0accb0f2cc77b3648abfa03580b9e0cdb61f1e618f.html", + "https://weibo.cn/mblog/picAll/J3xfm61AZ?rl=1": "tests/testdata/76233b3f90394581aac6f19cfa5d674a610e8b442b1f83de7673ab49.html", + "https://weibo.cn/comment/J5cVGuUNq": "tests/testdata/4d5ed0a3ebd0303cb45edd544dbc0ab5e86d43e103405f0c60515884.html", + "https://weibo.cn/1980768563/photo?tf=6_008": "tests/testdata/e4d541ecb02253c14abc1d52605fc00d91279df9ac4c1465c85b91b3.html", + "https://weibo.cn/album/166564740000001980768563?rl=1": "tests/testdata/b541fd1751117498b6d6f40d3321686ddf871651237c4ac854a5c3eb.html" +} diff --git a/weiboSpider.py b/weiboSpider.py deleted file mode 100644 index b1445653..00000000 --- a/weiboSpider.py +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -import os -import re -import requests -import sys -import traceback -from datetime import datetime -from datetime import timedelta -from lxml import etree - - -class Weibo: - cookie = {"Cookie": "your cookie"} # 将your cookie替换成自己的cookie - - # Weibo类初始化 - def __init__(self, user_id, filter=0): - self.user_id = user_id # 用户id,即需要我们输入的数字,如昵称为“Dear-迪丽热巴”的id为1669879400 - self.filter = filter # 取值范围为0、1,程序默认值为0,代表要爬取用户的全部微博,1代表只爬取用户的原创微博 - self.username = '' # 用户名,如“Dear-迪丽热巴” - self.weibo_num = 0 # 用户全部微博数 - self.weibo_num2 = 0 # 爬取到的微博数 - self.following = 0 # 用户关注数 - self.followers = 0 # 用户粉丝数 - self.weibo_content = [] # 微博内容 - self.weibo_place = [] # 微博位置 - self.publish_time = [] # 微博发布时间 - self.up_num = [] # 微博对应的点赞数 - self.retweet_num = [] # 微博对应的转发数 - self.comment_num = [] # 微博对应的评论数 - self.publish_tool = [] # 微博发布工具 - - # 获取用户昵称 - def get_username(self): - try: - url = "https://weibo.cn/%d/info" % (self.user_id) - html = requests.get(url, cookies=self.cookie).content - selector = etree.HTML(html) - username = selector.xpath("//title/text()")[0] - self.username = username[:-3] - print(u"用户名: " + self.username) - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 获取用户微博数、关注数、粉丝数 - def get_user_info(self): - try: - url = "https://weibo.cn/u/%d?filter=%d&page=1" % ( - self.user_id, self.filter) - html = requests.get(url, cookies=self.cookie).content - selector = etree.HTML(html) - pattern = r"\d+\.?\d*" - - # 微博数 - str_wb = selector.xpath( - "//div[@class='tip2']/span[@class='tc']/text()")[0] - guid = re.findall(pattern, str_wb, re.S | re.M) - for value in guid: - num_wb = int(value) - break - self.weibo_num = num_wb - print(u"微博数: " + str(self.weibo_num)) - - # 关注数 - str_gz = selector.xpath("//div[@class='tip2']/a/text()")[0] - guid = re.findall(pattern, str_gz, re.M) - self.following = int(guid[0]) - print(u"关注数: " + str(self.following)) - - # 粉丝数 - str_fs = selector.xpath("//div[@class='tip2']/a/text()")[1] - guid = re.findall(pattern, str_fs, re.M) - self.followers = int(guid[0]) - print(u"粉丝数: " + str(self.followers)) - print( - "===========================================================================") - - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 获取"长微博"全部文字内容 - def get_long_weibo(self, weibo_link): - try: - html = requests.get(weibo_link, cookies=self.cookie).content - selector = etree.HTML(html) - info = selector.xpath("//div[@class='c']")[1] - wb_content = info.xpath("div/span[@class='ctt']")[0].xpath( - "string(.)").encode(sys.stdout.encoding, "ignore").decode( - sys.stdout.encoding) - return wb_content - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 获取转发微博信息 - def get_retweet(self, is_retweet, info, wb_content): - try: - original_user = is_retweet[0].xpath("a/text()") - if not original_user: - wb_content = u"转发微博已被删除" - return wb_content - else: - original_user = original_user[0] - retweet_reason = info.xpath("div")[-1].xpath("string(.)").encode( - sys.stdout.encoding, "ignore").decode( - sys.stdout.encoding) - retweet_reason = retweet_reason[:retweet_reason.rindex(u"赞")] - wb_content = (retweet_reason + "\n" + u"原始用户: " + - original_user + "\n" + u"转发内容: " + wb_content) - return wb_content - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 获取用户微博内容及对应的发布时间、点赞数、转发数、评论数 - def get_weibo_info(self): - try: - url = "https://weibo.cn/u/%d?filter=%d&page=1" % ( - self.user_id, self.filter) - html = requests.get(url, cookies=self.cookie).content - selector = etree.HTML(html) - if selector.xpath("//input[@name='mp']") == []: - page_num = 1 - else: - page_num = (int)(selector.xpath( - "//input[@name='mp']")[0].attrib["value"]) - pattern = r"\d+\.?\d*" - for page in range(1, page_num + 1): - url2 = "https://weibo.cn/u/%d?filter=%d&page=%d" % ( - self.user_id, self.filter, page) - html2 = requests.get(url2, cookies=self.cookie).content - selector2 = etree.HTML(html2) - info = selector2.xpath("//div[@class='c']") - is_empty = info[0].xpath("div/span[@class='ctt']") - if is_empty: - for i in range(0, len(info) - 2): - # 微博内容 - str_t = info[i].xpath("div/span[@class='ctt']") - weibo_content = str_t[0].xpath("string(.)").encode( - sys.stdout.encoding, "ignore").decode( - sys.stdout.encoding) - weibo_content = weibo_content[:-1] - weibo_id = info[i].xpath("@id")[0][2:] - a_link = info[i].xpath( - "div/span[@class='ctt']/a") - is_retweet = info[i].xpath("div/span[@class='cmt']") - if a_link: - if a_link[-1].xpath("text()")[0] == u"全文": - weibo_link = "https://weibo.cn/comment/" + weibo_id - wb_content = self.get_long_weibo(weibo_link) - if wb_content: - if not is_retweet: - wb_content = wb_content[1:] - weibo_content = wb_content - if is_retweet: - weibo_content = self.get_retweet( - is_retweet, info[i], weibo_content) - self.weibo_content.append(weibo_content) - print(weibo_content) - - # 微博位置 - div_first = info[i].xpath("div")[0] - a_list = div_first.xpath("a") - weibo_place = u"无" - for a in a_list: - if ("place.weibo.com" in a.xpath("@href")[0] and - a.xpath("text()")[0] == u"显示地图"): - weibo_place = div_first.xpath( - "span[@class='ctt']/a")[-1] - if u"的秒拍视频" in div_first.xpath("span[@class='ctt']/a/text()")[-1]: - weibo_place = div_first.xpath( - "span[@class='ctt']/a")[-2] - weibo_place = weibo_place.xpath("string(.)").encode( - sys.stdout.encoding, "ignore").decode(sys.stdout.encoding) - break - self.weibo_place.append(weibo_place) - print(u"微博位置: " + weibo_place) - - # 微博发布时间 - str_time = info[i].xpath("div/span[@class='ct']") - str_time = str_time[0].xpath("string(.)").encode( - sys.stdout.encoding, "ignore").decode( - sys.stdout.encoding) - publish_time = str_time.split(u'来自')[0] - if u"刚刚" in publish_time: - publish_time = datetime.now().strftime( - '%Y-%m-%d %H:%M') - elif u"分钟" in publish_time: - minute = publish_time[:publish_time.find(u"分钟")] - minute = timedelta(minutes=int(minute)) - publish_time = ( - datetime.now() - minute).strftime( - "%Y-%m-%d %H:%M") - elif u"今天" in publish_time: - today = datetime.now().strftime("%Y-%m-%d") - time = publish_time[3:] - publish_time = today + " " + time - elif u"月" in publish_time: - year = datetime.now().strftime("%Y") - month = publish_time[0:2] - day = publish_time[3:5] - time = publish_time[7:12] - publish_time = ( - year + "-" + month + "-" + day + " " + time) - else: - publish_time = publish_time[:16] - self.publish_time.append(publish_time) - print(u"微博发布时间: " + publish_time) - - # 微博发布工具 - if len(str_time.split(u'来自')) > 1: - publish_tool = str_time.split(u'来自')[1] - else: - publish_tool = u"无" - self.publish_tool.append(publish_tool) - print(u"微博发布工具: " + publish_tool) - - str_footer = info[i].xpath("div")[-1] - str_footer = str_footer.xpath("string(.)").encode( - sys.stdout.encoding, "ignore").decode(sys.stdout.encoding) - str_footer = str_footer[str_footer.rfind(u'赞'):] - guid = re.findall(pattern, str_footer, re.M) - - # 点赞数 - up_num = int(guid[0]) - self.up_num.append(up_num) - print(u"点赞数: " + str(up_num)) - - # 转发数 - retweet_num = int(guid[1]) - self.retweet_num.append(retweet_num) - print(u"转发数: " + str(retweet_num)) - - # 评论数 - comment_num = int(guid[2]) - self.comment_num.append(comment_num) - print(u"评论数: " + str(comment_num)) - print( - "===========================================================================") - - self.weibo_num2 += 1 - - if not self.filter: - print(u"共" + str(self.weibo_num2) + u"条微博") - else: - print(u"共" + str(self.weibo_num) + u"条微博,其中" + - str(self.weibo_num2) + u"条为原创微博" - ) - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 将爬取的信息写入文件 - def write_txt(self): - try: - if self.filter: - result_header = u"\n\n原创微博内容: \n" - else: - result_header = u"\n\n微博内容: \n" - result = (u"用户信息\n用户昵称:" + self.username + - u"\n用户id: " + str(self.user_id) + - u"\n微博数: " + str(self.weibo_num) + - u"\n关注数: " + str(self.following) + - u"\n粉丝数: " + str(self.followers) + - result_header - ) - for i in range(1, self.weibo_num2 + 1): - text = (str(i) + ":" + self.weibo_content[i - 1] + "\n" + - u"微博位置: " + self.weibo_place[i - 1] + "\n" + - u"发布时间: " + self.publish_time[i - 1] + "\n" + - u"点赞数: " + str(self.up_num[i - 1]) + - u" 转发数: " + str(self.retweet_num[i - 1]) + - u" 评论数: " + str(self.comment_num[i - 1]) + "\n" + - u"发布工具: " + self.publish_tool[i - 1] + "\n\n" - ) - result = result + text - file_dir = os.path.split(os.path.realpath(__file__))[ - 0] + os.sep + "weibo" - if not os.path.isdir(file_dir): - os.mkdir(file_dir) - file_path = file_dir + os.sep + "%d" % self.user_id + ".txt" - f = open(file_path, "wb") - f.write(result.encode(sys.stdout.encoding)) - f.close() - print(u"微博写入文件完毕,保存路径:") - print(file_path) - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - # 运行爬虫 - def start(self): - try: - self.get_username() - self.get_user_info() - self.get_weibo_info() - self.write_txt() - print(u"信息抓取完毕") - print( - "===========================================================================") - except Exception as e: - print("Error: ", e) - - -def main(): - try: - # 使用实例,输入一个用户id,所有信息都会存储在wb实例中 - user_id = 5053084638 # 可以改成任意合法的用户id(爬虫的微博id除外) - filter = 0 # 值为0表示爬取全部微博(原创微博+转发微博),值为1表示只爬取原创微博 - wb = Weibo(user_id, filter) # 调用Weibo类,创建微博实例wb - wb.start() # 爬取微博信息 - print(u"用户名: " + wb.username) - print(u"全部微博数: " + str(wb.weibo_num)) - print(u"关注数: " + str(wb.following)) - print(u"粉丝数: " + str(wb.followers)) - if wb.weibo_content: - print(u"最新/置顶 微博为: " + wb.weibo_content[0]) - print(u"最新/置顶 微博位置: " + wb.weibo_place[0]) - print(u"最新/置顶 微博发布时间: " + wb.publish_time[0]) - print(u"最新/置顶 微博获得赞数: " + str(wb.up_num[0])) - print(u"最新/置顶 微博获得转发数: " + str(wb.retweet_num[0])) - print(u"最新/置顶 微博获得评论数: " + str(wb.comment_num[0])) - print(u"最新/置顶 微博发布工具: " + wb.publish_tool[0]) - except Exception as e: - print("Error: ", e) - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/weibo_spider/__init__.py b/weibo_spider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/weibo_spider/__main__.py b/weibo_spider/__main__.py new file mode 100644 index 00000000..f1eafa65 --- /dev/null +++ b/weibo_spider/__main__.py @@ -0,0 +1,8 @@ +import os +import sys + +from absl import app +sys.path.append(os.path.abspath(os.path.dirname(os.getcwd()))) +from weibo_spider.spider import main + +app.run(main) diff --git a/weibo_spider/config_sample.json b/weibo_spider/config_sample.json new file mode 100644 index 00000000..262398d9 --- /dev/null +++ b/weibo_spider/config_sample.json @@ -0,0 +1,37 @@ +{ + "user_id_list": ["1669879400"], + "filter": 1, + "since_date": "2018-01-01", + "end_date": "now", + "random_wait_pages": [1, 5], + "random_wait_seconds": [6, 10], + "global_wait": [[1000, 3600], [500, 2000]], + "write_mode": ["csv", "txt"], + "pic_download": 1, + "video_download": 1, + "file_download_timeout": [5, 5, 10], + "result_dir_name": 0, + "cookie": "your cookie", + "mysql_config": { + "host": "localhost", + "port": 3306, + "user": "root", + "password": "123456", + "charset": "utf8mb4" + }, + "kafka_config": { + "bootstrap-server": "127.0.0.1:9092", + "weibo_topics": ["spider_weibo"], + "user_topics": ["spider_weibo"] + }, + "sqlite_config": "weibo.db", + "mongo_config": { + "connection_string": "mongodb://admin:password@localhost:27017/weibo", + "dba_name": "", + "dba_password": "" + }, + "post_config": { + "api_url": "", + "api_token": "" + } +} diff --git a/weibo_spider/config_util.py b/weibo_spider/config_util.py new file mode 100644 index 00000000..ba4676b3 --- /dev/null +++ b/weibo_spider/config_util.py @@ -0,0 +1,174 @@ +import codecs +import logging +import os +import sys +from datetime import datetime + +logger = logging.getLogger('spider.config_util') + + +def _is_date(date_str): + """判断日期格式是否正确""" + try: + if ':' in date_str: + datetime.strptime(date_str, '%Y-%m-%d %H:%M') + else: + datetime.strptime(date_str, '%Y-%m-%d') + return True + except ValueError: + return False + + +def validate_config(config): + """验证配置是否正确""" + + # 验证filter、pic_download、video_download + argument_list = ['filter', 'pic_download', 'video_download'] + for argument in argument_list: + if config[argument] != 0 and config[argument] != 1: + logger.warning(u'%s值应为0或1,请重新输入', config[argument]) + sys.exit() + + # 验证since_date + since_date = config['since_date'] + if (not _is_date(str(since_date))) and (not isinstance(since_date, int)): + logger.warning(u'since_date值应为yyyy-mm-dd形式或整数,请重新输入') + sys.exit() + + # 验证end_date + end_date = str(config['end_date']) + if (not _is_date(end_date)) and (end_date != 'now'): + logger.warning(u'end_date值应为yyyy-mm-dd形式或"now",请重新输入') + sys.exit() + + # 验证random_wait_pages + random_wait_pages = config['random_wait_pages'] + if not isinstance(random_wait_pages, list): + logger.warning(u'random_wait_pages参数值应为list类型,请重新输入') + sys.exit() + if (not isinstance(min(random_wait_pages), int)) or (not isinstance( + max(random_wait_pages), int)): + logger.warning(u'random_wait_pages列表中的值应为整数类型,请重新输入') + sys.exit() + if min(random_wait_pages) < 1: + logger.warning(u'random_wait_pages列表中的值应大于0,请重新输入') + sys.exit() + + # 验证random_wait_seconds + random_wait_seconds = config['random_wait_seconds'] + if not isinstance(random_wait_seconds, list): + logger.warning(u'random_wait_seconds参数值应为list类型,请重新输入') + sys.exit() + if (not isinstance(min(random_wait_seconds), int)) or (not isinstance( + max(random_wait_seconds), int)): + logger.warning(u'random_wait_seconds列表中的值应为整数类型,请重新输入') + sys.exit() + if min(random_wait_seconds) < 1: + logger.warning(u'random_wait_seconds列表中的值应大于0,请重新输入') + sys.exit() + + # 验证global_wait + global_wait = config['global_wait'] + if not isinstance(global_wait, list): + logger.warning(u'global_wait参数值应为list类型,请重新输入') + sys.exit() + for g in global_wait: + if not isinstance(g, list): + logger.warning(u'global_wait参数内的值应为长度为2的list类型,请重新输入') + sys.exit() + if len(g) != 2: + logger.warning(u'global_wait参数内的list长度应为2,请重新输入') + sys.exit() + for i in g: + if (not isinstance(i, int)) or i < 1: + logger.warning(u'global_wait列表中的值应为大于0的整数,请重新输入') + sys.exit() + + # 验证write_mode + write_mode = ['txt', 'csv', 'json', 'mongo', 'mysql', 'sqlite', 'kafka','post'] + if not isinstance(config['write_mode'], list): + logger.warning(u'write_mode值应为list类型') + sys.exit() + for mode in config['write_mode']: + if mode not in write_mode: + logger.warning( + u'%s为无效模式,请从txt、csv、json、post、mongo、sqlite, kafka和mysql中挑选一个或多个作为write_mode', + mode) + sys.exit() + + # 验证user_id_list + user_id_list = config['user_id_list'] + if (not isinstance(user_id_list, + list)) and (not user_id_list.endswith('.txt')): + logger.warning(u'user_id_list值应为list类型或txt文件路径') + sys.exit() + if not isinstance(user_id_list, list): + if not os.path.isabs(user_id_list): + user_id_list = os.getcwd() + os.sep + user_id_list + if not os.path.isfile(user_id_list): + logger.warning(u'不存在%s文件', user_id_list) + sys.exit() + + +def get_user_config_list(file_name, default_since_date): + """获取文件中的微博id信息""" + with open(file_name, 'rb') as f: + try: + lines = f.read().splitlines() + lines = [line.decode('utf-8-sig') for line in lines] + except UnicodeDecodeError: + logger.error(u'%s文件应为utf-8编码,请先将文件编码转为utf-8再运行程序', file_name) + sys.exit() + user_config_list = [] + for line in lines: + info = line.split(' ') + if len(info) > 0 and info[0].isdigit(): + user_config = {} + user_config['user_uri'] = info[0] + if len(info) > 2 and _is_date(info[2]): + if len(info) > 3 and _is_date(info[2] + ' ' + info[3]): + user_config['since_date'] = info[2] + ' ' + info[3] + else: + user_config['since_date'] = info[2] + else: + user_config['since_date'] = default_since_date + if user_config not in user_config_list: + user_config_list.append(user_config) + return user_config_list + + +def update_user_config_file(user_config_file_path, user_uri, nickname, + start_time): + """更新用户配置文件""" + if not user_config_file_path: + user_config_file_path = os.getcwd() + os.sep + 'user_id_list.txt' + with open(user_config_file_path, 'rb') as f: + lines = f.read().splitlines() + lines = [line.decode('utf-8-sig') for line in lines] + for i, line in enumerate(lines): + info = line.split(' ') + if len(info) > 0: + if user_uri == info[0]: + if len(info) == 1: + info.append(nickname) + info.append(start_time) + if len(info) == 2: + info.append(start_time) + if len(info) > 3 and _is_date(info[2] + ' ' + info[3]): + del info[3] + if len(info) > 2: + info[2] = start_time + lines[i] = ' '.join(info) + break + with codecs.open(user_config_file_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + + +def add_user_uri_list(user_config_file_path, user_uri_list): + """向user_id_list.txt文件添加若干user_uri""" + if not user_config_file_path: + user_config_file_path = os.getcwd() + os.sep + 'user_id_list.txt' + if os.path.isfile(user_config_file_path): + user_uri_list[0] = '\n' + user_uri_list[0] + with codecs.open(user_config_file_path, 'a', encoding='utf-8') as f: + f.write('\n'.join(user_uri_list)) diff --git a/weibo_spider/datetime_util.py b/weibo_spider/datetime_util.py new file mode 100644 index 00000000..1228af00 --- /dev/null +++ b/weibo_spider/datetime_util.py @@ -0,0 +1,10 @@ +from datetime import datetime + + +def str_to_time(text): + """将字符串转换成时间类型""" + if ':' in text: + result = datetime.strptime(text, '%Y-%m-%d %H:%M') + else: + result = datetime.strptime(text, '%Y-%m-%d') + return result diff --git a/weibo_spider/downloader/__init__.py b/weibo_spider/downloader/__init__.py new file mode 100644 index 00000000..53e9dfdf --- /dev/null +++ b/weibo_spider/downloader/__init__.py @@ -0,0 +1,9 @@ +from .origin_picture_downloader import OriginPictureDownloader +from .retweet_picture_downloader import RetweetPictureDownloader +from .avatar_picture_downloader import AvatarPictureDownloader +from .video_downloader import VideoDownloader + +__all__ = [ + OriginPictureDownloader, RetweetPictureDownloader, AvatarPictureDownloader, + VideoDownloader +] diff --git a/weibo_spider/downloader/avatar_picture_downloader.py b/weibo_spider/downloader/avatar_picture_downloader.py new file mode 100644 index 00000000..e7a64e82 --- /dev/null +++ b/weibo_spider/downloader/avatar_picture_downloader.py @@ -0,0 +1,22 @@ +import os + +from .img_downloader import ImgDownloader + + +class AvatarPictureDownloader(ImgDownloader): + def __init__(self, file_dir, file_download_timeout): + super().__init__(file_dir, file_download_timeout) + self.describe = u'头像图片' + self.key = 'avatar_pictures' + + def handle_download(self, urls): + """处理下载相关操作""" + file_dir = self.file_dir + os.sep + self.describe + if not os.path.isdir(file_dir): + os.makedirs(file_dir) + + for i, url in enumerate(urls): + index = url.rfind('/') + file_name = url[index:] + file_path = file_dir + os.sep + file_name + self.download_one_file(url, file_path, 'xxx') \ No newline at end of file diff --git a/weibo_spider/downloader/downloader.py b/weibo_spider/downloader/downloader.py new file mode 100644 index 00000000..ac75c608 --- /dev/null +++ b/weibo_spider/downloader/downloader.py @@ -0,0 +1,63 @@ +# -*- coding: UTF-8 -*- +import logging +import os +import sys +from abc import ABC, abstractmethod + +import requests +from requests.adapters import HTTPAdapter +from tqdm import tqdm + +logger = logging.getLogger('spider.downloader') + + +class Downloader(ABC): + def __init__(self, file_dir, file_download_timeout): + self.file_dir = file_dir + self.describe = '' + self.key = '' + self.file_download_timeout = [5, 5, 10] + if (isinstance(file_download_timeout, list) + and len(file_download_timeout) == 3): + for i in range(3): + v = file_download_timeout[i] + if isinstance(v, (int, float)) and v > 0: + self.file_download_timeout[i] = v + + @abstractmethod + def handle_download(self, urls, w): + """下载 urls 里所指向的图片或视频文件,使用 w 里的信息来生成文件名""" + pass + + def download_one_file(self, url, file_path, weibo_id): + """下载单个文件(图片/视频)""" + try: + if not os.path.isfile(file_path): + s = requests.Session() + s.mount(url, + HTTPAdapter(max_retries=self.file_download_timeout[0])) + from ..parser.util import get_proxies + downloaded = s.get(url, + timeout=(self.file_download_timeout[1], + self.file_download_timeout[2]), + proxies=get_proxies()) + with open(file_path, 'wb') as f: + f.write(downloaded.content) + except Exception as e: + error_file = self.file_dir + os.sep + 'not_downloaded.txt' + with open(error_file, 'ab') as f: + url = weibo_id + ':' + file_path + ':' + url + '\n' + f.write(url.encode(sys.stdout.encoding)) + logger.exception(e) + + def download_files(self, weibos): + """下载文件(图片/视频)""" + try: + logger.info(u'即将进行%s下载', self.describe) + for w in tqdm(weibos, desc='Download progress'): + if getattr(w, self.key) != u'无': + self.handle_download(getattr(w, self.key), w) + logger.info(u'%s下载完毕,保存路径:', self.describe) + logger.info(self.file_dir) + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/downloader/img_downloader.py b/weibo_spider/downloader/img_downloader.py new file mode 100644 index 00000000..f87ca2ae --- /dev/null +++ b/weibo_spider/downloader/img_downloader.py @@ -0,0 +1,37 @@ +import os + +from .downloader import Downloader + + +class ImgDownloader(Downloader): + def __init__(self, file_dir, file_download_timeout): + super().__init__(file_dir, file_download_timeout) + self.describe = u'图片' + self.key = '' + + def handle_download(self, urls, w): + """处理下载相关操作""" + file_prefix = w.publish_time[:10].replace('-', '') + '_' + w.id + file_dir = self.file_dir + os.sep + self.describe + if not os.path.isdir(file_dir): + os.makedirs(file_dir) + if ',' in urls: + url_list = urls.split(',') + for i, url in enumerate(url_list): + index = url.rfind('.') + if len(url) - index >= 5: + file_suffix = '.jpg' + else: + file_suffix = url[index:] + file_name = file_prefix + '_' + str(i + 1) + file_suffix + file_path = file_dir + os.sep + file_name + self.download_one_file(url, file_path, w.id) + else: + index = urls.rfind('.') + if len(urls) - index > 5: + file_suffix = '.jpg' + else: + file_suffix = urls[index:] + file_name = file_prefix + file_suffix + file_path = file_dir + os.sep + file_name + self.download_one_file(urls, file_path, w.id) diff --git a/weibo_spider/downloader/origin_picture_downloader.py b/weibo_spider/downloader/origin_picture_downloader.py new file mode 100644 index 00000000..1f4e76c7 --- /dev/null +++ b/weibo_spider/downloader/origin_picture_downloader.py @@ -0,0 +1,8 @@ +from .img_downloader import ImgDownloader + + +class OriginPictureDownloader(ImgDownloader): + def __init__(self, file_dir, file_download_timeout): + super().__init__(file_dir, file_download_timeout) + self.describe = u'原创微博图片' + self.key = 'original_pictures' diff --git a/weibo_spider/downloader/retweet_picture_downloader.py b/weibo_spider/downloader/retweet_picture_downloader.py new file mode 100644 index 00000000..7ab39c5c --- /dev/null +++ b/weibo_spider/downloader/retweet_picture_downloader.py @@ -0,0 +1,8 @@ +from .img_downloader import ImgDownloader + + +class RetweetPictureDownloader(ImgDownloader): + def __init__(self, file_dir, file_download_timeout): + super().__init__(file_dir, file_download_timeout) + self.describe = u'转发微博图片' + self.key = 'retweet_pictures' diff --git a/weibo_spider/downloader/video_downloader.py b/weibo_spider/downloader/video_downloader.py new file mode 100644 index 00000000..ba6a8f39 --- /dev/null +++ b/weibo_spider/downloader/video_downloader.py @@ -0,0 +1,18 @@ +import os + +from .downloader import Downloader + + +class VideoDownloader(Downloader): + def __init__(self, file_dir, file_download_timeout): + super().__init__(file_dir, file_download_timeout) + self.describe = u'视频' + self.key = 'video_url' + + def handle_download(self, urls, w): + """处理下载相关操作""" + file_prefix = w.publish_time[:10].replace('-', '') + '_' + w.id + file_suffix = '.mp4' + file_name = file_prefix + file_suffix + file_path = self.file_dir + os.sep + file_name + self.download_one_file(urls, file_path, w.id) diff --git a/weibo_spider/logging.conf b/weibo_spider/logging.conf new file mode 100644 index 00000000..84233792 --- /dev/null +++ b/weibo_spider/logging.conf @@ -0,0 +1,45 @@ +[loggers] +keys=root,spider + +[handlers] +keys=consoleHandler,fileHandler,errorHandler + +[formatters] +keys=consoleFormatter,fileFormatter,errorFormatter + +[logger_root] +level=DEBUG +handlers=consoleHandler,fileHandler,errorHandler + +[logger_spider] +level=DEBUG +handlers=consoleHandler,fileHandler,errorHandler +qualname=spider +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=DEBUG +formatter=consoleFormatter +args=(sys.stdout,) + +[handler_fileHandler] +class=handlers.TimedRotatingFileHandler +level=INFO +formatter=fileFormatter +args=('all.log', 'D', 1, 5, 'utf-8', False, False) + +[handler_errorHandler] +class=FileHandler +level=WARNING +formatter=errorFormatter +args=('error.log', 'a','utf-8') + +[formatter_consoleFormatter] +format=%(message)s + +[formatter_fileFormatter] +format=%(asctime)s - %(filename)s - %(levelname)s - %(message)s + +[formatter_errorFormatter] +format=%(asctime)s - %(levelname)s - %(filename)s[:%(lineno)d] - %(message)s \ No newline at end of file diff --git a/weibo_spider/parser/__init__.py b/weibo_spider/parser/__init__.py new file mode 100644 index 00000000..27af1f76 --- /dev/null +++ b/weibo_spider/parser/__init__.py @@ -0,0 +1,6 @@ +from .index_parser import IndexParser +from .page_parser import PageParser +from .photo_parser import PhotoParser +from .album_parser import AlbumParser + +__all__ = [IndexParser, PageParser, PhotoParser, AlbumParser] diff --git a/weibo_spider/parser/album_parser.py b/weibo_spider/parser/album_parser.py new file mode 100644 index 00000000..546bb672 --- /dev/null +++ b/weibo_spider/parser/album_parser.py @@ -0,0 +1,18 @@ +from .parser import Parser +from .util import handle_html + + +class AlbumParser(Parser): + def __init__(self, cookie, album_url): + self.cookie = cookie + self.url = album_url + self.selector = handle_html(self.cookie, self.url) + + def extract_pic_urls(self): + # + pic_list = self.selector.xpath('//div[@class="c"]//img/@src') + for i, pic in enumerate(pic_list): + if "?" in pic: + pic = pic[:pic.index("?")] + pic_list[i] = pic + return pic_list diff --git a/weibo_spider/parser/comment_parser.py b/weibo_spider/parser/comment_parser.py new file mode 100644 index 00000000..c0117d80 --- /dev/null +++ b/weibo_spider/parser/comment_parser.py @@ -0,0 +1,69 @@ +import logging +import random +import requests +import re +from time import sleep +from lxml.html import tostring +from lxml.html import fromstring +from lxml import etree +from .parser import Parser +from .util import handle_garbled, handle_html + +logger = logging.getLogger('spider.comment_parser') + + +class CommentParser(Parser): + def __init__(self, cookie, weibo_id): + self.cookie = cookie + self.url = 'https://weibo.cn/comment/' + weibo_id + self.selector = handle_html(self.cookie, self.url) + + def get_long_weibo(self): + """获取长原创微博""" + try: + for i in range(5): + self.selector = handle_html(self.cookie, self.url) + if self.selector is not None: + info_div = self.selector.xpath("//div[@class='c' and @id='M_']")[0] + info_span = info_div.xpath("//span[@class='ctt']")[0] + # 1. 获取 info_span 中的所有 HTML 代码作为字符串 + html_string = etree.tostring(info_span, encoding='unicode', method='html') + # 2. 将
替换为 \n + html_string = html_string.replace('
', '\n') + # 3. 去掉所有 HTML 标签,但保留标签内的有效文本 + new_content = fromstring(html_string).text_content() + # 4. 替换多个连续的 \n 为一个 \n + new_content = re.sub(r'\n+\s*', '\n', new_content) + weibo_content = handle_garbled(new_content) + if weibo_content is not None: + return weibo_content + sleep(random.randint(6, 10)) + except Exception: + logger.exception(u'网络出错') + + def get_long_retweet(self): + """获取长转发微博""" + try: + wb_content = self.get_long_weibo() + weibo_content = wb_content[:wb_content.rfind(u'原文转发')] + return weibo_content + except Exception as e: + logger.exception(e) + + def get_video_page_url(self): + """获取微博视频页面的链接""" + video_url = '' + try: + self.selector = handle_html(self.cookie, self.url) + if self.selector is not None: + # 来自微博视频号的格式与普通格式不一致,不加 span 层级 + links = self.selector.xpath("body/div[@class='c' and @id][1]/div//a") + for a in links: + if 'm.weibo.cn/s/video/show?object_id=' in a.xpath( + '@href')[0]: + video_url = a.xpath('@href')[0] + break + except Exception: + logger.exception(u'网络出错') + + return video_url diff --git a/weibo_spider/parser/index_parser.py b/weibo_spider/parser/index_parser.py new file mode 100644 index 00000000..0c5f5e4a --- /dev/null +++ b/weibo_spider/parser/index_parser.py @@ -0,0 +1,56 @@ +import logging + +from .info_parser import InfoParser +from .parser import Parser +from .util import handle_html, string_to_int + +logger = logging.getLogger('spider.index_parser') + + +class IndexParser(Parser): + def __init__(self, cookie, user_uri): + self.cookie = cookie + self.user_uri = user_uri + self.url = 'https://weibo.cn/%s/profile' % (user_uri) + self.selector = handle_html(self.cookie, self.url) + + def _get_user_id(self): + """获取用户id,使用者输入的user_id不一定是正确的,可能是个性域名等,需要获取真正的user_id""" + user_id = self.user_uri + url_list = self.selector.xpath("//div[@class='u']//a") + for url in url_list: + if (url.xpath('string(.)')) == u'资料': + if url.xpath('@href') and url.xpath('@href')[0].endswith( + '/info'): + link = url.xpath('@href')[0] + user_id = link[1:-5] + break + return user_id + + def get_user(self): + """获取用户信息、微博数、关注数、粉丝数""" + try: + user_id = self._get_user_id() + self.user = InfoParser(self.cookie, + user_id).extract_user_info() # 获取用户信息 + self.user.id = user_id + + user_info = self.selector.xpath("//div[@class='tip2']/*/text()") + self.user.weibo_num = string_to_int(user_info[0][3:-1]) + self.user.following = string_to_int(user_info[1][3:-1]) + self.user.followers = string_to_int(user_info[2][3:-1]) + return self.user + except Exception as e: + logger.exception(e) + + def get_page_num(self): + """获取微博总页数""" + try: + if self.selector.xpath("//input[@name='mp']") == []: + page_num = 1 + else: + page_num = (int)(self.selector.xpath("//input[@name='mp']") + [0].attrib['value']) + return page_num + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/parser/info_parser.py b/weibo_spider/parser/info_parser.py new file mode 100644 index 00000000..ad39a2b2 --- /dev/null +++ b/weibo_spider/parser/info_parser.py @@ -0,0 +1,88 @@ +import logging +import sys + +from ..user import User +from .parser import Parser +from .util import handle_html + +logger = logging.getLogger('spider.info_parser') + + +class InfoParser(Parser): + def __init__(self, cookie, user_id): + self.cookie = cookie + self.url = 'https://weibo.cn/%s/info' % (user_id) + self.selector = handle_html(self.cookie, self.url) + + def extract_user_info(self): + """提取用户信息""" + try: + user = User() + nickname = self.selector.xpath('//title/text()')[0] + nickname = nickname[:-3] + if nickname == u'登录 - 新' or nickname == u'新浪': + logger.warning(u'cookie错误或已过期,请按照README中方法重新获取') + sys.exit() + user.nickname = nickname + + zh_list = [u'性别', u'地区', u'生日', u'简介', u'认证', u'达人'] + en_list = [ + 'gender', 'location', 'birthday', 'description', + 'verified_reason', 'talent' + ] + + # 先尝试标准格式(查看他人资料页) + basic_info = self.selector.xpath("//div[@class='c'][3]/text()") + has_info = any( + ':' in str(i) and str(i).split(':', 1)[0] in zh_list + for i in basic_info) + + if not has_info: + # 自己查看自己的资料页:标签在标签内,值在的tail文本中 + basic_info = [] + for c_div in self.selector.xpath("//div[@class='c']"): + a_texts = c_div.xpath('a/text()') + if u'性别' in a_texts or u'昵称' in a_texts: + for a in c_div.xpath('a'): + label = (a.text or '').strip() + tail = (a.tail or '').strip() + if label in zh_list and tail.startswith(':'): + basic_info.append(label + tail) + break + + for i in basic_info: + if ':' in str(i) and str(i).split(':', 1)[0] in zh_list: + setattr(user, en_list[zh_list.index(str(i).split(':', 1)[0])], + str(i).split(':', 1)[1].replace('\u3000', '')) + + # 提取学习经历和工作经历,使用following-sibling定位,兼容自己和他人页面 + tip_divs = self.selector.xpath("//div[@class='tip']") + for tip in tip_divs: + tip_text = tip.xpath('string(.)').strip() + if tip_text == u'学习经历': + edu_div = tip.xpath( + 'following-sibling::div[@class="c"][1]') + if edu_div: + # 优先用text()(他人页面),fallback用string(.)(自己页面) + edu_text = edu_div[0].xpath('text()') + if edu_text and len(edu_text[0].strip()) > 1: + user.education = edu_text[0][1:].replace( + u'\xa0', u' ') + else: + user.education = ' '.join( + edu_div[0].xpath('string(.)').split()) + elif tip_text == u'工作经历': + work_div = tip.xpath( + 'following-sibling::div[@class="c"][1]') + if work_div: + work_text = work_div[0].xpath('text()') + if work_text and len(work_text[0].strip()) > 1: + user.work = work_text[0][1:].replace( + u'\xa0', u' ') + else: + user.work = ' '.join( + work_div[0].xpath('string(.)').split()) + + return user + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/parser/mblog_picAll_parser.py b/weibo_spider/parser/mblog_picAll_parser.py new file mode 100644 index 00000000..1bc66610 --- /dev/null +++ b/weibo_spider/parser/mblog_picAll_parser.py @@ -0,0 +1,12 @@ +from .parser import Parser +from .util import handle_html + + +class MblogPicAllParser(Parser): + def __init__(self, cookie, weibo_id): + self.cookie = cookie + self.url = 'https://weibo.cn/mblog/picAll/' + weibo_id + '?rl=1' + self.selector = handle_html(self.cookie, self.url) + + def extract_preview_picture_list(self): + return self.selector.xpath('//img/@src') diff --git a/weibo_spider/parser/page_parser.py b/weibo_spider/parser/page_parser.py new file mode 100644 index 00000000..6acf1ad9 --- /dev/null +++ b/weibo_spider/parser/page_parser.py @@ -0,0 +1,380 @@ +import logging +import re +import sys +from datetime import datetime, timedelta + +from .. import datetime_util +from ..weibo import Weibo +from .comment_parser import CommentParser +from .mblog_picAll_parser import MblogPicAllParser +from .parser import Parser +from .util import handle_garbled, handle_html, to_video_download_url + +MAX_PINNED_COUNT = 2 + +logger = logging.getLogger('spider.page_parser') + + +class PageParser(Parser): + empty_count = 0 + + def __init__(self, cookie, user_config, page, filter): + self.cookie = cookie + if hasattr(PageParser, + 'user_uri') and self.user_uri != user_config['user_uri']: + PageParser.empty_count = 0 + self.user_uri = user_config['user_uri'] + self.since_date = user_config['since_date'] + self.end_date = user_config['end_date'] + self.page = page + self.url = 'https://weibo.cn/%s/profile?page=%d' % (self.user_uri, page) + if self.end_date != 'now': + since_date = self.since_date.split(' ')[0].split('-') + end_date = self.end_date.split(' ')[0].split('-') + for date in [since_date, end_date]: + for i in [1, 2]: + if len(date[i]) == 1: + date[i] = '0' + date[i] + starttime = ''.join(since_date) + endtime = ''.join(end_date) + self.url = 'https://weibo.cn/%s/profile?starttime=%s&endtime=%s&advancedfilter=1&page=%d' % ( + self.user_uri, starttime, endtime, page) + self.selector = '' + self.to_continue = True + is_exist = '' + for i in range(3): + self.selector = handle_html(self.cookie, self.url) + if self.selector: + info = self.selector.xpath("//div[@class='c']") + if info is None or len(info) == 0: + continue + is_exist = info[0].xpath("div/span[@class='ctt']") + if is_exist: + PageParser.empty_count = 0 + break + if not is_exist: + PageParser.empty_count += 1 + if PageParser.empty_count > 2: + self.to_continue = False + PageParser.empty_count = 0 + self.filter = filter + + def get_one_page(self, weibo_id_list): + """获取第page页的全部微博""" + cur_pinned_count = 0 + try: + info = self.selector.xpath("//div[@class='c']") + is_exist = info[0].xpath("div/span[@class='ctt']") + weibos = [] + if is_exist: + since_date = datetime_util.str_to_time(self.since_date) + for i in range(0, len(info) - 1): + weibo = self.get_one_weibo(info[i]) + if weibo: + if weibo.id in weibo_id_list: + continue + publish_time = datetime_util.str_to_time( + weibo.publish_time) + + if publish_time < since_date: + # As of 2023.05, there can be at most 2 pinned weibo. + # We will continue for at most 2 times before return. + if self.page == 1 and cur_pinned_count < MAX_PINNED_COUNT: + cur_pinned_count += 1 + continue + else: + return weibos, weibo_id_list, False + logger.info(weibo) + logger.info('-' * 100) + weibos.append(weibo) + weibo_id_list.append(weibo.id) + return weibos, weibo_id_list, self.to_continue + except Exception as e: + logger.exception(e) + return [], weibo_id_list, self.to_continue + + def is_original(self, info): + """判断微博是否为原创微博""" + is_original = info.xpath("div/span[@class='cmt']") + if len(is_original) > 3: + return False + else: + return True + + def get_original_weibo(self, info, weibo_id): + """获取原创微博""" + try: + weibo_content = handle_garbled(info) + weibo_content = weibo_content[:weibo_content.rfind(u'赞')] + a_text = info.xpath('div//a/text()') + if u'全文' in a_text: + wb_content = CommentParser(self.cookie, + weibo_id).get_long_weibo() + if wb_content: + weibo_content = wb_content + return weibo_content + except Exception as e: + logger.exception(e) + + def get_retweet(self, info, weibo_id): + """获取转发微博""" + try: + weibo_content = handle_garbled(info) + weibo_content = weibo_content[weibo_content.find(':') + + 1:weibo_content.rfind(u'赞')] + weibo_content = weibo_content[:weibo_content.rfind(u'赞')] + a_text = info.xpath('div//a/text()') + if u'全文' in a_text: + wb_content = CommentParser(self.cookie, + weibo_id).get_long_retweet() + if wb_content: + weibo_content = wb_content + retweet_reason = handle_garbled(info.xpath('div')[-1]) + retweet_reason = retweet_reason[:retweet_reason.rindex(u'赞')] + original_user = info.xpath("div/span[@class='cmt']/a/text()") + if original_user: + original_user = original_user[0] + weibo_content = (retweet_reason + '\n' + u'原始用户: ' + + original_user + '\n' + u'转发内容: ' + + weibo_content) + else: + weibo_content = (retweet_reason + '\n' + u'转发内容: ' + + weibo_content) + return weibo_content + except Exception as e: + logger.exception(e) + + def get_weibo_content(self, info, is_original): + """获取微博内容""" + try: + weibo_id = info.xpath('@id')[0][2:] + if is_original: + weibo_content = self.get_original_weibo(info, weibo_id) + else: + weibo_content = self.get_retweet(info, weibo_id) + return weibo_content + except Exception as e: + logger.exception(e) + + def get_article_url(self, info): + """获取微博头条文章的url""" + article_url = '' + text = handle_garbled(info) + if text.startswith(u'发布了头条文章') or text.startswith(u'我发表了头条文章'): + url = info.xpath('.//a/@href') + if url and url[0].startswith('https://weibo.com/ttarticle'): + article_url = url[0] + return article_url + + def get_publish_place(self, info): + """获取微博发布位置""" + try: + div_first = info.xpath('div')[0] + a_list = div_first.xpath('a') + publish_place = u'无' + for a in a_list: + if ('place.weibo.com' in a.xpath('@href')[0] + and a.xpath('text()')[0] == u'显示地图'): + weibo_a = div_first.xpath("span[@class='ctt']/a") + if len(weibo_a) >= 1: + publish_place = weibo_a[-1] + if (u'视频' == div_first.xpath( + "span[@class='ctt']/a/text()")[-1][-2:]): + if len(weibo_a) >= 2: + publish_place = weibo_a[-2] + else: + publish_place = u'无' + publish_place = handle_garbled(publish_place) + break + return publish_place + except Exception as e: + logger.exception(e) + + def get_publish_time(self, info): + """获取微博发布时间""" + try: + str_time = info.xpath("div/span[@class='ct']") + str_time = handle_garbled(str_time[0]) + publish_time = str_time.split(u'来自')[0] + if u'刚刚' in publish_time: + publish_time = datetime.now().strftime('%Y-%m-%d %H:%M') + elif u'分钟' in publish_time: + minute = publish_time[:publish_time.find(u'分钟')] + minute = timedelta(minutes=int(minute)) + publish_time = (datetime.now() - + minute).strftime('%Y-%m-%d %H:%M') + elif u'今天' in publish_time: + today = datetime.now().strftime('%Y-%m-%d') + time = publish_time[3:] + publish_time = today + ' ' + time + if len(publish_time) > 16: + publish_time = publish_time[:16] + elif u'月' in publish_time: + year = datetime.now().strftime('%Y') + month = publish_time[0:2] + day = publish_time[3:5] + time = publish_time[7:12] + publish_time = year + '-' + month + '-' + day + ' ' + time + else: + publish_time = publish_time[:16] + return publish_time + except Exception as e: + logger.exception(e) + + def get_publish_tool(self, info): + """获取微博发布工具""" + try: + str_time = info.xpath("div/span[@class='ct']") + str_time = handle_garbled(str_time[0]) + if len(str_time.split(u'来自')) > 1: + publish_tool = str_time.split(u'来自')[1] + else: + publish_tool = u'无' + return publish_tool + except Exception as e: + logger.exception(e) + + def get_weibo_footer(self, info): + """获取微博点赞数、转发数、评论数""" + try: + footer = {} + pattern = r'\d+' + str_footer = info.xpath('div')[-1] + str_footer = handle_garbled(str_footer) + str_footer = str_footer[str_footer.rfind(u'赞'):] + weibo_footer = re.findall(pattern, str_footer, re.M) + + up_num = int(weibo_footer[0]) + footer['up_num'] = up_num + + retweet_num = int(weibo_footer[1]) + footer['retweet_num'] = retweet_num + + comment_num = int(weibo_footer[2]) + footer['comment_num'] = comment_num + return footer + except Exception as e: + logger.exception(e) + + def get_picture_urls(self, info, is_original): + """获取微博原始图片url""" + try: + weibo_id = info.xpath('@id')[0][2:] + picture_urls = {} + if is_original: + original_pictures = self.extract_picture_urls(info, weibo_id) + picture_urls['original_pictures'] = original_pictures + if not self.filter: + picture_urls['retweet_pictures'] = u'无' + else: + retweet_url = info.xpath("div/a[@class='cc']/@href")[0] + retweet_id = retweet_url.split('/')[-1].split('?')[0] + retweet_pictures = self.extract_picture_urls(info, retweet_id) + picture_urls['retweet_pictures'] = retweet_pictures + a_list = info.xpath('div[last()]/a/@href') + original_picture = u'无' + for a in a_list: + if a.endswith(('.gif', '.jpeg', '.jpg', '.png')): + original_picture = a + break + picture_urls['original_pictures'] = original_picture + return picture_urls + except Exception as e: + logger.exception(e) + + def get_video_url(self, info): + """获取微博视频url""" + video_url = u'无' + + weibo_id = info.xpath('@id')[0][2:] + try: + video_page_url = '' + a_text = info.xpath('./div[1]//a/text()') + if u'全文' in a_text: + video_page_url = CommentParser(self.cookie, + weibo_id).get_video_page_url() + else: + # 来自微博视频号的格式与普通格式不一致,不加 span 层级 + a_list = info.xpath('./div[1]//a') + for a in a_list: + if 'm.weibo.cn/s/video/show?object_id=' in a.xpath( + '@href')[0]: + video_page_url = a.xpath('@href')[0] + break + + if video_page_url != '': + video_url = to_video_download_url(self.cookie, video_page_url) + except Exception as e: + logger.exception(e) + + return video_url + + def get_one_weibo(self, info): + """获取一条微博的全部信息""" + try: + weibo = Weibo() + is_original = self.is_original(info) + weibo.original = is_original # 是否原创微博 + if (not self.filter) or is_original: + weibo.id = info.xpath('@id')[0][2:] + weibo.content = self.get_weibo_content(info, + is_original) # 微博内容 + weibo.article_url = self.get_article_url(info) # 头条文章url + picture_urls = self.get_picture_urls(info, is_original) + weibo.original_pictures = picture_urls[ + 'original_pictures'] # 原创图片url + if not self.filter: + weibo.retweet_pictures = picture_urls[ + 'retweet_pictures'] # 转发图片url + weibo.video_url = self.get_video_url(info) # 微博视频url + weibo.publish_place = self.get_publish_place(info) # 微博发布位置 + weibo.publish_time = self.get_publish_time(info) # 微博发布时间 + weibo.publish_tool = self.get_publish_tool(info) # 微博发布工具 + footer = self.get_weibo_footer(info) + weibo.up_num = footer['up_num'] # 微博点赞数 + weibo.retweet_num = footer['retweet_num'] # 转发数 + weibo.comment_num = footer['comment_num'] # 评论数 + else: + weibo = None + logger.info(u'正在过滤转发微博') + return weibo + except Exception as e: + logger.exception(e) + + def extract_picture_urls(self, info, weibo_id): + """提取微博原始图片url""" + try: + a_list = info.xpath('div/a/@href') + first_pic = 'https://weibo.cn/mblog/pic/' + weibo_id + all_pic = 'https://weibo.cn/mblog/picAll/' + weibo_id + picture_urls = u'无' + if first_pic in ''.join(a_list): + if all_pic in ''.join(a_list): + preview_picture_list = MblogPicAllParser( + self.cookie, weibo_id).extract_preview_picture_list() + picture_list = [ + p.replace('/thumb180/', '/large/') + for p in preview_picture_list + ] + picture_urls = ','.join(picture_list) + else: + if info.xpath('.//img/@src'): + for link in info.xpath('div/a'): + if len(link.xpath('@href')) > 0: + if first_pic in link.xpath('@href')[0]: + if len(link.xpath('img/@src')) > 0: + preview_picture = link.xpath( + 'img/@src')[0] + picture_urls = preview_picture.replace( + '/wap180/', '/large/') + break + else: + logger.warning( + u'爬虫微博可能被设置成了"不显示图片",请前往' + u'"https://weibo.cn/account/customize/pic",修改为"显示"' + ) + sys.exit() + return picture_urls + except Exception as e: + logger.exception(e) + return u'无' diff --git a/weibo_spider/parser/parser.py b/weibo_spider/parser/parser.py new file mode 100644 index 00000000..41302ba4 --- /dev/null +++ b/weibo_spider/parser/parser.py @@ -0,0 +1,5 @@ +class Parser: + def __init__(self, cookie): + self.cookie = cookie + self.url = '' + self.selector = None diff --git a/weibo_spider/parser/photo_parser.py b/weibo_spider/parser/photo_parser.py new file mode 100644 index 00000000..236e76e2 --- /dev/null +++ b/weibo_spider/parser/photo_parser.py @@ -0,0 +1,19 @@ +from .parser import Parser +from .util import handle_html + + +class PhotoParser(Parser): + def __init__(self, cookie, user_id): + self.cookie = cookie + self.url = "https://weibo.cn/" + str(user_id) + "/photo?tf=6_008" + self.selector = handle_html(self.cookie, self.url) + self.user_id = user_id + + def extract_avatar_album_url(self): + # Finds the href attribute of the table td div element with text 头像相册, e.g. + # 头像相册 + result = self.selector.xpath('//img[@alt="头像相册"]/../@href') + if len(result) > 0: + return "https://weibo.cn" + result[0] + else: + return "https://weibo.cn/" + str(self.user_id) + "/avatar?rl=0" diff --git a/weibo_spider/parser/util.py b/weibo_spider/parser/util.py new file mode 100644 index 00000000..b7c95736 --- /dev/null +++ b/weibo_spider/parser/util.py @@ -0,0 +1,150 @@ +import hashlib +import json +import logging +import sys + +import requests +from lxml import etree + +# Set GENERATE_TEST_DATA to True when generating test data. +GENERATE_TEST_DATA = False +TEST_DATA_DIR = 'tests/testdata' +URL_MAP_FILE = 'url_map.json' +logger = logging.getLogger('spider.util') + +# 全局代理配置,由 spider.py 初始化 +_proxies = None + + +def set_proxies(proxy_url): + """设置全局代理""" + global _proxies + if proxy_url: + _proxies = {'http': proxy_url, 'https': proxy_url} + logger.info(u'已启用代理: %s', proxy_url) + + +def get_proxies(): + return _proxies + + +def hash_url(url): + return hashlib.sha224(url.encode('utf8')).hexdigest() + + +DEFAULT_UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/133.0.0.0 Safari/537.36') + + +def handle_html(cookie, url): + """处理html""" + from time import sleep + headers = {'User-Agent': DEFAULT_UA, 'Cookie': cookie} + for attempt in range(5): + try: + resp = requests.get(url, headers=headers, timeout=10, + proxies=_proxies) + if resp.status_code == 200 and len(resp.content) > 0: + selector = etree.HTML(resp.content) + return selector + elif resp.status_code == 403: + wait = 300 * (attempt + 1) + logger.warning(u'403 IP被限制,等待%d秒后重试(第%d次)', + wait, attempt + 1) + sleep(wait) + elif resp.status_code == 432: + logger.error(u'432 User-Agent被拒绝,请更新UA') + return None + else: + wait = 60 * (attempt + 1) + logger.warning(u'请求返回状态码%d,等待%d秒后重试(第%d次)', + resp.status_code, wait, attempt + 1) + sleep(wait) + except Exception as e: + wait = 60 * (attempt + 1) + logger.warning(u'请求异常,等待%d秒后重试(第%d次): %s', + wait, attempt + 1, str(e)) + sleep(wait) + logger.error(u'请求%s失败,已重试5次', url) + return None + + +def handle_garbled(info): + """处理乱码""" + try: + if hasattr(info, 'xpath'): # 检查 info 是否具有 xpath 方法 + info_str = info.xpath('string(.)') # 提取字符串内容 + else: + info_str = str(info) # 若不支持 xpath,将其转换为字符串 + + info = info_str.replace(u'\u200b', '').encode( + sys.stdout.encoding, 'ignore').decode(sys.stdout.encoding) + return info + except Exception as e: + logger.exception(e) + return u'无' + + +def bid2mid(bid): + """convert string bid to string mid""" + alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + base = len(alphabet) + bidlen = len(bid) + head = bidlen % 4 + digit = int((bidlen - head) / 4) + dlist = [bid[0:head]] + for d in range(1, digit + 1): + dlist.append(bid[head:head + d * 4]) + head += 4 + mid = '' + for d in dlist: + num = 0 + idx = 0 + strlen = len(d) + for char in d: + power = (strlen - (idx + 1)) + num += alphabet.index(char) * (base**power) + idx += 1 + strnum = str(num) + while (len(d) == 4 and len(strnum) < 7): + strnum = '0' + strnum + mid += strnum + return mid + + +def to_video_download_url(cookie, video_page_url): + if video_page_url == '': + return '' + + video_object_url = video_page_url.replace('m.weibo.cn/s/video/show', + 'm.weibo.cn/s/video/object') + try: + headers = {'User-Agent': DEFAULT_UA, 'Cookie': cookie} + wb_info = requests.get(video_object_url, headers=headers, + proxies=_proxies).json() + video_url = wb_info['data']['object']['stream'].get('hd_url') + if not video_url: + video_url = wb_info['data']['object']['stream']['url'] + if not video_url: # 说明该视频为直播 + video_url = '' + except json.decoder.JSONDecodeError: + logger.warning(u'当前账号没有浏览该视频的权限') + + return video_url + + +def string_to_int(string): + """字符串转换为整数""" + if len(string) == 0: + logger.warning("string to int, the input string is empty!") + return 0 + if isinstance(string, int): + return string + elif string.endswith(u'万+'): + string = string[:-2] + '0000' + elif string.endswith(u'万'): + string = float(string[:-1]) * 10000 + elif string.endswith(u'亿'): + string = float(string[:-1]) * 100000000 + return int(string) diff --git a/weibo_spider/spider.py b/weibo_spider/spider.py new file mode 100644 index 00000000..09125233 --- /dev/null +++ b/weibo_spider/spider.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +import json +import logging +import logging.config +import os +import random +import shutil +import sys +from datetime import date, datetime, timedelta +from time import sleep + +from absl import app, flags +from tqdm import tqdm + +from . import config_util, datetime_util +from .downloader import AvatarPictureDownloader +from .parser import AlbumParser, IndexParser, PageParser, PhotoParser +from .user import User + +FLAGS = flags.FLAGS + +flags.DEFINE_string('config_path', None, 'The path to config.json.') +flags.DEFINE_string('u', None, 'The user_id we want to input.') +flags.DEFINE_string('user_id_list', None, 'The path to user_id_list.txt.') +flags.DEFINE_string('output_dir', None, 'The dir path to store results.') + +logging_path = os.path.split( + os.path.realpath(__file__))[0] + os.sep + 'logging.conf' +logging.config.fileConfig(logging_path) +logger = logging.getLogger('spider') + + +class Spider: + def __init__(self, config): + """Weibo类初始化""" + self.filter = config[ + 'filter'] # 取值范围为0、1,程序默认值为0,代表要爬取用户的全部微博,1代表只爬取用户的原创微博 + since_date = config['since_date'] + if isinstance(since_date, int): + since_date = date.today() - timedelta(since_date) + self.since_date = str( + since_date) # 起始时间,即爬取发布日期从该值到结束时间的微博,形式为yyyy-mm-dd + self.end_date = config[ + 'end_date'] # 结束时间,即爬取发布日期从起始时间到该值的微博,形式为yyyy-mm-dd,特殊值"now"代表现在 + random_wait_pages = config['random_wait_pages'] + self.random_wait_pages = [ + min(random_wait_pages), + max(random_wait_pages) + ] # 随机等待频率,即每爬多少页暂停一次 + random_wait_seconds = config['random_wait_seconds'] + self.random_wait_seconds = [ + min(random_wait_seconds), + max(random_wait_seconds) + ] # 随机等待时间,即每次暂停要sleep多少秒 + self.global_wait = config['global_wait'] # 配置全局等待时间,如每爬1000页等待3600秒等 + self.page_count = 0 # 统计每次全局等待后,爬取了多少页,若页数满足全局等待要求就进入下一次全局等待 + self.write_mode = config[ + 'write_mode'] # 结果信息保存类型,为list形式,可包含txt、csv、json、mongo和mysql五种类型 + self.pic_download = config[ + 'pic_download'] # 取值范围为0、1,程序默认值为0,代表不下载微博原始图片,1代表下载 + self.video_download = config[ + 'video_download'] # 取值范围为0、1,程序默认为0,代表不下载微博视频,1代表下载 + self.file_download_timeout = config.get( + 'file_download_timeout', + [5, 5, 10 + ]) # 控制文件下载“超时”时的操作,值是list形式,包含三个数字,依次分别是最大超时重试次数、最大连接时间和最大读取时间 + self.result_dir_name = config.get( + 'result_dir_name', 0) # 结果目录名,取值为0或1,决定结果文件存储在用户昵称文件夹里还是用户id文件夹里 + self.cookie = config['cookie'] + self.mysql_config = config.get('mysql_config') # MySQL数据库连接配置,可以不填 + + self.sqlite_config = config.get('sqlite_config') + self.kafka_config = config.get('kafka_config') + self.mongo_config = config.get('mongo_config') + self.post_config = config.get('post_config') + self.user_config_file_path = '' + user_id_list = config['user_id_list'] + if FLAGS.user_id_list: + user_id_list = FLAGS.user_id_list + if not isinstance(user_id_list, list): + if not os.path.isabs(user_id_list): + user_id_list = os.getcwd() + os.sep + user_id_list + if not os.path.isfile(user_id_list): + logger.warning('不存在%s文件', user_id_list) + sys.exit() + self.user_config_file_path = user_id_list + if FLAGS.u: + user_id_list = FLAGS.u.split(',') + if isinstance(user_id_list, list): + # 第一部分是处理dict类型的 + # 第二部分是其他类型,其他类型提供去重功能 + user_config_list = list( + map( + lambda x: { + 'user_uri': x['id'], + 'since_date': x.get('since_date', self.since_date), + 'end_date': x.get('end_date', self.end_date), + }, [ + user_id for user_id in user_id_list + if isinstance(user_id, dict) + ])) + list( + map( + lambda x: { + 'user_uri': x, + 'since_date': self.since_date, + 'end_date': self.end_date + }, + set([ + user_id for user_id in user_id_list + if not isinstance(user_id, dict) + ]))) + if FLAGS.u: + config_util.add_user_uri_list(self.user_config_file_path, + user_id_list) + else: + user_config_list = config_util.get_user_config_list( + user_id_list, self.since_date) + for user_config in user_config_list: + user_config['end_date'] = self.end_date + self.user_config_list = user_config_list # 要爬取的微博用户的user_config列表 + self.user_config = {} # 用户配置,包含用户id和since_date + self.new_since_date = '' # 完成某用户爬取后,自动生成对应用户新的since_date + self.user = User() # 存储爬取到的用户信息 + self.got_num = 0 # 存储爬取到的微博数 + self.weibo_id_list = [] # 存储爬取到的所有微博id + + def write_weibo(self, weibos): + """将爬取到的信息写入文件或数据库""" + for writer in self.writers: + writer.write_weibo(weibos) + for downloader in self.downloaders: + downloader.download_files(weibos) + + def write_user(self, user): + """将用户信息写入数据库""" + for writer in self.writers: + writer.write_user(user) + + def get_user_info(self, user_uri): + """获取用户信息""" + self.user = IndexParser(self.cookie, user_uri).get_user() + self.page_count += 1 + + def download_user_avatar(self, user_uri): + """下载用户头像""" + avatar_album_url = PhotoParser(self.cookie, + user_uri).extract_avatar_album_url() + pic_urls = AlbumParser(self.cookie, + avatar_album_url).extract_pic_urls() + AvatarPictureDownloader( + self._get_filepath('img'), + self.file_download_timeout).handle_download(pic_urls) + + def get_weibo_info(self): + """获取微博信息""" + try: + since_date = datetime_util.str_to_time( + self.user_config['since_date']) + now = datetime.now() + if since_date <= now: + page_num = IndexParser( + self.cookie, + self.user_config['user_uri']).get_page_num() # 获取微博总页数 + self.page_count += 1 + if self.page_count > 2 and (self.page_count + + page_num) > self.global_wait[0][0]: + wait_seconds = int( + self.global_wait[0][1] * + min(1, self.page_count / self.global_wait[0][0])) + logger.info(u'即将进入全局等待时间,%d秒后程序继续执行' % wait_seconds) + for i in tqdm(range(wait_seconds)): + sleep(1) + self.page_count = 0 + self.global_wait.append(self.global_wait.pop(0)) + page1 = 0 + random_pages = random.randint(*self.random_wait_pages) + for page in tqdm(range(1, page_num + 1), desc='Progress'): + weibos, self.weibo_id_list, to_continue = PageParser( + self.cookie, + self.user_config, page, self.filter).get_one_page( + self.weibo_id_list) # 获取第page页的全部微博 + logger.info( + u'%s已获取%s(%s)的第%d页微博%s', + '-' * 30, + self.user.nickname, + self.user.id, + page, + '-' * 30, + ) + self.page_count += 1 + if weibos: + yield weibos + if not to_continue: + break + + # 通过加入随机等待避免被限制。爬虫速度过快容易被系统限制(一段时间后限 + # 制会自动解除),加入随机等待模拟人的操作,可降低被系统限制的风险。默 + # 认是每爬取1到5页随机等待6到10秒,如果仍然被限,可适当增加sleep时间 + if (page - page1) % random_pages == 0 and page < page_num: + sleep(random.randint(*self.random_wait_seconds)) + page1 = page + random_pages = random.randint(*self.random_wait_pages) + + if self.page_count >= self.global_wait[0][0]: + logger.info(u'即将进入全局等待时间,%d秒后程序继续执行' % + self.global_wait[0][1]) + for i in tqdm(range(self.global_wait[0][1])): + sleep(1) + self.page_count = 0 + self.global_wait.append(self.global_wait.pop(0)) + + # 更新用户user_id_list.txt中的since_date + if self.user_config_file_path or FLAGS.u: + config_util.update_user_config_file( + self.user_config_file_path, + self.user_config['user_uri'], + self.user.nickname, + self.new_since_date, + ) + except Exception as e: + logger.exception(e) + + def _get_filepath(self, type): + """获取结果文件路径""" + try: + dir_name = self.user.nickname + if self.result_dir_name: + dir_name = self.user.id + if FLAGS.output_dir is not None: + file_dir = FLAGS.output_dir + os.sep + dir_name + else: + file_dir = (os.getcwd() + os.sep + 'weibo' + os.sep + dir_name) + if type == 'img' or type == 'video': + file_dir = file_dir + os.sep + type + if not os.path.isdir(file_dir): + os.makedirs(file_dir) + if type == 'img' or type == 'video': + return file_dir + file_path = file_dir + os.sep + self.user.id + '.' + type + return file_path + except Exception as e: + logger.exception(e) + + def initialize_info(self, user_config): + """初始化爬虫信息""" + self.got_num = 0 + self.user_config = user_config + self.weibo_id_list = [] + if self.end_date == 'now': + self.new_since_date = datetime.now().strftime('%Y-%m-%d %H:%M') + else: + self.new_since_date = self.end_date + self.writers = [] + if 'csv' in self.write_mode: + from .writer import CsvWriter + + self.writers.append( + CsvWriter(self._get_filepath('csv'), self.filter)) + if 'txt' in self.write_mode: + from .writer import TxtWriter + + self.writers.append( + TxtWriter(self._get_filepath('txt'), self.filter)) + if 'json' in self.write_mode: + from .writer import JsonWriter + + self.writers.append(JsonWriter(self._get_filepath('json'))) + if 'mysql' in self.write_mode: + from .writer import MySqlWriter + + self.writers.append(MySqlWriter(self.mysql_config)) + if 'mongo' in self.write_mode: + from .writer import MongoWriter + + self.writers.append(MongoWriter(self.mongo_config)) + if 'sqlite' in self.write_mode: + from .writer import SqliteWriter + + self.writers.append(SqliteWriter(self.sqlite_config)) + + if 'kafka' in self.write_mode: + from .writer import KafkaWriter + + self.writers.append(KafkaWriter(self.kafka_config)) + + if 'post' in self.write_mode: + from .writer import PostWriter + + self.writers.append(PostWriter(self.post_config)) + + self.downloaders = [] + if self.pic_download == 1: + from .downloader import (OriginPictureDownloader, + RetweetPictureDownloader) + + self.downloaders.append( + OriginPictureDownloader(self._get_filepath('img'), + self.file_download_timeout)) + if self.pic_download and not self.filter: + self.downloaders.append( + RetweetPictureDownloader(self._get_filepath('img'), + self.file_download_timeout)) + if self.video_download == 1: + from .downloader import VideoDownloader + + self.downloaders.append( + VideoDownloader(self._get_filepath('video'), + self.file_download_timeout)) + + def get_one_user(self, user_config): + """获取一个用户的微博""" + try: + self.get_user_info(user_config['user_uri']) + logger.info(self.user) + logger.info('*' * 100) + + self.initialize_info(user_config) + self.write_user(self.user) + logger.info('*' * 100) + + # 下载用户头像相册中的图片。 + if self.pic_download: + self.download_user_avatar(user_config['user_uri']) + + for weibos in self.get_weibo_info(): + self.write_weibo(weibos) + self.got_num += len(weibos) + if not self.filter: + logger.info(u'共爬取' + str(self.got_num) + u'条微博') + else: + logger.info(u'共爬取' + str(self.got_num) + u'条原创微博') + logger.info(u'信息抓取完毕') + logger.info('*' * 100) + except Exception as e: + logger.exception(e) + + def start(self): + """运行爬虫""" + try: + if not self.user_config_list: + logger.info( + u'没有配置有效的user_id,请通过config.json或user_id_list.txt配置user_id') + return + user_count = 0 + user_count1 = random.randint(*self.random_wait_pages) + random_users = random.randint(*self.random_wait_pages) + for user_config in self.user_config_list: + if (user_count - user_count1) % random_users == 0: + sleep(random.randint(*self.random_wait_seconds)) + user_count1 = user_count + random_users = random.randint(*self.random_wait_pages) + user_count += 1 + self.get_one_user(user_config) + except Exception as e: + logger.exception(e) + + +def _get_config(): + """获取config.json数据""" + src = os.path.split( + os.path.realpath(__file__))[0] + os.sep + 'config_sample.json' + config_path = os.getcwd() + os.sep + 'config.json' + if FLAGS.config_path: + config_path = FLAGS.config_path + elif not os.path.isfile(config_path): + shutil.copy(src, config_path) + logger.info(u'请先配置当前目录(%s)下的config.json文件,' + u'如果想了解config.json参数的具体意义及配置方法,请访问\n' + u'https://github.com/dataabc/weiboSpider#2程序设置' % + os.getcwd()) + sys.exit() + try: + with open(config_path) as f: + config = json.loads(f.read()) + return config + except ValueError: + logger.error(u'config.json 格式不正确,请访问 ' + u'https://github.com/dataabc/weiboSpider#2程序设置') + sys.exit() + + +def main(_): + try: + config = _get_config() + config_util.validate_config(config) + # 初始化代理 + proxy = config.get('proxy') + if proxy: + from .parser.util import set_proxies + set_proxies(proxy) + wb = Spider(config) + wb.start() # 爬取微博信息 + except Exception as e: + logger.exception(e) + + +if __name__ == '__main__': + app.run(main) diff --git a/weibo_spider/user.py b/weibo_spider/user.py new file mode 100644 index 00000000..dc135799 --- /dev/null +++ b/weibo_spider/user.py @@ -0,0 +1,29 @@ +class User: + def __init__(self): + self.id = '' + + self.nickname = '' + + self.gender = '' + self.location = '' + self.birthday = '' + self.description = '' + self.verified_reason = '' + self.talent = '' + + self.education = '' + self.work = '' + + self.weibo_num = 0 + self.following = 0 + self.followers = 0 + + def __str__(self): + """打印微博用户信息""" + result = '' + result += u'用户昵称: %s\n' % self.nickname + result += u'用户id: %s\n' % self.id + result += u'微博数: %d\n' % self.weibo_num + result += u'关注数: %d\n' % self.following + result += u'粉丝数: %d\n' % self.followers + return result diff --git a/weibo_spider/user_id_list.txt b/weibo_spider/user_id_list.txt new file mode 100644 index 00000000..ead74227 --- /dev/null +++ b/weibo_spider/user_id_list.txt @@ -0,0 +1,3 @@ +1669879400 Dear-迪丽热巴 2020-01-13 19:18 +1223178222 胡歌 2020-01-13 19:28 +1729370543 郭碧婷 2020-01-13 19:33 \ No newline at end of file diff --git a/weibo_spider/weibo.py b/weibo_spider/weibo.py new file mode 100644 index 00000000..54cec7ff --- /dev/null +++ b/weibo_spider/weibo.py @@ -0,0 +1,32 @@ +class Weibo: + def __init__(self): + self.id = '' + self.user_id = '' + + self.content = '' + self.article_url = '' + + self.original_pictures = [] + self.retweet_pictures = None + self.original = None + self.video_url = '' + + self.publish_place = '' + self.publish_time = '' + self.publish_tool = '' + + self.up_num = 0 + self.retweet_num = 0 + self.comment_num = 0 + + def __str__(self): + """打印一条微博""" + result = self.content + '\n' + result += u'微博发布位置:%s\n' % self.publish_place + result += u'发布时间:%s\n' % self.publish_time + result += u'发布工具:%s\n' % self.publish_tool + result += u'点赞数:%d\n' % self.up_num + result += u'转发数:%d\n' % self.retweet_num + result += u'评论数:%d\n' % self.comment_num + result += u'url:https://weibo.cn/comment/%s\n' % self.id + return result diff --git a/weibo_spider/writer/__init__.py b/weibo_spider/writer/__init__.py new file mode 100644 index 00000000..f6b24bd6 --- /dev/null +++ b/weibo_spider/writer/__init__.py @@ -0,0 +1,10 @@ +from .csv_writer import CsvWriter +from .json_writer import JsonWriter +from .mongo_writer import MongoWriter +from .mysql_writer import MySqlWriter +from .txt_writer import TxtWriter +from .sqlite_writer import SqliteWriter +from .kafka_writer import KafkaWriter +from .post_writer import PostWriter + +__all__ = [CsvWriter, TxtWriter, JsonWriter, MongoWriter, MySqlWriter, SqliteWriter, KafkaWriter, PostWriter] diff --git a/weibo_spider/writer/csv_writer.py b/weibo_spider/writer/csv_writer.py new file mode 100644 index 00000000..193803da --- /dev/null +++ b/weibo_spider/writer/csv_writer.py @@ -0,0 +1,46 @@ +import csv +import logging + +from .writer import Writer + +logger = logging.getLogger('spider.csv_writer') + + +class CsvWriter(Writer): + def __init__(self, file_path, filter): + self.file_path = file_path + + self.result_headers = [('微博id', 'id'), ('微博正文', 'content'), + ('头条文章url', 'article_url'), + ('原始图片url', 'original_pictures'), + ('微博视频url', 'video_url'), + ('发布位置', 'publish_place'), + ('发布时间', 'publish_time'), + ('发布工具', 'publish_tool'), ('点赞数', 'up_num'), + ('转发数', 'retweet_num'), ('评论数', 'comment_num')] + if not filter: + self.result_headers.insert(4, ('被转发微博原始图片url', 'retweet_pictures')) + self.result_headers.insert(5, ('是否为原创微博', 'original')) + try: + with open(self.file_path, 'a', encoding='utf-8-sig', + newline='') as f: + writer = csv.writer(f) + writer.writerows([[kv[0] for kv in self.result_headers]]) + except Exception as e: + logger.exception(e) + + def write_user(self, user): + self.user = user + + def write_weibo(self, weibos): + """将爬取的信息写入csv文件""" + try: + result_data = [[w.__dict__[kv[1]] for kv in self.result_headers] + for w in weibos] + with open(self.file_path, 'a', encoding='utf-8-sig', + newline='') as f: + writer = csv.writer(f) + writer.writerows(result_data) + logger.info(u'%d条微博写入csv文件完毕,保存路径:%s', len(weibos), self.file_path) + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/writer/json_writer.py b/weibo_spider/writer/json_writer.py new file mode 100644 index 00000000..bca61c2d --- /dev/null +++ b/weibo_spider/writer/json_writer.py @@ -0,0 +1,52 @@ +import codecs +import json +import logging +import os + +from .writer import Writer + +logger = logging.getLogger('spider.json_writer') + + +class JsonWriter(Writer): + def __init__(self, file_path): + self.file_path = file_path + + def write_user(self, user): + self.user = user + + def _update_json_data(self, data, weibo_info): + """更新要写入json结果文件中的数据,已经存在于json中的信息更新为最新值,不存在的信息添加到data中""" + data['user'] = self.user.__dict__ + if data.get('weibo'): + is_new = 1 # 待写入微博是否全部为新微博,即待写入微博与json中的数据不重复 + for old in data['weibo']: + if weibo_info[-1]['id'] == old['id']: + is_new = 0 + break + if is_new == 0: + for new in weibo_info: + flag = 1 + for i, old in enumerate(data['weibo']): + if new['id'] == old['id']: + data['weibo'][i] = new + flag = 0 + break + if flag: + data['weibo'].append(new) + else: + data['weibo'] += weibo_info + else: + data['weibo'] = weibo_info + return data + + def write_weibo(self, weibos): + """将爬到的信息写入json文件""" + data = {} + if os.path.isfile(self.file_path): + with codecs.open(self.file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + data = self._update_json_data(data, [w.__dict__ for w in weibos]) + with codecs.open(self.file_path, 'w', encoding='utf-8') as f: + f.write(json.dumps(data, indent=4, ensure_ascii=False)) + logger.info(u'%d条微博写入json文件完毕,保存路径:%s', len(weibos), self.file_path) diff --git a/weibo_spider/writer/kafka_writer.py b/weibo_spider/writer/kafka_writer.py new file mode 100644 index 00000000..247fd3a2 --- /dev/null +++ b/weibo_spider/writer/kafka_writer.py @@ -0,0 +1,41 @@ +import json +import logging +import sys + +from .writer import Writer + +logger = logging.getLogger('spider.kafka_writer') + + +class KafkaWriter(Writer): + def __init__(self, kafka_config): + try: + from kafka import KafkaProducer + except ImportError: + logger.warning( + u'系统中可能没有安装kafka库,请先运行 pip install kafka-python ,再运行程序') + sys.exit() + + self.kafka_config = kafka_config + self.producer = KafkaProducer( + bootstrap_servers=str(kafka_config['bootstrap-server']).split(','), + value_serializer=lambda m: json.dumps(m, ensure_ascii=False + ).encode('UTF-8')) + self.weibo_topics = list(kafka_config['weibo_topics']) + self.user_topics = list(kafka_config['user_topics']) + logger.info('{}', kafka_config) + + def write_weibo(self, weibo): + for w in weibo: + w.user_id = self.user.id + for topic in self.weibo_topics: + self.producer.send(topic, value=w.__dict__) + + def write_user(self, user): + self.user = user + + for topic in self.user_topics: + self.producer.send(topic, value=user.__dict__) + + def __del__(self): + self.producer.close() diff --git a/weibo_spider/writer/mongo_writer.py b/weibo_spider/writer/mongo_writer.py new file mode 100644 index 00000000..c6c08c5b --- /dev/null +++ b/weibo_spider/writer/mongo_writer.py @@ -0,0 +1,62 @@ +import copy +import logging +import sys + +from .writer import Writer + +logger = logging.getLogger('spider.mongo_writer') + + +class MongoWriter(Writer): + def __init__(self, mongo_config): + self.mongo_config = mongo_config + self.connection_string = mongo_config['connection_string'] + self.dba_name = mongo_config.get('dba_name', None) + self.dba_password = mongo_config.get('dba_password', None) + + def _info_to_mongodb(self, collection, info_list): + """将爬取的信息写入MongoDB数据库""" + try: + import pymongo + except ImportError: + logger.warning( + u'系统中可能没有安装pymongo库,请先运行 pip install pymongo ,再运行程序') + sys.exit() + try: + from pymongo import MongoClient + + client = MongoClient(self.connection_string) + if self.dba_name or self.dba_password: + # authenticate() 在PyMongo3.6版本就已弃用,这一段可能需要后续跟进 + client.admin.authenticate( + self.dba_name, self.dba_password, mechanism='SCRAM-SHA-1' + ) + + db = client['weibo'] + collection = db[collection] + new_info_list = copy.deepcopy(info_list) + for info in new_info_list: + if not collection.find_one({'id': info['id']}): + collection.insert_one(info) + else: + collection.update_one({'id': info['id']}, {'$set': info}) + except pymongo.errors.ServerSelectionTimeoutError: + logger.warning( + u'系统中可能没有安装或启动MongoDB数据库,请先根据系统环境安装或启动MongoDB,再运行程序') + sys.exit() + + def write_weibo(self, weibos): + """将爬取的微博信息写入MongoDB数据库""" + weibo_list = [] + for w in weibos: + w.user_id = self.user.id + weibo_list.append(w.__dict__) + self._info_to_mongodb('weibo', weibo_list) + logger.info(u'%d条微博写入MongoDB数据库完毕', len(weibos)) + + def write_user(self, user): + """将爬取的用户信息写入MongoDB数据库""" + self.user = user + user_list = [user.__dict__] + self._info_to_mongodb('user', user_list) + logger.info(u'%s信息写入MongoDB数据库完毕', user.nickname) diff --git a/weibo_spider/writer/mysql_writer.py b/weibo_spider/writer/mysql_writer.py new file mode 100644 index 00000000..7118c083 --- /dev/null +++ b/weibo_spider/writer/mysql_writer.py @@ -0,0 +1,142 @@ +import copy +import logging +import sys + +from .writer import Writer + +logger = logging.getLogger('spider.mysql_writer') + + +class MySqlWriter(Writer): + def __init__(self, mysql_config): + self.mysql_config = mysql_config + + # 创建'weibo'数据库 + create_database = """CREATE DATABASE IF NOT EXISTS weibo DEFAULT + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci""" + self._mysql_create_database(create_database) + self.mysql_config['db'] = 'weibo' + + def _mysql_create(self, connection, sql): + """创建MySQL数据库或表""" + try: + with connection.cursor() as cursor: + cursor.execute(sql) + finally: + connection.close() + + def _mysql_create_database(self, sql): + """创建MySQL数据库""" + try: + import pymysql + except ImportError: + logger.warning( + u'系统中可能没有安装pymysql库,请先运行 pip install pymysql ,再运行程序') + sys.exit() + try: + connection = pymysql.connect(**self.mysql_config) + self._mysql_create(connection, sql) + except pymysql.OperationalError: + logger.warning(u'系统中可能没有安装或正确配置MySQL数据库,请先根据系统环境安装或配置MySQL,再运行程序') + sys.exit() + + def _mysql_create_table(self, sql): + """创建MySQL表""" + import pymysql + connection = pymysql.connect(**self.mysql_config) + self._mysql_create(connection, sql) + + def _mysql_insert(self, table, data_list): + """向MySQL表插入或更新数据""" + import pymysql + if len(data_list) > 0: + # We use this to filter out unset values. + data_list = [{k: v + for k, v in data.items() if v is not None} + for data in data_list] + + keys = ', '.join(data_list[0].keys()) + values = ', '.join(['%s'] * len(data_list[0])) + connection = pymysql.connect(**self.mysql_config) + cursor = connection.cursor() + sql = """INSERT INTO {table}({keys}) VALUES ({values}) ON + DUPLICATE KEY UPDATE""".format(table=table, + keys=keys, + values=values) + update = ','.join([ + ' {key} = values({key})'.format(key=key) + for key in data_list[0] + ]) + sql += update + try: + cursor.executemany( + sql, [tuple(data.values()) for data in data_list]) + connection.commit() + except Exception as e: + connection.rollback() + logger.exception(e) + finally: + connection.close() + + def write_weibo(self, weibos): + """将爬取的微博信息写入MySQL数据库""" + # 创建'weibo'表 + try: + create_table = """ + CREATE TABLE IF NOT EXISTS weibo ( + id varchar(10) NOT NULL, + user_id varchar(12), + content varchar(5000), + article_url varchar(200), + original_pictures varchar(3000), + retweet_pictures varchar(3000), + original BOOLEAN NOT NULL DEFAULT 1, + video_url varchar(300), + publish_place varchar(100), + publish_time DATETIME NOT NULL, + publish_tool varchar(30), + up_num INT NOT NULL, + retweet_num INT NOT NULL, + comment_num INT NOT NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""" + self._mysql_create_table(create_table) + # 在'weibo'表中插入或更新微博数据 + weibo_list = [] + info_list = copy.deepcopy(weibos) + for weibo in info_list: + weibo.user_id = self.user.id + weibo_list.append(weibo.__dict__) + self._mysql_insert('weibo', weibo_list) + logger.info(u'%d条微博写入MySQL数据库完毕', len(weibos)) + except Exception as e: + logger.exception(e) + + def write_user(self, user): + """将爬取的用户信息写入MySQL数据库""" + try: + self.user = user + + # 创建'user'表 + create_table = """ + CREATE TABLE IF NOT EXISTS user ( + id varchar(20) NOT NULL, + nickname varchar(30), + gender varchar(10), + location varchar(200), + birthday varchar(40), + description varchar(400), + verified_reason varchar(140), + talent varchar(200), + education varchar(200), + work varchar(200), + weibo_num INT, + following INT, + followers INT, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""" + self._mysql_create_table(create_table) + self._mysql_insert('user', [user.__dict__]) + logger.info(u'%s信息写入MySQL数据库完毕', user.nickname) + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/writer/post_writer.py b/weibo_spider/writer/post_writer.py new file mode 100644 index 00000000..af536623 --- /dev/null +++ b/weibo_spider/writer/post_writer.py @@ -0,0 +1,59 @@ +import codecs +import json +import logging +import os +import requests + +from .writer import Writer +from time import sleep +from requests.exceptions import RequestException + +logger = logging.getLogger('spider.post_writer') + +class PostWriter(Writer): + def __init__(self, post_config): + self.post_config = post_config + self.api_url = post_config['api_url'] + self.api_token = post_config.get('api_token', None) + self.dba_password = post_config.get('dba_password', None) + + def write_user(self, user): + self.user = user + + def _update_json_data(self, data, weibo_info): + """将获取到的微博数据转换为json输出模式一致""" + data['user'] = self.user.__dict__ + if data.get('weibo'): + data['weibo'] += weibo_info + else: + data['weibo'] = weibo_info + return data + + def send_post_request_with_token(self, url, data, token, max_retries, backoff_factor): + headers = { + 'Content-Type': 'application/json', + 'api-token': f'{token}', + } + for attempt in range(max_retries + 1): + try: + response = requests.post(url, json=data, headers=headers) + if response.status_code == requests.codes.ok: + return response.json() + else: + raise RequestException(f"Unexpected response status: {response.status_code}") + except RequestException as e: + if attempt < max_retries: + sleep(backoff_factor * (attempt + 1)) # 逐步增加等待时间,避免频繁重试 + continue + else: + logger.error(f"在尝试{max_retries}次发出POST连接后,请求失败:{e}") + + def write_weibo(self, weibos): + """将爬到的信息POST到API""" + data = {} + data = self._update_json_data(data, [w.__dict__ for w in weibos]) + if data: + self.send_post_request_with_token(self.api_url, data, self.api_token, 3, 2) + logger.info(u'%d条微博通过POST发送到 %s', len(weibos), self.api_url) + else: + logger.info(u'没有获取到微博,略过API POST') diff --git a/weibo_spider/writer/sqlite_writer.py b/weibo_spider/writer/sqlite_writer.py new file mode 100644 index 00000000..cea0ccd9 --- /dev/null +++ b/weibo_spider/writer/sqlite_writer.py @@ -0,0 +1,108 @@ +import copy +import logging +import sys + +from .writer import Writer + +logger = logging.getLogger('spider.sqlite_writer') + + +class SqliteWriter(Writer): + def __init__(self, sqlite_config): + self.sqlite_config = sqlite_config + + def _sqlite_create(self, connection, sql): + """创建sqlite数据库或表""" + try: + cursor = connection.cursor() + cursor.execute(sql) + finally: + connection.close() + + def _sqlite_create_table(self, sql): + """创建sqlite表""" + import sqlite3 + connection = sqlite3.connect(self.sqlite_config) + self._sqlite_create(connection, sql) + + def _sqlite_insert(self, table, data_list): + """向sqlite表插入或更新数据""" + import sqlite3 + if len(data_list) > 0: + # We use this to filter out unset values. + data_list = [{k: v + for k, v in data.items() if v is not None} + for data in data_list] + + keys = ', '.join(data_list[0].keys()) + values = ', '.join(['?'] * len(data_list[0])) + connection = sqlite3.connect(self.sqlite_config) + cursor = connection.cursor() + sql = """INSERT OR REPLACE INTO {table}({keys}) VALUES ({values})""".format( + table=table, keys=keys, values=values) + try: + cursor.executemany( + sql, [tuple(data.values()) for data in data_list]) + connection.commit() + except Exception as e: + connection.rollback() + logger.exception(e) + finally: + connection.close() + + def write_weibo(self, weibos): + """将爬取的微博信息写入sqlite数据库""" + # 创建'weibo'表 + create_table = """ + CREATE TABLE IF NOT EXISTS weibo ( + id varchar(10) NOT NULL, + user_id varchar(12), + content varchar(2000), + article_url varchar(200), + original_pictures varchar(3000), + retweet_pictures varchar(3000), + original BOOLEAN NOT NULL DEFAULT 1, + video_url varchar(300), + publish_place varchar(100), + publish_time DATETIME NOT NULL, + publish_tool varchar(30), + up_num INT NOT NULL, + retweet_num INT NOT NULL, + comment_num INT NOT NULL, + PRIMARY KEY (id) + )""" + self._sqlite_create_table(create_table) + # 在'weibo'表中插入或更新微博数据 + weibo_list = [] + info_list = copy.deepcopy(weibos) + for weibo in info_list: + weibo.user_id = self.user.id + weibo_list.append(weibo.__dict__) + self._sqlite_insert('weibo', weibo_list) + logger.info(u'%d条微博写入sqlite数据库完毕', len(weibos)) + + def write_user(self, user): + """将爬取的用户信息写入sqlite数据库""" + self.user = user + + # 创建'user'表 + create_table = """ + CREATE TABLE IF NOT EXISTS user ( + id varchar(20) NOT NULL, + nickname varchar(30), + gender varchar(10), + location varchar(200), + birthday varchar(40), + description varchar(400), + verified_reason varchar(140), + talent varchar(200), + education varchar(200), + work varchar(200), + weibo_num INT, + following INT, + followers INT, + PRIMARY KEY (id) + )""" + self._sqlite_create_table(create_table) + self._sqlite_insert('user', [user.__dict__]) + logger.info(u'%s信息写入sqlite数据库完毕', user.nickname) diff --git a/weibo_spider/writer/txt_writer.py b/weibo_spider/writer/txt_writer.py new file mode 100644 index 00000000..6eddd862 --- /dev/null +++ b/weibo_spider/writer/txt_writer.py @@ -0,0 +1,57 @@ +import logging +import sys + +from .writer import Writer + +logger = logging.getLogger('spider.txt_writer') + + +class TxtWriter(Writer): + def __init__(self, file_path, filter): + self.file_path = file_path + + self.user_header = u'用户信息' + self.user_desc = [('nickname', '用户昵称'), ('id', '用户id'), + ('weibo_num', '微博数'), ('following', '关注数'), + ('followers', '粉丝数')] + + if filter: + self.weibo_header = u'原创微博内容' + else: + self.weibo_header = u'微博内容' + self.weibo_desc = [('publish_place', '微博位置'), ('publish_time', '发布时间'), + ('up_num', '点赞数'), ('retweet_num', '转发数'), + ('comment_num', '评论数'), ('publish_tool', '发布工具')] + + def write_user(self, user): + self.user = user + user_info = '\n'.join( + [v + ':' + str(self.user.__dict__[k]) for k, v in self.user_desc]) + + with open(self.file_path, 'ab') as f: + f.write((self.user_header + ':\n' + user_info + '\n\n').encode( + sys.stdout.encoding)) + logger.info(u'%s信息写入txt文件完毕,保存路径:%s', self.user.nickname, + self.file_path) + + def write_weibo(self, weibo): + """将爬取的信息写入txt文件""" + + weibo_header = '' + if self.weibo_header: + weibo_header = self.weibo_header + ':\n' + self.weibo_header = '' + + try: + temp_result = [] + for w in weibo: + temp_result.append(w.__dict__['content'] + '\n' + '\n'.join( + [v + ':' + str(w.__dict__[k]) + for k, v in self.weibo_desc])) + result = '\n\n'.join(temp_result) + '\n\n' + + with open(self.file_path, 'ab') as f: + f.write((weibo_header + result).encode(sys.stdout.encoding)) + logger.info(u'%d条微博写入txt文件完毕,保存路径:%s', len(weibo), self.file_path) + except Exception as e: + logger.exception(e) diff --git a/weibo_spider/writer/writer.py b/weibo_spider/writer/writer.py new file mode 100644 index 00000000..45366510 --- /dev/null +++ b/weibo_spider/writer/writer.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + + +class Writer(ABC): + def __init__(self): + """根据需要,初始化结果路径、初始化表头、初始化数据库等""" + pass + + @abstractmethod + def write_weibo(self, weibo): + """给定微博信息,写入对应文本或数据库""" + pass + + @abstractmethod + def write_user(self, user): + """给定用户信息,写入对应文本或数据库""" + pass