|
|
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 |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
class IllustType(str, Enum): |
|
|
"""画作类型""" |
|
|
|
|
|
illust = "illust" |
|
|
"""插画""" |
|
|
manga = "manga" |
|
|
"""漫画""" |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
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" |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
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" |
|
|
"""标题说明文""" |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
class SearchNovelModeType(str, Enum): |
|
|
"""搜索匹配类型""" |
|
|
|
|
|
partial_match_for_tags = "partial_match_for_tags" |
|
|
"""标签部分一致""" |
|
|
exact_match_for_tags = "exact_match_for_tags" |
|
|
"""标签完全一致""" |
|
|
text = "text" |
|
|
"""正文""" |
|
|
keyword = "keyword" |
|
|
"""关键词""" |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
class SearchSortType(str, Enum): |
|
|
"""搜索排序类型""" |
|
|
|
|
|
date_desc = "date_desc" |
|
|
"""按日期倒序""" |
|
|
date_asc = "date_asc" |
|
|
"""按日期正序""" |
|
|
popular_desc = "popular_desc" |
|
|
"""受欢迎降序(Premium功能)""" |
|
|
|
|
|
|
|
|
@enum_auto_doc |
|
|
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): |
|
|
@classmethod |
|
|
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") |
|
|
|
|
|
@classmethod |
|
|
def new(cls, date: date) -> "RankingDate": |
|
|
return cls(date.year, date.month, date.day) |
|
|
|
|
|
|
|
|
class PixivEndpoints(BaseEndpoint): |
|
|
@staticmethod |
|
|
def _parse_accept_language(accept_language: str) -> str: |
|
|
first_language, *_ = accept_language.partition(",") |
|
|
language_code, *_ = first_language.partition(";") |
|
|
return language_code.lower().strip() |
|
|
|
|
|
@overload |
|
|
async def request( |
|
|
self, |
|
|
endpoint: str, |
|
|
*, |
|
|
params: Optional[dict[str, Any]] = None, |
|
|
return_text: Literal[False] = False, |
|
|
) -> dict[str, Any]: ... |
|
|
|
|
|
@overload |
|
|
async def request( |
|
|
self, |
|
|
endpoint: str, |
|
|
*, |
|
|
params: Optional[dict[str, Any]] = None, |
|
|
return_text: Literal[True], |
|
|
) -> str: ... |
|
|
|
|
|
@dont_route |
|
|
@catch_network_error |
|
|
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() |
|
|
|
|
|
@cache_config(ttl=timedelta(days=3)) |
|
|
async def illust(self, *, id: int): |
|
|
return await self.request("v1/illust/detail", params={"illust_id": id}) |
|
|
|
|
|
@cache_config(ttl=timedelta(days=1)) |
|
|
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, |
|
|
}, |
|
|
) |
|
|
|
|
|
@cache_config(ttl=timedelta(hours=12)) |
|
|
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, |
|
|
): |
|
|
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, |
|
|
}, |
|
|
) |
|
|
|
|
|
@cache_config(ttl=timedelta(hours=12)) |
|
|
async def tags(self): |
|
|
return await self.request("v1/trending-tags/illust") |
|
|
|
|
|
@cache_config(ttl=timedelta(minutes=15)) |
|
|
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, |
|
|
}, |
|
|
) |
|
|
|
|
|
@cache_config(ttl=timedelta(days=3)) |
|
|
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", |
|
|
}, |
|
|
) |
|
|
|
|
|
|
|
|
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}) |
|
|
|
|
|
|
|
|
async def novel_text(self, *, id: int): |
|
|
|
|
|
response = await self.webview_novel(id=id) |
|
|
return {"novel_text": response["text"] or ""} |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
@cache_config(ttl=timedelta(hours=12)) |
|
|
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, |
|
|
): |
|
|
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} |
|
|
) |
|
|
|