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)}
+
+
| 转发: ${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 ``;
+ };
+
+ 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)}
+ ${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)}的相册
+
+
+
+
+
+
+ ${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("