youke 0.2.1__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.
Potentially problematic release.
This version of youke might be problematic. Click here for more details.
- hiddenapi/__init__.py +103 -0
- hiddenapi/__main__.py +4 -0
- hiddenapi/adapters/__init__.py +40 -0
- hiddenapi/adapters/bilibili.py +257 -0
- hiddenapi/adapters/coolapk.py +364 -0
- hiddenapi/adapters/netease.py +258 -0
- hiddenapi/adapters/weibo.py +327 -0
- hiddenapi/cli.py +459 -0
- hiddenapi/core/__init__.py +21 -0
- hiddenapi/core/base.py +135 -0
- hiddenapi/core/errors.py +77 -0
- hiddenapi/core/http.py +286 -0
- hiddenapi/core/registry.py +65 -0
- hiddenapi/experimental/__init__.py +70 -0
- hiddenapi/experimental/douyin.py +263 -0
- hiddenapi/experimental/xiaohongshu.py +439 -0
- hiddenapi/experimental/zhihu.py +331 -0
- hiddenapi/signers/__init__.py +2 -0
- hiddenapi/signers/coolapk_token.py +273 -0
- hiddenapi/signers/douyin_abogus.py +645 -0
- youke-0.2.1.dist-info/METADATA +282 -0
- youke-0.2.1.dist-info/RECORD +26 -0
- youke-0.2.1.dist-info/WHEEL +5 -0
- youke-0.2.1.dist-info/entry_points.txt +2 -0
- youke-0.2.1.dist-info/licenses/LICENSE +21 -0
- youke-0.2.1.dist-info/top_level.txt +1 -0
hiddenapi/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""hiddenapi —— 把"APP 里有、网页上没有"的隐藏接口,做成统一的 Python 调用层。
|
|
3
|
+
|
|
4
|
+
from hiddenapi import open_adapter
|
|
5
|
+
api = open_adapter("coolapk")
|
|
6
|
+
page = api.search("微信", type="apk")
|
|
7
|
+
for it in page.items:
|
|
8
|
+
print(it.title, it.extra.get("apkname"))
|
|
9
|
+
|
|
10
|
+
设计原则:
|
|
11
|
+
1. 一个平台一个 adapter,统一 search()/detail()/suggest()
|
|
12
|
+
2. 签名算法单独放 signers/,可独立复用
|
|
13
|
+
3. 不登录就说不登录,被风控就说被风控 —— 绝不把失败伪装成"0 条结果"
|
|
14
|
+
4. 主包只装「装上就能用」的平台;有登录墙的放 experimental/,默认不加载
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, List, Optional
|
|
19
|
+
|
|
20
|
+
from .core import (
|
|
21
|
+
DEFAULT_UA,
|
|
22
|
+
MOBILE_UA,
|
|
23
|
+
TIER_EXPERIMENTAL,
|
|
24
|
+
TIER_STABLE,
|
|
25
|
+
AuthRequired,
|
|
26
|
+
BaseAdapter,
|
|
27
|
+
EndpointMissing,
|
|
28
|
+
HiddenApiError,
|
|
29
|
+
HttpClient,
|
|
30
|
+
HttpError,
|
|
31
|
+
Item,
|
|
32
|
+
NotSupported,
|
|
33
|
+
RequestRejected,
|
|
34
|
+
RiskControl,
|
|
35
|
+
SearchPage,
|
|
36
|
+
all_adapters,
|
|
37
|
+
get,
|
|
38
|
+
names,
|
|
39
|
+
tier_of,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.2.1"
|
|
43
|
+
|
|
44
|
+
# 触发稳定适配器自动注册(失败只 warn)
|
|
45
|
+
from . import adapters # noqa: E402,F401
|
|
46
|
+
from . import experimental # noqa: E402
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enable_experimental(*which: str) -> List[str]:
|
|
50
|
+
"""显式开启实验性平台(有登录墙 / native 签名门槛的那些)。
|
|
51
|
+
|
|
52
|
+
enable_experimental() # 全部
|
|
53
|
+
enable_experimental("zhihu") # 只开知乎
|
|
54
|
+
|
|
55
|
+
不调用也能用:open_adapter() 发现名字在实验区时会自动加载一次。
|
|
56
|
+
"""
|
|
57
|
+
if not which:
|
|
58
|
+
return experimental.load(None)
|
|
59
|
+
out: List[str] = []
|
|
60
|
+
for w in which:
|
|
61
|
+
out.extend(experimental.load(w))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def open_adapter(name: str, http: Optional[HttpClient] = None, **opts: Any) -> BaseAdapter:
|
|
66
|
+
"""按名字创建适配器实例。
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
name : 平台名,如 "coolapk" / "bilibili" / "netease" / "weibo"
|
|
71
|
+
也可以是实验性的 "douyin" / "zhihu" / "xiaohongshu"(会自动加载)
|
|
72
|
+
http : 复用已有 HttpClient(想共享代理/限速时用)
|
|
73
|
+
cookie : 登录态 Cookie 字符串,传给需要登录的平台
|
|
74
|
+
"""
|
|
75
|
+
from .core.registry import has
|
|
76
|
+
|
|
77
|
+
if not has(name):
|
|
78
|
+
# 名字可能在实验区还没被 import,试一次再失败
|
|
79
|
+
try:
|
|
80
|
+
experimental.load(name)
|
|
81
|
+
except KeyError:
|
|
82
|
+
pass
|
|
83
|
+
cls = get(name)
|
|
84
|
+
return cls(http=http, **opts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def search(name: str, keyword: str, page: int = 1, **opts: Any) -> SearchPage:
|
|
88
|
+
"""一行搜索:search("coolapk", "微信", type="apk")"""
|
|
89
|
+
adapter_opts = {k: opts.pop(k) for k in list(opts) if k in ("cookie", "http", "proxy", "delay")}
|
|
90
|
+
return open_adapter(name, **adapter_opts).search(keyword, page=page, **opts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"open_adapter", "search", "enable_experimental",
|
|
95
|
+
"BaseAdapter", "Item", "SearchPage", "HttpClient", "HttpError",
|
|
96
|
+
# 失败类型:调用方应该按这五个分别 catch,而不是笼统的 RuntimeError
|
|
97
|
+
"HiddenApiError", "AuthRequired", "RiskControl", "RequestRejected",
|
|
98
|
+
"EndpointMissing", "NotSupported",
|
|
99
|
+
"DEFAULT_UA", "MOBILE_UA",
|
|
100
|
+
"get", "names", "all_adapters", "tier_of",
|
|
101
|
+
"TIER_STABLE", "TIER_EXPERIMENTAL",
|
|
102
|
+
"__version__",
|
|
103
|
+
]
|
hiddenapi/__main__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""稳定平台适配器:装上就能用,search() 直接出数据。
|
|
3
|
+
|
|
4
|
+
import 时自动注册。某个平台的依赖缺失时只 warn,不影响其它平台可用。
|
|
5
|
+
有登录墙/native 签名门槛的平台不在这里,见 hiddenapi.experimental。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
_BUILTIN = (
|
|
13
|
+
"coolapk",
|
|
14
|
+
"bilibili",
|
|
15
|
+
"netease",
|
|
16
|
+
"weibo",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_loaded = []
|
|
20
|
+
_failed = {}
|
|
21
|
+
|
|
22
|
+
for _mod in _BUILTIN:
|
|
23
|
+
# 只对"文件在、导入却失败"的情况报警;未实现的平台静默跳过
|
|
24
|
+
_path = os.path.join(os.path.dirname(__file__), _mod + ".py")
|
|
25
|
+
if not os.path.exists(_path):
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
__import__("%s.%s" % (__name__, _mod), fromlist=["*"])
|
|
29
|
+
_loaded.append(_mod)
|
|
30
|
+
except Exception as _exc: # noqa: BLE001
|
|
31
|
+
_failed[_mod] = "%s: %s" % (type(_exc).__name__, _exc)
|
|
32
|
+
|
|
33
|
+
if _failed:
|
|
34
|
+
warnings.warn(
|
|
35
|
+
"hiddenapi: 以下适配器未加载(依赖缺失或平台改版):%s" % _failed,
|
|
36
|
+
RuntimeWarning,
|
|
37
|
+
stacklevel=2,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = list(_loaded)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""B站(bilibili.com)Web 端适配器 —— WBI 签名 + buvid3 匿名指纹。
|
|
3
|
+
|
|
4
|
+
算法出处 / 许可证
|
|
5
|
+
----------------
|
|
6
|
+
- WBI 签名(mixinKeyEncTab 重排表 + `md5(urlencode(sorted_params) + mixinKey)`)
|
|
7
|
+
出处: SocialSisterYi/bilibili-API-collect, MIT License
|
|
8
|
+
https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md
|
|
9
|
+
- buvid3 / buvid4 匿名指纹(/x/frontend/finger/spi)
|
|
10
|
+
出处: Nemo2011/bilibili-api, MIT License
|
|
11
|
+
https://github.com/Nemo2011/bilibili-api
|
|
12
|
+
|
|
13
|
+
本文件只移植上述两个公开算法常量与流程,不复制其工程代码。仅依赖 requests。
|
|
14
|
+
|
|
15
|
+
实测(2025-11,未登录):
|
|
16
|
+
✅ /x/web-interface/wbi/search/type 搜索视频,返回 bvid/title/author/play/duration
|
|
17
|
+
✅ /x/web-interface/view 视频详情,返回 stat/pages
|
|
18
|
+
⚠️ nav 接口未登录时 code=-101,但 data.wbi_img 仍然下发,mixinKey 照常可取
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import time
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from ..core.base import BaseAdapter, Item, SearchPage
|
|
28
|
+
from ..core.registry import register
|
|
29
|
+
|
|
30
|
+
MIXIN_KEY_ENC_TAB = [
|
|
31
|
+
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
|
32
|
+
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
|
33
|
+
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
|
34
|
+
36, 20, 34, 44, 52,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
NAV = "https://api.bilibili.com/x/web-interface/nav"
|
|
38
|
+
SPI = "https://api.bilibili.com/x/frontend/finger/spi"
|
|
39
|
+
SEARCH = "https://api.bilibili.com/x/web-interface/wbi/search/type"
|
|
40
|
+
VIEW = "https://api.bilibili.com/x/web-interface/view"
|
|
41
|
+
HOME = "https://www.bilibili.com/"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _img_key(url: str) -> str:
|
|
45
|
+
"""https://i0.hdslb.com/bfs/wbi/<32hex>.png -> <32hex>"""
|
|
46
|
+
return url.rsplit("/", 1)[-1].split(".")[0]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mixin_key(img_key: str, sub_key: str) -> str:
|
|
50
|
+
raw = img_key + sub_key
|
|
51
|
+
return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB if i < len(raw))[:32]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sign_wbi(params: Dict[str, Any], mixin: str) -> Dict[str, Any]:
|
|
55
|
+
"""排序 + 过滤 !'()* + urlencode,拼 mixinKey 取 md5 => w_rid。
|
|
56
|
+
|
|
57
|
+
注意:只有「参与哈希计算」的那份副本会被 str() 化 + 过滤字符;
|
|
58
|
+
返回给 requests 的 params 保留调用方传进来的原始类型(page 仍是 int),
|
|
59
|
+
这样日志/复现时看到的和实际发出的一致,也方便下游做类型判断。
|
|
60
|
+
"""
|
|
61
|
+
out = dict(params)
|
|
62
|
+
out["wts"] = int(time.time())
|
|
63
|
+
out = {k: out[k] for k in sorted(out)}
|
|
64
|
+
hashed = {
|
|
65
|
+
k: "".join(ch for ch in str(v) if ch not in "!'()*")
|
|
66
|
+
for k, v in out.items()
|
|
67
|
+
}
|
|
68
|
+
query = urllib.parse.urlencode(hashed)
|
|
69
|
+
out["w_rid"] = hashlib.md5((query + mixin).encode()).hexdigest()
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clean(s: Optional[str]) -> str:
|
|
74
|
+
"""搜索结果标题里带 <em class="keyword"> 高亮。"""
|
|
75
|
+
if not s:
|
|
76
|
+
return ""
|
|
77
|
+
for a, b in (
|
|
78
|
+
('<em class="keyword">', ""),
|
|
79
|
+
("</em>", ""),
|
|
80
|
+
("&", "&"),
|
|
81
|
+
(""", '"'),
|
|
82
|
+
("'", "'"),
|
|
83
|
+
("<", "<"),
|
|
84
|
+
(">", ">"),
|
|
85
|
+
):
|
|
86
|
+
s = s.replace(a, b)
|
|
87
|
+
return s
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pic(u: Optional[str]) -> str:
|
|
91
|
+
if not u:
|
|
92
|
+
return ""
|
|
93
|
+
return u if u.startswith("http") else "https:" + u
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@register
|
|
97
|
+
class BilibiliAdapter(BaseAdapter):
|
|
98
|
+
name = "bilibili"
|
|
99
|
+
aliases = ("B站", "哔哩哔哩", "bilibili.com")
|
|
100
|
+
home = HOME
|
|
101
|
+
needs_login = False
|
|
102
|
+
note = "WBI 签名 + buvid3 匿名指纹;搜索/视频详情均免登录(移植 bilibili-API-collect, MIT)"
|
|
103
|
+
|
|
104
|
+
def __init__(self, http: Optional[Any] = None, cookie: str = "", **opts: Any):
|
|
105
|
+
super().__init__(http=http, cookie=cookie, **opts)
|
|
106
|
+
self.http.set_host_headers(
|
|
107
|
+
"bilibili.com",
|
|
108
|
+
{
|
|
109
|
+
"Referer": HOME,
|
|
110
|
+
"Origin": "https://www.bilibili.com",
|
|
111
|
+
"Accept": "application/json, text/plain, */*",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
if cookie:
|
|
115
|
+
self.http.update_headers({"Cookie": cookie})
|
|
116
|
+
self._mixin: Optional[str] = None
|
|
117
|
+
self._mixin_ts: float = 0.0
|
|
118
|
+
self._init_cookies()
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------ 初始化
|
|
121
|
+
def _init_cookies(self) -> None:
|
|
122
|
+
"""无 buvid3 时搜索接口极容易被 -352 风控,先取匿名指纹。"""
|
|
123
|
+
if self.http.cookies.get("buvid3"):
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
js = self.http.get_json(SPI)
|
|
127
|
+
d = js.get("data") or {}
|
|
128
|
+
if d.get("b_3"):
|
|
129
|
+
self.http.set_cookie("buvid3", d["b_3"], domain=".bilibili.com")
|
|
130
|
+
if d.get("b_4"):
|
|
131
|
+
self.http.set_cookie("buvid4", d["b_4"], domain=".bilibili.com")
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
if not self.http.cookies.get("buvid3"):
|
|
135
|
+
try:
|
|
136
|
+
self.http.get(HOME)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def _get_mixin(self, refresh: bool = False) -> str:
|
|
141
|
+
if not refresh and self._mixin and time.time() - self._mixin_ts < 3600:
|
|
142
|
+
return self._mixin
|
|
143
|
+
js = self.http.get_json(NAV)
|
|
144
|
+
wbi = ((js.get("data") or {}).get("wbi_img")) or {}
|
|
145
|
+
if not wbi.get("img_url"):
|
|
146
|
+
raise RuntimeError("bilibili nav 未返回 wbi_img,WBI 签名不可用")
|
|
147
|
+
self._mixin = mixin_key(_img_key(wbi["img_url"]), _img_key(wbi["sub_url"]))
|
|
148
|
+
self._mixin_ts = time.time()
|
|
149
|
+
return self._mixin
|
|
150
|
+
|
|
151
|
+
def _signed_get_json(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
152
|
+
js: Dict[str, Any] = {}
|
|
153
|
+
for attempt in (0, 1):
|
|
154
|
+
signed = sign_wbi(params, self._get_mixin(refresh=bool(attempt)))
|
|
155
|
+
js = self.http.get_json(url, params=signed)
|
|
156
|
+
if js.get("code") in (-403, -352) and attempt == 0:
|
|
157
|
+
continue
|
|
158
|
+
break
|
|
159
|
+
return js
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------ search
|
|
162
|
+
def search(self, keyword: str, page: int = 1, **kw: Any) -> SearchPage:
|
|
163
|
+
"""搜索视频。kw 支持 page_size(默认 20)。"""
|
|
164
|
+
page_size = int(kw.get("page_size") or 20)
|
|
165
|
+
params = {
|
|
166
|
+
"search_type": "video",
|
|
167
|
+
"keyword": keyword,
|
|
168
|
+
"page": page,
|
|
169
|
+
"page_size": page_size,
|
|
170
|
+
}
|
|
171
|
+
js = self._signed_get_json(SEARCH, params)
|
|
172
|
+
if js.get("code") != 0:
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
"bilibili search failed: code=%s message=%s"
|
|
175
|
+
% (js.get("code"), js.get("message"))
|
|
176
|
+
)
|
|
177
|
+
data = js.get("data") or {}
|
|
178
|
+
raw_items = data.get("result") or []
|
|
179
|
+
items: List[Item] = []
|
|
180
|
+
for it in raw_items:
|
|
181
|
+
if it.get("type") and it.get("type") != "video":
|
|
182
|
+
continue
|
|
183
|
+
bvid = it.get("bvid") or ""
|
|
184
|
+
items.append(
|
|
185
|
+
Item(
|
|
186
|
+
title=_clean(it.get("title")),
|
|
187
|
+
subtitle="%s · %s 播放 · %s" % (
|
|
188
|
+
it.get("author") or "",
|
|
189
|
+
it.get("play") or 0,
|
|
190
|
+
it.get("duration") or "",
|
|
191
|
+
),
|
|
192
|
+
url="https://www.bilibili.com/video/%s" % bvid if bvid else (it.get("arcurl") or ""),
|
|
193
|
+
image=_pic(it.get("pic")),
|
|
194
|
+
author=it.get("author") or "",
|
|
195
|
+
extra={
|
|
196
|
+
"bvid": bvid,
|
|
197
|
+
"aid": it.get("aid"),
|
|
198
|
+
"play": it.get("play"),
|
|
199
|
+
"danmaku": it.get("video_review"),
|
|
200
|
+
"favorites": it.get("favorites"),
|
|
201
|
+
"duration": it.get("duration"),
|
|
202
|
+
"pubdate": it.get("pubdate"),
|
|
203
|
+
"mid": it.get("mid"),
|
|
204
|
+
},
|
|
205
|
+
raw=it,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
total = data.get("numResults") or data.get("total") or data.get("numPages")
|
|
209
|
+
num_pages = data.get("numPages")
|
|
210
|
+
has_more = len(items) >= page_size
|
|
211
|
+
if isinstance(num_pages, int) and num_pages:
|
|
212
|
+
has_more = page < num_pages
|
|
213
|
+
return SearchPage(
|
|
214
|
+
items=items,
|
|
215
|
+
page=page,
|
|
216
|
+
has_more=has_more,
|
|
217
|
+
cursor=str(page + 1) if has_more else None,
|
|
218
|
+
total=total if isinstance(total, int) else None,
|
|
219
|
+
raw=data,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------ detail
|
|
223
|
+
def detail(self, id_: str, **kw: Any) -> Dict[str, Any]:
|
|
224
|
+
"""视频详情。id_ 可以是 bvid(BV...)或 aid(数字)。"""
|
|
225
|
+
key = str(id_).strip()
|
|
226
|
+
params: Dict[str, Any] = {"aid": key} if key.isdigit() else {"bvid": key}
|
|
227
|
+
js = self.http.get_json(VIEW, params=params)
|
|
228
|
+
if js.get("code") != 0:
|
|
229
|
+
raise RuntimeError(
|
|
230
|
+
"bilibili view failed: code=%s message=%s"
|
|
231
|
+
% (js.get("code"), js.get("message"))
|
|
232
|
+
)
|
|
233
|
+
d = js.get("data") or {}
|
|
234
|
+
st = d.get("stat") or {}
|
|
235
|
+
ow = d.get("owner") or {}
|
|
236
|
+
return {
|
|
237
|
+
"bvid": d.get("bvid"),
|
|
238
|
+
"aid": d.get("aid"),
|
|
239
|
+
"title": d.get("title"),
|
|
240
|
+
"desc": d.get("desc"),
|
|
241
|
+
"pic": d.get("pic"),
|
|
242
|
+
"pubdate": d.get("pubdate"),
|
|
243
|
+
"duration": d.get("duration"),
|
|
244
|
+
"owner": ow.get("name"),
|
|
245
|
+
"owner_mid": ow.get("mid"),
|
|
246
|
+
"view": st.get("view"),
|
|
247
|
+
"danmaku": st.get("danmaku"),
|
|
248
|
+
"like": st.get("like"),
|
|
249
|
+
"coin": st.get("coin"),
|
|
250
|
+
"favorite": st.get("favorite"),
|
|
251
|
+
"share": st.get("share"),
|
|
252
|
+
"pages": [
|
|
253
|
+
{"cid": p.get("cid"), "page": p.get("page"), "part": p.get("part")}
|
|
254
|
+
for p in (d.get("pages") or [])
|
|
255
|
+
],
|
|
256
|
+
"raw": js,
|
|
257
|
+
}
|