Compare commits

..

2 Commits

Author SHA1 Message Date
wxs
286b73a287 fix: Instagram 热点过滤低互动内容并按点赞数排序
- 适配器过滤 like_count < 100 的低质量内容,按点赞数降序排列
- 后端 dev/start 脚本添加 --env-file=.env 自动加载环境变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:11:40 +08:00
wxs
e933c71b3d fix: 修复哔哩哔哩平台封面图无法显示的问题
- 后端 Bilibili 适配器添加 URL 规范化,处理协议相对路径和 http 协议
- 前端 Image 组件添加 referrerPolicy="no-referrer" 绕过 CDN 防盗链

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:55:06 +08:00
6 changed files with 40 additions and 7 deletions

View File

@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"dev": "tsx watch --env-file=.env src/index.ts",
"start": "tsx --env-file=.env src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"

View File

@ -1,6 +1,13 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
/** Ensure Bilibili URLs use https:// (API may return protocol-relative "//..." or http) */
function normalizeUrl(url: string): string {
if (url.startsWith("//")) return `https:${url}`;
if (url.startsWith("http://")) return url.replace("http://", "https://");
return url;
}
export class BilibiliAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -44,10 +51,10 @@ export class BilibiliAdapter implements PlatformAdapter {
return {
id: String(aid || bvid),
title: raw?.title || "无标题",
cover_url: raw?.pic || undefined,
cover_url: raw?.pic ? normalizeUrl(raw.pic) : undefined,
video_url: undefined,
author_name: owner?.name || raw?.author || "未知作者",
author_avatar: owner?.face || undefined,
author_avatar: owner?.face ? normalizeUrl(owner.face) : undefined,
play_count: stat?.view ?? undefined,
like_count: stat?.like ?? undefined,
collect_count: stat?.favorite ?? undefined,

View File

@ -58,7 +58,7 @@ describe("InstagramAdapter", () => {
code: "SEC001",
caption: "Section post",
user: { username: "user1" },
like_count: 1000,
like_count: 5000,
},
],
},
@ -87,6 +87,7 @@ describe("InstagramAdapter", () => {
code: "cap-obj",
caption: { text: "Caption from object" },
user: { username: "test" },
like_count: 500,
},
],
});
@ -102,6 +103,7 @@ describe("InstagramAdapter", () => {
code: "cap-str",
caption: "String caption",
user: { username: "test" },
like_count: 500,
},
],
});
@ -117,6 +119,7 @@ describe("InstagramAdapter", () => {
code: "cap-null",
caption: null,
user: { username: "test" },
like_count: 500,
},
],
});
@ -132,6 +135,7 @@ describe("InstagramAdapter", () => {
code: "thumb-test",
thumbnail_url: "https://ig.com/thumb.jpg",
user: { username: "test" },
like_count: 500,
},
],
});
@ -139,6 +143,22 @@ describe("InstagramAdapter", () => {
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg");
});
it("filters out low-engagement items and sorts by likes", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{ code: "low", caption: "Low", user: { username: "a" }, like_count: 5 },
{ code: "high", caption: "High", user: { username: "b" }, like_count: 10000 },
{ code: "mid", caption: "Mid", user: { username: "c" }, like_count: 500 },
{ code: "none", caption: "None", user: { username: "d" } },
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(2);
expect(items[0].id).toBe("high");
expect(items[1].id).toBe("mid");
});
});
describe("fetchDetail", () => {

View File

@ -34,10 +34,12 @@ export class InstagramAdapter implements PlatformAdapter {
}
return items
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
)
.filter((item) => item.like_count != null && item.like_count >= 100)
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
.slice(0, count);
}
async fetchDetail(id: string): Promise<ContentItem> {

View File

@ -35,6 +35,7 @@ export function ContentCard({ item }: ContentCardProps) {
alt={item.title}
fill
unoptimized
referrerPolicy="no-referrer"
className="object-cover"
loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw"
@ -84,6 +85,7 @@ export function ContentCard({ item }: ContentCardProps) {
width={20}
height={20}
unoptimized
referrerPolicy="no-referrer"
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";

View File

@ -46,6 +46,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
alt={item.title}
fill
unoptimized
referrerPolicy="no-referrer"
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
@ -87,6 +88,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
width={40}
height={40}
unoptimized
referrerPolicy="no-referrer"
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";