picaapi 1.0.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- picaapi/__init__.py +23 -0
- picaapi/base.py +88 -0
- picaapi/client.py +313 -0
- picaapi/downloader.py +169 -0
- picaapi/error.py +13 -0
- picaapi/objects.py +212 -0
- picaapi-1.0.0.dist-info/METADATA +59 -0
- picaapi-1.0.0.dist-info/RECORD +9 -0
- picaapi-1.0.0.dist-info/WHEEL +5 -0
picaapi/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
''' 这个,不需要了
|
|
2
|
+
|
|
3
|
+
from .client import *
|
|
4
|
+
from .objects import *
|
|
5
|
+
from .error import *
|
|
6
|
+
from .downloader import *
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'Client',
|
|
11
|
+
'Picture',
|
|
12
|
+
'ComicPicture',
|
|
13
|
+
'User',
|
|
14
|
+
'Profile',
|
|
15
|
+
'Comic',
|
|
16
|
+
'ComicDetailed',
|
|
17
|
+
'Page',
|
|
18
|
+
'Category',
|
|
19
|
+
'PicaAPIError',
|
|
20
|
+
'Downloader'
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
'''
|
picaapi/base.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Gender(Enum):
|
|
8
|
+
MALE = 'm'
|
|
9
|
+
FEMALE = 'f'
|
|
10
|
+
BOT = 'bot'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def random_str(length = 32) -> str:
|
|
14
|
+
"""
|
|
15
|
+
生成随机的Nonce字串。
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
length(str, optional): 字串长度,默认32
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str: Nonce字串
|
|
22
|
+
"""
|
|
23
|
+
lib = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
|
|
24
|
+
result = ''
|
|
25
|
+
for _ in range(0, length):
|
|
26
|
+
result += lib[int(random.random() * 48)]
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_signature(url_dir: str, stime: str, method: str, nonce: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
生成哔咔API签名。
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
url_dir(str): 请求路径(包含URL参数,没有前导斜杠)
|
|
36
|
+
stime(str): 以秒计的时间戳
|
|
37
|
+
method(str): 请求方法
|
|
38
|
+
nonce(str): 随机密钥
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
str: Signature签名
|
|
42
|
+
"""
|
|
43
|
+
ready = url_dir + stime + nonce + method + 'C69BAF41DA5ABD1FFEDC6D2FEA56B'
|
|
44
|
+
rb = ready.lower().encode()
|
|
45
|
+
key = b'~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn\0'
|
|
46
|
+
a = bytes(92 ^ b for b in key)
|
|
47
|
+
b = bytes(54 ^ b for b in key)
|
|
48
|
+
c = hashlib.sha256(b + rb).digest()
|
|
49
|
+
m = hashlib.sha256(a + c)
|
|
50
|
+
return m.hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_headers(url_dir: str, method: str, authorization: str | None = None, quality: str = 'medium') -> dict:
|
|
54
|
+
"""
|
|
55
|
+
生成哔咔API请求头。
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
url_dir(str): 请求路径(包含URL参数,没有前导斜杠)
|
|
59
|
+
method(str): 请求方法
|
|
60
|
+
authorization(str, optional): 登录用户token,默认为None,注意除注册登录外不可为None
|
|
61
|
+
quality(str, optional): 图像品质,可选项:low, medium, high, original,默认为medium
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
dict: 构造的headers
|
|
65
|
+
"""
|
|
66
|
+
stime = str(int(time.time()))
|
|
67
|
+
nonce = random_str()
|
|
68
|
+
signature = make_signature(url_dir, stime, method, nonce)
|
|
69
|
+
headers = {
|
|
70
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0',
|
|
71
|
+
'Accept': 'application/vnd.picacomic.com.v1+json',
|
|
72
|
+
'Accept-Encoding': 'gzip, deflate',
|
|
73
|
+
'Accept-Language': 'zh-CN, zh; q=0.9, en; q=0.8',
|
|
74
|
+
'App-Channel': '1',
|
|
75
|
+
'App-Platform': 'android',
|
|
76
|
+
'App-Uuid': 'webUUIDv2',
|
|
77
|
+
'App-Version': '20251017',
|
|
78
|
+
'Time': stime,
|
|
79
|
+
'Image-Quality': quality,
|
|
80
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
81
|
+
'Nonce': nonce,
|
|
82
|
+
'Signature': signature,
|
|
83
|
+
'Origin': 'https://manhuabika.com',
|
|
84
|
+
'Referer': 'https://manhuabika.com/',
|
|
85
|
+
}
|
|
86
|
+
if authorization is not None:
|
|
87
|
+
headers['Authorization'] = authorization
|
|
88
|
+
return headers
|
picaapi/client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from httpx import AsyncClient, Limits
|
|
2
|
+
from urllib import parse
|
|
3
|
+
from datetime import date
|
|
4
|
+
from .error import PicaAPIError
|
|
5
|
+
from .objects import *
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Client:
|
|
9
|
+
"""
|
|
10
|
+
用于执行各项逻辑的PicaAPI客户端,代表一个登录用户。
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
client(AsyncClient): 异步连接池
|
|
14
|
+
token(str): 哔咔用户令牌
|
|
15
|
+
quality(str): 图像品质,可选项:low, medium, high, original
|
|
16
|
+
DEFAULT_DOMAIN(str, static): 默认哔咔API域名
|
|
17
|
+
COMMENTS_BOARD(str, static): 哔咔留言板漫画ID
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_DOMAIN = 'go2778.com'
|
|
21
|
+
COMMENTS_BOARD = '5822a6e3ad7ede654696e482'
|
|
22
|
+
|
|
23
|
+
def __init__(self, domain: str = DEFAULT_DOMAIN, token: str | None = None, quality: str = 'medium', max_conn: int | None = None):
|
|
24
|
+
"""
|
|
25
|
+
Args:
|
|
26
|
+
domain(str, optional): 哔咔API域名,默认为DEFAULT_DOMAIN
|
|
27
|
+
token(str, optional): 用户令牌,默认为None(未登录)
|
|
28
|
+
quality(str, optional): 图像品质,默认为medium(中等)
|
|
29
|
+
"""
|
|
30
|
+
self.token = token
|
|
31
|
+
self.quality = quality
|
|
32
|
+
self.client: AsyncClient = AsyncClient(base_url='https://picaapi.' + domain + '/', limits=Limits(max_connections=max_conn), timeout=8.0)
|
|
33
|
+
self.closed = False
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self):
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
async def __aexit__(self, *args):
|
|
39
|
+
await self.aclose()
|
|
40
|
+
|
|
41
|
+
async def aclose(self) -> None:
|
|
42
|
+
"""
|
|
43
|
+
关闭我。请避免在关闭后再调用。
|
|
44
|
+
"""
|
|
45
|
+
if not self.closed:
|
|
46
|
+
await self.client.aclose()
|
|
47
|
+
self.closed = True
|
|
48
|
+
|
|
49
|
+
async def request(self, method: str, url: str, json = None):
|
|
50
|
+
"""
|
|
51
|
+
发送一般哔咔API请求。
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
method(str): 请求方法
|
|
55
|
+
url(str): 请求路径(包含URL参数,没有前导斜杠)
|
|
56
|
+
json(dict, optional): 请求负载,默认为None
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
dict: 返回数据data
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
PicaAPIError: 请求失败
|
|
63
|
+
"""
|
|
64
|
+
response = await self.client.request(method=method, url=url, json=json, headers=make_headers(url, method, self.token, self.quality))
|
|
65
|
+
json = response.json()
|
|
66
|
+
if json['code'] != 200:
|
|
67
|
+
raise PicaAPIError(response.json()['error'], response.json()['message'])
|
|
68
|
+
return json.get('data', {})
|
|
69
|
+
|
|
70
|
+
async def login(self, email: str, password: str) -> None:
|
|
71
|
+
"""
|
|
72
|
+
登录哔咔并自动保存token。
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
email(str): 邮箱或用户名
|
|
76
|
+
password(str): 密码
|
|
77
|
+
"""
|
|
78
|
+
response = await self.request('POST', 'auth/sign-in', { 'email': email, 'password': password })
|
|
79
|
+
self.token = response['token']
|
|
80
|
+
|
|
81
|
+
async def register(self, email: str, password: str, name: str, gender: Gender, birthday: date, question1: str, answer1: str, question2: str, answer2: str, question3: str, answer3: str) -> None:
|
|
82
|
+
"""
|
|
83
|
+
登录哔咔并自动保存token。
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
email(str): 邮箱或用户名
|
|
87
|
+
password(str): 密码
|
|
88
|
+
name(str): 昵称
|
|
89
|
+
gender(Gender): 性别
|
|
90
|
+
birthday(date): 生日,必须满18
|
|
91
|
+
question1(str): 安全问题1
|
|
92
|
+
answer1(str): 安全答案1
|
|
93
|
+
question2(str): 安全问题2
|
|
94
|
+
answer2(str): 安全答案2
|
|
95
|
+
question3(str): 安全问题3
|
|
96
|
+
answer3(str): 安全答案3
|
|
97
|
+
"""
|
|
98
|
+
await self.request('POST', 'auth/register', { 'email': email, 'password': password, 'name': name, 'gender': gender.value, 'birthday': str(birthday), 'question1': question1, 'answer1': answer1, 'question2': question2, 'answer2': answer2, 'question3': question3, 'answer3': answer3 })
|
|
99
|
+
|
|
100
|
+
async def profile(self) -> User:
|
|
101
|
+
"""
|
|
102
|
+
获取登录用户信息。
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
User: 登录用户信息
|
|
106
|
+
"""
|
|
107
|
+
response = await self.request('GET', 'users/profile')
|
|
108
|
+
return Profile(response)
|
|
109
|
+
|
|
110
|
+
async def punch_in(self) -> None:
|
|
111
|
+
"""
|
|
112
|
+
每日签到(打哔卡)。
|
|
113
|
+
"""
|
|
114
|
+
await self.request('POST', 'users/punch-in')
|
|
115
|
+
|
|
116
|
+
async def comic(self, comic_id: str) -> DetailedComic:
|
|
117
|
+
"""
|
|
118
|
+
获取漫画详细信息。
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
comic_id(str): 漫画ID
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
DetailedComic: 漫画详细信息
|
|
125
|
+
"""
|
|
126
|
+
response = await self.request('GET', f'comics/{comic_id}')
|
|
127
|
+
return DetailedComic(response['comic'])
|
|
128
|
+
|
|
129
|
+
async def advanced_search(self, keyword: str, categories: list[str] | None = None, sort: str = 'dd', page: int = 1) -> Page:
|
|
130
|
+
"""
|
|
131
|
+
哔咔高级搜索。
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
keyword(str): 搜索关键字
|
|
135
|
+
categories(list[str], optional): 分类
|
|
136
|
+
sort(str, optional): 排序方式,默认为dd(新到旧)
|
|
137
|
+
page(int, optional): 分页页码,默认为1
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Page: 返回结果页,数据类型为Comic
|
|
141
|
+
"""
|
|
142
|
+
if categories is None:
|
|
143
|
+
categories = []
|
|
144
|
+
json_map:dict[str, str|list[str]] = { 'keyword': keyword, 'sort': sort }
|
|
145
|
+
if categories != []: json_map['categories'] = categories
|
|
146
|
+
response = await self.request('POST', f'comics/advanced-search?page={page}&s={sort}', json_map)
|
|
147
|
+
return Page(response['comics'], Comic)
|
|
148
|
+
|
|
149
|
+
async def comments(self, comic_id: str, page: int = 1) -> tuple[Page, list[Comment]]:
|
|
150
|
+
"""
|
|
151
|
+
查看某漫画的评论。
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
comic_id(str): 漫画ID
|
|
155
|
+
page(int, optional): 分页页码,默认为1
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Page: 返回结果页,数据类型为Comment
|
|
159
|
+
list[Comment]: 置顶评论
|
|
160
|
+
"""
|
|
161
|
+
response = await self.request('GET', f'comics/{comic_id}/comments?page={page}')
|
|
162
|
+
return Page(response['comments'], Comment), [Comment(com) for com in response['topComments']]
|
|
163
|
+
|
|
164
|
+
async def children(self, comment_id: str, page: int = 1) -> Page:
|
|
165
|
+
"""
|
|
166
|
+
查看某评论的评论。
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
comment_id(str): 评论ID
|
|
170
|
+
page(int, optional): 分页页码,默认为1
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Page: 返回结果页,数据类型为Comment
|
|
174
|
+
"""
|
|
175
|
+
response = await self.request('GET', f'comments/{comment_id}/children?page={page}')
|
|
176
|
+
return Page(response['comments'], Comment)
|
|
177
|
+
|
|
178
|
+
async def favourite(self, sort: str = 'dd', page: int = 1, limit: int = 20) -> Page:
|
|
179
|
+
"""
|
|
180
|
+
查看收藏夹。
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
sort(str, optional): 排序方式,默认为dd(新到旧)
|
|
184
|
+
page(int, optional): 分页页码,默认为1
|
|
185
|
+
limit(int, optional): 单页最大值,默认为20
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Page: 返回结果页,数据类型为Comic
|
|
189
|
+
"""
|
|
190
|
+
response = await self.request('GET', f'users/favourite?page={page}&s={sort}&limit={limit}')
|
|
191
|
+
return Page(response['comics'], Comic)
|
|
192
|
+
|
|
193
|
+
async def eps(self, comic_id: str, page: int = 1) -> Page:
|
|
194
|
+
"""
|
|
195
|
+
查看某漫画的各话信息。
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
comic_id(str): 漫画ID
|
|
199
|
+
page(int, optional): 分页页码,默认为1
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Page: 返回结果页,数据类型为Eps
|
|
203
|
+
"""
|
|
204
|
+
response = await self.request('GET', f'comics/{comic_id}/eps?page={page}')
|
|
205
|
+
return Page(response['eps'], Eps)
|
|
206
|
+
|
|
207
|
+
async def pages(self, comic_id: str, order: int = 1, page: int = 1) -> tuple[Page[ComicPicture], str]:
|
|
208
|
+
"""
|
|
209
|
+
获取漫画内容的图片路径。
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
comic_id(str): 漫画ID
|
|
213
|
+
order(int, optional): 单话序号,默认为1
|
|
214
|
+
page(int, optional): 分页页码,默认为1
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Page: 返回结果页,数据类型为ComicPicture
|
|
218
|
+
str: 此话标题
|
|
219
|
+
"""
|
|
220
|
+
response = await self.request('GET', f'comics/{comic_id}/order/{order}/pages?page={page}')
|
|
221
|
+
return Page(response['pages'], ComicPicture), response['ep']['title']
|
|
222
|
+
|
|
223
|
+
async def leaderboard(self, tt: str='H24') -> list[LeaderboardComic]:
|
|
224
|
+
"""
|
|
225
|
+
获取哔咔排行榜。
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
tt(str, optional): 时间,可选H24、D7、D30,默认为H24(近24小时)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
list[Comic]: 排行榜
|
|
232
|
+
"""
|
|
233
|
+
response = await self.request('GET', f'comics/leaderboard?tt={tt}&ct=VC')
|
|
234
|
+
return [LeaderboardComic(comic) for comic in response['comics']]
|
|
235
|
+
|
|
236
|
+
async def categories(self) -> list[Category]:
|
|
237
|
+
"""
|
|
238
|
+
获取哔咔分类。
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
list[Category]: 分类列表
|
|
242
|
+
"""
|
|
243
|
+
response = await self.request('GET', 'categories')
|
|
244
|
+
return [Category(cate) for cate in response['categories']]
|
|
245
|
+
|
|
246
|
+
async def comics(self, key: str, value: str, sort: str = 'dd', page: int = 1) -> Page[Comic]:
|
|
247
|
+
"""
|
|
248
|
+
通过分类/标签/作者/汉化/骑士查询漫画
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
key(str): 键
|
|
252
|
+
value(str): 查询值
|
|
253
|
+
sort(str, optional): 排序方式,默认为dd(旧到新)
|
|
254
|
+
page(int, optional): 分页页码
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Page[Comic]: 查询结果
|
|
258
|
+
"""
|
|
259
|
+
response = await self.request('GET', f'comics?page={page}&s={sort}&{key}={parse.quote(value)}')
|
|
260
|
+
return Page(response['comics'], Comic)
|
|
261
|
+
|
|
262
|
+
async def like_comic(self, comic_id: str) -> None:
|
|
263
|
+
"""
|
|
264
|
+
点赞漫画。
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
comic_id(str): 漫画ID
|
|
268
|
+
"""
|
|
269
|
+
await self.request('POST', f'comics/{comic_id}/like')
|
|
270
|
+
|
|
271
|
+
async def favourite_comic(self, comic_id: str) -> None:
|
|
272
|
+
"""
|
|
273
|
+
收藏漫画。
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
comic_id(str): 漫画ID
|
|
277
|
+
"""
|
|
278
|
+
await self.request('POST', f'comics/{comic_id}/favourite')
|
|
279
|
+
|
|
280
|
+
async def like_comment(self, comment_id: str) -> None:
|
|
281
|
+
"""
|
|
282
|
+
点赞评论。
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
comment_id(str): 评论ID
|
|
286
|
+
"""
|
|
287
|
+
await self.request('POST', f'comments/{comment_id}/like')
|
|
288
|
+
|
|
289
|
+
async def comment_comic(self, comic_id: str, content: str) -> None:
|
|
290
|
+
"""
|
|
291
|
+
评论漫画。
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
comic_id(str): 漫画ID
|
|
295
|
+
content(str): 评论内容
|
|
296
|
+
"""
|
|
297
|
+
await self.request('POST', f'comics/{comic_id}/comments', {'content': content})
|
|
298
|
+
|
|
299
|
+
async def comment_comment(self, comment_id: str, content: str) -> None:
|
|
300
|
+
"""
|
|
301
|
+
评论评论。
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
comment_id(str): 评论ID
|
|
305
|
+
content(str): 评论内容
|
|
306
|
+
"""
|
|
307
|
+
await self.request('POST', f'comments/{comment_id}', {'content': content})
|
|
308
|
+
|
|
309
|
+
async def status(self) -> None:
|
|
310
|
+
"""
|
|
311
|
+
检查状态。
|
|
312
|
+
"""
|
|
313
|
+
await self.request('GET', 'status.json')
|
picaapi/downloader.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import aiofiles
|
|
3
|
+
from typing import AsyncIterator, Any
|
|
4
|
+
from httpx import HTTPError
|
|
5
|
+
|
|
6
|
+
from .client import *
|
|
7
|
+
from .objects import Picture
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_HEADERS: dict[str, str] = {
|
|
11
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0',
|
|
12
|
+
'Accept': 'image/*',
|
|
13
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
14
|
+
'Accept-Language': 'zh-CN, zh; q=0.9, en; q=0.8',
|
|
15
|
+
'Referer': 'https://manhuabika.com/',
|
|
16
|
+
}
|
|
17
|
+
DEFAULT_PICTURE_SERVER: list[str] = ['https://storage-b.go2778.com/static/', 'https://storage1.go2778.com/static/']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PictureClient:
|
|
21
|
+
"""
|
|
22
|
+
图片服务器客户端类。仅可进行简单的图片下载。
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, base_url: str, headers: dict[str, str] | None = None, max_conn: int | None = None):
|
|
25
|
+
"""
|
|
26
|
+
Args:
|
|
27
|
+
base_url(str): 根目录,注意是否有/static/
|
|
28
|
+
headers(dict[str, str], optional): 请求头,默认为DEFAULT_HEADERS
|
|
29
|
+
max_conn(int, optional): 最大连接数,建议10,默认遵循httpx
|
|
30
|
+
"""
|
|
31
|
+
if headers is None:
|
|
32
|
+
headers = DEFAULT_HEADERS
|
|
33
|
+
self.client = AsyncClient(base_url=base_url, headers=headers, limits=Limits(max_connections=max_conn), follow_redirects=True, timeout=(5.0, 30.0, 5.0, 5.0))
|
|
34
|
+
self.closed = False
|
|
35
|
+
|
|
36
|
+
async def aclose(self) -> None:
|
|
37
|
+
if not self.closed:
|
|
38
|
+
await self.client.aclose()
|
|
39
|
+
self.closed = True
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
async def __aexit__(self, *args):
|
|
45
|
+
await self.aclose()
|
|
46
|
+
|
|
47
|
+
async def fetch(self, path: str) -> bytes:
|
|
48
|
+
"""
|
|
49
|
+
请求图片。一次性返回bytes。
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path(str): 图片路径
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bytes: 图片数据
|
|
56
|
+
"""
|
|
57
|
+
response = await self.client.get(path)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
return response.content
|
|
60
|
+
|
|
61
|
+
async def fetch_stream(self, path: str, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
|
62
|
+
"""
|
|
63
|
+
请求图片。流式返回生成器。
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path(str): 图片路径
|
|
67
|
+
chunk_size(int, optional): 每块大小
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
AsyncIterator[bytes]: 生成器
|
|
71
|
+
"""
|
|
72
|
+
async with self.client.stream('GET', path) as stream:
|
|
73
|
+
stream.raise_for_status()
|
|
74
|
+
async for chunk in stream.aiter_bytes(chunk_size=chunk_size):
|
|
75
|
+
yield chunk
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Downloader:
|
|
79
|
+
"""
|
|
80
|
+
掌管图片下载类。允许使用多个服务器进行下载。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, servers: list[str] | None = None, max_conn: int | None = None):
|
|
84
|
+
"""
|
|
85
|
+
Args:
|
|
86
|
+
servers(list[str], optional): 服务器根
|
|
87
|
+
max_conn(int, optional): 最大连接数
|
|
88
|
+
"""
|
|
89
|
+
if servers is None:
|
|
90
|
+
servers = DEFAULT_PICTURE_SERVER
|
|
91
|
+
self.servers = servers
|
|
92
|
+
self.clients = [PictureClient(base_url=server, max_conn=max_conn) for server in self.servers]
|
|
93
|
+
self.closed = False
|
|
94
|
+
|
|
95
|
+
async def aclose(self) -> None:
|
|
96
|
+
"""
|
|
97
|
+
关闭我。请避免在关闭后再调用。
|
|
98
|
+
"""
|
|
99
|
+
if not self.closed:
|
|
100
|
+
tasks = [client.aclose() for client in self.clients]
|
|
101
|
+
await asyncio.gather(*tasks)
|
|
102
|
+
self.closed = True
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self):
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, *args):
|
|
108
|
+
await self.aclose()
|
|
109
|
+
|
|
110
|
+
async def download_picture(self, picture: Picture, filename: str | None = None) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
下载单个图片。
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
picture(Picture): 哔咔API图片对象
|
|
116
|
+
filename(str, optional): 保存文件路径,为空则按照picture的源名称保存
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
bool: 是否成功,成功为True
|
|
120
|
+
"""
|
|
121
|
+
data = None
|
|
122
|
+
for i, client in enumerate(self.clients):
|
|
123
|
+
try:
|
|
124
|
+
data = await client.fetch(picture.path)
|
|
125
|
+
break
|
|
126
|
+
except HTTPError:
|
|
127
|
+
if i + 1 == len(self.clients):
|
|
128
|
+
raise
|
|
129
|
+
if data is None:
|
|
130
|
+
return False
|
|
131
|
+
async with aiofiles.open(picture.originalName if filename is None else filename, 'wb') as f:
|
|
132
|
+
await f.write(data)
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
async def download_pictures(self, pictures:list[Picture], filename: Callable[[int, Picture], str | None]) -> tuple[BaseException | Any]:
|
|
136
|
+
"""
|
|
137
|
+
批量下载图片。
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
pictures(list[Picture]): 哔咔API图片列表
|
|
141
|
+
filename(Callable[[int, Picture], str|None]): 保存文件路径构造器,传入图片的列表索引与其自身,返回路径
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
tuple[BaseException | Any]: 对应于每张图片是否成功下载,会回复下载的错误消息
|
|
145
|
+
"""
|
|
146
|
+
tasks = [self.download_picture(pic, filename(i, pic)) for i, pic in enumerate(pictures)]
|
|
147
|
+
return await asyncio.gather(*tasks, return_exceptions=True)
|
|
148
|
+
|
|
149
|
+
async def download_eps(self, client: Client, comic_id: str, filename: Callable[[int, Picture], str | None], order: int = 1) -> tuple[BaseException | Any]:
|
|
150
|
+
"""
|
|
151
|
+
下载单话漫画。
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
client(Client): 用于哔咔API请求的客户端实例
|
|
155
|
+
comic_id(str): 漫画ID
|
|
156
|
+
filename(Callable[[int, Picture], str|None]): 保存文件路径构造器,传入图片的列表索引与其自身,返回路径
|
|
157
|
+
order(int, optional): 代表具体的单话编号,默认为1
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
list[bool | BaseException]: 对应于每张图片是否成功下载,会回复下载的错误消息
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
p1, title = await client.pages(comic_id, order)
|
|
164
|
+
picture_list = p1.docs
|
|
165
|
+
tasks = [client.pages(comic_id, order, i) for i in range(2, p1.pages + 1)]
|
|
166
|
+
pages = await asyncio.gather(*tasks)
|
|
167
|
+
for page in pages:
|
|
168
|
+
picture_list += page.docs
|
|
169
|
+
return await self.download_pictures(picture_list, filename)
|
picaapi/error.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class PicaAPIError(RuntimeError):
|
|
2
|
+
"""
|
|
3
|
+
用于返回PicaAPI错误信息与错误码
|
|
4
|
+
|
|
5
|
+
Attributes:
|
|
6
|
+
err(str): 哔咔API返回的错误码
|
|
7
|
+
message(str): 哔咔API返回的错误消息
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, err: str, message: str):
|
|
11
|
+
self.err = err
|
|
12
|
+
self.message = message
|
|
13
|
+
super().__init__(f'{err}: {message}')
|
picaapi/objects.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import TypeVar, Generic, Callable
|
|
3
|
+
from .base import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Page(Generic[T]):
|
|
10
|
+
"""
|
|
11
|
+
用于存储哔咔API分页返回的信息。
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
docs(list[T]): 本页内容,具体元素类型与调用方法有关。
|
|
15
|
+
limit(int): 一页的最大可承载数量
|
|
16
|
+
page(int): 页码
|
|
17
|
+
pages(int): 全部页码
|
|
18
|
+
total(int): 总内容量
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, info: dict, constructor: Callable[[dict], T]):
|
|
22
|
+
self.docs: list[T] = [constructor(i) for i in info['docs']]
|
|
23
|
+
self.page: int = int(info['page']) # 有的返回会出现字符串
|
|
24
|
+
self.pages: int = info['pages']
|
|
25
|
+
self.total: int = info['total']
|
|
26
|
+
self.limit: int = info['limit']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Picture:
|
|
30
|
+
"""
|
|
31
|
+
用于存储哔咔API返回的图片地址。
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
fileServer(str): 访问的文件服务器,为URL前缀,不以斜杠结尾,返回的仅供参考
|
|
35
|
+
originalName(str): 源文件名称
|
|
36
|
+
path(str): 路径,不以斜杠开头
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, info: dict):
|
|
40
|
+
self.fileServer: str = info['fileServer']
|
|
41
|
+
self.originalName: str = info['originalName']
|
|
42
|
+
self.path: str = info['path']
|
|
43
|
+
|
|
44
|
+
def url(self, server: str = '') -> str:
|
|
45
|
+
"""
|
|
46
|
+
生成指定图片服务器的URL。
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
server(str, optional): 指定的图片服务器,如果为空则为默认服务器
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str: 指定图片服务器的URL
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
if server == '': server = self.fileServer
|
|
56
|
+
server = server.replace('https://', '').replace('http://', '') + '/static/' + self.path
|
|
57
|
+
server = 'https://' + server.replace('//', '/')
|
|
58
|
+
return server
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ComicPicture(Picture):
|
|
62
|
+
"""
|
|
63
|
+
针对漫画页面的图片类。
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
id(str): 漫画图片ID
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, info: dict):
|
|
70
|
+
Picture.__init__(self, info['media'])
|
|
71
|
+
self.id: str = info['_id']
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class User:
|
|
75
|
+
"""
|
|
76
|
+
用户信息。
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, info: dict):
|
|
80
|
+
# TODO: 这个不返回avatar的情况怎么办啊
|
|
81
|
+
self.avatar: Picture | None = Picture(info['avatar']) if 'avatar' in info else None
|
|
82
|
+
self.characters: list[str] = info['characters']
|
|
83
|
+
self.exp: int = info['exp']
|
|
84
|
+
self.gender: Gender = Gender(info['gender'])
|
|
85
|
+
self.level: int = info['level']
|
|
86
|
+
self.name: str = info['name']
|
|
87
|
+
self.role: str = info['role']
|
|
88
|
+
self.slogan: str = info['slogan'] if 'slogan' in info else ''
|
|
89
|
+
self.title: str = info['title']
|
|
90
|
+
self.verified: bool = info.get('verified', False)
|
|
91
|
+
self.id: str = info['_id']
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Profile(User):
|
|
95
|
+
"""
|
|
96
|
+
当前登录账号的个人信息。
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, info):
|
|
100
|
+
User.__init__(self, info)
|
|
101
|
+
self.isPunched: bool = info['isPunched']
|
|
102
|
+
self.birthday: float = datetime.fromisoformat(info['birthday']).timestamp()
|
|
103
|
+
self.created_at: float = datetime.fromisoformat(info['created_at']).timestamp()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Comment:
|
|
107
|
+
"""
|
|
108
|
+
单条评论。
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, info: dict):
|
|
112
|
+
"""
|
|
113
|
+
从API返回的JSON构造。
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
info: (dict[str, Any]): 单条comment的JSON字典
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
self.isTop: bool = info['isTop']
|
|
120
|
+
self.hide: bool = info['hide']
|
|
121
|
+
self.created_at: float = datetime.fromisoformat(info['created_at']).timestamp()
|
|
122
|
+
self.id: str = info['_id']
|
|
123
|
+
self.isLiked: bool = info['isLiked']
|
|
124
|
+
self.comic: str = info['_comic']
|
|
125
|
+
self.likes: int = info['likesCount']
|
|
126
|
+
self.comments: int = info.get('commentsCount', info.get('totalComments', 0))
|
|
127
|
+
self.content: str = info['content']
|
|
128
|
+
self.user: User = User(info['_user'])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Comic:
|
|
132
|
+
"""
|
|
133
|
+
存储漫画基本信息,用于搜索和列表。
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
id(str): 漫画ID
|
|
137
|
+
title: 标题
|
|
138
|
+
author(str): 作者
|
|
139
|
+
categories(list[str]): 分类
|
|
140
|
+
tags(list[str]): 标签
|
|
141
|
+
thumb(Picture): 封面
|
|
142
|
+
views(int): 浏览次数(绅士指名数)
|
|
143
|
+
likes(int): 赞数
|
|
144
|
+
pagesCount(int): 页数
|
|
145
|
+
epsCount(int): 集数
|
|
146
|
+
finished(bool): 完结情况
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, info: dict):
|
|
150
|
+
self.author: str = info['author']
|
|
151
|
+
self.categories: list[str] = info['categories']
|
|
152
|
+
self.finished: bool = info['finished']
|
|
153
|
+
self.likes: int = info.get('likesCount', info.get('totalLikes', 0))
|
|
154
|
+
self.tags: list[str] = info.get('tags', [])
|
|
155
|
+
self.thumb: Picture = Picture(info['thumb'])
|
|
156
|
+
self.title: str = info['title']
|
|
157
|
+
self.views: int = info.get('totalViews', 0)
|
|
158
|
+
self.pagesCount: int = info.get('pagesCount', 0)
|
|
159
|
+
self.epsCount: int = info.get('epsCount', 0)
|
|
160
|
+
self.id: str = info['_id']
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class LeaderboardComic(Comic):
|
|
164
|
+
"""
|
|
165
|
+
排行榜API的漫画信息。
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, info: dict):
|
|
169
|
+
Comic.__init__(self, info)
|
|
170
|
+
self.leaderboardCount: int = info.get('leaderboardCount', 0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DetailedComic(Comic):
|
|
174
|
+
"""
|
|
175
|
+
存储漫画完整信息,用于漫画详情页。
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, info: dict):
|
|
179
|
+
Comic.__init__(self, info)
|
|
180
|
+
self.description: str = info['description']
|
|
181
|
+
self.creator: User = User(info['_creator'])
|
|
182
|
+
self.chineseTeam: str = info['chineseTeam']
|
|
183
|
+
self.updated_at: float = datetime.fromisoformat(info['updated_at']).timestamp()
|
|
184
|
+
self.created_at: float = datetime.fromisoformat(info['created_at']).timestamp()
|
|
185
|
+
self.comments: int = info.get('totalComments', 0)
|
|
186
|
+
self.isFavourite: bool = info['isFavourite']
|
|
187
|
+
self.isLiked: bool = info['isLiked']
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Eps:
|
|
191
|
+
"""
|
|
192
|
+
存储漫画单话信息。
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, info: dict):
|
|
196
|
+
self.id: str = info['_id']
|
|
197
|
+
self.order: int = info['order']
|
|
198
|
+
self.title: str = info['title']
|
|
199
|
+
self.updated_at: float = datetime.fromisoformat(info['updated_at']).timestamp()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Category:
|
|
203
|
+
"""
|
|
204
|
+
存储分类信息。
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, info: dict):
|
|
208
|
+
#self.active: bool = info['active']
|
|
209
|
+
self.isWeb: bool = info.get('isWeb', False)
|
|
210
|
+
self.link: str | None = info.get('link', None)
|
|
211
|
+
self.thumb: Picture = Picture(info['thumb'])
|
|
212
|
+
self.title: str = info['title']
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: picaapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: API for PicACG
|
|
5
|
+
Author-email: TalkDe <1160480410@qq.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Keywords: bikabika,comic,dojin,manga,picacg
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
13
|
+
Requires-Dist: brotli
|
|
14
|
+
Requires-Dist: httpx
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# picaapi
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## 简介
|
|
22
|
+
|
|
23
|
+
基于httpx的便捷的哔咔漫画API的Python调用库,允许手动设置API服务器和图片服务器。支持异步调用,支持并发下载图片。
|
|
24
|
+
|
|
25
|
+
## 开发进度
|
|
26
|
+
|
|
27
|
+
> 这个还没有开发完呢!
|
|
28
|
+
>
|
|
29
|
+
> 下载漫画的异常处理方面尚有缺陷!
|
|
30
|
+
|
|
31
|
+
- [x] API调用签名验证
|
|
32
|
+
- [x] 登录和打哔卡
|
|
33
|
+
- [x] 搜索漫画、查看分类、查看收藏
|
|
34
|
+
- [x] 查看漫画、下载漫画
|
|
35
|
+
- [x] 点赞、收藏、评论
|
|
36
|
+
- [ ] 骑士榜
|
|
37
|
+
- [ ] 游戏区
|
|
38
|
+
- [ ] 举报违规评论
|
|
39
|
+
|
|
40
|
+
## 安装方法
|
|
41
|
+
|
|
42
|
+
`pip install picaapi`
|
|
43
|
+
|
|
44
|
+
## 示例
|
|
45
|
+
|
|
46
|
+
打哔卡
|
|
47
|
+
``` Python
|
|
48
|
+
import asyncio
|
|
49
|
+
from picaapi.client import Client
|
|
50
|
+
async def main():
|
|
51
|
+
async with Client() as client:
|
|
52
|
+
await client.login(input('用户名:'), input('密码:'))
|
|
53
|
+
await client.punchin()
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 注意事项
|
|
58
|
+
|
|
59
|
+
爱护哔咔服务器,严禁滥用,禁止将该项目用于商用。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
picaapi/__init__.py,sha256=X20LzGY-409LRSV8GKwRZy-xzwsIZQ0wYdZ5dh5mt9I,337
|
|
2
|
+
picaapi/base.py,sha256=Lpb8HHD_abYfA0RyGuv0BME4JIIwtUJEK0IJk9X2GFc,2806
|
|
3
|
+
picaapi/client.py,sha256=JyzJ8tBdFAPcquIj7hExdF0jrFt0HfF-DRt_XlzyMto,11112
|
|
4
|
+
picaapi/downloader.py,sha256=WANk3Ytrc88XOR16kPUGoIY4OCcgmEqT26vnvPR-4eQ,6246
|
|
5
|
+
picaapi/error.py,sha256=Ebf6SnjDHMz-tevsFHOt_qa1-1X2PrGAVElzKC8c0yY,380
|
|
6
|
+
picaapi/objects.py,sha256=4dDAB3qbTGfhz5j0pR6fZm01nYaLe7aNSeZmt6S6yH8,6644
|
|
7
|
+
picaapi-1.0.0.dist-info/METADATA,sha256=RMRt-ePGtEW3C3FkFsl2OKBbHrmCBJfBNC2jptYNU7c,1522
|
|
8
|
+
picaapi-1.0.0.dist-info/WHEEL,sha256=65OZpnqn5Q1e9Q6d3rS1FnhyYsWCXoZHnlzG_20Jla0,105
|
|
9
|
+
picaapi-1.0.0.dist-info/RECORD,,
|