diff --git a/nonebot_plugin_ddcheck/__init__.py b/nonebot_plugin_ddcheck/__init__.py index 8e966fc..c8d989f 100644 --- a/nonebot_plugin_ddcheck/__init__.py +++ b/nonebot_plugin_ddcheck/__init__.py @@ -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, @@ -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("获取用户信息失败,请检查名称或稍后再试") diff --git a/nonebot_plugin_ddcheck/data_source.py b/nonebot_plugin_ddcheck/data_source.py index 78f85e7..6c515fd 100644 --- a/nonebot_plugin_ddcheck/data_source.py +++ b/nonebot_plugin_ddcheck/data_source.py @@ -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 = [ @@ -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: @@ -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, } diff --git a/nonebot_plugin_ddcheck/template/info.html b/nonebot_plugin_ddcheck/template/info.html index 741b6fc..d9cce38 100644 --- a/nonebot_plugin_ddcheck/template/info.html +++ b/nonebot_plugin_ddcheck/template/info.html @@ -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; @@ -157,6 +164,9 @@
关注:{{ info['follows'] }}
{{ info['percent'] }}
+ {% if info['partial_followings'] %} +
注意:B站仅公开 {{ info['followings_fetched'] }}/{{ info['follows'] }} 个关注,成分结果可能不完整
+ {% endif %}