Spaces:
Running
Running
import json | |
import re | |
from datetime import date, timedelta | |
from enum import Enum | |
from typing import Any, Literal, Optional, Union, cast, overload | |
from hibiapi.api.pixiv.constants import PixivConstants | |
from hibiapi.api.pixiv.net import NetRequest as PixivNetClient | |
from hibiapi.utils.cache import cache_config | |
from hibiapi.utils.decorators import enum_auto_doc | |
from hibiapi.utils.net import catch_network_error | |
from hibiapi.utils.routing import BaseEndpoint, dont_route, request_headers | |
class IllustType(str, Enum): | |
"""画作类型""" | |
illust = "illust" | |
"""插画""" | |
manga = "manga" | |
"""漫画""" | |
class RankingType(str, Enum): | |
"""排行榜内容类型""" | |
day = "day" | |
"""日榜""" | |
week = "week" | |
"""周榜""" | |
month = "month" | |
"""月榜""" | |
day_male = "day_male" | |
"""男性向""" | |
day_female = "day_female" | |
"""女性向""" | |
week_original = "week_original" | |
"""原创周榜""" | |
week_rookie = "week_rookie" | |
"""新人周榜""" | |
day_ai = "day_ai" | |
"""AI日榜""" | |
day_manga = "day_manga" | |
"""漫画日榜""" | |
week_manga = "week_manga" | |
"""漫画周榜""" | |
month_manga = "month_manga" | |
"""漫画月榜""" | |
week_rookie_manga = "week_rookie_manga" | |
"""漫画新人周榜""" | |
day_r18 = "day_r18" | |
day_male_r18 = "day_male_r18" | |
day_female_r18 = "day_female_r18" | |
week_r18 = "week_r18" | |
week_r18g = "week_r18g" | |
day_r18_ai = "day_r18_ai" | |
day_r18_manga = "day_r18_manga" | |
week_r18_manga = "week_r18_manga" | |
class SearchModeType(str, Enum): | |
"""搜索匹配类型""" | |
partial_match_for_tags = "partial_match_for_tags" | |
"""标签部分一致""" | |
exact_match_for_tags = "exact_match_for_tags" | |
"""标签完全一致""" | |
title_and_caption = "title_and_caption" | |
"""标题说明文""" | |
class SearchNovelModeType(str, Enum): | |
"""搜索匹配类型""" | |
partial_match_for_tags = "partial_match_for_tags" | |
"""标签部分一致""" | |
exact_match_for_tags = "exact_match_for_tags" | |
"""标签完全一致""" | |
text = "text" | |
"""正文""" | |
keyword = "keyword" | |
"""关键词""" | |
class SearchSortType(str, Enum): | |
"""搜索排序类型""" | |
date_desc = "date_desc" | |
"""按日期倒序""" | |
date_asc = "date_asc" | |
"""按日期正序""" | |
popular_desc = "popular_desc" | |
"""受欢迎降序(Premium功能)""" | |
class SearchDurationType(str, Enum): | |
"""搜索时段类型""" | |
within_last_day = "within_last_day" | |
"""一天内""" | |
within_last_week = "within_last_week" | |
"""一周内""" | |
within_last_month = "within_last_month" | |
"""一个月内""" | |
class RankingDate(date): | |
def yesterday(cls) -> "RankingDate": | |
yesterday = cls.today() - timedelta(days=1) | |
return cls(yesterday.year, yesterday.month, yesterday.day) | |
def toString(self) -> str: | |
return self.strftime(r"%Y-%m-%d") | |
def new(cls, date: date) -> "RankingDate": | |
return cls(date.year, date.month, date.day) | |
class PixivEndpoints(BaseEndpoint): | |
def _parse_accept_language(accept_language: str) -> str: | |
first_language, *_ = accept_language.partition(",") | |
language_code, *_ = first_language.partition(";") | |
return language_code.lower().strip() | |
async def request( | |
self, | |
endpoint: str, | |
*, | |
params: Optional[dict[str, Any]] = None, | |
return_text: Literal[False] = False, | |
) -> dict[str, Any]: ... | |
async def request( | |
self, | |
endpoint: str, | |
*, | |
params: Optional[dict[str, Any]] = None, | |
return_text: Literal[True], | |
) -> str: ... | |
async def request( | |
self, | |
endpoint: str, | |
*, | |
params: Optional[dict[str, Any]] = None, | |
return_text: bool = False, | |
) -> Union[dict[str, Any], str]: | |
headers = self.client.headers.copy() | |
net_client = cast(PixivNetClient, self.client.net_client) | |
async with net_client.auth_lock: | |
auth, token = net_client.get_available_user() | |
if auth is None: | |
auth = await net_client.auth(token) | |
headers["Authorization"] = f"Bearer {auth.access_token}" | |
if language := request_headers.get().get("Accept-Language"): | |
language = self._parse_accept_language(language) | |
headers["Accept-Language"] = language | |
response = await self.client.get( | |
self._join( | |
base=PixivConstants.APP_HOST, | |
endpoint=endpoint, | |
params=params or {}, | |
), | |
headers=headers, | |
) | |
if return_text: | |
return response.text | |
return response.json() | |
async def illust(self, *, id: int): | |
return await self.request("v1/illust/detail", params={"illust_id": id}) | |
async def member(self, *, id: int): | |
return await self.request("v1/user/detail", params={"user_id": id}) | |
async def member_illust( | |
self, | |
*, | |
id: int, | |
illust_type: IllustType = IllustType.illust, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v1/user/illusts", | |
params={ | |
"user_id": id, | |
"type": illust_type, | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def favorite( | |
self, | |
*, | |
id: int, | |
tag: Optional[str] = None, | |
max_bookmark_id: Optional[int] = None, | |
): | |
return await self.request( | |
"v1/user/bookmarks/illust", | |
params={ | |
"user_id": id, | |
"tag": tag, | |
"restrict": "public", | |
"max_bookmark_id": max_bookmark_id or None, | |
}, | |
) | |
# 用户收藏的小说 | |
async def favorite_novel( | |
self, | |
*, | |
id: int, | |
tag: Optional[str] = None, | |
): | |
return await self.request( | |
"v1/user/bookmarks/novel", | |
params={ | |
"user_id": id, | |
"tag": tag, | |
"restrict": "public", | |
}, | |
) | |
async def following(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/user/following", | |
params={ | |
"user_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def follower(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/user/follower", | |
params={ | |
"user_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def rank( | |
self, | |
*, | |
mode: RankingType = RankingType.week, | |
date: Optional[RankingDate] = None, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v1/illust/ranking", | |
params={ | |
"mode": mode, | |
"date": RankingDate.new(date or RankingDate.yesterday()).toString(), | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def search( | |
self, | |
*, | |
word: str, | |
mode: SearchModeType = SearchModeType.partial_match_for_tags, | |
order: SearchSortType = SearchSortType.date_desc, | |
duration: Optional[SearchDurationType] = None, | |
page: int = 1, | |
size: int = 30, | |
include_translated_tag_results: bool = True, | |
search_ai_type: bool = True, # 搜索结果是否包含AI作品 | |
): | |
return await self.request( | |
"v1/search/illust", | |
params={ | |
"word": word, | |
"search_target": mode, | |
"sort": order, | |
"duration": duration, | |
"offset": (page - 1) * size, | |
"include_translated_tag_results": include_translated_tag_results, | |
"search_ai_type": 1 if search_ai_type else 0, | |
}, | |
) | |
# 热门插画作品预览 | |
async def popular_preview( | |
self, | |
*, | |
word: str, | |
mode: SearchModeType = SearchModeType.partial_match_for_tags, | |
merge_plain_keyword_results: bool = True, | |
include_translated_tag_results: bool = True, | |
filter: str = "for_ios", | |
): | |
return await self.request( | |
"v1/search/popular-preview/illust", | |
params={ | |
"word": word, | |
"search_target": mode, | |
"merge_plain_keyword_results": merge_plain_keyword_results, | |
"include_translated_tag_results": include_translated_tag_results, | |
"filter": filter, | |
}, | |
) | |
async def search_user( | |
self, | |
*, | |
word: str, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v1/search/user", | |
params={"word": word, "offset": (page - 1) * size}, | |
) | |
async def tags_autocomplete( | |
self, | |
*, | |
word: str, | |
merge_plain_keyword_results: bool = True, | |
): | |
return await self.request( | |
"/v2/search/autocomplete", | |
params={ | |
"word": word, | |
"merge_plain_keyword_results": merge_plain_keyword_results, | |
}, | |
) | |
async def tags(self): | |
return await self.request("v1/trending-tags/illust") | |
async def related(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v2/illust/related", | |
params={ | |
"illust_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def ugoira_metadata(self, *, id: int): | |
return await self.request( | |
"v1/ugoira/metadata", | |
params={ | |
"illust_id": id, | |
}, | |
) | |
# 大家的新作品(插画) | |
async def illust_new( | |
self, | |
*, | |
content_type: str = "illust", | |
): | |
return await self.request( | |
"v1/illust/new", | |
params={ | |
"content_type": content_type, | |
"filter": "for_ios", | |
}, | |
) | |
# pixivision(亮点/特辑) 列表 | |
async def spotlights( | |
self, | |
*, | |
category: str = "all", | |
page: int = 1, | |
size: int = 10, | |
): | |
return await self.request( | |
"v1/spotlight/articles", | |
params={ | |
"filter": "for_ios", | |
"category": category, | |
"offset": (page - 1) * size, | |
}, | |
) | |
# 插画评论 | |
async def illust_comments( | |
self, | |
*, | |
id: int, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v3/illust/comments", | |
params={ | |
"illust_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
# 插画评论回复 | |
async def illust_comment_replies( | |
self, | |
*, | |
id: int, | |
): | |
return await self.request( | |
"v2/illust/comment/replies", | |
params={ | |
"comment_id": id, | |
}, | |
) | |
# 小说评论 | |
async def novel_comments( | |
self, | |
*, | |
id: int, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v3/novel/comments", | |
params={ | |
"novel_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
# 小说评论回复 | |
async def novel_comment_replies( | |
self, | |
*, | |
id: int, | |
): | |
return await self.request( | |
"v2/novel/comment/replies", | |
params={ | |
"comment_id": id, | |
}, | |
) | |
# 小说排行榜 | |
async def rank_novel( | |
self, | |
*, | |
mode: str = "day", | |
date: Optional[RankingDate] = None, | |
page: int = 1, | |
size: int = 30, | |
): | |
return await self.request( | |
"v1/novel/ranking", | |
params={ | |
"mode": mode, | |
"date": RankingDate.new(date or RankingDate.yesterday()).toString(), | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def member_novel(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"/v1/user/novels", | |
params={ | |
"user_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
async def novel_series(self, *, id: int): | |
return await self.request("/v2/novel/series", params={"series_id": id}) | |
async def novel_detail(self, *, id: int): | |
return await self.request("/v2/novel/detail", params={"novel_id": id}) | |
# 已被官方移除,调用 webview/v2/novel 作兼容处理 | |
async def novel_text(self, *, id: int): | |
# return await self.request("/v1/novel/text", params={"novel_id": id}) | |
response = await self.webview_novel(id=id) | |
return {"novel_text": response["text"] or ""} | |
# 获取小说 HTML 后解析 JSON | |
async def webview_novel(self, *, id: int): | |
response = await self.request( | |
"webview/v2/novel", | |
params={ | |
"id": id, | |
"viewer_version": "20221031_ai", | |
}, | |
return_text=True, | |
) | |
novel_match = re.search(r"novel:\s+(?P<data>{.+?}),\s+isOwnWork", response) | |
return json.loads(novel_match["data"] if novel_match else response) | |
async def tags_novel(self): | |
return await self.request("v1/trending-tags/novel") | |
async def search_novel( | |
self, | |
*, | |
word: str, | |
mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags, | |
sort: SearchSortType = SearchSortType.date_desc, | |
merge_plain_keyword_results: bool = True, | |
include_translated_tag_results: bool = True, | |
duration: Optional[SearchDurationType] = None, | |
page: int = 1, | |
size: int = 30, | |
search_ai_type: bool = True, # 搜索结果是否包含AI作品 | |
): | |
return await self.request( | |
"/v1/search/novel", | |
params={ | |
"word": word, | |
"search_target": mode, | |
"sort": sort, | |
"merge_plain_keyword_results": merge_plain_keyword_results, | |
"include_translated_tag_results": include_translated_tag_results, | |
"duration": duration, | |
"offset": (page - 1) * size, | |
"search_ai_type": 1 if search_ai_type else 0, | |
}, | |
) | |
# 热门小说作品预览 | |
async def popular_preview_novel( | |
self, | |
*, | |
word: str, | |
mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags, | |
merge_plain_keyword_results: bool = True, | |
include_translated_tag_results: bool = True, | |
filter: str = "for_ios", | |
): | |
return await self.request( | |
"v1/search/popular-preview/novel", | |
params={ | |
"word": word, | |
"search_target": mode, | |
"merge_plain_keyword_results": merge_plain_keyword_results, | |
"include_translated_tag_results": include_translated_tag_results, | |
"filter": filter, | |
}, | |
) | |
async def novel_new(self, *, max_novel_id: Optional[int] = None): | |
return await self.request( | |
"/v1/novel/new", params={"max_novel_id": max_novel_id} | |
) | |
# 人气直播列表 | |
async def live_list(self, *, page: int = 1, size: int = 30): | |
params = {"list_type": "popular", "offset": (page - 1) * size} | |
if not params["offset"]: | |
del params["offset"] | |
return await self.request("v1/live/list", params=params) | |
# 相关小说作品 | |
async def related_novel(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/novel/related", | |
params={ | |
"novel_id": id, | |
"offset": (page - 1) * size, | |
}, | |
) | |
# 相关用户 | |
async def related_member(self, *, id: int): | |
return await self.request("v1/user/related", params={"seed_user_id": id}) | |
# 漫画系列 | |
async def illust_series(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/illust/series", | |
params={"illust_series_id": id, "offset": (page - 1) * size}, | |
) | |
# 用户的漫画系列 | |
async def member_illust_series(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/user/illust-series", | |
params={"user_id": id, "offset": (page - 1) * size}, | |
) | |
# 用户的小说系列 | |
async def member_novel_series(self, *, id: int, page: int = 1, size: int = 30): | |
return await self.request( | |
"v1/user/novel-series", params={"user_id": id, "offset": (page - 1) * size} | |
) | |