diff --git a/README.md b/README.md
index e849f9f..d7cd461 100644
--- a/README.md
+++ b/README.md
@@ -13,17 +13,8 @@
原理:利用[playwright](https://playwright.dev/)搭桥,保留登录成功后的上下文浏览器环境,通过执行JS表达式获取一些加密参数
通过使用此方式,免去了复现核心加密JS代码,逆向难度大大降低
-[MediaCrawlerPro](https://github.com/MediaCrawlerPro) 版本已经迭代出来了,相较于开源版本的优势:
-- 多账号+IP代理支持(重点!)
-- 去除Playwright依赖,使用更加简单
-- 支持linux部署(Docker docker-compose)
-- 代码重构优化,更加易读易维护(解耦JS签名逻辑)
-- 完美的架构设计,更加易扩展,源码学习的价值更大
-
-
MediaCrawler仓库白金赞助商:
⚡️【IPCola全球独家海外IP代理】⚡️新鲜的原生住宅代理,超高性价比,超多稀缺国家
-> 【IPCola全球独家海外IP代理】使用此处阿江专属推荐码注册:atxtupzfjhpbdbl ,获得10%金额补贴。
## 功能列表
| 平台 | 关键词搜索 | 指定帖子ID爬取 | 二级评论 | 指定创作者主页 | 登录态缓存 | IP代理池 | 生成评论词云图 |
@@ -36,8 +27,80 @@ MediaCrawler仓库白金赞助商:
| 贴吧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 知乎 | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
+
+## 创建并激活 python 虚拟环境
+> 如果是爬取抖音和知乎,需要提前安装nodejs环境,版本大于等于:`16`即可
+ ```shell
+ # 进入项目根目录
+ cd MediaCrawler
+
+ # 创建虚拟环境
+ # 我的python版本是:3.9.6,requirements.txt中的库是基于这个版本的,如果是其他python版本,可能requirements.txt中的库不兼容,自行解决一下。
+ python -m venv venv
+
+ # macos & linux 激活虚拟环境
+ source venv/bin/activate
+
+ # windows 激活虚拟环境
+ venv\Scripts\activate
+
+ ```
+
+## 安装依赖库
+
+ ```shell
+ pip install -r requirements.txt
+ ```
+
+## 安装 playwright浏览器驱动
+
+ ```shell
+ playwright install
+ ```
+
+## 运行爬虫程序
+
+ ```shell
+ ### 项目默认是没有开启评论爬取模式,如需评论请在config/base_config.py中的 ENABLE_GET_COMMENTS 变量修改
+ ### 一些其他支持项,也可以在config/base_config.py查看功能,写的有中文注释
+
+ # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论
+ python main.py --platform xhs --lt qrcode --type search
+
+ # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息
+ python main.py --platform xhs --lt qrcode --type detail
+
+ # 打开对应APP扫二维码登录
+
+ # 其他平台爬虫使用示例,执行下面的命令查看
+ python main.py --help
+ ```
+
+## 数据保存
+- 支持关系型数据库Mysql中保存(需要提前创建数据库)
+ - 执行 `python db.py` 初始化数据库数据库表结构(只在首次执行)
+- 支持保存到csv中(data/目录下)
+- 支持保存到json中(data/目录下)
+
+## MediaCrawlerPro
+[MediaCrawlerPro](https://github.com/MediaCrawlerPro) 版本已经重构出来了,相较于开源版本的优势:
+- 多账号+IP代理支持(重点!)
+- 去除Playwright依赖,使用更加简单
+- 支持linux部署(Docker docker-compose)
+- 代码重构优化,更加易读易维护(解耦JS签名逻辑)
+- 代码质量更高,对于构建更大型的爬虫项目更加友好
+- 完美的架构设计,更加易扩展,源码学习的价值更大
+
+
+## 其他常见问题可以查看在线文档
+>
+> 在线文档包含使用方法、常见问题、加入项目交流群等。
+> [MediaCrawler在线文档](https://nanmicoder.github.io/MediaCrawler/)
+>
+
## 开发者服务
-> 开源不易,希望大家可以Star一下MediaCrawler仓库、支持下我的课程、星球,十分感谢!!!
+> 开源不易,希望大家可以Star一下MediaCrawler仓库!!!!十分感谢!!!
+> 如果你对知识付费认可,可以看下下面我提供的付费服务,如果你是学生,请一定提前告知,会有优惠💰
- MediaCrawler源码剖析课程:
如果你想很快入门这个项目,或者想了具体实现原理,我推荐你看看这个我录制的视频课程,从设计出发一步步带你如何使用,门槛大大降低
@@ -65,12 +128,6 @@ MediaCrawler仓库白金赞助商:
- [Python协程在并发场景下的幂等性问题](https://articles.zsxq.com/id_wocdwsfmfcmp.html)
- [错误使用 Python 可变类型带来的隐藏 Bug](https://articles.zsxq.com/id_f7vn89l1d303.html)
-## 使用教程文档
-
-> MediaCrawler文档使用vitepress构建,包含使用方法、常见问题、加入项目交流群等。
->
-[MediaCrawler在线文档](https://nanmicoder.github.io/MediaCrawler/)
-
## 感谢下列Sponsors对本仓库赞助
> 【IPCola全球独家海外IP代理】使用此处阿江专属推荐码注册:atxtupzfjhpbdbl ,获得10%金额补贴。
@@ -80,6 +137,19 @@ MediaCrawler仓库白金赞助商:
成为赞助者,可以将您产品展示在这里,每天获得大量曝光,联系作者微信:yzglan 或 email:relakkes@gmail.com
+## MediaCrawler项目微信交流群
+
+👏👏👏 汇聚爬虫技术爱好者,共同学习,共同进步。
+
+❗️❗️❗️群内禁止广告,禁止发各类违规和MediaCrawler不相关的问题
+
+### 加群方式
+> 备注:github,会有拉群小助手自动拉你进群。
+>
+> 如果图片展示不出来或过期,可以直接添加我的微信号:yzglan,并备注github,会有拉群小助手自动拉你进群
+
+![relakkes_wechat](docs/static/images/relakkes_weichat.jpg)
+
## 打赏
如果觉得项目不错的话可以打赏哦。您的支持就是我最大的动力!
diff --git a/config/base_config.py b/config/base_config.py
index 2cb0b6c..efccb96 100644
--- a/config/base_config.py
+++ b/config/base_config.py
@@ -1,6 +1,6 @@
# 基础配置
PLATFORM = "xhs"
-KEYWORDS = "编程副业,编程兼职"
+KEYWORDS = "编程副业,编程兼职" # 关键词搜索配置,以英文逗号分隔
LOGIN_TYPE = "qrcode" # qrcode or phone or cookie
COOKIES = ""
# 具体值参见media_platform.xxx.field下的枚举值,暂时只支持小红书
@@ -45,8 +45,8 @@ MAX_CONCURRENCY_NUM = 1
# 是否开启爬图片模式, 默认不开启爬图片
ENABLE_GET_IMAGES = False
-# 是否开启爬评论模式, 默认不开启爬评论
-ENABLE_GET_COMMENTS = False
+# 是否开启爬评论模式, 默认开启爬评论
+ENABLE_GET_COMMENTS = True
# 是否开启爬二级评论模式, 默认不开启爬二级评论
# 老版本项目使用了 db, 则需参考 schema/tables.sql line 287 增加表字段
@@ -130,6 +130,13 @@ KS_CREATOR_ID_LIST = [
# ........................
]
+
+# 指定知乎创作者主页url列表
+ZHIHU_CREATOR_URL_LIST = [
+ "https://www.zhihu.com/people/yd1234567",
+ # ........................
+]
+
# 词云相关
# 是否开启生成评论词云图
ENABLE_GET_WORDCLOUD = False
diff --git a/media_platform/zhihu/client.py b/media_platform/zhihu/client.py
index ba29fbe..a1d28d8 100644
--- a/media_platform/zhihu/client.py
+++ b/media_platform/zhihu/client.py
@@ -5,18 +5,19 @@ from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urlencode
import httpx
+from httpx import Response
from playwright.async_api import BrowserContext, Page
from tenacity import retry, stop_after_attempt, wait_fixed
import config
from base.base_crawler import AbstractApiClient
from constant import zhihu as zhihu_constant
-from model.m_zhihu import ZhihuComment, ZhihuContent
+from model.m_zhihu import ZhihuComment, ZhihuContent, ZhihuCreator
from tools import utils
from .exception import DataFetchError, ForbiddenError
from .field import SearchSort, SearchTime, SearchType
-from .help import ZhiHuJsonExtractor, sign
+from .help import ZhihuExtractor, sign
class ZhiHuClient(AbstractApiClient):
@@ -33,7 +34,7 @@ class ZhiHuClient(AbstractApiClient):
self.timeout = timeout
self.default_headers = headers
self.cookie_dict = cookie_dict
- self._extractor = ZhiHuJsonExtractor()
+ self._extractor = ZhihuExtractor()
async def _pre_headers(self, url: str) -> Dict:
"""
@@ -95,7 +96,7 @@ class ZhiHuClient(AbstractApiClient):
raise DataFetchError(response.text)
- async def get(self, uri: str, params=None) -> Dict:
+ async def get(self, uri: str, params=None, **kwargs) -> Union[Response, Dict, str]:
"""
GET请求,对请求头签名
Args:
@@ -109,7 +110,7 @@ class ZhiHuClient(AbstractApiClient):
if isinstance(params, dict):
final_uri += '?' + urlencode(params)
headers = await self._pre_headers(final_uri)
- return await self.request(method="GET", url=zhihu_constant.ZHIHU_URL + final_uri, headers=headers)
+ return await self.request(method="GET", url=zhihu_constant.ZHIHU_URL + final_uri, headers=headers, **kwargs)
async def pong(self) -> bool:
"""
@@ -194,7 +195,7 @@ class ZhiHuClient(AbstractApiClient):
}
search_res = await self.get(uri, params)
utils.logger.info(f"[ZhiHuClient.get_note_by_keyword] Search result: {search_res}")
- return self._extractor.extract_contents(search_res)
+ return self._extractor.extract_contents_from_search(search_res)
async def get_root_comments(self, content_id: str, content_type: str, offset: str = "", limit: int = 10,
order_by: str = "sort") -> Dict:
@@ -317,3 +318,170 @@ class ZhiHuClient(AbstractApiClient):
all_sub_comments.extend(sub_comments)
await asyncio.sleep(crawl_interval)
return all_sub_comments
+
+ async def get_creator_info(self, url_token: str) -> Optional[ZhihuCreator]:
+ """
+ 获取创作者信息
+ Args:
+ url_token:
+
+ Returns:
+
+ """
+ uri = f"/people/{url_token}"
+ html_content: str = await self.get(uri, return_response=True)
+ return self._extractor.extract_creator(url_token, html_content)
+
+ async def get_creator_answers(self, url_token: str, offset: int = 0, limit: int = 20) -> Dict:
+ """
+ 获取创作者的回答
+ Args:
+ url_token:
+ offset:
+ limit:
+
+ Returns:
+
+
+ """
+ uri = f"/api/v4/members/{url_token}/answers"
+ params = {
+ "include":"data[*].is_normal,admin_closed_comment,reward_info,is_collapsed,annotation_action,annotation_detail,collapse_reason,collapsed_by,suggest_edit,comment_count,can_comment,content,editable_content,attachment,voteup_count,reshipment_settings,comment_permission,created_time,updated_time,review_info,excerpt,paid_info,reaction_instruction,is_labeled,label_info,relationship.is_authorized,voting,is_author,is_thanked,is_nothelp;data[*].vessay_info;data[*].author.badge[?(type=best_answerer)].topics;data[*].author.vip_info;data[*].question.has_publishing_draft,relationship",
+ "offset": offset,
+ "limit": limit,
+ "order_by": "created"
+ }
+ return await self.get(uri, params)
+
+ async def get_creator_articles(self, url_token: str, offset: int = 0, limit: int = 20) -> Dict:
+ """
+ 获取创作者的文章
+ Args:
+ url_token:
+ offset:
+ limit:
+
+ Returns:
+
+ """
+ uri = f"/api/v4/members/{url_token}/articles"
+ params = {
+ "include":"data[*].comment_count,suggest_edit,is_normal,thumbnail_extra_info,thumbnail,can_comment,comment_permission,admin_closed_comment,content,voteup_count,created,updated,upvoted_followees,voting,review_info,reaction_instruction,is_labeled,label_info;data[*].vessay_info;data[*].author.badge[?(type=best_answerer)].topics;data[*].author.vip_info;",
+ "offset": offset,
+ "limit": limit,
+ "order_by": "created"
+ }
+ return await self.get(uri, params)
+
+ async def get_creator_videos(self, url_token: str, offset: int = 0, limit: int = 20) -> Dict:
+ """
+ 获取创作者的视频
+ Args:
+ url_token:
+ offset:
+ limit:
+
+ Returns:
+
+ """
+ uri = f"/api/v4/members/{url_token}/zvideos"
+ params = {
+ "include":"similar_zvideo,creation_relationship,reaction_instruction",
+ "offset": offset,
+ "limit": limit,
+ "similar_aggregation": "true"
+ }
+ return await self.get(uri, params)
+
+ async def get_all_anwser_by_creator(self, creator: ZhihuCreator, crawl_interval: float = 1.0,
+ callback: Optional[Callable] = None) -> List[ZhihuContent]:
+ """
+ 获取创作者的所有回答
+ Args:
+ creator: 创作者信息
+ crawl_interval: 爬取一次笔记的延迟单位(秒)
+ callback: 一次笔记爬取结束后
+
+ Returns:
+
+ """
+ all_contents: List[ZhihuContent] = []
+ is_end: bool = False
+ offset: int = 0
+ limit: int = 20
+ while not is_end:
+ res = await self.get_creator_answers(creator.url_token, offset, limit)
+ if not res:
+ break
+ utils.logger.info(f"[ZhiHuClient.get_all_anwser_by_creator] Get creator {creator.url_token} answers: {res}")
+ paging_info = res.get("paging", {})
+ is_end = paging_info.get("is_end")
+ contents = self._extractor.extract_content_list_from_creator(res.get("data"))
+ if callback:
+ await callback(contents)
+ all_contents.extend(contents)
+ offset += limit
+ await asyncio.sleep(crawl_interval)
+ return all_contents
+
+
+ async def get_all_articles_by_creator(self, creator: ZhihuCreator, crawl_interval: float = 1.0,
+ callback: Optional[Callable] = None) -> List[ZhihuContent]:
+ """
+ 获取创作者的所有文章
+ Args:
+ creator:
+ crawl_interval:
+ callback:
+
+ Returns:
+
+ """
+ all_contents: List[ZhihuContent] = []
+ is_end: bool = False
+ offset: int = 0
+ limit: int = 20
+ while not is_end:
+ res = await self.get_creator_articles(creator.url_token, offset, limit)
+ if not res:
+ break
+ paging_info = res.get("paging", {})
+ is_end = paging_info.get("is_end")
+ contents = self._extractor.extract_content_list_from_creator(res.get("data"))
+ if callback:
+ await callback(contents)
+ all_contents.extend(contents)
+ offset += limit
+ await asyncio.sleep(crawl_interval)
+ return all_contents
+
+
+ async def get_all_videos_by_creator(self, creator: ZhihuCreator, crawl_interval: float = 1.0,
+ callback: Optional[Callable] = None) -> List[ZhihuContent]:
+ """
+ 获取创作者的所有视频
+ Args:
+ creator:
+ crawl_interval:
+ callback:
+
+ Returns:
+
+ """
+ all_contents: List[ZhihuContent] = []
+ is_end: bool = False
+ offset: int = 0
+ limit: int = 20
+ while not is_end:
+ res = await self.get_creator_videos(creator.url_token, offset, limit)
+ if not res:
+ break
+ paging_info = res.get("paging", {})
+ is_end = paging_info.get("is_end")
+ contents = self._extractor.extract_content_list_from_creator(res.get("data"))
+ if callback:
+ await callback(contents)
+ all_contents.extend(contents)
+ offset += limit
+ await asyncio.sleep(crawl_interval)
+ return all_contents
diff --git a/media_platform/zhihu/core.py b/media_platform/zhihu/core.py
index 6a3de2a..9898104 100644
--- a/media_platform/zhihu/core.py
+++ b/media_platform/zhihu/core.py
@@ -10,7 +10,7 @@ from playwright.async_api import (BrowserContext, BrowserType, Page,
import config
from base.base_crawler import AbstractCrawler
-from model.m_zhihu import ZhihuContent
+from model.m_zhihu import ZhihuContent, ZhihuCreator
from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool
from store import zhihu as zhihu_store
from tools import utils
@@ -18,7 +18,7 @@ from var import crawler_type_var, source_keyword_var
from .client import ZhiHuClient
from .exception import DataFetchError
-from .help import ZhiHuJsonExtractor
+from .help import ZhihuExtractor
from .login import ZhiHuLogin
@@ -31,7 +31,7 @@ class ZhihuCrawler(AbstractCrawler):
self.index_url = "https://www.zhihu.com"
# self.user_agent = utils.get_user_agent()
self.user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
- self._extractor = ZhiHuJsonExtractor()
+ self._extractor = ZhihuExtractor()
async def start(self) -> None:
"""
@@ -74,7 +74,7 @@ class ZhihuCrawler(AbstractCrawler):
await self.zhihu_client.update_cookies(browser_context=self.browser_context)
# 知乎的搜索接口需要打开搜索页面之后cookies才能访问API,单独的首页不行
- utils.logger.info("[ZhihuCrawler.start] Zhihu跳转到搜索页面获取搜索页面的Cookies,改过程需要5秒左右")
+ utils.logger.info("[ZhihuCrawler.start] Zhihu跳转到搜索页面获取搜索页面的Cookies,该过程需要5秒左右")
await self.context_page.goto(f"{self.index_url}/search?q=python&search_source=Guess&utm_content=search_hot&type=content")
await asyncio.sleep(5)
await self.zhihu_client.update_cookies(browser_context=self.browser_context)
@@ -88,7 +88,7 @@ class ZhihuCrawler(AbstractCrawler):
raise NotImplementedError
elif config.CRAWLER_TYPE == "creator":
# Get creator's information and their notes and comments
- raise NotImplementedError
+ await self.get_creators_and_notes()
else:
pass
@@ -169,6 +169,53 @@ class ZhihuCrawler(AbstractCrawler):
callback=zhihu_store.batch_update_zhihu_note_comments
)
+ async def get_creators_and_notes(self) -> None:
+ """
+ Get creator's information and their notes and comments
+ Returns:
+
+ """
+ utils.logger.info("[ZhihuCrawler.get_creators_and_notes] Begin get xiaohongshu creators")
+ for user_link in config.ZHIHU_CREATOR_URL_LIST:
+ utils.logger.info(f"[ZhihuCrawler.get_creators_and_notes] Begin get creator {user_link}")
+ user_url_token = user_link.split("/")[-1]
+ # get creator detail info from web html content
+ createor_info: ZhihuCreator = await self.zhihu_client.get_creator_info(url_token=user_url_token)
+ if not createor_info:
+ utils.logger.info(f"[ZhihuCrawler.get_creators_and_notes] Creator {user_url_token} not found")
+ continue
+
+ utils.logger.info(f"[ZhihuCrawler.get_creators_and_notes] Creator info: {createor_info}")
+ await zhihu_store.save_creator(creator=createor_info)
+
+ # 默认只提取回答信息,如果需要文章和视频,把下面的注释打开即可
+
+ # Get all anwser information of the creator
+ all_content_list = await self.zhihu_client.get_all_anwser_by_creator(
+ creator=createor_info,
+ crawl_interval=random.random(),
+ callback=zhihu_store.batch_update_zhihu_contents
+ )
+
+
+ # Get all articles of the creator's contents
+ # all_content_list = await self.zhihu_client.get_all_articles_by_creator(
+ # creator=createor_info,
+ # crawl_interval=random.random(),
+ # callback=zhihu_store.batch_update_zhihu_contents
+ # )
+
+ # Get all videos of the creator's contents
+ # all_content_list = await self.zhihu_client.get_all_videos_by_creator(
+ # creator=createor_info,
+ # crawl_interval=random.random(),
+ # callback=zhihu_store.batch_update_zhihu_contents
+ # )
+
+ # Get all comments of the creator's contents
+ await self.batch_get_content_comments(all_content_list)
+
+
@staticmethod
def format_proxy_info(ip_proxy_info: IpInfoModel) -> Tuple[Optional[Dict], Optional[Dict]]:
"""format proxy info for playwright and httpx"""
diff --git a/media_platform/zhihu/help.py b/media_platform/zhihu/help.py
index e9e929a..edd16a3 100644
--- a/media_platform/zhihu/help.py
+++ b/media_platform/zhihu/help.py
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
-from typing import Dict, List
+import json
+from typing import Dict, List, Optional
from urllib.parse import parse_qs, urlparse
import execjs
+from parsel import Selector
from constant import zhihu as zhihu_constant
from model.m_zhihu import ZhihuComment, ZhihuContent, ZhihuCreator
@@ -29,11 +31,11 @@ def sign(url: str, cookies: str) -> Dict:
return ZHIHU_SGIN_JS.call("get_sign", url, cookies)
-class ZhiHuJsonExtractor:
+class ZhihuExtractor:
def __init__(self):
pass
- def extract_contents(self, json_data: Dict) -> List[ZhihuContent]:
+ def extract_contents_from_search(self, json_data: Dict) -> List[ZhihuContent]:
"""
extract zhihu contents
Args:
@@ -45,21 +47,34 @@ class ZhiHuJsonExtractor:
if not json_data:
return []
- result: List[ZhihuContent] = []
search_result: List[Dict] = json_data.get("data", [])
search_result = [s_item for s_item in search_result if s_item.get("type") in ['search_result', 'zvideo']]
- for sr_item in search_result:
- sr_object: Dict = sr_item.get("object", {})
- if sr_object.get("type") == zhihu_constant.ANSWER_NAME:
- result.append(self._extract_answer_content(sr_object))
- elif sr_object.get("type") == zhihu_constant.ARTICLE_NAME:
- result.append(self._extract_article_content(sr_object))
- elif sr_object.get("type") == zhihu_constant.VIDEO_NAME:
- result.append(self._extract_zvideo_content(sr_object))
+ return self._extract_content_list([sr_item.get("object") for sr_item in search_result if sr_item.get("object")])
+
+
+ def _extract_content_list(self, content_list: List[Dict]) -> List[ZhihuContent]:
+ """
+ extract zhihu content list
+ Args:
+ content_list:
+
+ Returns:
+
+ """
+ if not content_list:
+ return []
+
+ res: List[ZhihuContent] = []
+ for content in content_list:
+ if content.get("type") == zhihu_constant.ANSWER_NAME:
+ res.append(self._extract_answer_content(content))
+ elif content.get("type") == zhihu_constant.ARTICLE_NAME:
+ res.append(self._extract_article_content(content))
+ elif content.get("type") == zhihu_constant.VIDEO_NAME:
+ res.append(self._extract_zvideo_content(content))
else:
continue
-
- return result
+ return res
def _extract_answer_content(self, answer: Dict) -> ZhihuContent:
"""
@@ -72,22 +87,23 @@ class ZhiHuJsonExtractor:
res = ZhihuContent()
res.content_id = answer.get("id")
res.content_type = answer.get("type")
- res.content_text = extract_text_from_html(answer.get("content"))
+ res.content_text = extract_text_from_html(answer.get("content", ""))
res.question_id = answer.get("question").get("id")
res.content_url = f"{zhihu_constant.ZHIHU_URL}/question/{res.question_id}/answer/{res.content_id}"
- res.title = extract_text_from_html(answer.get("title"))
- res.desc = extract_text_from_html(answer.get("description"))
+ res.title = extract_text_from_html(answer.get("title", ""))
+ res.desc = extract_text_from_html(answer.get("description", "") or answer.get("excerpt", ""))
res.created_time = answer.get("created_time")
res.updated_time = answer.get("updated_time")
- res.voteup_count = answer.get("voteup_count")
- res.comment_count = answer.get("comment_count")
+ res.voteup_count = answer.get("voteup_count", 0)
+ res.comment_count = answer.get("comment_count", 0)
# extract author info
- author_info = self._extract_author(answer.get("author"))
+ author_info = self._extract_content_or_comment_author(answer.get("author"))
res.user_id = author_info.user_id
res.user_link = author_info.user_link
res.user_nickname = author_info.user_nickname
res.user_avatar = author_info.user_avatar
+ res.user_url_token = author_info.url_token
return res
def _extract_article_content(self, article: Dict) -> ZhihuContent:
@@ -106,17 +122,18 @@ class ZhiHuJsonExtractor:
res.content_url = f"{zhihu_constant.ZHIHU_URL}/p/{res.content_id}"
res.title = extract_text_from_html(article.get("title"))
res.desc = extract_text_from_html(article.get("excerpt"))
- res.created_time = article.get("created_time")
- res.updated_time = article.get("updated_time")
- res.voteup_count = article.get("voteup_count")
- res.comment_count = article.get("comment_count")
+ res.created_time = article.get("created_time", 0) or article.get("created", 0)
+ res.updated_time = article.get("updated_time", 0) or article.get("updated", 0)
+ res.voteup_count = article.get("voteup_count", 0)
+ res.comment_count = article.get("comment_count", 0)
# extract author info
- author_info = self._extract_author(article.get("author"))
+ author_info = self._extract_content_or_comment_author(article.get("author"))
res.user_id = author_info.user_id
res.user_link = author_info.user_link
res.user_nickname = author_info.user_nickname
res.user_avatar = author_info.user_avatar
+ res.user_url_token = author_info.url_token
return res
def _extract_zvideo_content(self, zvideo: Dict) -> ZhihuContent:
@@ -129,25 +146,34 @@ class ZhiHuJsonExtractor:
"""
res = ZhihuContent()
- res.content_id = zvideo.get("zvideo_id")
+
+ if "video" in zvideo and isinstance(zvideo.get("video"), dict): # 说明是从创作者主页的视频列表接口来的
+ res.content_id = zvideo.get("video").get("video_id")
+ res.content_url = f"{zhihu_constant.ZHIHU_URL}/zvideo/{res.content_id}"
+ res.created_time = zvideo.get("published_at")
+ res.updated_time = zvideo.get("updated_at")
+ else:
+ res.content_id = zvideo.get("zvideo_id")
+ res.content_url = zvideo.get("video_url")
+ res.created_time = zvideo.get("created_at")
+
res.content_type = zvideo.get("type")
- res.content_url = zvideo.get("video_url")
res.title = extract_text_from_html(zvideo.get("title"))
res.desc = extract_text_from_html(zvideo.get("description"))
- res.created_time = zvideo.get("created_at")
res.voteup_count = zvideo.get("voteup_count")
res.comment_count = zvideo.get("comment_count")
# extract author info
- author_info = self._extract_author(zvideo.get("author"))
+ author_info = self._extract_content_or_comment_author(zvideo.get("author"))
res.user_id = author_info.user_id
res.user_link = author_info.user_link
res.user_nickname = author_info.user_nickname
res.user_avatar = author_info.user_avatar
+ res.user_url_token = author_info.url_token
return res
@staticmethod
- def _extract_author(author: Dict) -> ZhihuCreator:
+ def _extract_content_or_comment_author(author: Dict) -> ZhihuCreator:
"""
extract zhihu author
Args:
@@ -165,6 +191,7 @@ class ZhiHuJsonExtractor:
res.user_link = f"{zhihu_constant.ZHIHU_URL}/people/{author.get('url_token')}"
res.user_nickname = author.get("name")
res.user_avatar = author.get("avatar_url")
+ res.url_token = author.get("url_token")
return res
def extract_comments(self, page_content: ZhihuContent, comments: List[Dict]) -> List[ZhihuComment]:
@@ -209,7 +236,7 @@ class ZhiHuJsonExtractor:
res.content_type = page_content.content_type
# extract author info
- author_info = self._extract_author(comment.get("author"))
+ author_info = self._extract_content_or_comment_author(comment.get("author"))
res.user_id = author_info.user_id
res.user_link = author_info.user_link
res.user_nickname = author_info.user_nickname
@@ -254,3 +281,80 @@ class ZhiHuJsonExtractor:
query_params = parse_qs(parsed_url.query)
offset = query_params.get('offset', [""])[0]
return offset
+
+ @staticmethod
+ def _foramt_gender_text(gender: int) -> str:
+ """
+ format gender text
+ Args:
+ gender:
+
+ Returns:
+
+ """
+ if gender == 1:
+ return "男"
+ elif gender == 0:
+ return "女"
+ else:
+ return "未知"
+
+
+ def extract_creator(self, user_url_token: str, html_content: str) -> Optional[ZhihuCreator]:
+ """
+ extract zhihu creator
+ Args:
+ user_url_token : zhihu creator url token
+ html_content: zhihu creator html content
+
+ Returns:
+
+ """
+ if not html_content:
+ return None
+
+ js_init_data = Selector(text=html_content).xpath("//script[@id='js-initialData']/text()").get(default="").strip()
+ if not js_init_data:
+ return None
+
+ js_init_data_dict: Dict = json.loads(js_init_data)
+ users_info: Dict = js_init_data_dict.get("initialState", {}).get("entities", {}).get("users", {})
+ if not users_info:
+ return None
+
+ creator_info: Dict = users_info.get(user_url_token)
+ if not creator_info:
+ return None
+
+ res = ZhihuCreator()
+ res.user_id = creator_info.get("id")
+ res.user_link = f"{zhihu_constant.ZHIHU_URL}/people/{user_url_token}"
+ res.user_nickname = creator_info.get("name")
+ res.user_avatar = creator_info.get("avatarUrl")
+ res.url_token = creator_info.get("urlToken") or user_url_token
+ res.gender = self._foramt_gender_text(creator_info.get("gender"))
+ res.ip_location = creator_info.get("ipInfo")
+ res.follows = creator_info.get("followingCount")
+ res.fans = creator_info.get("followerCount")
+ res.anwser_count = creator_info.get("answerCount")
+ res.video_count = creator_info.get("zvideoCount")
+ res.question_count = creator_info.get("questionCount")
+ res.article_count = creator_info.get("articlesCount")
+ res.column_count = creator_info.get("columnsCount")
+ res.get_voteup_count = creator_info.get("voteupCount")
+ return res
+
+
+ def extract_content_list_from_creator(self, anwser_list: List[Dict]) -> List[ZhihuContent]:
+ """
+ extract content list from creator
+ Args:
+ anwser_list:
+
+ Returns:
+
+ """
+ if not anwser_list:
+ return []
+
+ return self._extract_content_list(anwser_list)
diff --git a/model/m_zhihu.py b/model/m_zhihu.py
index 0dc6b6a..08c5142 100644
--- a/model/m_zhihu.py
+++ b/model/m_zhihu.py
@@ -15,8 +15,8 @@ class ZhihuContent(BaseModel):
question_id: str = Field(default="", description="问题ID, type为answer时有值")
title: str = Field(default="", description="内容标题")
desc: str = Field(default="", description="内容描述")
- created_time: int = Field(default="", description="创建时间")
- updated_time: int = Field(default="", description="更新时间")
+ created_time: int = Field(default=0, description="创建时间")
+ updated_time: int = Field(default=0, description="更新时间")
voteup_count: int = Field(default=0, description="赞同人数")
comment_count: int = Field(default=0, description="评论数量")
source_keyword: str = Field(default="", description="来源关键词")
@@ -25,6 +25,7 @@ class ZhihuContent(BaseModel):
user_link: str = Field(default="", description="用户主页链接")
user_nickname: str = Field(default="", description="用户昵称")
user_avatar: str = Field(default="", description="用户头像地址")
+ user_url_token: str = Field(default="", description="用户url_token")
class ZhihuComment(BaseModel):
@@ -57,7 +58,15 @@ class ZhihuCreator(BaseModel):
user_link: str = Field(default="", description="用户主页链接")
user_nickname: str = Field(default="", description="用户昵称")
user_avatar: str = Field(default="", description="用户头像地址")
+ url_token: str = Field(default="", description="用户url_token")
gender: str = Field(default="", description="用户性别")
ip_location: Optional[str] = Field(default="", description="IP地理位置")
follows: int = Field(default=0, description="关注数")
fans: int = Field(default=0, description="粉丝数")
+ anwser_count: int = Field(default=0, description="回答数")
+ video_count: int = Field(default=0, description="视频数")
+ question_count: int = Field(default=0, description="提问数")
+ article_count: int = Field(default=0, description="文章数")
+ column_count: int = Field(default=0, description="专栏数")
+ get_voteup_count: int = Field(default=0, description="获得的赞同数")
+
diff --git a/schema/tables.sql b/schema/tables.sql
index 6e304f5..33e02d3 100644
--- a/schema/tables.sql
+++ b/schema/tables.sql
@@ -474,6 +474,7 @@ CREATE TABLE `zhihu_content` (
`user_link` varchar(255) NOT NULL COMMENT '用户主页链接',
`user_nickname` varchar(64) NOT NULL COMMENT '用户昵称',
`user_avatar` varchar(255) NOT NULL COMMENT '用户头像地址',
+ `user_url_token` varchar(255) NOT NULL COMMENT '用户url_token',
`add_ts` bigint NOT NULL COMMENT '记录添加时间戳',
`last_modify_ts` bigint NOT NULL COMMENT '记录最后修改时间戳',
PRIMARY KEY (`id`),
@@ -482,6 +483,7 @@ CREATE TABLE `zhihu_content` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知乎内容(回答、文章、视频)';
+
CREATE TABLE `zhihu_comment` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`comment_id` varchar(64) NOT NULL COMMENT '评论ID',
@@ -513,10 +515,17 @@ CREATE TABLE `zhihu_creator` (
`user_link` varchar(255) NOT NULL COMMENT '用户主页链接',
`user_nickname` varchar(64) NOT NULL COMMENT '用户昵称',
`user_avatar` varchar(255) NOT NULL COMMENT '用户头像地址',
+ `url_token` varchar(64) NOT NULL COMMENT '用户URL Token',
`gender` varchar(16) DEFAULT NULL COMMENT '用户性别',
`ip_location` varchar(64) DEFAULT NULL COMMENT 'IP地理位置',
- `follows` int NOT NULL DEFAULT '0' COMMENT '关注数',
- `fans` int NOT NULL DEFAULT '0' COMMENT '粉丝数',
+ `follows` int NOT NULL DEFAULT 0 COMMENT '关注数',
+ `fans` int NOT NULL DEFAULT 0 COMMENT '粉丝数',
+ `anwser_count` int NOT NULL DEFAULT 0 COMMENT '回答数',
+ `video_count` int NOT NULL DEFAULT 0 COMMENT '视频数',
+ `question_count` int NOT NULL DEFAULT 0 COMMENT '问题数',
+ `article_count` int NOT NULL DEFAULT 0 COMMENT '文章数',
+ `column_count` int NOT NULL DEFAULT 0 COMMENT '专栏数',
+ `get_voteup_count` int NOT NULL DEFAULT 0 COMMENT '获得的赞同数',
`add_ts` bigint NOT NULL COMMENT '记录添加时间戳',
`last_modify_ts` bigint NOT NULL COMMENT '记录最后修改时间戳',
PRIMARY KEY (`id`),
diff --git a/store/zhihu/__init__.py b/store/zhihu/__init__.py
index fc944d7..703d9cb 100644
--- a/store/zhihu/__init__.py
+++ b/store/zhihu/__init__.py
@@ -3,7 +3,7 @@ from typing import List
import config
from base.base_crawler import AbstractStore
-from model.m_zhihu import ZhihuComment, ZhihuContent
+from model.m_zhihu import ZhihuComment, ZhihuContent, ZhihuCreator
from store.zhihu.zhihu_store_impl import (ZhihuCsvStoreImplement,
ZhihuDbStoreImplement,
ZhihuJsonStoreImplement)
@@ -25,6 +25,21 @@ class ZhihuStoreFactory:
raise ValueError("[ZhihuStoreFactory.create_store] Invalid save option only supported csv or db or json ...")
return store_class()
+async def batch_update_zhihu_contents(contents: List[ZhihuContent]):
+ """
+ 批量更新知乎内容
+ Args:
+ contents:
+
+ Returns:
+
+ """
+ if not contents:
+ return
+
+ for content_item in contents:
+ await update_zhihu_content(content_item)
+
async def update_zhihu_content(content_item: ZhihuContent):
"""
更新知乎内容
@@ -71,3 +86,19 @@ async def update_zhihu_content_comment(comment_item: ZhihuComment):
local_db_item.update({"last_modify_ts": utils.get_current_timestamp()})
utils.logger.info(f"[store.zhihu.update_zhihu_note_comment] zhihu content comment:{local_db_item}")
await ZhihuStoreFactory.create_store().store_comment(local_db_item)
+
+
+async def save_creator(creator: ZhihuCreator):
+ """
+ 保存知乎创作者信息
+ Args:
+ creator:
+
+ Returns:
+
+ """
+ if not creator:
+ return
+ local_db_item = creator.model_dump()
+ local_db_item.update({"last_modify_ts": utils.get_current_timestamp()})
+ await ZhihuStoreFactory.create_store().store_creator(local_db_item)
\ No newline at end of file