feat(publish): add bilibili and qiehao desktop adapters

Wire bilibili and qiehao (om.qq.com) into the publish adapter registry
and auth-failure classifier. Bilibili bind uses WBI-signed space-info
fallback to retrieve the avatar when nav lacks a face URL, and the
account list image renders with a no-referrer policy so bilibili CDN
serves the avatar cross-origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:57:04 +08:00
parent 2044e179e1
commit 0ce3f51f62
9 changed files with 1429 additions and 5 deletions
+126 -2
View File
@@ -175,6 +175,17 @@ type BilibiliNavResponse = {
mid?: string | number;
uname?: string;
face?: string;
wbi_img?: {
img_url?: string;
sub_url?: string;
};
};
};
type BilibiliSpaceInfoResponse = {
code?: number;
data?: {
face?: string;
};
};
@@ -218,6 +229,17 @@ const MAX_CONCURRENT_BIND_WINDOWS = 2;
const TOUTIAO_LOGIN_URL = "https://mp.toutiao.com/auth/page/login";
const TOUTIAO_CONSOLE_HOME_URL = "https://mp.toutiao.com/profile_v4/index";
const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i;
const BILIBILI_API_ORIGIN = "https://api.bilibili.com";
const BILIBILI_WBI_MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32,
15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19,
29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63,
57, 62, 11, 36, 20, 34, 44, 52,
];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -276,6 +298,59 @@ function hashText(value: string): string {
return createHash("sha1").update(value).digest("hex").slice(0, 20);
}
function buildBilibiliMixinKey(imgKey: string, subKey: string): string {
const raw = imgKey + subKey;
return BILIBILI_WBI_MIXIN_KEY_ENC_TAB
.map((index) => raw.charAt(index))
.filter(Boolean)
.join("")
.slice(0, 32);
}
function bilibiliKeyFromWbiURL(value: string | undefined): string {
const source = value?.trim() || "";
if (!source) {
return "";
}
const slashIndex = source.lastIndexOf("/");
const dotIndex = source.lastIndexOf(".");
if (slashIndex < 0 || dotIndex <= slashIndex) {
return "";
}
return source.slice(slashIndex + 1, dotIndex);
}
function buildBilibiliWbiQuery(
nav: BilibiliNavResponse | null | undefined,
params: Record<string, string | number>,
): string | null {
const imgKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.img_url);
const subKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.sub_url);
if (!imgKey || !subKey) {
return null;
}
const mixinKey = buildBilibiliMixinKey(imgKey, subKey);
const next: Record<string, string | number> = {
...params,
wts: Math.round(Date.now() / 1000),
};
const illegalChars = /[!'()*]/g;
const query = Object.keys(next)
.sort()
.map((key) => {
const value = String(next[key]).replace(illegalChars, "");
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join("&");
const wRid = createHash("md5").update(`${query}${mixinKey}`).digest("hex");
return `${query}&w_rid=${wRid}`;
}
function normalizeBilibiliAvatarUrl(value?: string | null): string | null {
return normalizeRemoteUrl(value)?.replace(/^http:\/\//i, "https://") ?? null;
}
function boundedIdentity(prefix: string, raw: string): string {
const normalizedPrefix = prefix.trim().replace(/:+$/, "");
const normalizedRaw = raw.trim();
@@ -2242,11 +2317,56 @@ async function detectJianshu({ session }: DetectContext): Promise<DetectedAccoun
};
}
async function fetchBilibiliSpaceAvatar(
session: Session,
uid: string,
nav: BilibiliNavResponse | null,
cookie: string,
): Promise<string | null> {
const query = buildBilibiliWbiQuery(nav, {
mid: uid,
platform: "web",
token: "",
web_location: 1550101,
});
if (!query) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 1800);
try {
const response = await sessionFetchJson<BilibiliSpaceInfoResponse>(
session,
`${BILIBILI_API_ORIGIN}/x/space/wbi/acc/info?${query}`,
{
signal: controller.signal,
headers: {
accept: "application/json, text/plain, */*",
referer: `https://space.bilibili.com/${encodeURIComponent(uid)}`,
...(cookie ? { cookie } : {}),
},
},
).catch(() => null);
if (response?.code !== 0) {
return null;
}
return normalizeBilibiliAvatarUrl(response.data?.face);
} finally {
clearTimeout(timeout);
}
}
async function detectBilibili({ session }: DetectContext): Promise<DetectedAccount | null> {
const cookie = await sessionCookieHeader(session, "bilibili.com");
const response = await sessionFetchJson<BilibiliNavResponse>(
session,
"https://api.bilibili.com/x/web-interface/nav?build=0&mobi_app=web",
`${BILIBILI_API_ORIGIN}/x/web-interface/nav?build=0&mobi_app=web`,
{
headers: {
accept: "application/json, text/plain, */*",
@@ -2261,10 +2381,14 @@ async function detectBilibili({ session }: DetectContext): Promise<DetectedAccou
return null;
}
const avatarUrl =
normalizeBilibiliAvatarUrl(user.face)
?? await fetchBilibiliSpaceAvatar(session, uid, response, cookie);
return {
platformUid: uid,
displayName: user.uname,
avatarUrl: normalizeRemoteUrl(user.face),
avatarUrl,
};
}