Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions nonebot_plugin_ddcheck/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from .config import Config
from .data_source import (
BilibiliPrivateFollowingsError,
get_attention_uids,
get_medal_list,
get_uid_by_name,
get_user_info,
Expand Down Expand Up @@ -49,20 +51,28 @@

@ddcheck.handle()
async def _(matcher: Matcher, name: str):
if name.isdigit():
uid = int(name)
query = name.strip()
if query.upper().startswith(("UID:", "UID:")):
query = query[4:].strip()

if query.isdigit():
uid = int(query)
else:
try:
uid = await get_uid_by_name(name)
uid = await get_uid_by_name(query)
except Exception:
logger.warning(traceback.format_exc())
await matcher.finish("获取用户信息失败,请检查名称或使用uid查询")

if not uid:
await matcher.finish(f"未找到名为 {name} 的用户")
await matcher.finish(f"未找到名为 {query} 的用户")

try:
user_info = await get_user_info(uid)
attentions = await get_attention_uids(uid)
user_info["attentions"] = attentions
except BilibiliPrivateFollowingsError:
await matcher.finish("该用户已将关注列表设为不可见,无法查询成分")
except Exception:
logger.warning(traceback.format_exc())
await matcher.finish("获取用户信息失败,请检查名称或稍后再试")
Expand Down
83 changes: 80 additions & 3 deletions nonebot_plugin_ddcheck/data_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
homepage_cookies: dict[str, str] = {}


class BilibiliPrivateFollowingsError(Exception):
"""Raised when Bilibili hides a user's following list."""


async def update_vtb_list():
vtb_list = []
urls = [
Expand Down Expand Up @@ -142,14 +146,85 @@ async def get_medal_list(uid: int) -> list[dict]:


async def get_user_info(uid: int) -> dict:
url = "https://account.bilibili.com/api/member/getCardByMid"
params = {"mid": uid}
# 旧接口 account.bilibili.com/api/member/getCardByMid 已失效。
# 新接口的用户卡片位于 result["data"]["card"]。
url = "https://api.bilibili.com/x/web-interface/card"
params = {"mid": uid, "photo": "1"}

async with httpx.AsyncClient(timeout=10) as client:
cookies.update(await get_homepage_cookies(client))
resp = await client.get(url, params=params, headers=HEADERS, cookies=cookies)
resp.raise_for_status()
cookies.update(resp.cookies)

result = resp.json()
return result["card"]

if result.get("code") != 0:
raise RuntimeError(
f"Bilibili user-info API failed: "
f"{result.get('code')} {result.get('message')}"
)

data = result.get("data")
if not isinstance(data, dict) or not isinstance(data.get("card"), dict):
raise RuntimeError("Bilibili user-info API returned no card data")

return data["card"]


async def get_attention_uids(uid: int) -> list[int]:
"""Return a user's followed-account UIDs from Bilibili's dedicated API."""
url = "https://api.bilibili.com/x/relation/followings"
attention_uids: list[int] = []
follows_total: Optional[int] = None

async with httpx.AsyncClient(timeout=10) as client:
for page in range(1, 41):
params = {"vmid": uid, "pn": page, "ps": 50, "order": "desc"}
cookies.update(await get_homepage_cookies(client))
resp = await client.get(url, params=params, headers=HEADERS, cookies=cookies)
resp.raise_for_status()
cookies.update(resp.cookies)
result = resp.json()

if result.get("code") != 0:
if result.get("code") == 22115:
raise BilibiliPrivateFollowingsError
raise RuntimeError(
"Bilibili followings API failed: "
f"{result.get('code')} {result.get('message')}"
)

data = result.get("data")
if not isinstance(data, dict):
raise RuntimeError("Bilibili followings API returned invalid data")

followings = data.get("list")
if followings is None:
# Bilibili returns null when the follow list is private.
return []
if not isinstance(followings, list):
raise RuntimeError("Bilibili followings API returned invalid list")

if isinstance(data.get("total"), int):
follows_total = data["total"]

attention_uids.extend(int(following["mid"]) for following in followings)
if not followings or (
follows_total is not None and len(attention_uids) >= follows_total
):
break
else:
logger.warning(f"Only fetched the first 2000 followings for UID {uid}")

attention_uids = list(dict.fromkeys(attention_uids))
if follows_total is not None and len(attention_uids) < follows_total:
logger.warning(
f"Only got {len(attention_uids)}/{follows_total} followings for UID {uid}"
)
else:
logger.info(f"Got {len(attention_uids)} Bilibili followings for UID {uid}")
return attention_uids


def format_color(color: int) -> str:
Expand Down Expand Up @@ -193,6 +268,8 @@ async def render_ddcheck_image(
"fans": user_info["fans"],
"follows": follows_num,
"percent": f"{percent:.2f}% ({vtbs_num}/{follows_num})",
"partial_followings": 0 < len(attentions) < follows_num,
"followings_fetched": len(attentions),
"vtbs": vtbs,
"num_per_col": num_per_col,
}
Expand Down
10 changes: 10 additions & 0 deletions nonebot_plugin_ddcheck/template/info.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@
margin-top: 5px;
font-weight: bold;
}
.notice {
margin-top: 5px;
max-width: 300px;
color: #c0392b;
font-size: small;
text-align: center;
}
.list {
margin-top: 10px;
margin-right: 10px;
Expand Down Expand Up @@ -157,6 +164,9 @@
<div class="follows"><b>关注:</b>{{ info['follows'] }}</div>
</div>
<div class="data-bar">{{ info['percent'] }}</div>
{% if info['partial_followings'] %}
<div class="notice">注意:B站仅公开 {{ info['followings_fetched'] }}/{{ info['follows'] }} 个关注,成分结果可能不完整</div>
{% endif %}
</div>
</div>
<div class="list">
Expand Down