2023-06-09 12:41:53 +00:00
|
|
|
|
import asyncio
|
2023-07-29 07:35:40 +00:00
|
|
|
|
import json
|
2024-03-01 17:49:42 +00:00
|
|
|
|
import re
|
2024-03-17 06:50:10 +00:00
|
|
|
|
from typing import Callable, Dict, List, Optional, Union, Any
|
2023-11-23 15:27:35 +00:00
|
|
|
|
from urllib.parse import urlencode
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
|
|
|
|
import httpx
|
2023-07-29 07:35:40 +00:00
|
|
|
|
from playwright.async_api import BrowserContext, Page
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
2023-07-15 09:11:53 +00:00
|
|
|
|
from tools import utils
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
2023-07-29 07:35:40 +00:00
|
|
|
|
from .exception import DataFetchError, IPBlockError
|
|
|
|
|
from .field import SearchNoteType, SearchSortType
|
|
|
|
|
from .help import get_search_id, sign
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
2024-03-30 13:17:33 +00:00
|
|
|
|
class XiaoHongShuClient:
|
2023-06-09 12:41:53 +00:00
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
timeout=10,
|
|
|
|
|
proxies=None,
|
2023-07-16 09:57:18 +00:00
|
|
|
|
*,
|
|
|
|
|
headers: Dict[str, str],
|
|
|
|
|
playwright_page: Page,
|
|
|
|
|
cookie_dict: Dict[str, str],
|
2023-06-09 12:41:53 +00:00
|
|
|
|
):
|
|
|
|
|
self.proxies = proxies
|
|
|
|
|
self.timeout = timeout
|
|
|
|
|
self.headers = headers
|
|
|
|
|
self._host = "https://edith.xiaohongshu.com"
|
2024-03-17 06:50:10 +00:00
|
|
|
|
self._domain = "https://www.xiaohongshu.com"
|
2023-06-09 12:41:53 +00:00
|
|
|
|
self.IP_ERROR_STR = "网络连接异常,请检查网络设置或重启试试"
|
|
|
|
|
self.IP_ERROR_CODE = 300012
|
|
|
|
|
self.NOTE_ABNORMAL_STR = "笔记状态异常,请稍后查看"
|
|
|
|
|
self.NOTE_ABNORMAL_CODE = -510001
|
|
|
|
|
self.playwright_page = playwright_page
|
|
|
|
|
self.cookie_dict = cookie_dict
|
|
|
|
|
|
2024-01-15 16:40:07 +00:00
|
|
|
|
async def _pre_headers(self, url: str, data=None) -> Dict:
|
|
|
|
|
"""
|
|
|
|
|
请求头参数签名
|
|
|
|
|
Args:
|
|
|
|
|
url:
|
|
|
|
|
data:
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-06-09 12:41:53 +00:00
|
|
|
|
encrypt_params = await self.playwright_page.evaluate("([url, data]) => window._webmsxyw(url,data)", [url, data])
|
|
|
|
|
local_storage = await self.playwright_page.evaluate("() => window.localStorage")
|
|
|
|
|
signs = sign(
|
|
|
|
|
a1=self.cookie_dict.get("a1", ""),
|
|
|
|
|
b1=local_storage.get("b1", ""),
|
|
|
|
|
x_s=encrypt_params.get("X-s", ""),
|
|
|
|
|
x_t=str(encrypt_params.get("X-t", ""))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
"X-S": signs["x-s"],
|
|
|
|
|
"X-T": signs["x-t"],
|
|
|
|
|
"x-S-Common": signs["x-s-common"],
|
|
|
|
|
"X-B3-Traceid": signs["x-b3-traceid"]
|
|
|
|
|
}
|
|
|
|
|
self.headers.update(headers)
|
|
|
|
|
return self.headers
|
|
|
|
|
|
2024-03-17 06:50:10 +00:00
|
|
|
|
async def request(self, method, url, **kwargs) -> Union[str, Any]:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
封装httpx的公共请求方法,对请求响应做一些处理
|
|
|
|
|
Args:
|
|
|
|
|
method: 请求方法
|
|
|
|
|
url: 请求的URL
|
|
|
|
|
**kwargs: 其他请求参数,例如请求头、请求体等
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2024-03-01 17:49:42 +00:00
|
|
|
|
# return response.text
|
|
|
|
|
return_response = kwargs.pop('return_response', False)
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
async with httpx.AsyncClient(proxies=self.proxies) as client:
|
|
|
|
|
response = await client.request(
|
|
|
|
|
method, url, timeout=self.timeout,
|
|
|
|
|
**kwargs
|
|
|
|
|
)
|
2024-03-17 06:50:10 +00:00
|
|
|
|
|
2024-03-01 17:49:42 +00:00
|
|
|
|
if return_response:
|
|
|
|
|
return response.text
|
2024-03-17 06:50:10 +00:00
|
|
|
|
|
2023-07-16 09:57:18 +00:00
|
|
|
|
data: Dict = response.json()
|
2023-06-09 12:41:53 +00:00
|
|
|
|
if data["success"]:
|
2023-07-16 09:57:18 +00:00
|
|
|
|
return data.get("data", data.get("success", {}))
|
2023-06-09 12:41:53 +00:00
|
|
|
|
elif data["code"] == self.IP_ERROR_CODE:
|
|
|
|
|
raise IPBlockError(self.IP_ERROR_STR)
|
|
|
|
|
else:
|
|
|
|
|
raise DataFetchError(data.get("msg", None))
|
|
|
|
|
|
2023-07-16 09:57:18 +00:00
|
|
|
|
async def get(self, uri: str, params=None) -> Dict:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
GET请求,对请求头签名
|
|
|
|
|
Args:
|
|
|
|
|
uri: 请求路由
|
|
|
|
|
params: 请求参数
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-06-09 12:41:53 +00:00
|
|
|
|
final_uri = uri
|
|
|
|
|
if isinstance(params, dict):
|
|
|
|
|
final_uri = (f"{uri}?"
|
2023-11-23 15:27:35 +00:00
|
|
|
|
f"{urlencode(params)}")
|
2023-06-09 12:41:53 +00:00
|
|
|
|
headers = await self._pre_headers(final_uri)
|
|
|
|
|
return await self.request(method="GET", url=f"{self._host}{final_uri}", headers=headers)
|
|
|
|
|
|
2023-07-16 09:57:18 +00:00
|
|
|
|
async def post(self, uri: str, data: dict) -> Dict:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
POST请求,对请求头签名
|
|
|
|
|
Args:
|
|
|
|
|
uri: 请求路由
|
|
|
|
|
data: 请求体参数
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-06-09 12:41:53 +00:00
|
|
|
|
headers = await self._pre_headers(uri, data)
|
|
|
|
|
json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
|
|
|
|
return await self.request(method="POST", url=f"{self._host}{uri}",
|
|
|
|
|
data=json_str, headers=headers)
|
|
|
|
|
|
2023-12-05 14:47:36 +00:00
|
|
|
|
async def pong(self) -> bool:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
用于检查登录态是否失效了
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-07-15 09:11:53 +00:00
|
|
|
|
"""get a note to check if login state is ok"""
|
2024-03-30 13:17:33 +00:00
|
|
|
|
utils.logger.info("[XiaoHongShuClient.pong] Begin to pong xhs...")
|
2023-07-24 12:59:43 +00:00
|
|
|
|
ping_flag = False
|
2023-07-15 09:11:53 +00:00
|
|
|
|
try:
|
2023-07-24 12:59:43 +00:00
|
|
|
|
note_card: Dict = await self.get_note_by_keyword(keyword="小红书")
|
|
|
|
|
if note_card.get("items"):
|
|
|
|
|
ping_flag = True
|
|
|
|
|
except Exception as e:
|
2024-03-30 13:17:33 +00:00
|
|
|
|
utils.logger.error(f"[XiaoHongShuClient.pong] Ping xhs failed: {e}, and try to login again...")
|
2023-07-24 12:59:43 +00:00
|
|
|
|
ping_flag = False
|
|
|
|
|
return ping_flag
|
2023-07-15 09:11:53 +00:00
|
|
|
|
|
|
|
|
|
async def update_cookies(self, browser_context: BrowserContext):
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
API客户端提供的更新cookies方法,一般情况下登录成功后会调用此方法
|
|
|
|
|
Args:
|
|
|
|
|
browser_context: 浏览器上下文对象
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-07-15 09:11:53 +00:00
|
|
|
|
cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies())
|
|
|
|
|
self.headers["Cookie"] = cookie_str
|
|
|
|
|
self.cookie_dict = cookie_dict
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
async def get_note_by_keyword(
|
|
|
|
|
self, keyword: str,
|
|
|
|
|
page: int = 1, page_size: int = 20,
|
|
|
|
|
sort: SearchSortType = SearchSortType.GENERAL,
|
|
|
|
|
note_type: SearchNoteType = SearchNoteType.ALL
|
2023-07-15 14:25:56 +00:00
|
|
|
|
) -> Dict:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
根据关键词搜索笔记
|
|
|
|
|
Args:
|
|
|
|
|
keyword: 关键词参数
|
|
|
|
|
page: 分页第几页
|
|
|
|
|
page_size: 分页数据长度
|
|
|
|
|
sort: 搜索结果排序指定
|
|
|
|
|
note_type: 搜索的笔记类型
|
|
|
|
|
|
|
|
|
|
Returns:
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
uri = "/api/sns/web/v1/search/notes"
|
|
|
|
|
data = {
|
|
|
|
|
"keyword": keyword,
|
|
|
|
|
"page": page,
|
|
|
|
|
"page_size": page_size,
|
|
|
|
|
"search_id": get_search_id(),
|
|
|
|
|
"sort": sort.value,
|
|
|
|
|
"note_type": note_type.value
|
|
|
|
|
}
|
|
|
|
|
return await self.post(uri, data)
|
|
|
|
|
|
2023-07-15 14:25:56 +00:00
|
|
|
|
async def get_note_by_id(self, note_id: str) -> Dict:
|
2023-06-09 12:41:53 +00:00
|
|
|
|
"""
|
2024-01-15 16:40:07 +00:00
|
|
|
|
获取笔记详情API
|
|
|
|
|
Args:
|
|
|
|
|
note_id:笔记ID
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
"""
|
|
|
|
|
data = {"source_note_id": note_id}
|
|
|
|
|
uri = "/api/sns/web/v1/feed"
|
|
|
|
|
res = await self.post(uri, data)
|
2023-12-04 13:54:12 +00:00
|
|
|
|
if res and res.get("items"):
|
|
|
|
|
res_dict: Dict = res["items"][0]["note_card"]
|
|
|
|
|
return res_dict
|
2024-03-30 13:17:33 +00:00
|
|
|
|
utils.logger.error(f"[XiaoHongShuClient.get_note_by_id] get note empty and res:{res}")
|
2023-12-04 13:54:12 +00:00
|
|
|
|
return dict()
|
2023-06-09 12:41:53 +00:00
|
|
|
|
|
2023-07-15 14:25:56 +00:00
|
|
|
|
async def get_note_comments(self, note_id: str, cursor: str = "") -> Dict:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
"""
|
|
|
|
|
获取一级评论的API
|
|
|
|
|
Args:
|
|
|
|
|
note_id: 笔记ID
|
|
|
|
|
cursor: 分页游标
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
"""
|
|
|
|
|
uri = "/api/sns/web/v2/comment/page"
|
|
|
|
|
params = {
|
|
|
|
|
"note_id": note_id,
|
2024-03-07 14:30:44 +00:00
|
|
|
|
"cursor": cursor,
|
2024-03-17 06:50:10 +00:00
|
|
|
|
"top_comment_id": "",
|
2024-03-07 14:30:44 +00:00
|
|
|
|
"image_formats": "jpg,webp,avif"
|
2023-06-09 12:41:53 +00:00
|
|
|
|
}
|
|
|
|
|
return await self.get(uri, params)
|
|
|
|
|
|
2024-01-15 16:40:07 +00:00
|
|
|
|
async def get_note_sub_comments(self, note_id: str, root_comment_id: str, num: int = 30, cursor: str = ""):
|
2023-07-15 14:25:56 +00:00
|
|
|
|
"""
|
2024-01-15 16:40:07 +00:00
|
|
|
|
获取指定父评论下的子评论的API
|
|
|
|
|
Args:
|
|
|
|
|
note_id: 子评论的帖子ID
|
|
|
|
|
root_comment_id: 根评论ID
|
|
|
|
|
num: 分页数量
|
|
|
|
|
cursor: 分页游标
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
2023-06-09 12:41:53 +00:00
|
|
|
|
"""
|
|
|
|
|
uri = "/api/sns/web/v2/comment/sub/page"
|
|
|
|
|
params = {
|
|
|
|
|
"note_id": note_id,
|
|
|
|
|
"root_comment_id": root_comment_id,
|
|
|
|
|
"num": num,
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
}
|
|
|
|
|
return await self.get(uri, params)
|
|
|
|
|
|
2024-01-15 16:40:07 +00:00
|
|
|
|
async def get_note_all_comments(self, note_id: str, crawl_interval: float = 1.0,
|
|
|
|
|
callback: Optional[Callable] = None) -> List[Dict]:
|
2023-06-09 12:41:53 +00:00
|
|
|
|
"""
|
2024-01-15 16:40:07 +00:00
|
|
|
|
获取指定笔记下的所有一级评论,该方法会一直查找一个帖子下的所有评论信息
|
|
|
|
|
Args:
|
|
|
|
|
note_id: 笔记ID
|
|
|
|
|
crawl_interval: 爬取一次笔记的延迟单位(秒)
|
|
|
|
|
callback: 一次笔记爬取结束后
|
2023-06-16 11:35:43 +00:00
|
|
|
|
|
2024-01-15 16:40:07 +00:00
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
2023-06-09 12:41:53 +00:00
|
|
|
|
result = []
|
|
|
|
|
comments_has_more = True
|
|
|
|
|
comments_cursor = ""
|
|
|
|
|
while comments_has_more:
|
|
|
|
|
comments_res = await self.get_note_comments(note_id, comments_cursor)
|
|
|
|
|
comments_has_more = comments_res.get("has_more", False)
|
|
|
|
|
comments_cursor = comments_res.get("cursor", "")
|
2023-11-14 13:11:18 +00:00
|
|
|
|
if "comments" not in comments_res:
|
2024-01-15 16:40:07 +00:00
|
|
|
|
utils.logger.info(
|
2024-03-30 13:17:33 +00:00
|
|
|
|
f"[XiaoHongShuClient.get_note_all_comments] No 'comments' key found in response: {comments_res}")
|
2023-11-14 13:11:18 +00:00
|
|
|
|
break
|
2023-06-09 12:41:53 +00:00
|
|
|
|
comments = comments_res["comments"]
|
2024-01-15 16:40:07 +00:00
|
|
|
|
if callback:
|
|
|
|
|
await callback(note_id, comments)
|
2023-06-09 12:41:53 +00:00
|
|
|
|
await asyncio.sleep(crawl_interval)
|
2024-01-15 16:40:07 +00:00
|
|
|
|
result.extend(comments)
|
2023-06-09 12:41:53 +00:00
|
|
|
|
return result
|
2024-03-17 06:50:10 +00:00
|
|
|
|
|
|
|
|
|
async def get_creator_info(self, user_id: str) -> Dict:
|
|
|
|
|
"""
|
|
|
|
|
通过解析网页版的用户主页HTML,获取用户个人简要信息
|
|
|
|
|
PC端用户主页的网页存在window.__INITIAL_STATE__这个变量上的,解析它即可
|
|
|
|
|
eg: https://www.xiaohongshu.com/user/profile/59d8cb33de5fb4696bf17217
|
|
|
|
|
"""
|
|
|
|
|
uri = f"/user/profile/{user_id}"
|
|
|
|
|
html_content = await self.request("GET", self._domain + uri, return_response=True, headers=self.headers)
|
|
|
|
|
match = re.search(r'<script>window.__INITIAL_STATE__=(.+)<\/script>', html_content, re.M)
|
|
|
|
|
|
|
|
|
|
if match is None:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
info = json.loads(match.group(1).replace(':undefined', ':null'), strict=False)
|
|
|
|
|
if info is None:
|
|
|
|
|
return {}
|
|
|
|
|
return info.get('user').get('userPageData')
|
|
|
|
|
|
|
|
|
|
async def get_notes_by_creator(
|
|
|
|
|
self, creator: str,
|
|
|
|
|
cursor: str,
|
|
|
|
|
page_size: int = 30
|
|
|
|
|
) -> Dict:
|
|
|
|
|
"""
|
|
|
|
|
获取博主的笔记
|
|
|
|
|
Args:
|
|
|
|
|
creator: 博主ID
|
|
|
|
|
cursor: 上一页最后一条笔记的ID
|
|
|
|
|
page_size: 分页数据长度
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
uri = "/api/sns/web/v1/user_posted"
|
|
|
|
|
data = {
|
|
|
|
|
"user_id": creator,
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
"num": page_size,
|
|
|
|
|
"image_formats": "jpg,webp,avif"
|
|
|
|
|
}
|
|
|
|
|
return await self.get(uri, data)
|
|
|
|
|
|
|
|
|
|
async def get_all_notes_by_creator(self, user_id: str, crawl_interval: float = 1.0,
|
|
|
|
|
callback: Optional[Callable] = None) -> List[Dict]:
|
|
|
|
|
"""
|
|
|
|
|
获取指定用户下的所有发过的帖子,该方法会一直查找一个用户下的所有帖子信息
|
|
|
|
|
Args:
|
|
|
|
|
user_id: 用户ID
|
|
|
|
|
crawl_interval: 爬取一次的延迟单位(秒)
|
|
|
|
|
callback: 一次分页爬取结束后的更新回调函数
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
result = []
|
|
|
|
|
notes_has_more = True
|
|
|
|
|
notes_cursor = ""
|
|
|
|
|
while notes_has_more:
|
|
|
|
|
notes_res = await self.get_notes_by_creator(user_id, notes_cursor)
|
|
|
|
|
notes_has_more = notes_res.get("has_more", False)
|
|
|
|
|
notes_cursor = notes_res.get("cursor", "")
|
|
|
|
|
if "notes" not in notes_res:
|
2024-03-30 13:17:33 +00:00
|
|
|
|
utils.logger.info(f"[XiaoHongShuClient.get_all_notes_by_creator] No 'notes' key found in response: {notes_res}")
|
2024-03-17 06:50:10 +00:00
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
notes = notes_res["notes"]
|
2024-03-30 13:17:33 +00:00
|
|
|
|
utils.logger.info(f"[XiaoHongShuClient.get_all_notes_by_creator] got user_id:{user_id} notes len : {len(notes)}")
|
2024-03-17 06:50:10 +00:00
|
|
|
|
if callback:
|
|
|
|
|
await callback(notes)
|
|
|
|
|
await asyncio.sleep(crawl_interval)
|
|
|
|
|
result.extend(notes)
|
|
|
|
|
return result
|