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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 内嵌(绕过防盗链),视频直接播放,用户主页含封面背景、置顶微博和近期微博。

## 技术栈

Expand All @@ -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
```

## 本地开发
Expand Down
105 changes: 101 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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(
`
Expand Down Expand Up @@ -99,6 +174,28 @@ app.onError((err, c) => {
);
}

if (err instanceof WeiboApiError) {
return c.html(
`
<!DOCTYPE html>
<html>
<head><title>Weibo API Error</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Weibo API Error</h1>
<p>The upstream Weibo API returned an error.</p>
<div style="background: #f8dede; color: #9c1c1c; padding: 15px; border-radius: 5px; display: inline-block; text-align: left; margin: 20px 0;">
<p><strong>Code:</strong> ${err.code}</p>
<p><strong>Message:</strong> ${err.message}</p>
</div>
<br/>
<a href="/">Go Home</a>
</body>
</html>
`,
502,
);
}

return c.html(
`
<!DOCTYPE html>
Expand Down
11 changes: 11 additions & 0 deletions src/weibo/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
121 changes: 121 additions & 0 deletions src/weibo/headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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, string>): string {
return Object.entries(cookieMap)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}

async function fetchVisitorCookie(): Promise<string> {
const cookieMap: Record<string, string> = {};

// 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<Record<string, string>> {
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 };
Loading
Loading