nonebot-plugin-botui 0.1.0__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.
- nonebot_plugin_botui/__init__.py +382 -0
- nonebot_plugin_botui/api.py +1683 -0
- nonebot_plugin_botui/capture.py +570 -0
- nonebot_plugin_botui/config.py +170 -0
- nonebot_plugin_botui/ingest.py +216 -0
- nonebot_plugin_botui/media.py +438 -0
- nonebot_plugin_botui/mediastore.py +954 -0
- nonebot_plugin_botui/models.py +221 -0
- nonebot_plugin_botui/paths.py +36 -0
- nonebot_plugin_botui/segments.py +900 -0
- nonebot_plugin_botui/static/app.css +1774 -0
- nonebot_plugin_botui/static/app.js +3799 -0
- nonebot_plugin_botui/static/index.html +232 -0
- nonebot_plugin_botui/store.py +1271 -0
- nonebot_plugin_botui/webui.py +386 -0
- nonebot_plugin_botui-0.1.0.dist-info/METADATA +231 -0
- nonebot_plugin_botui-0.1.0.dist-info/RECORD +18 -0
- nonebot_plugin_botui-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""媒体资源的小工具:SSRF 校验、流式转发、文本读取与内存缓存。
|
|
2
|
+
|
|
3
|
+
WebUI 通过 ``/media`` 代理远程图片/文件:QQ 的媒体链接大多带防盗链,直接
|
|
4
|
+
``<img src>`` / ``<a href>`` 会加载或下载失败。
|
|
5
|
+
|
|
6
|
+
安全说明:``/media`` 是一个「按用户给的 URL 去取内容」的接口,天然容易变成
|
|
7
|
+
SSRF 跳板(拿它去打内网、打云元数据接口 ``169.254.169.254``)。所以这里对
|
|
8
|
+
目标地址做了限制,默认只允许公网地址,见 :func:`_blocked_reason`。
|
|
9
|
+
|
|
10
|
+
小资源(图片、缩略图)走 :func:`fetch` 读进内存缓存;大文件走
|
|
11
|
+
:func:`stream`,边收边转发、支持 Range 断点续传,不把文件读进内存。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import socket
|
|
17
|
+
import asyncio
|
|
18
|
+
import ipaddress
|
|
19
|
+
import mimetypes
|
|
20
|
+
from typing import Any
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urljoin, urlsplit
|
|
23
|
+
from collections.abc import AsyncIterator
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from nonebot import logger
|
|
27
|
+
|
|
28
|
+
from .config import plugin_config as cfg
|
|
29
|
+
|
|
30
|
+
# 单个资源最大体积(超过则不内联)
|
|
31
|
+
MAX_BYTES = 6 * 1024 * 1024
|
|
32
|
+
# 缓存条目上限
|
|
33
|
+
MAX_ENTRIES = 512
|
|
34
|
+
# 跟随重定向的最大跳数(每一跳都要重新校验目标地址)
|
|
35
|
+
MAX_REDIRECTS = 3
|
|
36
|
+
# 单次流式读取的块大小
|
|
37
|
+
CHUNK_SIZE = 64 * 1024
|
|
38
|
+
# 文本预览最多读取的字节数(避免预览一个几百 MB 的日志把内存吃光)
|
|
39
|
+
MAX_TEXT_BYTES = 512 * 1024
|
|
40
|
+
|
|
41
|
+
# 允许内联预览的文本类型(MIME)
|
|
42
|
+
_TEXT_TYPES = frozenset(
|
|
43
|
+
{
|
|
44
|
+
'application/json',
|
|
45
|
+
'application/xml',
|
|
46
|
+
'application/javascript',
|
|
47
|
+
'application/x-javascript',
|
|
48
|
+
'application/x-yaml',
|
|
49
|
+
'application/yaml',
|
|
50
|
+
'application/x-sh',
|
|
51
|
+
'application/sql',
|
|
52
|
+
'application/x-python',
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
_TEXT_SUFFIXES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
'.txt',
|
|
58
|
+
'.md',
|
|
59
|
+
'.markdown',
|
|
60
|
+
'.log',
|
|
61
|
+
'.json',
|
|
62
|
+
'.jsonl',
|
|
63
|
+
'.xml',
|
|
64
|
+
'.yaml',
|
|
65
|
+
'.yml',
|
|
66
|
+
'.toml',
|
|
67
|
+
'.ini',
|
|
68
|
+
'.cfg',
|
|
69
|
+
'.conf',
|
|
70
|
+
'.csv',
|
|
71
|
+
'.tsv',
|
|
72
|
+
'.env',
|
|
73
|
+
'.py',
|
|
74
|
+
'.js',
|
|
75
|
+
'.mjs',
|
|
76
|
+
'.cjs',
|
|
77
|
+
'.ts',
|
|
78
|
+
'.tsx',
|
|
79
|
+
'.jsx',
|
|
80
|
+
'.java',
|
|
81
|
+
'.kt',
|
|
82
|
+
'.go',
|
|
83
|
+
'.rs',
|
|
84
|
+
'.c',
|
|
85
|
+
'.h',
|
|
86
|
+
'.cpp',
|
|
87
|
+
'.hpp',
|
|
88
|
+
'.cs',
|
|
89
|
+
'.rb',
|
|
90
|
+
'.php',
|
|
91
|
+
'.sh',
|
|
92
|
+
'.bash',
|
|
93
|
+
'.zsh',
|
|
94
|
+
'.fish',
|
|
95
|
+
'.ps1',
|
|
96
|
+
'.bat',
|
|
97
|
+
'.cmd',
|
|
98
|
+
'.lua',
|
|
99
|
+
'.sql',
|
|
100
|
+
'.html',
|
|
101
|
+
'.htm',
|
|
102
|
+
'.css',
|
|
103
|
+
'.scss',
|
|
104
|
+
'.less',
|
|
105
|
+
'.vue',
|
|
106
|
+
'.svelte',
|
|
107
|
+
'.patch',
|
|
108
|
+
'.diff',
|
|
109
|
+
'.srt',
|
|
110
|
+
'.vtt',
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
_cache: dict[str, tuple[bytes, str]] = {}
|
|
115
|
+
_order: list[str] = []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _remember(url: str, data: bytes, content_type: str) -> None:
|
|
119
|
+
if url in _cache:
|
|
120
|
+
return
|
|
121
|
+
_cache[url] = (data, content_type)
|
|
122
|
+
_order.append(url)
|
|
123
|
+
while len(_order) > MAX_ENTRIES:
|
|
124
|
+
_cache.pop(_order.pop(0), None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ip_blocked_reason(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str:
|
|
128
|
+
"""地址是否属于「不该让这里去访问」的范围,返回原因(可访问则返回空串)。"""
|
|
129
|
+
# IPv4-mapped IPv6(::ffff:127.0.0.1)要先还原成 IPv4 再判断,
|
|
130
|
+
# 否则 is_global 会把它当成普通 IPv6 地址放过
|
|
131
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
132
|
+
ip = ip.ipv4_mapped
|
|
133
|
+
if ip.is_loopback:
|
|
134
|
+
return '环回地址'
|
|
135
|
+
if ip.is_link_local:
|
|
136
|
+
return '链路本地地址(含云元数据接口)'
|
|
137
|
+
if ip.is_private:
|
|
138
|
+
return '内网地址'
|
|
139
|
+
if ip.is_reserved:
|
|
140
|
+
return '保留地址'
|
|
141
|
+
if ip.is_multicast:
|
|
142
|
+
return '组播地址'
|
|
143
|
+
if ip.is_unspecified:
|
|
144
|
+
return '未指定地址'
|
|
145
|
+
if not ip.is_global:
|
|
146
|
+
return '非公网地址'
|
|
147
|
+
return ''
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _blocked_reason(url: str) -> str:
|
|
151
|
+
"""检查 URL 的**目标地址**是否不允许访问,允许则返回空串。
|
|
152
|
+
|
|
153
|
+
域名会先解析成 IP,并且**解析出的每一个**地址都要通过检查:只查第一个
|
|
154
|
+
的话,攻击者可以用一个同时返回公网与内网地址的域名绕过。
|
|
155
|
+
|
|
156
|
+
注意这不能完全防住 DNS rebinding(这里解析一次、httpx 连接时可能再解析
|
|
157
|
+
一次)。对「只在本机使用的管理后台」来说这个强度够用;真要严格防住,
|
|
158
|
+
需要把解析结果固定下来再直连 IP。
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
parts = urlsplit(url)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return '链接格式不合法'
|
|
164
|
+
if parts.scheme not in ('http', 'https'):
|
|
165
|
+
return '仅支持 http/https 链接'
|
|
166
|
+
host = parts.hostname
|
|
167
|
+
if not host:
|
|
168
|
+
return '链接里没有主机名'
|
|
169
|
+
|
|
170
|
+
# 字面量 IP 直接判断,不走 DNS
|
|
171
|
+
try:
|
|
172
|
+
return _ip_blocked_reason(ipaddress.ip_address(host))
|
|
173
|
+
except ValueError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# 域名:解析出全部地址逐个判断
|
|
177
|
+
try:
|
|
178
|
+
infos = await asyncio.get_running_loop().getaddrinfo(
|
|
179
|
+
host, parts.port or (443 if parts.scheme == 'https' else 80)
|
|
180
|
+
)
|
|
181
|
+
except (OSError, socket.gaierror) as e:
|
|
182
|
+
return f'域名无法解析({e})'
|
|
183
|
+
if not infos:
|
|
184
|
+
return '域名无法解析'
|
|
185
|
+
for info in infos:
|
|
186
|
+
addr = info[4][0]
|
|
187
|
+
try:
|
|
188
|
+
reason = _ip_blocked_reason(ipaddress.ip_address(addr))
|
|
189
|
+
except ValueError:
|
|
190
|
+
continue
|
|
191
|
+
if reason:
|
|
192
|
+
return f'{host} 解析到{reason} {addr}'
|
|
193
|
+
return ''
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Blocked(Exception):
|
|
197
|
+
"""目标地址不允许访问(或跳转太多)"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _timeout() -> httpx.Timeout:
|
|
201
|
+
return httpx.Timeout(cfg.botui_api_timeout, connect=min(5.0, cfg.botui_api_timeout))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _client(headers: dict[str, str] | None = None) -> httpx.AsyncClient:
|
|
205
|
+
"""构造代理用的客户端。
|
|
206
|
+
|
|
207
|
+
``follow_redirects=False``:重定向要自己逐跳校验,不能让 httpx 直接跟
|
|
208
|
+
——否则公网地址 302 到内网就绕过了 :func:`_blocked_reason`。
|
|
209
|
+
"""
|
|
210
|
+
return httpx.AsyncClient(
|
|
211
|
+
timeout=_timeout(), follow_redirects=False, headers=headers
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _open(
|
|
216
|
+
client: httpx.AsyncClient, url: str, *, headers: dict[str, str] | None = None
|
|
217
|
+
) -> httpx.Response:
|
|
218
|
+
"""发起请求并处理重定向,每一跳都重新校验目标地址。
|
|
219
|
+
|
|
220
|
+
``headers`` 会被 httpx **合并**到客户端的默认请求头上,所以调用方只需
|
|
221
|
+
传 ``Range`` 这类逐次不同的头。
|
|
222
|
+
"""
|
|
223
|
+
allow_private = bool(cfg.botui_media_allow_private)
|
|
224
|
+
current = url
|
|
225
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
226
|
+
if not allow_private:
|
|
227
|
+
reason = await _blocked_reason(current)
|
|
228
|
+
if reason:
|
|
229
|
+
raise Blocked(f'拒绝代理媒体 {current[:80]}:{reason}')
|
|
230
|
+
resp = await client.get(current, headers=headers)
|
|
231
|
+
if resp.is_redirect and resp.headers.get('location'):
|
|
232
|
+
current = urljoin(current, resp.headers['location'])
|
|
233
|
+
continue
|
|
234
|
+
resp.raise_for_status()
|
|
235
|
+
return resp
|
|
236
|
+
raise Blocked(f'重定向次数过多:{url[:64]}')
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def fetch(url: str) -> tuple[bytes, str] | None:
|
|
240
|
+
"""下载小资源(图片、缩略图)并缓存;失败或目标地址不允许时返回 None。"""
|
|
241
|
+
if not url or not url.startswith(('http://', 'https://')):
|
|
242
|
+
return None
|
|
243
|
+
if url in _cache:
|
|
244
|
+
return _cache[url]
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
async with _client() as client:
|
|
248
|
+
resp = await _open(client, url)
|
|
249
|
+
data = resp.content
|
|
250
|
+
if not data or len(data) > MAX_BYTES:
|
|
251
|
+
logger.debug(f'Skip media {url[:64]}: size={len(data)}')
|
|
252
|
+
return None
|
|
253
|
+
ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
|
|
254
|
+
if not ctype:
|
|
255
|
+
ctype = mimetypes.guess_type(url)[0] or 'application/octet-stream'
|
|
256
|
+
_remember(url, data, ctype)
|
|
257
|
+
return data, ctype
|
|
258
|
+
except Blocked as e:
|
|
259
|
+
logger.warning(str(e))
|
|
260
|
+
return None
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.debug(f'Failed to fetch media {url[:64]}: {e}')
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def probe(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
|
267
|
+
"""发起请求但立刻关闭 body,只留下响应头供调用方读取。
|
|
268
|
+
|
|
269
|
+
流式转发需要先把 ``content-type`` / ``content-length`` / ``content-range``
|
|
270
|
+
告诉浏览器,所以单独探一次头。返回的 response 已经关闭,只用它的
|
|
271
|
+
``status_code`` 与 ``headers``。
|
|
272
|
+
"""
|
|
273
|
+
async with _client() as client:
|
|
274
|
+
resp = await _open(client, url, headers=headers)
|
|
275
|
+
await resp.aclose()
|
|
276
|
+
return resp
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def stream(
|
|
280
|
+
url: str, headers: dict[str, str] | None = None
|
|
281
|
+
) -> AsyncIterator[bytes]:
|
|
282
|
+
"""边下边转发远程资源,不在内存里完整驻留。
|
|
283
|
+
|
|
284
|
+
``headers`` 用于透传 ``Range``(音视频拖动进度条会用到)。目标地址不允许
|
|
285
|
+
时抛 :class:`Blocked`,其他网络错误原样向上抛,由调用方决定怎么回给浏览器。
|
|
286
|
+
"""
|
|
287
|
+
async with _client() as client:
|
|
288
|
+
resp = await _open(client, url, headers=headers)
|
|
289
|
+
async for chunk in resp.aiter_bytes(CHUNK_SIZE):
|
|
290
|
+
yield chunk
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class TooLarge(Exception):
|
|
294
|
+
"""下载的内容超过了允许的体积(只用来中断缓存下载)"""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def download(
|
|
298
|
+
url: str,
|
|
299
|
+
dest: Path,
|
|
300
|
+
*,
|
|
301
|
+
max_bytes: int = 0,
|
|
302
|
+
timeout: float | None = None,
|
|
303
|
+
) -> tuple[int, str]:
|
|
304
|
+
"""把远程资源流式写入本地文件,返回 ``(字节数, content-type)``。
|
|
305
|
+
|
|
306
|
+
与 :func:`stream` 的区别是「边下边落盘」:预缓存媒体时不该先把整个文件读进
|
|
307
|
+
内存,尤其是视频。超过 ``max_bytes`` 时中断下载并抛 :class:`TooLarge`
|
|
308
|
+
(调用方负责删掉半截的临时文件);目标地址不允许时抛 :class:`Blocked`。
|
|
309
|
+
"""
|
|
310
|
+
dest = Path(dest)
|
|
311
|
+
limit = int(max_bytes)
|
|
312
|
+
client = (
|
|
313
|
+
_client()
|
|
314
|
+
if timeout is None
|
|
315
|
+
else httpx.AsyncClient(
|
|
316
|
+
timeout=httpx.Timeout(timeout, connect=min(5.0, timeout)),
|
|
317
|
+
follow_redirects=False,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
written = 0
|
|
321
|
+
try:
|
|
322
|
+
async with client:
|
|
323
|
+
resp = await _open(client, url)
|
|
324
|
+
ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
|
|
325
|
+
declared = resp.headers.get('content-length')
|
|
326
|
+
if limit > 0 and declared and declared.isdigit() and int(declared) > limit:
|
|
327
|
+
raise TooLarge(f'资源过大({int(declared)} 字节)')
|
|
328
|
+
# 这里就地把下载内容写进本地文件:调用方(预缓存)本来就是后台任务,
|
|
329
|
+
# 而每块只有 64 KB,比起「每块都切一次线程」的开销这样更划算。
|
|
330
|
+
handle = dest.open('wb') # noqa: ASYNC230
|
|
331
|
+
try:
|
|
332
|
+
async for chunk in resp.aiter_bytes(CHUNK_SIZE):
|
|
333
|
+
written += len(chunk)
|
|
334
|
+
if limit > 0 and written > limit:
|
|
335
|
+
raise TooLarge(f'资源过大(>{limit} 字节)')
|
|
336
|
+
handle.write(chunk)
|
|
337
|
+
finally:
|
|
338
|
+
handle.close()
|
|
339
|
+
except Exception:
|
|
340
|
+
dest.unlink(missing_ok=True)
|
|
341
|
+
raise
|
|
342
|
+
if not ctype:
|
|
343
|
+
ctype = mimetypes.guess_type(url)[0] or 'application/octet-stream'
|
|
344
|
+
return written, ctype
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def is_textual(content_type: str, name: str = '') -> bool:
|
|
348
|
+
"""判断一个资源是否适合当纯文本在线预览。"""
|
|
349
|
+
ctype = (content_type or '').split(';')[0].strip().lower()
|
|
350
|
+
if ctype.startswith('text/') or ctype in _TEXT_TYPES:
|
|
351
|
+
return True
|
|
352
|
+
# MIME 不可靠(很多文件源返回 application/octet-stream),按扩展名兜底
|
|
353
|
+
target = name or ''
|
|
354
|
+
try:
|
|
355
|
+
target = urlsplit(target).path or target
|
|
356
|
+
except ValueError: # pragma: no cover - 防御性
|
|
357
|
+
target = name or ''
|
|
358
|
+
dot = target.rfind('.')
|
|
359
|
+
suffix = target[dot:].lower() if dot >= 0 else ''
|
|
360
|
+
return suffix in _TEXT_SUFFIXES
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def decode_text(data: bytes) -> tuple[str, bool]:
|
|
364
|
+
"""把字节解码成文本,返回 ``(文本, 是否被截断)``。
|
|
365
|
+
|
|
366
|
+
先看 BOM 定 UTF-16/32,再依次尝试 UTF-8 / GB18030:中文聊天里发来的 txt
|
|
367
|
+
大多是这几种。不带 BOM 的 UTF-16 与 GB18030 无法可靠区分,这里优先按
|
|
368
|
+
GB18030 解(UTF-16 文件一般带 BOM)。都不行就退回 UTF-8 + 替换字符。
|
|
369
|
+
"""
|
|
370
|
+
truncated = len(data) > MAX_TEXT_BYTES
|
|
371
|
+
head = data[:MAX_TEXT_BYTES]
|
|
372
|
+
# 带 BOM 的 UTF-16/32:交给 utf-16 处理(它认得 BOM)
|
|
373
|
+
if head[:2] in (b'\xff\xfe', b'\xfe\xff'):
|
|
374
|
+
try:
|
|
375
|
+
return head.decode('utf-16'), truncated
|
|
376
|
+
except UnicodeDecodeError:
|
|
377
|
+
pass
|
|
378
|
+
for encoding in ('utf-8', 'gb18030'):
|
|
379
|
+
try:
|
|
380
|
+
return head.decode(encoding), truncated
|
|
381
|
+
except (UnicodeDecodeError, LookupError):
|
|
382
|
+
continue
|
|
383
|
+
return head.decode('utf-8', errors='replace'), truncated
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def fetch_text(url: str) -> tuple[str, bool, str, int] | None:
|
|
387
|
+
"""读取文本文件内容用于在线预览,返回 ``(文本, 被截断, 类型, 总字节数)``。
|
|
388
|
+
|
|
389
|
+
用 ``Range: bytes=0-N`` 只取前 :data:`MAX_TEXT_BYTES` 个字节,避免预览
|
|
390
|
+
一个几百 MB 的日志把内存和带宽吃光;服务端不支持 Range 时读完再截断。
|
|
391
|
+
总字节数取自 ``Content-Range`` / ``Content-Length``,拿不到时为 0。
|
|
392
|
+
"""
|
|
393
|
+
if not url or not url.startswith(('http://', 'https://')):
|
|
394
|
+
return None
|
|
395
|
+
range_header = {'Range': f'bytes=0-{MAX_TEXT_BYTES - 1}'}
|
|
396
|
+
try:
|
|
397
|
+
async with _client() as client:
|
|
398
|
+
resp = await _open(client, url, headers=range_header)
|
|
399
|
+
try:
|
|
400
|
+
data = await resp.aread()
|
|
401
|
+
finally:
|
|
402
|
+
await resp.aclose()
|
|
403
|
+
except Blocked as e:
|
|
404
|
+
logger.warning(str(e))
|
|
405
|
+
return None
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.debug(f'Failed to read text {url[:64]}: {e}')
|
|
408
|
+
return None
|
|
409
|
+
if not data:
|
|
410
|
+
return None
|
|
411
|
+
ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
|
|
412
|
+
text, truncated = decode_text(data)
|
|
413
|
+
# 服务端忽略 Range 时也要截断,避免超长内容拖垮前端
|
|
414
|
+
if len(data) >= MAX_TEXT_BYTES:
|
|
415
|
+
truncated = True
|
|
416
|
+
return text, truncated, ctype, _total_bytes(resp.headers)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _total_bytes(headers: Any) -> int:
|
|
420
|
+
"""从响应头里推断资源总字节数(拿不到返回 0)。"""
|
|
421
|
+
content_range = headers.get('content-range') or ''
|
|
422
|
+
if '/' in content_range:
|
|
423
|
+
tail = content_range.rsplit('/', 1)[-1].strip()
|
|
424
|
+
if tail.isdigit():
|
|
425
|
+
return int(tail)
|
|
426
|
+
length = headers.get('content-length')
|
|
427
|
+
return int(length) if length and length.isdigit() else 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def avatar_of(avatar: Any) -> str:
|
|
431
|
+
"""从 uninfo 的 Scene/User 头像字段里取出可用的 url"""
|
|
432
|
+
if not avatar:
|
|
433
|
+
return ''
|
|
434
|
+
if isinstance(avatar, str):
|
|
435
|
+
return avatar
|
|
436
|
+
if isinstance(avatar, dict):
|
|
437
|
+
return str(avatar.get('url') or avatar.get('src') or '')
|
|
438
|
+
return str(getattr(avatar, 'url', '') or '')
|