diff --git a/README.md b/README.md index f0771ae..745b3eb 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,27 @@ Instant View 代理服务。为 Telegram 等支持 Instant View 的客户端提供网页的可读页面。 -目前支持 Bilibili 部分页面,后续会接入更多站点。 +目前支持 Bilibili 和 Weibo 部分页面,后续会接入更多站点。 ## 支持的站点与路由 ### Bilibili -| 路由 | 说明 | 示例 | -| -------------- | ------------------------------ | --------------------------- | -| `/video/:bvid` | 视频(含分P信息和热门评论) | `/video/BV1GJ411x7h7` | -| `/t/:id` | 动态(图文、纯文字、视频动态) | `/t/1141736312467882000` | -| `/opus/:id` | 专栏文章(图文混排) | `/opus/1056353752004427792` | +| 路由 | 说明 | 示例 | +| ---------------- | ------------------------------ | --------------------------- | +| `/b/video/:bvid` | 视频(含分P信息和热门评论) | `/b/video/BV1GJ411x7h7` | +| `/b/t/:id` | 动态(图文、纯文字、视频动态) | `/b/t/1141736312467882000` | +| `/b/opus/:id` | 专栏文章(图文混排) | `/b/opus/1056353752004427792` | + +### Weibo + +| 路由 | 说明 | 示例 | +| -------------------- | ------------------ | --------------------------------- | +| `/w/status/:id` | 微博动态/视频详情 | `/w/status/5303880858734627` | +| `/w/u/:uid` | 用户主页 | `/w/u/7643376782` | +| `/w/u/:uid?tabtype=album` | 用户相册 | `/w/u/7643376782?tabtype=album` | + +支持 bid 和数字 ID,图片自动转 base64 内嵌(绕过防盗链),视频直接播放,用户主页含封面背景、置顶微博和近期微博。 ## 技术栈 @@ -26,19 +36,26 @@ Instant View 代理服务。为 Telegram 等支持 Instant View 的客户端提 ``` src/ ├── index.ts # Hono 路由入口 -└── bilibili/ - ├── errors.ts # BilibiliApiError 错误类 - ├── headers.ts # 请求头与 buvid3 Cookie 管理 - ├── opus.ts # 专栏文章数据获取与解析 - ├── render.ts # HTML 页面渲染(含 Open Graph meta) - ├── timeline.ts # 动态数据获取与解析 - ├── utils.ts # 工具函数(safeJson) - ├── video.ts # 视频信息与评论获取 - └── wbi.ts # WBI 签名 +├── bilibili/ +│ ├── errors.ts # BilibiliApiError 错误类 +│ ├── headers.ts # 请求头与 buvid3 Cookie 管理 +│ ├── opus.ts # 专栏文章数据获取与解析 +│ ├── render.ts # HTML 页面渲染(含 Open Graph meta) +│ ├── timeline.ts # 动态数据获取与解析 +│ ├── utils.ts # 工具函数(safeJson) +│ ├── video.ts # 视频信息与评论获取 +│ └── wbi.ts # WBI 签名 +└── weibo/ + ├── errors.ts # WeiboApiError 错误类 + ├── headers.ts # 请求头与访客 Cookie 管理(m.weibo.cn) + ├── status.ts # 微博动态/视频数据获取与解析 + ├── user.ts # 用户主页数据获取与解析 + └── render.ts # HTML 页面渲染(含 Open Graph meta) api/ └── index.ts # Vercel Edge Function 入口 test/ -└── bilibili/ # 单元测试与 fixtures +├── bilibili/ # Bilibili 单元测试与 fixtures +└── weibo/ # Weibo 单元测试与 fixtures ``` ## 本地开发 diff --git a/src/index.ts b/src/index.ts index 0659153..31f3512 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,17 +8,59 @@ import { } from "./bilibili/render"; import { getTimelineInfo } from "./bilibili/timeline"; import { getComments, getVideoInfo } from "./bilibili/video"; +import { WeiboApiError } from "./weibo/errors"; +import { + renderAlbumPage, + renderStatusPage, + renderUserPage, +} from "./weibo/render"; +import { getStatusInfo } from "./weibo/status"; +import { getAlbumInfo, getUserInfo } from "./weibo/user"; const app = new Hono(); export { app }; app.get("/", (c) => { return c.text( - "Instant View Service. Use /video/:bvid, /t/:id, or /opus/:id for Bilibili.", + "Instant View Service.\nBilibili: /b/video/:bvid, /b/t/:id, /b/opus/:id\nWeibo: /w/status/:id, /w/u/:uid", ); }); -app.get("/video/:bvid", async (c) => { +// Image proxy to bypass Weibo/Bilibili hotlink protection +app.get("/proxy/image", async (c) => { + const url = c.req.query("url"); + if (!url) { + return c.text("Missing url parameter", 400); + } + + try { + const resp = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + Referer: "https://m.weibo.cn/", + }, + }); + + if (!resp.ok) { + return c.text(`Failed to fetch image: ${resp.status}`, resp.status); + } + + const contentType = resp.headers.get("content-type") || "image/jpeg"; + const body = await resp.arrayBuffer(); + + return new Response(body, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }, + }); + } catch (e) { + return c.text(`Proxy error: ${e}`, 502); + } +}); + +app.get("/b/video/:bvid", async (c) => { const bvid = c.req.param("bvid"); const video = await getVideoInfo(bvid); @@ -32,7 +74,7 @@ app.get("/video/:bvid", async (c) => { return c.html(html); }); -app.get("/t/:id", async (c) => { +app.get("/b/t/:id", async (c) => { const id = c.req.param("id"); const timeline = await getTimelineInfo(id); @@ -44,7 +86,7 @@ app.get("/t/:id", async (c) => { return c.html(html); }); -app.get("/opus/:id", async (c) => { +app.get("/b/opus/:id", async (c) => { const id = c.req.param("id"); // ID is a string (large number) const opus = await getOpusInfo(id); @@ -57,6 +99,39 @@ app.get("/opus/:id", async (c) => { return c.html(html); }); +app.get("/w/status/:id", async (c) => { + const id = c.req.param("id"); + const status = await getStatusInfo(id); + + if (!status) { + return c.notFound(); + } + + const html = await renderStatusPage(status); + return c.html(html); +}); + +app.get("/w/u/:uid", async (c) => { + const uid = c.req.param("uid"); + const tabtype = c.req.query("tabtype"); + + if (tabtype === "album") { + const album = await getAlbumInfo(uid); + if (!album) return c.notFound(); + const html = await renderAlbumPage(album); + return c.html(html); + } + + const user = await getUserInfo(uid); + + if (!user) { + return c.notFound(); + } + + const html = await renderUserPage(user); + return c.html(html); +}); + app.notFound((c) => { return c.html( ` @@ -99,6 +174,28 @@ app.onError((err, c) => { ); } + if (err instanceof WeiboApiError) { + return c.html( + ` + + + Weibo API Error + +

Weibo API Error

+

The upstream Weibo API returned an error.

+
+

Code: ${err.code}

+

Message: ${err.message}

