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 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,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .cli import main
3
+
4
+ raise SystemExit(main())
@@ -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
+ ("&amp;", "&"),
81
+ ("&quot;", '"'),
82
+ ("&#39;", "'"),
83
+ ("&lt;", "<"),
84
+ ("&gt;", ">"),
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
+ }