+
+
+ Go Home + + + `, + 502, + ); + } + return c.html( ` diff --git a/src/weibo/errors.ts b/src/weibo/errors.ts new file mode 100644 index 0000000..116e7b5 --- /dev/null +++ b/src/weibo/errors.ts @@ -0,0 +1,11 @@ +export class WeiboApiError extends Error { + code: number; + data: any; + + constructor(message: string, code: number, data?: any) { + super(message); + this.name = "WeiboApiError"; + this.code = code; + this.data = data; + } +} diff --git a/src/weibo/headers.ts b/src/weibo/headers.ts new file mode 100644 index 0000000..b4d0970 --- /dev/null +++ b/src/weibo/headers.ts @@ -0,0 +1,121 @@ +import { safeJson } from "../bilibili/utils"; + +const USER_AGENT = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + +const VISITOR_HOST = "https://visitor.passport.weibo.cn"; + +let cachedCookie: string | null = null; +let lastFetch = 0; + +function mergeCookies(cookieMap: Record, setCookies: string[]) { + for (const c of setCookies) { + const m = c.match(/^([^=]+)=([^;]*)/); + if (m && m[2] !== "deleted") { + cookieMap[m[1].trim()] = m[2].trim(); + } + } +} + +function toCookieStr(cookieMap: Record): string { + return Object.entries(cookieMap) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +async function fetchVisitorCookie(): Promise { + const cookieMap: Record = {}; + + // Step 1: Visit m.weibo.cn to get WEIBOCN_FROM cookie + const homeResp = await fetch("https://m.weibo.cn/", { + headers: { "User-Agent": USER_AGENT }, + redirect: "manual", + }); + mergeCookies(cookieMap, homeResp.headers.getSetCookie?.() ?? []); + + // Step 2: genvisitor on visitor.passport.weibo.cn (domain .weibo.cn) + const genResp = await fetch(`${VISITOR_HOST}/visitor/genvisitor`, { + method: "POST", + headers: { + "User-Agent": USER_AGENT, + "Content-Type": "application/x-www-form-urlencoded", + Referer: "https://m.weibo.cn/", + Cookie: toCookieStr(cookieMap), + }, + body: new URLSearchParams({ + cb: "gen_callback", + fp: JSON.stringify({ + os: "2", + browser: "Safari,17,0,0", + fonts: "undefined", + screenInfo: "375*812*30", + plugins: "", + }), + }), + }); + mergeCookies(cookieMap, genResp.headers.getSetCookie?.() ?? []); + const genText = await genResp.text(); + const genMatch = genText.match(/gen_callback\((.+)\)/s); + if (!genMatch) throw new Error("Failed to parse genvisitor response"); + const tid = JSON.parse(genMatch[1])?.data?.tid; + if (!tid) throw new Error("No tid from genvisitor"); + + // Step 3: incarnate to get SUB (domain .weibo.cn) + const incResp = await fetch( + `${VISITOR_HOST}/visitor/visitor?a=incarnate&t=${tid}&w=2&c=100&gc=&cb=cross_domain&from=weibo&_rand=${Date.now()}`, + { + headers: { + "User-Agent": USER_AGENT, + Referer: "https://m.weibo.cn/", + Cookie: toCookieStr(cookieMap), + }, + }, + ); + mergeCookies(cookieMap, incResp.headers.getSetCookie?.() ?? []); + const incText = await incResp.text(); + const incMatch = incText.match(/cross_domain\((.+)\)/s); + if (incMatch) { + const incData = JSON.parse(incMatch[1]); + if (incData?.data?.sub) { + cookieMap.SUB = incData.data.sub; + if (incData.data.subp) cookieMap.SUBP = incData.data.subp; + } + } + + // Step 4: Visit m.weibo.cn with SUB to get _T_WM + const revisitResp = await fetch("https://m.weibo.cn/", { + headers: { + "User-Agent": USER_AGENT, + Cookie: toCookieStr(cookieMap), + }, + redirect: "manual", + }); + mergeCookies(cookieMap, revisitResp.headers.getSetCookie?.() ?? []); + + return toCookieStr(cookieMap); +} + +export async function getHeaders(): Promise> { + const now = Date.now(); + if (!cachedCookie || now - lastFetch > 3600000) { + try { + cachedCookie = await fetchVisitorCookie(); + lastFetch = now; + } catch (e) { + console.error("Failed to fetch Weibo visitor cookie:", e); + if (!cachedCookie) cachedCookie = ""; + } + } + + return { + "User-Agent": USER_AGENT, + Accept: "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + Referer: "https://m.weibo.cn/", + "X-Requested-With": "XMLHttpRequest", + "MWeibo-Pwa": "1", + Cookie: cachedCookie, + }; +} + +export { safeJson }; diff --git a/src/weibo/render.ts b/src/weibo/render.ts new file mode 100644 index 0000000..f65642e --- /dev/null +++ b/src/weibo/render.ts @@ -0,0 +1,384 @@ +import type { WeiboStatusInfo } from "./status"; +import type { WeiboAlbumInfo, WeiboUserInfo } from "./user"; + +/** + * Fetch an image and return as base64 data URL. + * Caches results within a single render call via the shared cache map. + */ +async function imageToBase64( + url: string, + cache: Map, +): Promise { + if (!url) return url; + if (cache.has(url)) return cache.get(url)!; + try { + const resp = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + Referer: "https://m.weibo.cn/", + }, + }); + if (!resp.ok) return url; + const contentType = resp.headers.get("content-type") || "image/jpeg"; + const buf = await resp.arrayBuffer(); + const b64 = Buffer.from(buf).toString("base64"); + const dataUrl = `data:${contentType};base64,${b64}`; + cache.set(url, dataUrl); + return dataUrl; + } catch { + return url; + } +} + +/** + * Upgrade sinaimg.cn URLs to their full-resolution version. + * Weibo serves cropped/scaled variants via path prefixes like: + * crop.0.0.640.640.640/ thumbnail/ square/ bmiddle/ orj480/ orj960/ + * mw600/ mw690/ mw1024/ thumb150/ thumb180/ + * Replacing with `large/` returns the original uncropped image. + */ +function upgradeSinaImage(url: string): string { + if (!url) return url; + return url.replace( + /^(https?:\/\/[^/]*sinaimg\.cn\/)(?:crop\.\d+(?:\.\d+){4}|thumbnail|square|bmiddle|orj480|orj960|mw600|mw690|mw1024|mw2000|thumb150|thumb180)\/(.+)$/, + "$1large/$2", + ); +} + +/** + * Replace all sinaimg.cn image URLs in HTML with base64 data URLs. + */ +async function rewriteImageUrls( + html: string, + cache: Map, +): Promise { + const urls = new Set(); + const regex = /src=["'](https?:\/\/[^"']*sinaimg\.cn[^"']*?)["']/g; + let m: RegExpExecArray | null; + while ((m = regex.exec(html)) !== null) { + urls.add(m[1]); + } + let result = html; + for (const url of urls) { + const dataUrl = await imageToBase64(url, cache); + result = result.split(url).join(dataUrl); + } + return result; +} + +export async function renderStatusPage( + status: WeiboStatusInfo, +): Promise { + const cache = new Map(); + const statusUrl = `https://weibo.com/${status.bid ? `detail/${status.bid}` : `detail/${status.id}`}`; + const plainText = stripHtml(status.textHtml); + + const videoPosterUrl = status.videoPoster + ? await imageToBase64(status.videoPoster, cache) + : ""; + const videoHtml = status.videoUrl + ? `
` + : ""; + + const imagesHtmlParts: string[] = []; + for (const img of status.images) { + const dataUrl = await imageToBase64(img, cache); + imagesHtmlParts.push( + ``, + ); + } + const imagesHtml = imagesHtmlParts.join(""); + + const playCountHtml = status.playCount ? ` | ${status.playCount}` : ""; + + const sourceHtml = status.source + ? `来自 ${stripHtml(status.source)}` + : ""; + + const avatarUrl = await imageToBase64(status.author.avatar, cache); + const textHtml = await rewriteImageUrls(status.textHtml, cache); + + return ` + + + + + ${escapeHtml(plainText.substring(0, 50) || status.videoTitle || "微博")} + + + + + + + + ${status.videoUrl ? `` : ""} + + + + + + + + + +

${status.videoTitle ? escapeHtml(status.videoTitle) : ""}

+
+
+ ${escapeHtml(status.author.name)} + ${escapeHtml(status.author.name)} +
+ | 转发: ${status.stat.reposts} | 评论: ${status.stat.comments} | 点赞: ${status.stat.attitudes}${playCountHtml} +
+ +
+ ${textHtml} + ${videoHtml} + ${imagesHtml ? `
${imagesHtml}` : ""} +
+ + ${sourceHtml} + +

+ 查看原微博 + +`; +} + +export async function renderUserPage(user: WeiboUserInfo): Promise { + const cache = new Map(); + const userUrl = `https://weibo.com/u/${user.uid}`; + + const coverDataUrl = user.coverImage + ? await imageToBase64(upgradeSinaImage(user.coverImage), cache) + : ""; + const avatarDataUrl = await imageToBase64( + user.avatarHd || user.avatar, + cache, + ); + + const renderStatusCard = async (s: { + coverImage: string | null; + videoTitle: string | null; + bid: string | null; + id: string; + textHtml: string; + reposts: number; + comments: number; + attitudes: number; + }) => { + const coverHtml = s.coverImage + ? `` + : ""; + const titleHtml = s.videoTitle + ? `
${escapeHtml(s.videoTitle)}
` + : ""; + const statusLink = s.bid + ? `https://weibo.com/detail/${s.bid}` + : `https://weibo.com/detail/${s.id}`; + + return `
+ + ${coverHtml} + ${titleHtml} +
${truncateHtml(s.textHtml, 100)}
+
+
+ 转发 ${s.reposts} + 评论 ${s.comments} + 点赞 ${s.attitudes} +
+
`; + }; + + const pinnedCards = await Promise.all( + user.pinnedStatuses.map(renderStatusCard), + ); + const pinnedHtml = + pinnedCards.length > 0 + ? `
置顶微博
${pinnedCards.join("")}` + : ""; + + const recentCards = await Promise.all( + user.recentStatuses.map(renderStatusCard), + ); + const recentStatusesHtml = + recentCards.length > 0 + ? recentCards.join("") + : '
暂无微博
'; + + const coverBg = coverDataUrl + ? `background-image: url('${coverDataUrl}');` + : `background: linear-gradient(135deg,#ff8200,#ff5500);`; + + return ` + + + + + ${escapeHtml(user.name)}的微博主页 + + + + + + + + + + + + + + + + + + +
+
+
+
+ ${escapeHtml(user.name)} +

${escapeHtml(user.name)}

+ ${user.verified && user.verifiedReason ? `
V ${escapeHtml(user.verifiedReason)}
` : ""} + ${user.description ? `
${escapeHtml(user.description)}
` : ""} +
+
+
${escapeHtml(user.followersCount)}
+
粉丝
+
+
+
${user.followCount}
+
关注
+
+
+
${user.statusesCount}
+
微博
+
+
+
+
+
+ + ${pinnedHtml} + +
近期微博
+ ${recentStatusesHtml} + + + +`; +} + +export async function renderAlbumPage(album: WeiboAlbumInfo): Promise { + const cache = new Map(); + const userUrl = `https://weibo.com/u/${album.user.uid}`; + + const picHtmlParts: string[] = []; + for (const item of album.items) { + const dataUrl = await imageToBase64(item.picSmall, cache); + const linkUrl = item.bid ? `https://weibo.com/detail/${item.bid}` : "#"; + picHtmlParts.push( + ``, + ); + } + const picsHtml = + picHtmlParts.length > 0 + ? picHtmlParts.join("") + : '
暂无图片
'; + + return ` + + + + + ${escapeHtml(album.user.name)}的相册 + + + + +
+ ${escapeHtml(album.user.name)} +

${escapeHtml(album.user.name)}的相册

+
+
+ ${picsHtml} +
+ + +`; +} + +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function truncateHtml(html: string, maxLen: number): string { + const text = stripHtml(html); + if (text.length <= maxLen) return escapeHtml(text); + return escapeHtml(text.substring(0, maxLen)) + "..."; +} diff --git a/src/weibo/status.ts b/src/weibo/status.ts new file mode 100644 index 0000000..c9aec23 --- /dev/null +++ b/src/weibo/status.ts @@ -0,0 +1,140 @@ +import { safeJson } from "../bilibili/utils"; +import { WeiboApiError } from "./errors"; +import { getHeaders } from "./headers"; + +export interface WeiboStatusInfo { + id: string; + textHtml: string; + images: string[]; + videoUrl: string | null; + videoTitle: string | null; + videoPoster: string | null; + playCount: string | null; + author: { + name: string; + avatar: string; + profileUrl: string; + }; + stat: { + reposts: number; + comments: number; + attitudes: number; + }; + created_at: string; + bid: string | null; + source: string | null; + isLongText: boolean; + longTextContent: string | null; +} + +function parseMblog(mblog: any): WeiboStatusInfo { + const pageInfo = mblog.page_info; + const videoUrl = + pageInfo?.media_info?.stream_url_hd || + pageInfo?.media_info?.stream_url || + pageInfo?.urls?.mp4_720p_mp4 || + pageInfo?.urls?.mp4_hd_mp4 || + null; + + const images: string[] = []; + if (mblog.pic_ids && Array.isArray(mblog.pic_ids)) { + // pic_ids are IDs; we need pic_infos for URLs + if (mblog.pic_infos) { + for (const picId of mblog.pic_ids) { + const pic = mblog.pic_infos[picId]; + if (pic?.url) images.push(pic.url); + } + } + } + // Also check pics array + if (mblog.pics && Array.isArray(mblog.pics)) { + for (const pic of mblog.pics) { + if (pic?.large?.url) images.push(pic.large.url); + else if (pic?.url) images.push(pic.url); + } + } + + return { + id: mblog.id?.toString() || mblog.mid?.toString() || "", + textHtml: mblog.text || "", + images, + videoUrl, + videoTitle: pageInfo?.title || null, + videoPoster: pageInfo?.page_pic?.url || null, + playCount: pageInfo?.play_count || null, + author: { + name: mblog.user?.screen_name || "", + avatar: mblog.user?.profile_image_url || "", + profileUrl: mblog.user?.profile_url || "", + }, + stat: { + reposts: mblog.reposts_count || 0, + comments: mblog.comments_count || 0, + attitudes: mblog.attitudes_count || 0, + }, + created_at: mblog.created_at || "", + bid: mblog.bid || null, + source: mblog.source || null, + isLongText: mblog.isLongText || false, + longTextContent: null, + }; +} + +export async function getStatusInfo( + id: string, +): Promise { + try { + // Use statuses/show which returns full mblog data including page_info (video/images) + const url = `https://m.weibo.cn/statuses/show?id=${id}`; + const response = await fetch(url, { + headers: await getHeaders(), + }); + + if (!response.ok) { + throw new WeiboApiError( + `HTTP Error: ${response.status}`, + response.status, + ); + } + + const data = (await safeJson(response)) as any; + + if (data.ok === 1 && data.data) { + const mblog = data.data; + const info = parseMblog(mblog); + + // Fetch long text if needed + if (info.isLongText && !info.longTextContent) { + try { + const extendUrl = `https://m.weibo.cn/statuses/extend?id=${id}`; + const extendResp = await fetch(extendUrl, { + headers: await getHeaders(), + }); + if (extendResp.ok) { + const extendData = (await safeJson(extendResp)) as any; + if (extendData.ok === 1 && extendData.data?.longTextContent) { + info.textHtml = extendData.data.longTextContent; + info.longTextContent = extendData.data.longTextContent; + } + } + } catch { + // Ignore long text fetch errors + } + } + + return info; + } + + if (data.ok !== 1) { + throw new WeiboApiError(data.msg || "Weibo API Error", data.ok, data); + } + + return null; + } catch (error) { + if (error instanceof WeiboApiError) { + throw error; + } + console.error("Fetch Error (Weibo Status):", error); + throw error; + } +} diff --git a/src/weibo/user.ts b/src/weibo/user.ts new file mode 100644 index 0000000..d7cb3c8 --- /dev/null +++ b/src/weibo/user.ts @@ -0,0 +1,218 @@ +import { safeJson } from "../bilibili/utils"; +import { WeiboApiError } from "./errors"; +import { getHeaders } from "./headers"; + +export interface WeiboRecentStatus { + id: string; + textHtml: string; + createdAt: string; + bid: string | null; + reposts: number; + comments: number; + attitudes: number; + coverImage: string | null; + videoTitle: string | null; +} + +export interface WeiboUserInfo { + uid: string; + name: string; + description: string; + avatar: string; + avatarHd: string; + profileUrl: string; + followersCount: string; + followCount: number; + statusesCount: number; + gender: "m" | "f" | "n"; + verified: boolean; + verifiedReason: string | null; + coverImage: string | null; + pinnedStatuses: WeiboRecentStatus[]; + recentStatuses: WeiboRecentStatus[]; +} + +function parseRecentStatus(mblog: any): WeiboRecentStatus { + const pageInfo = mblog.page_info; + return { + id: mblog.id?.toString() || "", + textHtml: mblog.text || "", + createdAt: mblog.created_at || "", + bid: mblog.bid || null, + reposts: mblog.reposts_count || 0, + comments: mblog.comments_count || 0, + attitudes: mblog.attitudes_count || 0, + coverImage: pageInfo?.page_pic?.url || null, + videoTitle: pageInfo?.title || null, + }; +} + +export interface WeiboAlbumItem { + picSmall: string; + picBig: string; + pid: string; + bid: string | null; +} + +export interface WeiboAlbumInfo { + user: { + uid: string; + name: string; + avatar: string; + avatarHd: string; + }; + items: WeiboAlbumItem[]; +} + +export async function getAlbumInfo( + uid: string, +): Promise { + try { + // Fetch user profile for header info + const profileUrl = `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}`; + const profileResp = await fetch(profileUrl, { + headers: await getHeaders(), + }); + + let user = { uid, name: "", avatar: "", avatarHd: "" }; + if (profileResp.ok) { + const profileData = (await safeJson(profileResp)) as any; + if (profileData.ok === 1 && profileData.data?.userInfo) { + const u = profileData.data.userInfo; + user = { + uid: u.id?.toString() || uid, + name: u.screen_name || "", + avatar: u.profile_image_url || "", + avatarHd: u.avatar_hd || u.profile_image_url || "", + }; + } + } + + // Fetch album container (107803 + uid) + const albumUrl = `https://m.weibo.cn/api/container/getIndex?containerid=107803${uid}&page=1`; + const albumResp = await fetch(albumUrl, { + headers: await getHeaders(), + }); + + if (!albumResp.ok) return { user, items: [] }; + + const albumData = (await safeJson(albumResp)) as any; + if (albumData.ok !== 1) return { user, items: [] }; + + const items: WeiboAlbumItem[] = []; + const cards = albumData.data?.cards || []; + for (const card of cards) { + if (card.card_type === 11 && card.card_group) { + for (const g of card.card_group) { + if (g.card_type === 47 && g.pics) { + for (const pic of g.pics) { + items.push({ + picSmall: pic.pic_middle || pic.pic_small || "", + picBig: pic.pic_big || pic.pic_mw2000 || pic.pic_middle || "", + pid: pic.pic_id || "", + bid: g.mblog?.bid || null, + }); + } + } + } + } + } + + return { user, items }; + } catch (error) { + if (error instanceof WeiboApiError) throw error; + console.error("Fetch Error (Weibo Album):", error); + throw error; + } +} + +export async function getUserInfo(uid: string): Promise { + try { + // Step 1: Get user profile + const profileUrl = `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}`; + const response = await fetch(profileUrl, { + headers: await getHeaders(), + }); + + if (!response.ok) { + throw new WeiboApiError( + `HTTP Error: ${response.status}`, + response.status, + ); + } + + const data = (await safeJson(response)) as any; + + if (data.ok !== 1 || !data.data?.userInfo) { + if (data.ok !== 1) { + throw new WeiboApiError(data.msg || "Weibo API Error", data.ok, data); + } + return null; + } + + const u = data.data.userInfo; + + // Step 2: Get feed containerid from tabsInfo + const feedTab = data.data?.tabsInfo?.tabs?.find( + (t: any) => t.tabKey === "weibo", + ); + const feedContainerId = feedTab?.containerid || `107603${uid}`; + + // Step 3: Fetch statuses (pinned + recent) + let pinnedStatuses: WeiboRecentStatus[] = []; + let recentStatuses: WeiboRecentStatus[] = []; + try { + const feedUrl = `https://m.weibo.cn/api/container/getIndex?containerid=${feedContainerId}&page=1`; + const feedResp = await fetch(feedUrl, { + headers: await getHeaders(), + }); + + if (feedResp.ok) { + const feedData = (await safeJson(feedResp)) as any; + if (feedData.ok === 1 && feedData.data?.cards) { + const mblogs: { isPinned: boolean; status: WeiboRecentStatus }[] = + feedData.data.cards + .filter((card: any) => card.card_type === 9 && card.mblog) + .map((card: any) => ({ + isPinned: card.mblog?.title?.text === "置顶", + status: parseRecentStatus(card.mblog), + })); + pinnedStatuses = mblogs + .filter((m: { isPinned: boolean }) => m.isPinned) + .map((m: { status: WeiboRecentStatus }) => m.status) + .slice(0, 2); + recentStatuses = mblogs + .filter((m: { isPinned: boolean }) => !m.isPinned) + .map((m: { status: WeiboRecentStatus }) => m.status) + .slice(0, 3); + } + } + } catch (e) { + console.error("Failed to fetch recent statuses:", e); + } + + return { + uid: u.id?.toString() || uid, + name: u.screen_name || "", + description: u.description || "", + avatar: u.profile_image_url || "", + avatarHd: u.avatar_hd || u.profile_image_url || "", + profileUrl: u.profile_url || `https://m.weibo.cn/u/${uid}`, + followersCount: u.followers_count || u.followers_count_str || "0", + followCount: u.follow_count || 0, + statusesCount: u.statuses_count || 0, + gender: u.gender === "m" ? "m" : u.gender === "f" ? "f" : "n", + verified: u.verified || false, + verifiedReason: u.verified_reason || null, + coverImage: u.cover_image_phone || null, + pinnedStatuses, + recentStatuses, + }; + } catch (error) { + if (error instanceof WeiboApiError) { + throw error; + } + console.error("Fetch Error (Weibo User):", error); + throw error; + } +} diff --git a/test/weibo/fixtures/album-7643376782.json b/test/weibo/fixtures/album-7643376782.json new file mode 100644 index 0000000..ebf4129 --- /dev/null +++ b/test/weibo/fixtures/album-7643376782.json @@ -0,0 +1,81 @@ +{ + "ok": 1, + "data": { + "cards": [ + { + "card_type": 11, + "card_group": [ + { + "card_type": 47, + "pics": [ + { + "pic_small": "https://wx3.sinaimg.cn/thumb150/008lgPsGgy1idxp33wt23j30p05ht7wl.jpg", + "pic_middle": "https://wx3.sinaimg.cn/orj480/008lgPsGgy1idxp33wt23j30p05ht7wl.jpg", + "pic_big": "https://wx3.sinaimg.cn/woriginal/008lgPsGgy1idxp33wt23j30p05ht7wl.jpg", + "pic_id": "008lgPsGgy1idxp33wt23j30p05ht7wl", + "mblog": { + "id": "5307519834260611" + } + }, + { + "pic_small": "https://wx2.sinaimg.cn/thumb150/008lgPsGgy1idxocfgob0j30p00xckjl.jpg", + "pic_middle": "https://wx2.sinaimg.cn/orj480/008lgPsGgy1idxocfgob0j30p00xckjl.jpg", + "pic_big": "https://wx2.sinaimg.cn/woriginal/008lgPsGgy1idxocfgob0j30p00xckjl.jpg", + "pic_id": "008lgPsGgy1idxocfgob0j30p00xckjl", + "mblog": { + "id": "5307504738177665" + } + } + ] + }, + { + "card_type": 47, + "pics": [ + { + "pic_small": "https://wx2.sinaimg.cn/thumb150/008lgPsGgy1idxocjqskgj30p0158e81.jpg", + "pic_middle": "https://wx2.sinaimg.cn/orj480/008lgPsGgy1idxocjqskgj30p0158e81.jpg", + "pic_big": "https://wx2.sinaimg.cn/woriginal/008lgPsGgy1idxocjqskgj30p0158e81.jpg", + "pic_id": "008lgPsGgy1idxocjqskgj30p0158e81", + "mblog": { + "id": "5307504738177665" + } + }, + { + "pic_small": "https://wx3.sinaimg.cn/thumb150/008lgPsGgy1idxocnge4lg30p01gm7wn.gif", + "pic_middle": "https://wx3.sinaimg.cn/orj480/008lgPsGgy1idxocnge4lg30p01gm7wn.gif", + "pic_big": "https://wx3.sinaimg.cn/woriginal/008lgPsGgy1idxocnge4lg30p01gm7wn.gif", + "pic_id": "008lgPsGgy1idxocnge4lg30p01gm7wn", + "mblog": { + "id": "5307504738177665" + } + } + ] + }, + { + "card_type": 47, + "pics": [ + { + "pic_small": "https://wx3.sinaimg.cn/thumb150/008lgPsGgy1idptu8wb0zj30p00xch33.jpg", + "pic_middle": "https://wx3.sinaimg.cn/orj480/008lgPsGgy1idptu8wb0zj30p00xch33.jpg", + "pic_big": "https://wx3.sinaimg.cn/woriginal/008lgPsGgy1idptu8wb0zj30p00xch33.jpg", + "pic_id": "008lgPsGgy1idptu8wb0zj30p00xch33", + "mblog": { + "id": "5305692795440982" + } + }, + { + "pic_small": "https://wx4.sinaimg.cn/thumb150/008lgPsGgy1idptua1p8ej30p01f91i8.jpg", + "pic_middle": "https://wx4.sinaimg.cn/orj480/008lgPsGgy1idptua1p8ej30p01f91i8.jpg", + "pic_big": "https://wx4.sinaimg.cn/woriginal/008lgPsGgy1idptua1p8ej30p01f91i8.jpg", + "pic_id": "008lgPsGgy1idptua1p8ej30p01f91i8", + "mblog": { + "id": "5305692795440982" + } + } + ] + } + ] + } + ] + } +} diff --git a/test/weibo/fixtures/feed-7643376782.json b/test/weibo/fixtures/feed-7643376782.json new file mode 100644 index 0000000..d796ad0 --- /dev/null +++ b/test/weibo/fixtures/feed-7643376782.json @@ -0,0 +1,101 @@ +{ + "ok": 1, + "data": { + "cardlistInfo": { + "containerid": "1076037643376782", + "total": 3473, + "page": 2 + }, + "cards": [ + { + "card_type": 9, + "mblog": { + "id": "5303880858734627", + "mid": "5303880858734627", + "created_at": "Fri May 29 12:00:01 +0800 2026", + "text": "《崩坏:星穹铁道》千冶•刃角色PV——「灭度」

一名匠人,一位逝者,一柄新刃。", + "source": "崩坏星穹铁道超话", + "bid": "R1CXrAEh5", + "title": { "text": "置顶", "base_color": 1 }, + "user": { + "id": 7643376782, + "screen_name": "崩坏星穹铁道", + "profile_image_url": "https://tvax1.sinaimg.cn/crop.0.0.1080.1080.180/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg", + "profile_url": "https://m.weibo.cn/u/7643376782" + }, + "reposts_count": 4357, + "comments_count": 1181, + "attitudes_count": 8991, + "isLongText": true, + "page_info": { + "type": "video", + "page_pic": { + "url": "https://wx1.sinaimg.cn/orj480/008lgPsGgy1idm54s8o53j31hc0u0kjl.jpg" + }, + "title": "《崩坏:星穹铁道》千冶•刃角色PV——「灭度」", + "play_count": "28万次播放", + "media_info": { + "stream_url": "https://f.video.weibocdn.com/o0/mS8IPED6lx08y0ESV0Nq010412017ygn0E010.mp4?label=mp4_hd", + "duration": 104.026 + } + } + } + }, + { + "card_type": 9, + "mblog": { + "id": "5301463683170767", + "mid": "5301463683170767", + "created_at": "Fri May 22 19:55:00 +0800 2026", + "text": "《崩坏:星穹铁道》4.3版本PV:「沉于生者的忘川」

寻求归葬的过客,带去警告的旅者...", + "source": "崩坏星穹铁道超话", + "bid": "R0C4MdiRp", + "title": { "text": "置顶", "base_color": 1 }, + "user": { + "id": 7643376782, + "screen_name": "崩坏星穹铁道", + "profile_image_url": "https://tvax1.sinaimg.cn/crop.0.0.1080.1080.180/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg" + }, + "reposts_count": 3142, + "comments_count": 547, + "attitudes_count": 3647, + "isLongText": true, + "page_info": { + "type": "video", + "page_pic": { + "url": "https://wx1.sinaimg.cn/orj480/008lgPsGgy1ide2ppyq5vj31hc0u0qv6.jpg" + }, + "title": "《崩坏:星穹铁道》4.3版本PV:「沉于生者的忘川」", + "play_count": "32万次播放", + "media_info": { + "stream_url": "https://f.video.weibocdn.com/o0/IrjyrE9jlx08xPDXqRl601041201girD0E010.mp4?label=mp4_hd", + "duration": 185.991 + } + } + } + }, + { + "card_type": 9, + "mblog": { + "id": "5309679070348083", + "mid": "5309679070348083", + "created_at": "Sun Jun 14 12:00:02 +0800 2026", + "text": "【星琼补给站】开启!关注并转发,有机会获得星琼*500!", + "source": "崩坏星穹铁道超话", + "bid": "R43Np1syf", + "user": { + "id": 7643376782, + "screen_name": "崩坏星穹铁道", + "profile_image_url": "https://tvax1.sinaimg.cn/crop.0.0.1080.1080.180/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg" + }, + "reposts_count": 5000, + "comments_count": 800, + "attitudes_count": 6000, + "isLongText": false, + "pic_ids": [], + "page_info": null + } + } + ] + } +} diff --git a/test/weibo/fixtures/status-5303880858734627.json b/test/weibo/fixtures/status-5303880858734627.json new file mode 100644 index 0000000..6642f7a --- /dev/null +++ b/test/weibo/fixtures/status-5303880858734627.json @@ -0,0 +1,47 @@ +{ + "ok": 1, + "data": { + "created_at": "Fri May 29 12:00:01 +0800 2026", + "id": "5303880858734627", + "mid": "5303880858734627", + "text": "《崩坏:星穹铁道》千冶•刃角色PV——「灭度」

一名匠人,一位逝者,一柄新刃。", + "source": "崩坏星穹铁道超话", + "pic_ids": [], + "user": { + "id": 7643376782, + "screen_name": "崩坏星穹铁道", + "profile_image_url": "https://tvax1.sinaimg.cn/crop.0.0.1080.1080.180/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg", + "profile_url": "https://m.weibo.cn/u/7643376782", + "follow_count": 37, + "followers_count": "453.3万", + "avatar_hd": "https://wx1.sinaimg.cn/orj480/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg", + "statuses_count": 3473, + "verified": true, + "verified_type": 7, + "gender": "f", + "verified_reason": "《崩坏:星穹铁道》官方账号" + }, + "reposts_count": 4357, + "comments_count": 1181, + "attitudes_count": 8991, + "isLongText": true, + "bid": "R1CXrAEh5", + "page_info": { + "type": "video", + "page_pic": { + "url": "https://wx1.sinaimg.cn/orj480/008lgPsGgy1idm54s8o53j31hc0u0kjl.jpg" + }, + "title": "《崩坏:星穹铁道》千冶•刃角色PV——「灭度」", + "play_count": "28万次播放", + "media_info": { + "stream_url": "https://f.video.weibocdn.com/o0/mS8IPED6lx08y0ESV0Nq010412017ygn0E010.mp4?label=mp4_hd&template=1128x480.25.0", + "stream_url_hd": "https://f.video.weibocdn.com/o0/mS8IPED6lx08y0ESV0Nq010412017ygn0E010.mp4?label=mp4_hd&template=1128x480.25.0", + "duration": 104.026 + }, + "urls": { + "mp4_720p_mp4": "https://f.video.weibocdn.com/o0/TAnnvfa1lx08y0EUKrja010412022sl70E010.mp4?label=mp4_720p&template=1692x720.25.0", + "mp4_hd_mp4": "https://f.video.weibocdn.com/o0/mS8IPED6lx08y0ESV0Nq010412017ygn0E010.mp4?label=mp4_hd&template=1128x480.25.0" + } + } + } +} diff --git a/test/weibo/fixtures/user-7643376782.json b/test/weibo/fixtures/user-7643376782.json new file mode 100644 index 0000000..b0c787d --- /dev/null +++ b/test/weibo/fixtures/user-7643376782.json @@ -0,0 +1,35 @@ +{ + "ok": 1, + "data": { + "userInfo": { + "id": 7643376782, + "screen_name": "崩坏星穹铁道", + "profile_image_url": "https://tvax1.sinaimg.cn/crop.0.0.1080.1080.180/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg", + "profile_url": "https://m.weibo.cn/u/7643376782", + "description": "", + "follow_count": 37, + "followers_count": "453.3万", + "cover_image_phone": "https://wx2.sinaimg.cn/crop.0.0.640.640.640/008lgPsGgy1idpm7jvzo8j31o01o07wh.jpg", + "avatar_hd": "https://wx1.sinaimg.cn/orj480/008lgPsGly8idpmc8mlsyj30u00u0q5z.jpg", + "statuses_count": 3473, + "verified": true, + "verified_type": 7, + "gender": "f", + "mbtype": 12, + "svip": 1, + "urank": 0, + "mbrank": 2, + "verified_type_ext": 50, + "verified_reason": "《崩坏:星穹铁道》官方账号" + }, + "tabsInfo": { + "tabs": [ + { + "tabKey": "weibo", + "tab_type": "weibo", + "containerid": "1076037643376782" + } + ] + } + } +} diff --git a/test/weibo/render.test.ts b/test/weibo/render.test.ts new file mode 100644 index 0000000..78774c8 --- /dev/null +++ b/test/weibo/render.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { WeiboStatusInfo } from "@/weibo/status"; +import type { WeiboAlbumInfo, WeiboUserInfo } from "@/weibo/user"; +import { + renderAlbumPage, + renderStatusPage, + renderUserPage, +} from "@/weibo/render"; + +function mockImageFetch() { + const originalFetch = global.fetch; + global.fetch = mock((url: string) => { + if (typeof url === "string" && url.includes("sinaimg.cn")) { + // Return a tiny 1x1 JPEG for image requests + const jpeg = Buffer.from( + "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYI4Q/SFhSRFEiO0NVJjFFRkZUKjdDYWIzR2R1Y2UVFhcmQ0VERUZHRkpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoOEhYaHiImKi4yNjo+QkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3+Dh4uPk5ebn6Onq6+zt7u/w8fLz9PX29/j5+vv8/f7/2gAMAwEAAhEDEQA/AD8//9k=", + "base64", + ); + return Promise.resolve( + new Response(jpeg, { + headers: { "Content-Type": "image/jpeg" }, + }), + ); + } + return originalFetch(url); + }) as unknown as typeof global.fetch; + return () => { + global.fetch = originalFetch; + }; +} + +describe("Weibo Render Logic", () => { + test("renders status page with correct metadata", async () => { + const mockStatus: WeiboStatusInfo = { + id: "123456", + textHtml: "

测试微博内容

", + images: [], + videoUrl: "https://example.com/video.mp4", + videoTitle: "测试视频", + videoPoster: null, + playCount: "100次播放", + author: { + name: "TestUser", + avatar: "https://example.com/avatar.jpg", + profileUrl: "https://m.weibo.cn/u/123", + }, + stat: { reposts: 10, comments: 5, attitudes: 100 }, + created_at: "Mon Jan 01 12:00:00 +0800 2026", + bid: "abc123", + source: '微博客户端', + isLongText: false, + longTextContent: null, + }; + + const html = await renderStatusPage(mockStatus); + + expect(html).toContain("Weibo Instant View"); + expect(html).toContain("测试视频"); + expect(html).toContain("TestUser"); + expect(html).toContain("https://example.com/video.mp4"); + expect(html).toContain("weibo.com/detail/abc123"); + }); + + test("renders status page without video", async () => { + const cleanup = mockImageFetch(); + const mockStatus: WeiboStatusInfo = { + id: "789", + textHtml: "

纯文字微博

", + images: ["https://tvax1.sinaimg.cn/test/pic.jpg"], + videoUrl: null, + videoTitle: null, + videoPoster: null, + playCount: null, + author: { + name: "Writer", + avatar: "https://example.com/face.jpg", + profileUrl: "https://m.weibo.cn/u/789", + }, + stat: { reposts: 0, comments: 0, attitudes: 1 }, + created_at: "Tue Feb 02 10:00:00 +0800 2026", + bid: "xyz", + source: null, + isLongText: false, + longTextContent: null, + }; + + const html = await renderStatusPage(mockStatus); + + expect(html).toContain("纯文字微博"); + expect(html).toContain("data:image"); + expect(html).not.toContain(" { + const cleanup = mockImageFetch(); + const mockUser: WeiboUserInfo = { + uid: "7643376782", + name: "崩坏星穹铁道", + description: "", + avatar: "https://tvax1.sinaimg.cn/avatar.jpg", + avatarHd: "https://wx1.sinaimg.cn/avatar_hd.jpg", + profileUrl: "https://m.weibo.cn/u/7643376782", + followersCount: "453.3万", + followCount: 37, + statusesCount: 3473, + gender: "f", + verified: true, + verifiedReason: "官方账号", + coverImage: "https://wx2.sinaimg.cn/cover.jpg", + pinnedStatuses: [ + { + id: "5303880858734627", + textHtml: "千冶•刃角色PV——「灭度」", + createdAt: "Fri May 29 12:00:01 +0800 2026", + bid: "R1CXrAEh5", + reposts: 4357, + comments: 1181, + attitudes: 8991, + coverImage: "https://wx1.sinaimg.cn/video_cover.jpg", + videoTitle: "千冶•刃角色PV——「灭度」", + }, + ], + recentStatuses: [ + { + id: "5301463683170767", + textHtml: "4.3版本PV:「沉于生者的忘川」", + createdAt: "Fri May 22 19:55:00 +0800 2026", + bid: "R0C4MdiRp", + reposts: 3142, + comments: 547, + attitudes: 3647, + coverImage: "https://wx1.sinaimg.cn/video_cover2.jpg", + videoTitle: "4.3版本PV", + }, + ], + }; + + const html = await renderUserPage(mockUser); + + expect(html).toContain("Weibo Instant View"); + expect(html).toContain("崩坏星穹铁道"); + expect(html).toContain("453.3万"); + expect(html).toContain("官方账号"); + expect(html).toContain("3473"); + expect(html).toContain("weibo.com/u/7643376782"); + expect(html).toContain("background-image: url('data:image"); + expect(html).toContain("近期微博"); + expect(html).toContain("千冶•刃角色PV"); + expect(html).toContain("R1CXrAEh5"); + expect(html).toContain("置顶微博"); + expect(html).toContain("4.3版本PV"); + expect(html).toContain("R0C4MdiRp"); + cleanup(); + }); + + test("renders user page with empty statuses", async () => { + const mockUser: WeiboUserInfo = { + uid: "123", + name: "NewUser", + description: "新用户", + avatar: "https://example.com/avatar.jpg", + avatarHd: "https://example.com/avatar_hd.jpg", + profileUrl: "https://m.weibo.cn/u/123", + followersCount: "0", + followCount: 0, + statusesCount: 0, + gender: "n", + verified: false, + verifiedReason: null, + coverImage: null, + pinnedStatuses: [], + recentStatuses: [], + }; + + const html = await renderUserPage(mockUser); + + expect(html).toContain("NewUser"); + expect(html).toContain("暂无微博"); + expect(html).toContain("linear-gradient"); + }); + + test("renders album page with photo grid", async () => { + const cleanup = mockImageFetch(); + const mockAlbum: WeiboAlbumInfo = { + user: { + uid: "7643376782", + name: "崩坏星穹铁道", + avatar: "https://tvax1.sinaimg.cn/avatar.jpg", + avatarHd: "https://wx1.sinaimg.cn/avatar_hd.jpg", + }, + items: [ + { + picSmall: "https://wx1.sinaimg.cn/thumb150/pic1.jpg", + picBig: "https://wx1.sinaimg.cn/large/pic1.jpg", + pid: "abc123", + bid: "R1CXrAEh5", + }, + { + picSmall: "https://wx1.sinaimg.cn/thumb150/pic2.jpg", + picBig: "https://wx1.sinaimg.cn/large/pic2.jpg", + pid: "def456", + bid: null, + }, + ], + }; + + const html = await renderAlbumPage(mockAlbum); + + expect(html).toContain("崩坏星穹铁道的相册"); + expect(html).toContain("data:image"); + expect(html).toContain("weibo.com/u/7643376782"); + cleanup(); + }); + + test("renders album page with empty items", async () => { + const mockAlbum: WeiboAlbumInfo = { + user: { uid: "123", name: "Empty", avatar: "", avatarHd: "" }, + items: [], + }; + + const html = await renderAlbumPage(mockAlbum); + + expect(html).toContain("暂无图片"); + }); +}); diff --git a/test/weibo/status.test.ts b/test/weibo/status.test.ts new file mode 100644 index 0000000..9922d7a --- /dev/null +++ b/test/weibo/status.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, mock, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getStatusInfo } from "@/weibo/status"; + +describe("Weibo Status Logic", () => { + test("fetches and parses video status from fixture", async () => { + const fixturePath = join( + process.cwd(), + "test/weibo/fixtures/status-5303880858734627.json", + ); + const fixtureData = JSON.parse(readFileSync(fixturePath, "utf-8")); + + const originalFetch = global.fetch; + const fetchMock = mock((url: string) => { + // Mock m.weibo.cn homepage for initial cookie + if (url === "https://m.weibo.cn/") { + return Promise.resolve( + new Response("", { + headers: { "Set-Cookie": "WEIBOCN_FROM=1110003030" }, + }), + ); + } + // Return mock cookie for visitor system + if (url.includes("genvisitor")) { + return Promise.resolve( + new Response( + 'window.gen_callback && gen_callback({"retcode":20000000,"msg":"succ","data":{"tid":"mock_tid"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + if (url.includes("visitor/visitor")) { + return Promise.resolve( + new Response( + 'window.cross_domain && cross_domain({"retcode":20000000,"msg":"succ","data":{"sub":"mock_sub","subp":"mock_subp"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + // statuses/show API + if (url.includes("statuses/show")) { + return Promise.resolve(new Response(JSON.stringify(fixtureData))); + } + // statuses/extend for long text + if (url.includes("statuses/extend")) { + return Promise.resolve( + new Response( + JSON.stringify({ + ok: 1, + data: { longTextContent: "Long text content" }, + }), + ), + ); + } + return Promise.reject(new Error(`Unhandled fetch URL: ${url}`)); + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + try { + const status = await getStatusInfo("5303880858734627"); + + expect(status).toBeDefined(); + if (status) { + expect(status.id).toBe("5303880858734627"); + expect(status.author.name).toBe("崩坏星穹铁道"); + expect(status.videoUrl).toContain("f.video.weibocdn.com"); + expect(status.videoTitle).toContain("千冶"); + expect(status.playCount).toBe("28万次播放"); + expect(status.stat.attitudes).toBe(8991); + expect(status.bid).toBe("R1CXrAEh5"); + } + } finally { + global.fetch = originalFetch; + } + }); +}); diff --git a/test/weibo/user.test.ts b/test/weibo/user.test.ts new file mode 100644 index 0000000..8e54785 --- /dev/null +++ b/test/weibo/user.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, mock, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getAlbumInfo, getUserInfo } from "@/weibo/user"; + +describe("Weibo User Logic", () => { + test("fetches and parses user info and recent statuses from fixtures", async () => { + const userFixturePath = join( + process.cwd(), + "test/weibo/fixtures/user-7643376782.json", + ); + const userFixtureData = JSON.parse(readFileSync(userFixturePath, "utf-8")); + + const feedFixturePath = join( + process.cwd(), + "test/weibo/fixtures/feed-7643376782.json", + ); + const feedFixtureData = JSON.parse(readFileSync(feedFixturePath, "utf-8")); + + const originalFetch = global.fetch; + const fetchMock = mock((url: string) => { + // Mock m.weibo.cn homepage for initial cookie + if (url === "https://m.weibo.cn/") { + return Promise.resolve( + new Response("", { + headers: { "Set-Cookie": "WEIBOCN_FROM=1110003030" }, + }), + ); + } + // Return mock cookie for visitor system + if (url.includes("genvisitor")) { + return Promise.resolve( + new Response( + 'window.gen_callback && gen_callback({"retcode":20000000,"msg":"succ","data":{"tid":"mock_tid"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + if (url.includes("visitor/visitor")) { + return Promise.resolve( + new Response( + 'window.cross_domain && cross_domain({"retcode":20000000,"msg":"succ","data":{"sub":"mock_sub","subp":"mock_subp"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + // User profile API (type=uid) + if (url.includes("type=uid")) { + return Promise.resolve(new Response(JSON.stringify(userFixtureData))); + } + // Feed API (containerid=107603) + if (url.includes("containerid=107603")) { + return Promise.resolve(new Response(JSON.stringify(feedFixtureData))); + } + return Promise.reject(new Error(`Unhandled fetch URL: ${url}`)); + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + try { + const user = await getUserInfo("7643376782"); + + expect(user).toBeDefined(); + if (user) { + expect(user.uid).toBe("7643376782"); + expect(user.name).toBeDefined(); + expect(typeof user.followersCount).toBe("string"); + expect(user.verified).toBe(true); + expect(user.verifiedReason).toBeDefined(); + + // Verify pinned statuses were separated from recent + expect(user.pinnedStatuses).toBeDefined(); + expect(user.pinnedStatuses.length).toBe(2); + expect(user.pinnedStatuses[0].bid).toBe("R1CXrAEh5"); + + // Verify recent statuses (non-pinned) + expect(user.recentStatuses).toBeDefined(); + expect(Array.isArray(user.recentStatuses)).toBe(true); + expect(user.recentStatuses.length).toBe(1); + + const first = user.recentStatuses[0]; + expect(first.attitudes).toBeGreaterThan(0); + } + } finally { + global.fetch = originalFetch; + } + }); + + test("fetches and parses album info from fixture", async () => { + const albumFixturePath = join( + process.cwd(), + "test/weibo/fixtures/album-7643376782.json", + ); + const albumFixtureData = JSON.parse( + readFileSync(albumFixturePath, "utf-8"), + ); + + const userFixturePath = join( + process.cwd(), + "test/weibo/fixtures/user-7643376782.json", + ); + const userFixtureData = JSON.parse(readFileSync(userFixturePath, "utf-8")); + + const originalFetch = global.fetch; + const fetchMock = mock((url: string) => { + if (url === "https://m.weibo.cn/") { + return Promise.resolve( + new Response("", { + headers: { "Set-Cookie": "WEIBOCN_FROM=1110003030" }, + }), + ); + } + if (url.includes("genvisitor")) { + return Promise.resolve( + new Response( + 'window.gen_callback && gen_callback({"retcode":20000000,"msg":"succ","data":{"tid":"mock_tid"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + if (url.includes("visitor/visitor")) { + return Promise.resolve( + new Response( + 'window.cross_domain && cross_domain({"retcode":20000000,"msg":"succ","data":{"sub":"mock_sub","subp":"mock_subp"}});', + { headers: { "Content-Type": "text/html" } }, + ), + ); + } + if (url.includes("type=uid")) { + return Promise.resolve(new Response(JSON.stringify(userFixtureData))); + } + if (url.includes("containerid=107803")) { + return Promise.resolve(new Response(JSON.stringify(albumFixtureData))); + } + return Promise.reject(new Error(`Unhandled fetch URL: ${url}`)); + }); + global.fetch = fetchMock as unknown as typeof global.fetch; + + try { + const album = await getAlbumInfo("7643376782"); + + expect(album).toBeDefined(); + if (album) { + expect(album.user.name).toBeDefined(); + expect(album.items.length).toBeGreaterThan(0); + expect(album.items[0].picSmall).toContain("sinaimg.cn"); + expect(album.items[0].picBig).toContain("sinaimg.cn"); + } + } finally { + global.fetch = originalFetch; + } + }); +});