优化图文页

This commit is contained in:
feie9454 2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions

View File

@ -1,3 +1,3 @@
{ {
"editor.tabSize": 2 "editor.tabSize": 2
} }

42
.vscode/tasks.json vendored
View File

@ -1,27 +1,19 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "tsc-check", "label": "tsc-check",
"type": "shell", "type": "shell",
"command": "node", "command": "node",
"args": [ "args": ["-e", "require('typescript').transpile('const x: number = 1;')"],
"-e", "problemMatcher": ["$tsc"],
"require('typescript').transpile('const x: number = 1;')" "group": "build"
], },
"problemMatcher": [ {
"$tsc" "label": "tsc-check (one-off)",
], "type": "shell",
"group": "build" "command": "node",
}, "args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
{ }
"label": "tsc-check (one-off)", ]
"type": "shell",
"command": "node",
"args": [
"-e",
"require('typescript').transpile('const x: number = 1;')"
]
}
]
} }

View File

@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. This project uses local system font stacks so production builds do not depend on fetching Google Fonts.
## Learn More ## Learn More

View File

@ -1,7 +1,7 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import BackButton from '@/app/components/BackButton'; import BackButton from "@/app/components/BackButton";
export default function SttAdminPage() { export default function SttAdminPage() {
return ( return (
@ -10,12 +10,8 @@ export default function SttAdminPage() {
<BackButton /> <BackButton />
<div className="mt-8"> <div className="mt-8">
<h1 className="text-3xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">STT </h1>
STT <p className="text-gray-600 mt-2"></p>
</h1>
<p className="text-gray-600 mt-2">
</p>
</div> </div>
<div className="mt-8 grid gap-6"> <div className="mt-8 grid gap-6">
@ -98,9 +94,7 @@ export default function SttAdminPage() {
<p className="text-gray-600 mt-1"> <p className="text-gray-600 mt-1">
API API
</p> </p>
<div className="mt-4 text-gray-400 font-medium"> <div className="mt-4 text-gray-400 font-medium"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -130,9 +124,7 @@ export default function SttAdminPage() {
<p className="text-gray-600 mt-1"> <p className="text-gray-600 mt-1">
使 使
</p> </p>
<div className="mt-4 text-gray-400 font-medium"> <div className="mt-4 text-gray-400 font-medium"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,8 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import Link from 'next/link'; import Link from "next/link";
import BackButton from '@/app/components/BackButton'; import BackButton from "@/app/components/BackButton";
type VideoTranscript = { type VideoTranscript = {
id: string; id: string;
@ -34,7 +34,9 @@ export default function SttVideosPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [transcribing, setTranscribing] = useState<Set<string>>(new Set()); const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all'); const [filter, setFilter] = useState<"all" | "transcribed" | "pending">(
"all",
);
const [batchTranscribing, setBatchTranscribing] = useState(false); const [batchTranscribing, setBatchTranscribing] = useState(false);
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 }); const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
@ -45,21 +47,22 @@ export default function SttVideosPage() {
const fetchVideos = async () => { const fetchVideos = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch('/api/admin/stt/videos'); const response = await fetch("/api/admin/stt/videos");
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch videos'); throw new Error("Failed to fetch videos");
} }
const data: ApiResponse = await response.json(); const data: ApiResponse = await response.json();
setVideos(data.videos); setVideos(data.videos);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 简单的 sleep 工具 // 简单的 sleep 工具
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
type PollOptions = { type PollOptions = {
intervalMs?: number; intervalMs?: number;
@ -69,11 +72,11 @@ export default function SttVideosPage() {
// 轮询某个视频的转写状态直至完成或超时 // 轮询某个视频的转写状态直至完成或超时
const pollVideoUntilTranscribed = async ( const pollVideoUntilTranscribed = async (
awemeId: string, awemeId: string,
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {} { intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
): Promise<boolean> => { ): Promise<boolean> => {
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
const resp = await fetch('/api/admin/stt/videos'); const resp = await fetch("/api/admin/stt/videos");
if (resp.ok) { if (resp.ok) {
const data: ApiResponse = await resp.json(); const data: ApiResponse = await resp.json();
setVideos(data.videos); setVideos(data.videos);
@ -91,11 +94,11 @@ export default function SttVideosPage() {
}; };
const handleTranscribe = async (awemeId: string) => { const handleTranscribe = async (awemeId: string) => {
setTranscribing(prev => new Set(prev).add(awemeId)); setTranscribing((prev) => new Set(prev).add(awemeId));
try { try {
const response = await fetch(`/api/stt?awemeId=${awemeId}`); const response = await fetch(`/api/stt?awemeId=${awemeId}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to transcribe video'); throw new Error("Failed to transcribe video");
} }
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态 // 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
const done = await pollVideoUntilTranscribed(awemeId); const done = await pollVideoUntilTranscribed(awemeId);
@ -104,9 +107,9 @@ export default function SttVideosPage() {
await fetchVideos(); await fetchVideos();
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Transcription failed'); alert(err instanceof Error ? err.message : "Transcription failed");
} finally { } finally {
setTranscribing(prev => { setTranscribing((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(awemeId); next.delete(awemeId);
return next; return next;
@ -115,13 +118,17 @@ export default function SttVideosPage() {
}; };
const handleBatchTranscribe = async () => { const handleBatchTranscribe = async () => {
const pendingVideos = videos.filter(v => v.transcript === null); const pendingVideos = videos.filter((v) => v.transcript === null);
if (pendingVideos.length === 0) { if (pendingVideos.length === 0) {
alert('没有待转写的视频'); alert("没有待转写的视频");
return; return;
} }
if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) { if (
!confirm(
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
)
) {
return; return;
} }
@ -132,7 +139,7 @@ export default function SttVideosPage() {
for (let i = 0; i < pendingVideos.length; i++) { for (let i = 0; i < pendingVideos.length; i++) {
const video = pendingVideos[i]; const video = pendingVideos[i];
setBatchProgress({ current: i + 1, total: pendingVideos.length }); setBatchProgress({ current: i + 1, total: pendingVideos.length });
setTranscribing(prev => new Set(prev).add(video.aweme_id)); setTranscribing((prev) => new Set(prev).add(video.aweme_id));
try { try {
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`); const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
@ -141,7 +148,7 @@ export default function SttVideosPage() {
} }
// 为该视频启动后台轮询,不阻塞批处理的顺序执行 // 为该视频启动后台轮询,不阻塞批处理的顺序执行
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => { const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
setTranscribing(prev => { setTranscribing((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(video.aweme_id); next.delete(video.aweme_id);
return next; return next;
@ -160,19 +167,19 @@ export default function SttVideosPage() {
await fetchVideos(); await fetchVideos();
setBatchTranscribing(false); setBatchTranscribing(false);
setBatchProgress({ current: 0, total: 0 }); setBatchProgress({ current: 0, total: 0 });
alert('批量转写完成!'); alert("批量转写完成!");
}; };
const filteredVideos = videos.filter(v => { const filteredVideos = videos.filter((v) => {
if (filter === 'transcribed') return v.transcript !== null; if (filter === "transcribed") return v.transcript !== null;
if (filter === 'pending') return v.transcript === null; if (filter === "pending") return v.transcript === null;
return true; return true;
}); });
const stats = { const stats = {
total: videos.length, total: videos.length,
transcribed: videos.filter(v => v.transcript !== null).length, transcribed: videos.filter((v) => v.transcript !== null).length,
pending: videos.filter(v => v.transcript === null).length, pending: videos.filter((v) => v.transcript === null).length,
}; };
if (loading) { if (loading) {
@ -212,7 +219,9 @@ export default function SttVideosPage() {
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<BackButton /> <BackButton />
<h1 className="text-3xl font-bold text-gray-900 mt-4"></h1> <h1 className="text-3xl font-bold text-gray-900 mt-4">
</h1>
<p className="text-gray-600 mt-2"></p> <p className="text-gray-600 mt-2"></p>
</div> </div>
@ -220,15 +229,21 @@ export default function SttVideosPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div> <div className="text-3xl font-bold text-gray-900 mt-2">
{stats.total}
</div>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-green-600 mt-2">{stats.transcribed}</div> <div className="text-3xl font-bold text-green-600 mt-2">
{stats.transcribed}
</div>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
<div className="text-3xl font-bold text-orange-600 mt-2">{stats.pending}</div> <div className="text-3xl font-bold text-orange-600 mt-2">
{stats.pending}
</div>
</div> </div>
</div> </div>
@ -237,31 +252,31 @@ export default function SttVideosPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => setFilter('all')} onClick={() => setFilter("all")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'all' filter === "all"
? 'bg-blue-600 text-white' ? "bg-blue-600 text-white"
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`} }`}
> >
({stats.total}) ({stats.total})
</button> </button>
<button <button
onClick={() => setFilter('transcribed')} onClick={() => setFilter("transcribed")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'transcribed' filter === "transcribed"
? 'bg-green-600 text-white' ? "bg-green-600 text-white"
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`} }`}
> >
({stats.transcribed}) ({stats.transcribed})
</button> </button>
<button <button
onClick={() => setFilter('pending')} onClick={() => setFilter("pending")}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'pending' filter === "pending"
? 'bg-orange-600 text-white' ? "bg-orange-600 text-white"
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`} }`}
> >
({stats.pending}) ({stats.pending})
@ -284,7 +299,7 @@ export default function SttVideosPage() {
... ...
</span> </span>
) : ( ) : (
'全部转写' "全部转写"
)} )}
</button> </button>
</div> </div>
@ -326,7 +341,7 @@ export default function SttVideosPage() {
href={`/aweme/${video.aweme_id}`} href={`/aweme/${video.aweme_id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2" className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
> >
{video.desc || '无描述'} {video.desc || "无描述"}
</Link> </Link>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
@{video.author.nickname} @{video.author.nickname}
@ -357,7 +372,7 @@ export default function SttVideosPage() {
) : ( ) : (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
<span className="font-medium"> <span className="font-medium">
{video.transcript.audio_type || '非语音'} {video.transcript.audio_type || "非语音"}
</span> </span>
{video.transcript.non_speech_summary && ( {video.transcript.non_speech_summary && (
<span className="ml-1"> <span className="ml-1">
@ -386,7 +401,7 @@ export default function SttVideosPage() {
... ...
</span> </span>
) : ( ) : (
'重新转写' "重新转写"
)} )}
</button> </button>
) : ( ) : (
@ -401,7 +416,7 @@ export default function SttVideosPage() {
... ...
</span> </span>
) : ( ) : (
'开始转写' "开始转写"
)} )}
</button> </button>
)} )}
@ -413,9 +428,7 @@ export default function SttVideosPage() {
</div> </div>
{filteredVideos.length === 0 && ( {filteredVideos.length === 0 && (
<div className="text-center py-12 text-gray-500"> <div className="text-center py-12 text-gray-500"></div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,11 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { getFileUrl } from '@/lib/minio'; import { getFileUrl } from "@/lib/minio";
export async function GET() { export async function GET() {
try { try {
const videos = await prisma.video.findMany({ const videos = await prisma.video.findMany({
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: 1000, take: 1000,
include: { include: {
author: { author: {
@ -29,7 +29,7 @@ export async function GET() {
const formattedVideos = videos.map((v) => ({ const formattedVideos = videos.map((v) => ({
aweme_id: v.aweme_id, aweme_id: v.aweme_id,
desc: v.desc, desc: v.desc,
cover_url: getFileUrl(v.cover_url ?? ''), cover_url: getFileUrl(v.cover_url ?? ""),
duration_ms: v.duration_ms, duration_ms: v.duration_ms,
author: { author: {
nickname: v.author.nickname, nickname: v.author.nickname,
@ -51,10 +51,10 @@ export async function GET() {
total: videos.length, total: videos.length,
}); });
} catch (error) { } catch (error) {
console.error('Failed to fetch videos:', error); console.error("Failed to fetch videos:", error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch videos' }, { error: "Failed to fetch videos" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@ -1,15 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import type { FeedItem, FeedResponse } from '@/app/types/feed'; import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { getFileUrl } from '@/lib/minio'; import { getFileUrl } from "@/lib/minio";
export async function GET(req: NextRequest, { params }: { params: Promise<{ secUid: string }> }) { export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ secUid: string }> },
) {
const secUid = (await params).secUid; const secUid = (await params).secUid;
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const limitParam = searchParams.get('limit'); const limitParam = searchParams.get("limit");
const beforeParam = searchParams.get('before'); const beforeParam = searchParams.get("before");
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60 const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null; const before = beforeParam ? new Date(beforeParam) : null;
// fetch chunk from both tables // fetch chunk from both tables
@ -17,20 +20,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
prisma.video.findMany({ prisma.video.findMany({
where: { where: {
authorId: secUid, authorId: secUid,
...(before ? { created_at: { lt: before } } : {}) ...(before ? { created_at: { lt: before } } : {}),
}, },
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true }, include: { author: true },
}), }),
prisma.imagePost.findMany({ prisma.imagePost.findMany({
where: { where: {
authorId: secUid, authorId: secUid,
...(before ? { created_at: { lt: before } } : {}) ...(before ? { created_at: { lt: before } } : {}),
}, },
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } }, include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
}), }),
]); ]);
@ -41,11 +44,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: getFileUrl(v.video_url), video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, author: {
likes: Number(v.digg_count) nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
type: "image" as const, type: "image" as const,
@ -55,13 +62,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
cover_url: getFileUrl(p.images?.[0]?.url ?? null), cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null, width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null, height: p.images?.[0]?.height ?? null,
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, author: {
likes: Number(p.digg_count) nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})), })),
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) ]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit); .slice(0, limit);
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null; const nextCursor =
merged.length > 0
? new Date(merged[merged.length - 1].created_at as any).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor }; const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload); return NextResponse.json(payload);
} }

View File

@ -12,8 +12,14 @@ export async function GET(req: NextRequest) {
// Find current item timestamp from either table // Find current item timestamp from either table
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), prisma.video.findUnique({
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), where: { aweme_id: awemeId },
select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true, created_at: true },
}),
]); ]);
const current = video ?? post; const current = video ?? post;
@ -52,25 +58,61 @@ export async function GET(req: NextRequest) {
]); ]);
const pickPrev = (() => { const pickPrev = (() => {
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; const cands: {
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date }); type: "video" | "image";
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date }); aweme_id: string;
created_at: Date;
}[] = [];
if (newerVideo)
cands.push({
type: "video",
aweme_id: newerVideo.aweme_id,
created_at: newerVideo.created_at as unknown as Date,
});
if (newerPost)
cands.push({
type: "image",
aweme_id: newerPost.aweme_id,
created_at: newerPost.created_at as unknown as Date,
});
if (cands.length === 0) return undefined; if (cands.length === 0) return undefined;
// nearest newer -> minimal created_at // nearest newer -> minimal created_at
cands.sort((a, b) => +a.created_at - +b.created_at); cands.sort((a, b) => +a.created_at - +b.created_at);
const h = cands[0]; const h = cands[0];
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; return {
type: h.type,
aweme_id: h.aweme_id,
created_at: h.created_at.toISOString(),
};
})(); })();
const pickNext = (() => { const pickNext = (() => {
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; const cands: {
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date }); type: "video" | "image";
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date }); aweme_id: string;
created_at: Date;
}[] = [];
if (olderVideo)
cands.push({
type: "video",
aweme_id: olderVideo.aweme_id,
created_at: olderVideo.created_at as unknown as Date,
});
if (olderPost)
cands.push({
type: "image",
aweme_id: olderPost.aweme_id,
created_at: olderPost.created_at as unknown as Date,
});
if (cands.length === 0) return undefined; if (cands.length === 0) return undefined;
// nearest older -> maximal created_at among older // nearest older -> maximal created_at among older
cands.sort((a, b) => +b.created_at - +a.created_at); cands.sort((a, b) => +b.created_at - +a.created_at);
const h = cands[0]; const h = cands[0];
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; return {
type: h.type,
aweme_id: h.aweme_id,
created_at: h.created_at.toISOString(),
};
})(); })();
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null }); return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });

View File

@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ awemeId: string }> } { params }: { params: Promise<{ awemeId: string }> },
) { ) {
const awemeId = (await params).awemeId; const awemeId = (await params).awemeId;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@ -13,7 +13,8 @@ export async function GET(
const take = parseInt(searchParams.get("take") || "20", 10); const take = parseInt(searchParams.get("take") || "20", 10);
// ranked 模式参数(均为可选,提供合理默认值) // ranked 模式参数(均为可选,提供合理默认值)
const seed = searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定 const seed =
searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
const snapshotIso = searchParams.get("snapshot"); const snapshotIso = searchParams.get("snapshot");
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定 const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24"); const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
@ -21,8 +22,6 @@ export async function GET(
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重 const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机) const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
try { try {
// 查找是视频还是图文 // 查找是视频还是图文
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
@ -41,9 +40,7 @@ export async function GET(
} }
// 构建查询条件 // 构建查询条件
const where = video const where = video ? { videoId: awemeId } : { imagePostId: awemeId };
? { videoId: awemeId }
: { imagePostId: awemeId };
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序 // 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
const total = await prisma.comment.count({ where }); const total = await prisma.comment.count({ where });
@ -68,7 +65,9 @@ export async function GET(
${wJit} * ${jitterExpr} ${wJit} * ${jitterExpr}
)`; )`;
const whereField = (await video) ? Prisma.sql`c."videoId"` : Prisma.sql`c."imagePostId"`; const whereField = (await video)
? Prisma.sql`c."videoId"`
: Prisma.sql`c."imagePostId"`;
const rows: Array<{ const rows: Array<{
cid: string; cid: string;
@ -92,22 +91,41 @@ export async function GET(
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${take} LIMIT ${take}
` `,
); );
// 批量查询每条评论的配图/贴纸 // 批量查询每条评论的配图/贴纸
const cids = rows.map(r => r.cid); const cids = rows.map((r) => r.cid);
const images = cids.length const images = cids.length
? await prisma.commentImage.findMany({ ? await prisma.commentImage.findMany({
where: { commentId: { in: cids } }, where: { commentId: { in: cids } },
orderBy: { order: 'asc' }, orderBy: { order: "asc" },
select: { commentId: true, url: true, width: true, height: true, order: true }, select: {
}) commentId: true,
url: true,
width: true,
height: true,
order: true,
},
})
: []; : [];
const group = new Map<string, { url: string; width?: number | null; height?: number | null; order: number }[]>(); const group = new Map<
string,
{
url: string;
width?: number | null;
height?: number | null;
order: number;
}[]
>();
for (const img of images) { for (const img of images) {
const arr = group.get(img.commentId) || []; const arr = group.get(img.commentId) || [];
arr.push({ url: img.url, width: img.width, height: img.height, order: img.order }); arr.push({
url: img.url,
width: img.width,
height: img.height,
order: img.order,
});
group.set(img.commentId, arr); group.set(img.commentId, arr);
} }
const formattedComments = rows.map((c) => ({ const formattedComments = rows.map((c) => ({
@ -117,9 +135,15 @@ export async function GET(
digg_count: Number(c.digg_count), digg_count: Number(c.digg_count),
user: { user: {
nickname: c.nickname, nickname: c.nickname,
avatar_url: getFileUrl(c.avatar_url || 'default_avatar.png'), avatar_url: getFileUrl(c.avatar_url || "default_avatar.png"),
}, },
images: (group.get(c.cid) || []).sort((a, b) => a.order - b.order).map(i => ({ url: getFileUrl(i.url), width: i.width ?? undefined, height: i.height ?? undefined })), images: (group.get(c.cid) || [])
.sort((a, b) => a.order - b.order)
.map((i) => ({
url: getFileUrl(i.url),
width: i.width ?? undefined,
height: i.height ?? undefined,
})),
})); }));
return NextResponse.json({ return NextResponse.json({
@ -134,9 +158,6 @@ export async function GET(
}); });
} catch (error) { } catch (error) {
console.error("获取评论失败:", error); console.error("获取评论失败:", error);
return NextResponse.json( return NextResponse.json({ error: "获取评论失败" }, { status: 500 });
{ error: "获取评论失败" },
{ status: 500 }
);
} }
} }

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import type { FeedItem, FeedResponse } from '@/app/types/feed'; import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { getFileUrl } from '@/lib/minio'; import { getFileUrl } from "@/lib/minio";
// Contract // Contract
// Inputs: search params { before?: ISOString, limit?: number } // Inputs: search params { before?: ISOString, limit?: number }
@ -9,25 +9,25 @@ import { getFileUrl } from '@/lib/minio';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const limitParam = searchParams.get('limit'); const limitParam = searchParams.get("limit");
const beforeParam = searchParams.get('before'); const beforeParam = searchParams.get("before");
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60 const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null; const before = beforeParam ? new Date(beforeParam) : null;
// fetch chunk from both tables // fetch chunk from both tables
const [videos, posts] = await Promise.all([ const [videos, posts] = await Promise.all([
prisma.video.findMany({ prisma.video.findMany({
where: before ? { created_at: { lt: before } } : undefined, where: before ? { created_at: { lt: before } } : undefined,
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true }, include: { author: true },
}), }),
prisma.imagePost.findMany({ prisma.imagePost.findMany({
where: before ? { created_at: { lt: before } } : undefined, where: before ? { created_at: { lt: before } } : undefined,
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } }, include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
}), }),
]); ]);
@ -38,11 +38,15 @@ export async function GET(req: NextRequest) {
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: getFileUrl(v.video_url), video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, author: {
likes: Number(v.digg_count) nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
type: "image" as const, type: "image" as const,
@ -52,13 +56,21 @@ export async function GET(req: NextRequest) {
cover_url: getFileUrl(p.images?.[0]?.url ?? null), cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null, width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null, height: p.images?.[0]?.height ?? null,
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, author: {
likes: Number(p.digg_count) nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})), })),
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) ]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit); .slice(0, limit);
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null; const nextCursor =
merged.length > 0
? new Date(merged[merged.length - 1].created_at as any).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor }; const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload); return NextResponse.json(payload);
} }

View File

@ -1,6 +1,6 @@
// index.js // index.js
import { chromium } from 'playwright-extra'; import { chromium } from "playwright-extra";
import stealth from 'puppeteer-extra-plugin-stealth' import stealth from "puppeteer-extra-plugin-stealth";
chromium.use(stealth()); chromium.use(stealth());
@ -8,9 +8,9 @@ chromium.use(stealth());
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
await page.goto('https://bot.sannysoft.com/'); // 常用自测页 await page.goto("https://bot.sannysoft.com/"); // 常用自测页
console.log('Title:', await page.title()); console.log("Title:", await page.title());
await page.screenshot({ path: 'stealth.png', fullPage: true }); await page.screenshot({ path: "stealth.png", fullPage: true });
setTimeout(async () => { setTimeout(async () => {
await browser.close(); await browser.close();
}, 1000_000); }, 1000_000);

View File

@ -1,99 +1,98 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
import { type Browser, type BrowserContext } from 'playwright' import { type Browser, type BrowserContext } from "playwright";
import { chromium } from 'playwright-extra' // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely. import { chromium } from "playwright-extra"; // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
import stealth from 'puppeteer-extra-plugin-stealth' import stealth from "puppeteer-extra-plugin-stealth";
chromium.use(stealth()); chromium.use(stealth());
let contextPromise: Promise<BrowserContext> | null = null let contextPromise: Promise<BrowserContext> | null = null;
let context: BrowserContext | null = null let context: BrowserContext | null = null;
let refCount = 0 let refCount = 0;
let idleCloseTimer: NodeJS.Timeout | null = null let idleCloseTimer: NodeJS.Timeout | null = null;
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? 'chrome-profile/douyin' const USER_DATA_DIR = process.env.USER_DATA_DIR ?? "chrome-profile/douyin";
async function launchContext(): Promise<BrowserContext> { async function launchContext(): Promise<BrowserContext> {
const ctx = await chromium.launchPersistentContext( const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, {
USER_DATA_DIR, headless: process.env.CHROMIUM_HEADLESS === "true",
{ viewport: {
headless: process.env.CHROMIUM_HEADLESS === 'true', width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
viewport: { height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280), },
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080) });
}
}
)
// When the context is closed externally, reset manager state // When the context is closed externally, reset manager state
ctx.on('close', () => { ctx.on("close", () => {
context = null context = null;
contextPromise = null contextPromise = null;
refCount = 0 refCount = 0;
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
}) });
return ctx return ctx;
} }
export async function acquireBrowserContext(): Promise<BrowserContext> { export async function acquireBrowserContext(): Promise<BrowserContext> {
// Cancel any pending idle close if a new consumer arrives // Cancel any pending idle close if a new consumer arrives
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
if (context) { if (context) {
refCount += 1 refCount += 1;
return context return context;
} }
if (!contextPromise) { if (!contextPromise) {
contextPromise = launchContext() contextPromise = launchContext();
} }
context = await contextPromise context = await contextPromise;
refCount += 1 refCount += 1;
return context return context;
} }
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> { export async function releaseBrowserContext(options?: {
const idleMillis = options?.idleMillis ?? 15_000 idleMillis?: number;
refCount = Math.max(0, refCount - 1) }): Promise<void> {
const idleMillis = options?.idleMillis ?? 15_000;
refCount = Math.max(0, refCount - 1);
if (refCount > 0 || !context) return if (refCount > 0 || !context) return;
// Delay the close to allow bursty workloads to reuse the context // Delay the close to allow bursty workloads to reuse the context
if (idleCloseTimer) { if (idleCloseTimer) {
clearTimeout(idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = null idleCloseTimer = null;
} }
idleCloseTimer = setTimeout(async () => { idleCloseTimer = setTimeout(async () => {
try { try {
if (context && refCount === 0) { if (context && refCount === 0) {
await context.close() await context.close();
} }
} finally { } finally {
context = null context = null;
contextPromise = null contextPromise = null;
idleCloseTimer = null idleCloseTimer = null;
} }
}, idleMillis) }, idleMillis);
} }
// --- Isolated context support for per-scrape independence --- // --- Isolated context support for per-scrape independence ---
const isolatedMap = new WeakMap<BrowserContext, Browser>() const isolatedMap = new WeakMap<BrowserContext, Browser>();
async function getSharedStorageState(): Promise<any | undefined> { async function getSharedStorageState(): Promise<any | undefined> {
try { try {
const shared = await acquireBrowserContext() const shared = await acquireBrowserContext();
const state = await shared.storageState() const state = await shared.storageState();
// Do not force-close immediately; keep ref-counting behavior // Do not force-close immediately; keep ref-counting behavior
await releaseBrowserContext() await releaseBrowserContext();
return state return state;
} catch { } catch {
// If shared context not available, proceed without storageState // If shared context not available, proceed without storageState
return undefined return undefined;
} }
} }
@ -103,37 +102,39 @@ async function getSharedStorageState(): Promise<any | undefined> {
* so you remain logged-in, but isolates network events, cache and listeners. * so you remain logged-in, but isolates network events, cache and listeners.
*/ */
export async function acquireIsolatedContext(): Promise<BrowserContext> { export async function acquireIsolatedContext(): Promise<BrowserContext> {
const storageState = await getSharedStorageState() const storageState = await getSharedStorageState();
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: process.env.CHROMIUM_HEADLESS === 'true' headless: process.env.CHROMIUM_HEADLESS === "true",
}) });
const ctx = await browser.newContext({ const ctx = await browser.newContext({
storageState, storageState,
viewport: { viewport: {
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280), width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080) height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
} },
}) });
isolatedMap.set(ctx, browser) isolatedMap.set(ctx, browser);
ctx.on('close', () => { ctx.on("close", () => {
const b = isolatedMap.get(ctx) const b = isolatedMap.get(ctx);
if (b) { if (b) {
b.close().catch(() => {}) b.close().catch(() => {});
isolatedMap.delete(ctx) isolatedMap.delete(ctx);
} }
}) });
return ctx return ctx;
} }
export async function releaseIsolatedContext(ctx: BrowserContext | null | undefined): Promise<void> { export async function releaseIsolatedContext(
if (!ctx) return ctx: BrowserContext | null | undefined,
): Promise<void> {
if (!ctx) return;
try { try {
await ctx.close() await ctx.close();
} finally { } finally {
const b = isolatedMap.get(ctx) const b = isolatedMap.get(ctx);
if (b) { if (b) {
await b.close().catch(() => {}) await b.close().catch(() => {});
isolatedMap.delete(ctx) isolatedMap.delete(ctx);
} }
} }
} }

View File

@ -1,22 +1,31 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
// src/scrapeDouyin.ts // src/scrapeDouyin.ts
import { BrowserContext, Page, type Response } from 'playwright'; import { BrowserContext, Page, type Response } from "playwright";
import { chromium } from 'playwright-extra'; import { chromium } from "playwright-extra";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { uploadFile, generateUniqueFileName } from "@/lib/minio";
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils'; import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils";
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary, collectResponsesWithinTime } from '@/app/api/fetcher/network'; import {
import { pickBestPlayAddr } from '@/app/api/fetcher/media'; waitForFirstResponse,
import { handleImagePost } from '@/app/api/fetcher/uploader'; waitForResponseWithTimeout,
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist'; safeJson,
import chalk from 'chalk'; downloadBinary,
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser'; collectResponsesWithinTime,
import { extractFirstFrame } from '@/app/api/media'; } from "@/app/api/fetcher/network";
import { transcriptAweme } from '../stt'; import { pickBestPlayAddr } from "@/app/api/fetcher/media";
import { handleImagePost } from "@/app/api/fetcher/uploader";
import { saveToDB, saveImagePostToDB } from "@/app/api/fetcher/persist";
import chalk from "chalk";
import {
acquireIsolatedContext,
releaseIsolatedContext,
} from "@/app/api/fetcher/browser";
import { extractFirstFrame } from "@/app/api/media";
import { transcriptAweme } from "../stt";
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
const COMMENT_PATH = '/aweme/v1/web/comment/list/'; const COMMENT_PATH = "/aweme/v1/web/comment/list/";
const POST_PATH = '/aweme/v1/web/aweme/post/' const POST_PATH = "/aweme/v1/web/aweme/post/";
/** /**
* *
@ -26,324 +35,444 @@ const POST_PATH = '/aweme/v1/web/aweme/post/'
* @returns * @returns
*/ */
async function scrollAndCollectComments( async function scrollAndCollectComments(
context: BrowserContext, context: BrowserContext,
page: Page, page: Page,
durationMs: number = 10_000 durationMs: number = 10_000,
): Promise<Response[]> { ): Promise<Response[]> {
console.log(chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`)); console.log(
chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`),
);
// 启动评论响应收集器 // 启动评论响应收集器
const commentResponsesPromise = collectResponsesWithinTime( const commentResponsesPromise = collectResponsesWithinTime(
context, context,
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200 && r.request().frame()?.page() === page, (r: Response) =>
durationMs r.url().includes(COMMENT_PATH) &&
); r.status() === 200 &&
r.request().frame()?.page() === page,
durationMs,
);
// 在指定时间内持续滚动页面 // 在指定时间内持续滚动页面
const startTime = Date.now(); const startTime = Date.now();
const scrollInterval = 500; const scrollInterval = 500;
let scrollCount = 0; let scrollCount = 0;
const selector = "div[data-e2e='comment-list']"; const selector = "div[data-e2e='comment-list']";
// 1) 等元素出现并可见 // 1) 等元素出现并可见
await page.waitForSelector(selector, { state: 'visible', timeout: 5000 }); await page.waitForSelector(selector, { state: "visible", timeout: 5000 });
// 2) 确保滚动到可见区域 // 2) 确保滚动到可见区域
const list = page.locator(selector); const list = page.locator(selector);
await list.scrollIntoViewIfNeeded(); await list.scrollIntoViewIfNeeded();
// 3) 执行 hover推荐用 locator 的 hover // 3) 执行 hover推荐用 locator 的 hover
list.hover({ timeout: 5000 }).catch(() => { }); list.hover({ timeout: 5000 }).catch(() => {});
while (Date.now() - startTime < durationMs - 500) { // 留 500ms 缓冲 while (Date.now() - startTime < durationMs - 500) {
try { // 留 500ms 缓冲
list.hover({ timeout: 2000 }).catch(() => { }); try {
// 使用 Playwright 的 mouse.wheel 方法滚动 list.hover({ timeout: 2000 }).catch(() => {});
// 每次滚动一大段距离 // 使用 Playwright 的 mouse.wheel 方法滚动
// await list.hover(); // 每次滚动一大段距离
const scrollAmount = 1500; // await list.hover();
await page.mouse.wheel(0, scrollAmount); const scrollAmount = 1500;
await page.mouse.wheel(0, scrollAmount);
scrollCount++; scrollCount++;
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`)); console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
// 等待一段时间,让评论加载 // 等待一段时间,让评论加载
await page.waitForTimeout(scrollInterval); await page.waitForTimeout(scrollInterval);
} catch (e) {
} catch (e) { console.warn(
console.warn(chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`)); chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`),
} );
} }
}
// 等待收集器完成 // 等待收集器完成
const commentResponses = await commentResponsesPromise; const commentResponses = await commentResponsesPromise;
console.log(chalk.green(`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`)); console.log(
chalk.green(
`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`,
),
);
return commentResponses; return commentResponses;
} }
async function readPostMem(context: BrowserContext, page: Page) { async function readPostMem(context: BrowserContext, page: Page) {
const md = await page.evaluate(() => { const md = await page
// @ts-ignore .evaluate(() => {
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1] const captured = (window as any).__pace_captured__ as Array<unknown[]>;
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", '')) let data = captured.find(
// return {aweme: { detail: {} } }; (item) => typeof item[1] === "string" && item[1].includes(`"awemeId":`),
}).catch(() => null); )?.[1] as string | undefined;
if (!data) return null;
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ""));
// return {aweme: { detail: {} } };
})
.catch(() => null);
// await new Promise((res) => setTimeout(res, 1000000)); // await new Promise((res) => setTimeout(res, 1000000));
let aweme_mem = md?.aweme?.detail as DouyinImageAweme; let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情'); if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情");
// @ts-ignore // @ts-ignore
aweme_mem.author = aweme_mem.authorInfo aweme_mem.author = aweme_mem.authorInfo;
// @ts-ignore // @ts-ignore
aweme_mem.statistics = aweme_mem.stats aweme_mem.statistics = aweme_mem.stats;
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null; const comments = md.comment
const aweme = createCamelCompatibleProxy(aweme_mem); ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment)
: null;
const aweme = createCamelCompatibleProxy(aweme_mem);
return { aweme, comments } return { aweme, comments };
} }
export class ScrapeError extends Error { export class ScrapeError extends Error {
constructor( constructor(
message: string, message: string,
public statusCode: number = 500, public statusCode: number = 500,
public code?: string public code?: string,
) { ) {
super(message); super(message);
this.name = 'ScrapeError'; this.name = "ScrapeError";
} }
} }
export async function scrapeDouyin(url: string) { export async function scrapeDouyin(url: string) {
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...')); console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
let context: BrowserContext | null = await acquireIsolatedContext(); let context: BrowserContext | null = await acquireIsolatedContext();
const page = await context.newPage(); const page = await context.newPage();
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`)); console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
await page.addInitScript(() => { await page.addInitScript(() => {
// 建一个全局容器存捕获的数据 // 建一个全局容器存捕获的数据
(window as any).__pace_captured__ = []; (window as any).__pace_captured__ = [];
// 用 Proxy 包装一个数组,拦截 push // 用 Proxy 包装一个数组,拦截 push
const captured = (window as any).__pace_captured__; const captured = (window as any).__pace_captured__;
const proxyArr = new Proxy([] as any[], { const proxyArr = new Proxy([] as any[], {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === 'push') { if (prop === "push") {
return (...items: any[]) => { return (...items: any[]) => {
try { captured.push(...items); } catch { } try {
return Array.prototype.push.apply(target, items); captured.push(...items);
}; } catch {}
} return Array.prototype.push.apply(target, items);
return Reflect.get(target, prop, receiver); };
}, }
set(target, prop, value, receiver) { return Reflect.get(target, prop, receiver);
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b] },
if (prop === 'length') return Reflect.set(target, prop, value, receiver); set(target, prop, value, receiver) {
return Reflect.set(target, prop, value, receiver); // 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
} if (prop === "length")
}); return Reflect.set(target, prop, value, receiver);
return Reflect.set(target, prop, value, receiver);
(self as any).__pace_f = proxyArr; },
(window as any).__pace_f = proxyArr;
}); });
(self as any).__pace_f = proxyArr;
(window as any).__pace_f = proxyArr;
});
try {
// 先注册“先到先得”的监听,再导航,避免漏包
const firstTypePromise = waitForFirstResponse(
context,
[
{
key: "detail",
test: (r: Response) =>
r.url().includes(DETAIL_PATH) &&
r.status() === 200 &&
r.request().frame()?.page() === page,
},
{
key: "post",
test: (r: Response) =>
r.url().includes(POST_PATH) &&
r.status() === 200 &&
r.request().frame()?.page() === page,
},
],
10_000,
);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
// 查找页面中是否存在 "视频不存在" 的提示
const isNotFound = await page
.locator("text=视频不存在")
.count()
.then((count) => count > 0)
.catch(() => false);
if (isNotFound) {
console.error(chalk.red("✗ 视频不存在或已被删除"));
throw new ScrapeError("视频不存在或已被删除", 404, "VIDEO_NOT_FOUND");
}
// 等待作品类型判定
const firstType = await firstTypePromise;
// 尝试从内存读取图文数据(如果是图文作品)
let memoryData: {
aweme: any;
comments: DouyinCommentResponse | null;
} | null = null;
try { try {
// 先注册“先到先得”的监听,再导航,避免漏包 memoryData = await readPostMem(context, page);
const firstTypePromise = waitForFirstResponse(context, [ console.log(chalk.green("✓ 从内存读取图文数据成功"));
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 && r.request().frame()?.page() === page }, } catch {
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 && r.request().frame()?.page() === page }, // 内存读取失败,稍后通过网络获取
], 10_000); }
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 }); if (!firstType && !memoryData) {
console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据"));
throw new ScrapeError(
"无法获取作品数据,可能是网络问题或作品已下架",
404,
"NO_DATA",
);
}
// 查找页面中是否存在 "视频不存在" 的提示 console.log(
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false); chalk.cyan(
if (isNotFound) { `📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`,
console.error(chalk.red('✗ 视频不存在或已被删除')); ),
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND'); );
}
// 等待作品类型判定 let allComments: DouyinComment[] = [];
const firstType = await firstTypePromise; try {
// 开始滚动并收集评论
const commentResponses = await scrollAndCollectComments(context, page);
// 尝试从内存读取图文数据(如果是图文作品) // 解析所有收集到的评论响应
let memoryData: { aweme: any; comments: DouyinCommentResponse | null } | null = null; for (const commentRes of commentResponses) {
try { try {
memoryData = await readPostMem(context, page); const commentData = await safeJson<DouyinCommentResponse>(commentRes);
console.log(chalk.green('✓ 从内存读取图文数据成功')); if (commentData?.comments?.length) {
} catch { allComments.push(...commentData.comments);
// 内存读取失败,稍后通过网络获取 }
} catch (e) {
console.warn(
chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`),
);
} }
}
} catch (error) {
console.warn(
chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`),
);
}
if (!firstType && !memoryData) { // 去重评论(根据 cid
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据')); const uniqueComments = Array.from(
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA'); new Map(allComments.map((c) => [c.cid, c])).values(),
);
console.log(
chalk.green(
`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length}`,
),
);
// 如果从内存读取到了评论,合并进来作为兜底
let comments: DouyinCommentResponse;
if (memoryData?.comments?.comments?.length) {
console.log(
chalk.blue(
`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`,
),
);
const memComments = memoryData.comments.comments;
const mergedMap = new Map(uniqueComments.map((c) => [c.cid, c]));
for (const c of memComments) {
if (!mergedMap.has(c.cid)) {
mergedMap.set(c.cid, c);
} }
}
comments = {
comments: Array.from(mergedMap.values()),
total: mergedMap.size,
status_code: 0,
};
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
} else {
comments = {
comments: uniqueComments,
total: uniqueComments.length,
status_code: 0,
};
}
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType?.key === 'post' || memoryData ? '图文' : '视频')}`)); // 分支:视频 or 图文(两者只会有一个命中,先到先得)
// 优先处理内存数据(图文)
if (memoryData) {
const aweme = memoryData.aweme;
const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
console.log(chalk.green.bold("✓ 图文作品保存成功"));
return { type: "image", ...saved };
} else if (firstType?.key === "post") {
// 图文作品(网络)
const postJson = await safeJson<DouyinPostListResponse>(
firstType.response,
);
if (!postJson?.aweme_list?.length)
throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE");
let allComments: DouyinComment[] = []; const currentURL = page.url();
try { const target_aweme_id = currentURL.split("/").at(-1);
// 开始滚动并收集评论 const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
const commentResponses = await scrollAndCollectComments(context, page); let aweme = awemeList.find(
(pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id,
);
if (!aweme) {
throw new ScrapeError(
"无法找到目标作品,可能已被删除",
404,
"POST_NOT_FOUND",
);
}
// 解析所有收集到的评论响应 const uploads = await handleImagePost(context, aweme);
for (const commentRes of commentResponses) { const saved = await saveImagePostToDB(
try { context,
const commentData = await safeJson<DouyinCommentResponse>(commentRes); aweme,
if (commentData?.comments?.length) { comments,
allComments.push(...commentData.comments); uploads,
} postJson,
} catch (e) { ); // 传递完整 JSON
console.warn(chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`)); console.log(chalk.green.bold("✓ 图文作品保存成功"));
} return { type: "image", ...saved };
} } else if (firstType?.key === "detail") {
} catch (error) { // 视频作品
console.warn(chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`)); const detail = (await safeJson<DouyinVideoDetailResponse>(
} firstType.response,
))!;
// 找到比特率最高的 url
const bestPlayAddr = pickBestPlayAddr(
detail?.aweme_detail?.video.bit_rate,
);
const bestVUrl = bestPlayAddr?.url_list?.[0];
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
// 去重评论(根据 cid console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
const uniqueComments = Array.from( console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`));
new Map(allComments.map(c => [c.cid, c])).values() if (bestPlayAddr?.width && bestPlayAddr?.height) {
console.log(
chalk.cyan(
`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`,
),
);
}
// 下载视频并上传至 MinIO获取外链
let uploadedUrl: string | undefined;
let coverUrl: string | undefined;
if (bestVUrl && detail?.aweme_detail) {
console.log(chalk.blue("⬇️ 正在下载视频..."));
const { buffer, contentType, ext } = await downloadBinary(
context,
bestVUrl,
);
const awemeId = detail.aweme_detail.aweme_id;
const fileName = generateUniqueFileName(
`${awemeId}.${ext}`,
"douyin/videos",
); );
console.log(chalk.green(`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length}`)); console.log(chalk.blue("⬆️ 正在上传视频到 MinIO..."));
uploadedUrl = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
console.log(
chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`),
);
// 如果从内存读取到了评论,合并进来作为兜底 // 提取首帧作为封面并上传
let comments: DouyinCommentResponse; try {
if (memoryData?.comments?.comments?.length) { console.log(chalk.blue("🖼️ 正在提取视频封面..."));
console.log(chalk.blue(`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`)); const cover = await extractFirstFrame(buffer);
const memComments = memoryData.comments.comments; if (cover) {
const mergedMap = new Map(uniqueComments.map(c => [c.cid, c])); const coverName = generateUniqueFileName(
for (const c of memComments) { `${awemeId}.jpg`,
if (!mergedMap.has(c.cid)) { "douyin/covers",
mergedMap.set(c.cid, c);
}
}
comments = {
comments: Array.from(mergedMap.values()),
total: mergedMap.size,
status_code: 0
};
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
} else {
comments = {
comments: uniqueComments,
total: uniqueComments.length,
status_code: 0
};
}
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
// 优先处理内存数据(图文)
if (memoryData) {
const aweme = memoryData.aweme;
const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
console.log(chalk.green.bold('✓ 图文作品保存成功'));
return { type: "image", ...saved };
} else if (firstType?.key === 'post') {
// 图文作品(网络)
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
const currentURL = page.url();
const target_aweme_id = currentURL.split('/').at(-1);
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
if (!aweme) {
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
}
const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
console.log(chalk.green.bold('✓ 图文作品保存成功'));
return { type: "image", ...saved };
} else if (firstType?.key === 'detail') {
// 视频作品
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
// 找到比特率最高的 url
const bestPlayAddr = pickBestPlayAddr(
detail?.aweme_detail?.video.bit_rate
); );
const bestVUrl = bestPlayAddr?.url_list?.[0]; coverUrl = await uploadFile(cover.buffer, coverName, {
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS "Content-Type": cover.contentType,
});
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`)); console.log(
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`)); chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`),
if (bestPlayAddr?.width && bestPlayAddr?.height) { );
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`)); }
} } catch (e) {
console.warn(
// 下载视频并上传至 MinIO获取外链 chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`),
let uploadedUrl: string | undefined; );
let coverUrl: string | undefined;
if (bestVUrl && detail?.aweme_detail) {
console.log(chalk.blue('⬇️ 正在下载视频...'));
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
const awemeId = detail.aweme_detail.aweme_id;
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
// 提取首帧作为封面并上传
try {
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
const cover = await extractFirstFrame(buffer);
if (cover) {
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
}
} catch (e) {
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`));
}
}
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
console.log(chalk.green.bold('✓ 视频作品保存成功'));
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
return { type: "video", ...saved };
} else {
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
}
} catch (error) {
// 如果是我们自定义的错误,直接抛出
if (error instanceof ScrapeError) {
throw error;
} }
}
// 处理其他类型的错误 const saved = await saveToDB(
const errMsg = (error as Error)?.message || String(error); context,
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`)); detail,
comments,
// 根据错误类型返回不同的状态码 uploadedUrl,
if (errMsg.includes('timeout') || errMsg.includes('超时')) { bestPlayAddr?.width,
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT'); bestPlayAddr?.height,
} coverUrl,
if (errMsg.includes('页面内存数据中未找到作品详情')) { fps ?? undefined,
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED'); );
} console.log(chalk.green.bold("✓ 视频作品保存成功"));
if (errMsg.includes('net::')) { transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR'); return { type: "video", ...saved };
} } else {
throw new ScrapeError(
// 默认服务器错误 "无法判定作品类型,接口响应异常",
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR'); 500,
} finally { "UNKNOWN_TYPE",
console.log(chalk.gray('🧹 清理资源...')); );
try { await page.close({ runBeforeUnload: true }); } catch { } }
// 关闭本次任务的隔离上下文与浏览器 } catch (error) {
await releaseIsolatedContext(context); // 如果是我们自定义的错误,直接抛出
await prisma.$disconnect(); if (error instanceof ScrapeError) {
console.log(chalk.gray('✓ 资源清理完成')); throw error;
} }
}
// 处理其他类型的错误
const errMsg = (error as Error)?.message || String(error);
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
// 根据错误类型返回不同的状态码
if (errMsg.includes("timeout") || errMsg.includes("超时")) {
throw new ScrapeError("请求超时,请稍后重试", 408, "TIMEOUT");
}
if (errMsg.includes("页面内存数据中未找到作品详情")) {
throw new ScrapeError("作品数据加载失败", 404, "DATA_NOT_LOADED");
}
if (errMsg.includes("net::")) {
throw new ScrapeError("网络连接失败", 503, "NETWORK_ERROR");
}
// 默认服务器错误
throw new ScrapeError(
errMsg || "爬取过程中发生未知错误",
500,
"UNKNOWN_ERROR",
);
} finally {
console.log(chalk.gray("🧹 清理资源..."));
try {
await page.close({ runBeforeUnload: true });
} catch {}
// 关闭本次任务的隔离上下文与浏览器
await releaseIsolatedContext(context);
await prisma.$disconnect();
console.log(chalk.gray("✓ 资源清理完成"));
}
}

View File

@ -1,17 +1,16 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
import { execFile } from 'child_process';
import { promisify } from 'util';
import { execFile } from "child_process";
import { promisify } from "util";
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) { export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
if (!variants?.length) return null; if (!variants?.length) return null;
const best = variants.reduce((best, cur) => { const best = variants.reduce((best, cur) => {
const b1 = best?.bit_rate ?? -1; const b1 = best?.bit_rate ?? -1;
const b2 = cur?.bit_rate ?? -1; const b2 = cur?.bit_rate ?? -1;
return b2 > b1 ? cur : best; return b2 > b1 ? cur : best;
}); });
return best?.play_addr ?? null; return best?.play_addr ?? null;
} }

View File

@ -1,18 +1,18 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
import type { BrowserContext, Response } from 'playwright'; import type { BrowserContext, Response } from "playwright";
export async function safeJson<T>(res: Response): Promise<T | null> { export async function safeJson<T>(res: Response): Promise<T | null> {
const ctype = res.headers()['content-type'] || ''; const ctype = res.headers()["content-type"] || "";
if (ctype.includes('application/json')) { if (ctype.includes("application/json")) {
return (await res.json()) as T; return (await res.json()) as T;
} }
const t = await res.text(); const t = await res.text();
try { try {
return JSON.parse(t) as T; return JSON.parse(t) as T;
} catch { } catch {
return null; return null;
} }
} }
/** /**
@ -21,30 +21,31 @@ export async function safeJson<T>(res: Response): Promise<T | null> {
* - referrer 使 * - referrer 使
*/ */
export async function downloadBinary( export async function downloadBinary(
context: BrowserContext, context: BrowserContext,
url: string, url: string,
): Promise<{ buffer: Buffer; contentType: string; ext: string }> { ): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
console.log('下载:', url); console.log("下载:", url);
const headers = { const headers = {
referer: 'https://www.douyin.com/', referer: "https://www.douyin.com/",
} as Record<string, string>; } as Record<string, string>;
const res = await context.request.get(url, { const res = await context.request.get(url, {
headers, headers,
maxRedirects: 3, maxRedirects: 3,
timeout: 240_000, timeout: 240_000,
failOnStatusCode: true, failOnStatusCode: true,
}); });
if (!res.ok()) { if (!res.ok()) {
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`); throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
} }
const buffer = await res.body(); const buffer = await res.body();
const contentType = res.headers()['content-type'] || 'application/octet-stream'; const contentType =
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin'; res.headers()["content-type"] || "application/octet-stream";
return { buffer, contentType, ext }; const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin";
return { buffer, contentType, ext };
} }
/** /**
@ -52,46 +53,46 @@ export async function downloadBinary(
* - * -
*/ */
export function waitForFirstResponse( export function waitForFirstResponse(
context: BrowserContext, context: BrowserContext,
candidates: { key: string; test: (r: Response) => boolean }[], candidates: { key: string; test: (r: Response) => boolean }[],
timeoutMs = 20_000 timeoutMs = 20_000,
): Promise<{ key: string; response: Response } | null> { ): Promise<{ key: string; response: Response } | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
let timer: NodeJS.Timeout | undefined; let timer: NodeJS.Timeout | undefined;
const handler = (res: Response) => { const handler = (res: Response) => {
if (resolved) return; if (resolved) return;
for (const c of candidates) { for (const c of candidates) {
try { try {
if (c.test(res)) { if (c.test(res)) {
resolved = true; resolved = true;
cleanup(); cleanup();
resolve({ key: c.key, response: res }); resolve({ key: c.key, response: res });
return; return;
} }
} catch { } catch {
// ignore predicate errors // ignore predicate errors
}
}
};
const cleanup = () => {
context.off('response', handler);
if (timer) clearTimeout(timer);
};
context.on('response', handler);
if (timeoutMs > 0) {
timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
resolve(null);
}
}, timeoutMs);
} }
}); }
};
const cleanup = () => {
context.off("response", handler);
if (timer) clearTimeout(timer);
};
context.on("response", handler);
if (timeoutMs > 0) {
timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
resolve(null);
}
}, timeoutMs);
}
});
} }
/** /**
@ -99,76 +100,76 @@ export function waitForFirstResponse(
* *
*/ */
export function collectResponsesWithinTime( export function collectResponsesWithinTime(
context: BrowserContext, context: BrowserContext,
predicate: (r: Response) => boolean, predicate: (r: Response) => boolean,
durationMs: number durationMs: number,
): Promise<Response[]> { ): Promise<Response[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const collected: Response[] = []; const collected: Response[] = [];
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
let timer: NodeJS.Timeout | undefined; let timer: NodeJS.Timeout | undefined;
const handler = (res: Response) => { const handler = (res: Response) => {
try { try {
if (predicate(res)) { if (predicate(res)) {
// 使用 URL 去重,避免重复收集同一个请求 // 使用 URL 去重,避免重复收集同一个请求
const url = res.url(); const url = res.url();
if (!seenUrls.has(url)) { if (!seenUrls.has(url)) {
seenUrls.add(url); seenUrls.add(url);
collected.push(res); collected.push(res);
} }
} }
} catch { } catch {
// ignore predicate errors // ignore predicate errors
} }
}; };
const cleanup = () => { const cleanup = () => {
context.off('response', handler); context.off("response", handler);
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
context.on('response', handler); context.on("response", handler);
timer = setTimeout(() => { timer = setTimeout(() => {
cleanup(); cleanup();
resolve(collected); resolve(collected);
}, durationMs); }, durationMs);
}); });
} }
/** /**
* Response"可有可无" * Response"可有可无"
*/ */
export function waitForResponseWithTimeout( export function waitForResponseWithTimeout(
context: BrowserContext, context: BrowserContext,
predicate: (r: Response) => boolean, predicate: (r: Response) => boolean,
timeoutMs = 5_000 timeoutMs = 5_000,
): Promise<Response> { ): Promise<Response> {
return new Promise<Response>((resolve, reject) => { return new Promise<Response>((resolve, reject) => {
let timer: NodeJS.Timeout | undefined; let timer: NodeJS.Timeout | undefined;
const handler = (res: Response) => { const handler = (res: Response) => {
try { try {
if (predicate(res)) { if (predicate(res)) {
cleanup(); cleanup();
resolve(res); resolve(res);
}
} catch {
// ignore predicate errors
}
};
const cleanup = () => {
context.off('response', handler);
if (timer) clearTimeout(timer);
};
context.on('response', handler);
if (timeoutMs > 0) {
timer = setTimeout(() => {
cleanup();
reject(new Error('timeout'));
}, timeoutMs);
} }
}); } catch {
// ignore predicate errors
}
};
const cleanup = () => {
context.off("response", handler);
if (timer) clearTimeout(timer);
};
context.on("response", handler);
if (timeoutMs > 0) {
timer = setTimeout(() => {
cleanup();
reject(new Error("timeout"));
}, timeoutMs);
}
});
} }

View File

@ -1,359 +1,427 @@
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from "playwright";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { uploadAvatarFromUrl, uploadImageFromUrl } from './uploader'; import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader";
import { firstUrl } from './utils'; import { firstUrl } from "./utils";
export async function saveToDB( export async function saveToDB(
context: BrowserContext, context: BrowserContext,
detailResp: DouyinVideoDetailResponse, detailResp: DouyinVideoDetailResponse,
commentResp: DouyinCommentResponse, commentResp: DouyinCommentResponse,
videoUrl?: string, videoUrl?: string,
width?: number, width?: number,
height?: number, height?: number,
coverUrl?: string, coverUrl?: string,
fps?: number fps?: number,
) { ) {
if (!detailResp?.aweme_detail) throw new Error('视频详情为空'); if (!detailResp?.aweme_detail) throw new Error("视频详情为空");
const d = detailResp.aweme_detail; const d = detailResp.aweme_detail;
// 1) Upsert Author // 1) Upsert Author
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list); const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`); const authorAvatarUploaded = await uploadAvatarFromUrl(
const author = await prisma.author.upsert({ context,
where: { sec_uid: d.author.sec_uid }, authorAvatarSrc,
create: { `authors/${d.author.sec_uid}`,
sec_uid: d.author.sec_uid, );
uid: d.author.uid, const author = await prisma.author.upsert({
nickname: d.author.nickname, where: { sec_uid: d.author.sec_uid },
signature: d.author.signature ?? null, create: {
avatar_url: authorAvatarUploaded ?? null, sec_uid: d.author.sec_uid,
follower_count: BigInt(d.author.follower_count || 0), uid: d.author.uid,
total_favorited: BigInt(d.author.total_favorited || 0), nickname: d.author.nickname,
unique_id: d.author.unique_id ?? null, signature: d.author.signature ?? null,
short_id: d.author.short_id ?? null, avatar_url: authorAvatarUploaded ?? null,
}, follower_count: BigInt(d.author.follower_count || 0),
update: { total_favorited: BigInt(d.author.total_favorited || 0),
uid: d.author.uid, unique_id: d.author.unique_id ?? null,
nickname: d.author.nickname, short_id: d.author.short_id ?? null,
signature: d.author.signature ?? null, },
avatar_url: authorAvatarUploaded ?? null, update: {
follower_count: BigInt(d.author.follower_count || 0), uid: d.author.uid,
total_favorited: BigInt(d.author.total_favorited || 0), nickname: d.author.nickname,
unique_id: d.author.unique_id ?? null, signature: d.author.signature ?? null,
short_id: d.author.short_id ?? null, avatar_url: authorAvatarUploaded ?? null,
follower_count: BigInt(d.author.follower_count || 0),
total_favorited: BigInt(d.author.total_favorited || 0),
unique_id: d.author.unique_id ?? null,
short_id: d.author.short_id ?? null,
},
});
// 2) Upsert Video
const video = await prisma.video.upsert({
where: { aweme_id: d.aweme_id },
create: {
aweme_id: d.aweme_id,
desc: d.desc,
preview_title: d.preview_title ?? null,
duration_ms: d.duration,
created_at: new Date((d.create_time || 0) * 1000),
share_url: d.share_url,
digg_count: BigInt(d.statistics?.digg_count || 0),
comment_count: BigInt(d.statistics?.comment_count || 0),
share_count: BigInt(d.statistics?.share_count || 0),
collect_count: BigInt(d.statistics?.collect_count || 0),
authorId: author.sec_uid,
tags: d.tags?.map((t) => t.tag_name) ?? [],
video_url: videoUrl ?? "",
width: width ?? null,
height: height ?? null,
cover_url: coverUrl ?? null,
fps: fps ?? null,
raw_json: detailResp as any, // 保存完整接口 JSON
},
update: {
desc: d.desc,
preview_title: d.preview_title ?? null,
duration_ms: d.duration,
created_at: new Date((d.create_time || 0) * 1000),
share_url: d.share_url,
digg_count: BigInt(d.statistics?.digg_count || 0),
comment_count: BigInt(d.statistics?.comment_count || 0),
share_count: BigInt(d.statistics?.share_count || 0),
collect_count: BigInt(d.statistics?.collect_count || 0),
authorId: author.sec_uid,
...(videoUrl ? { video_url: videoUrl } : {}),
...(width ? { width } : {}),
...(height ? { height } : {}),
...(coverUrl ? { cover_url: coverUrl } : {}),
...(fps ? { fps } : {}),
raw_json: detailResp as any, // 更新完整接口 JSON
},
});
// 3) Upsert Comments + CommentUser
const comments = commentResp?.comments ?? [];
for (const c of comments) {
const origAvatar: string | null =
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
const uploadedAvatar = await uploadAvatarFromUrl(
context,
origAvatar ?? undefined,
nameHint,
);
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
const finalAvatarKey = finalAvatar ?? "";
const cu = await prisma.commentUser.upsert({
where: {
nickname_avatar_url: {
nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatarKey,
}, },
},
create: {
nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatar ?? null,
},
update: {
avatar_url: finalAvatar ?? null,
},
}); });
// 2) Upsert Video const savedComment = await prisma.comment.upsert({
const video = await prisma.video.upsert({ where: { cid: c.cid },
where: { aweme_id: d.aweme_id }, create: {
create: { cid: c.cid,
aweme_id: d.aweme_id, text: c.text,
desc: d.desc, digg_count: BigInt(c.digg_count || 0),
preview_title: d.preview_title ?? null, created_at: new Date((c.create_time || 0) * 1000),
duration_ms: d.duration, videoId: video.aweme_id,
created_at: new Date((d.create_time || 0) * 1000), userId: cu.id,
share_url: d.share_url, },
digg_count: BigInt(d.statistics?.digg_count || 0), update: {
comment_count: BigInt(d.statistics?.comment_count || 0), text: c.text,
share_count: BigInt(d.statistics?.share_count || 0), digg_count: BigInt(c.digg_count || 0),
collect_count: BigInt(d.statistics?.collect_count || 0), created_at: new Date((c.create_time || 0) * 1000),
authorId: author.sec_uid, videoId: video.aweme_id,
tags: (d.tags?.map(t => t.tag_name) ?? []), userId: cu.id,
video_url: videoUrl ?? '', },
width: width ?? null,
height: height ?? null,
cover_url: coverUrl ?? null,
fps: fps ?? null,
raw_json: detailResp as any, // 保存完整接口 JSON
},
update: {
desc: d.desc,
preview_title: d.preview_title ?? null,
duration_ms: d.duration,
created_at: new Date((d.create_time || 0) * 1000),
share_url: d.share_url,
digg_count: BigInt(d.statistics?.digg_count || 0),
comment_count: BigInt(d.statistics?.comment_count || 0),
share_count: BigInt(d.statistics?.share_count || 0),
collect_count: BigInt(d.statistics?.collect_count || 0),
authorId: author.sec_uid,
...(videoUrl ? { video_url: videoUrl } : {}),
...(width ? { width } : {}),
...(height ? { height } : {}),
...(coverUrl ? { cover_url: coverUrl } : {}),
...(fps ? { fps } : {}),
raw_json: detailResp as any, // 更新完整接口 JSON
},
}); });
// 3) Upsert Comments + CommentUser // 处理评论贴纸/配图上传与入库
const comments = commentResp?.comments ?? []; try {
for (const c of comments) { const sources: {
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null; url?: string | null;
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`; width?: number;
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint); height?: number;
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null }[] = [];
const finalAvatarKey = finalAvatar ?? ''; // 贴纸(当作第一张)
const cu = await prisma.commentUser.upsert({ const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
where: { if (stickerUrl) {
nickname_avatar_url: { sources.push({
nickname: c.user?.nickname || '未知用户', url: stickerUrl,
avatar_url: finalAvatarKey, width: c.sticker?.animate_url?.width,
}, height: c.sticker?.animate_url?.height,
},
create: {
nickname: c.user?.nickname || '未知用户',
avatar_url: finalAvatar ?? null,
},
update: {
avatar_url: finalAvatar ?? null,
},
}); });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u)
sources.push({
url: u,
width: it?.origin_url?.width as any,
height: it?.origin_url?.height as any,
});
}
const savedComment = await prisma.comment.upsert({ for (let i = 0; i < sources.length; i++) {
where: { cid: c.cid }, const s = sources[i];
create: { const uploaded = await uploadImageFromUrl(
cid: c.cid, context,
text: c.text, s.url ?? undefined,
digg_count: BigInt(c.digg_count || 0), `comments/${c.cid}/${i}`,
created_at: new Date((c.create_time || 0) * 1000), );
videoId: video.aweme_id, if (!uploaded) continue;
userId: cu.id, await prisma.commentImage.upsert({
}, where: { commentId_order: { commentId: savedComment.cid, order: i } },
update: { create: {
text: c.text, commentId: savedComment.cid,
digg_count: BigInt(c.digg_count || 0), order: i,
created_at: new Date((c.create_time || 0) * 1000), url: uploaded,
videoId: video.aweme_id, width: typeof s.width === "number" ? s.width : null,
userId: cu.id, height: typeof s.height === "number" ? s.height : null,
}, },
update: {
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
}); });
}
// 处理评论贴纸/配图上传与入库 } catch (e) {
try { console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
const sources: { url?: string | null; width?: number; height?: number }[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
}
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
});
}
} catch (e) {
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
}
} }
}
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length }; return {
aweme_id: video.aweme_id,
author_sec_uid: author.sec_uid,
comment_count: comments.length,
};
} }
export async function saveImagePostToDB( export async function saveImagePostToDB(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme, aweme: DouyinImageAweme,
commentResp: DouyinCommentResponse, commentResp: DouyinCommentResponse,
uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, uploads: {
rawJson?: any images: {
url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[];
musicUrl?: string;
},
rawJson?: any,
) { ) {
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); if (!aweme?.author?.sec_uid) throw new Error("作者 sec_uid 缺失");
// Upsert Author与视频一致 // Upsert Author与视频一致
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list); const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`); const authorAvatarUploaded = await uploadAvatarFromUrl(
const author = await prisma.author.upsert({ context,
where: { sec_uid: aweme.author.sec_uid }, authorAvatarSrc,
create: { `authors/${aweme.author.sec_uid}`,
sec_uid: aweme.author.sec_uid, );
uid: aweme.author.uid, const author = await prisma.author.upsert({
nickname: aweme.author.nickname, where: { sec_uid: aweme.author.sec_uid },
signature: aweme.author.signature ?? null, create: {
avatar_url: authorAvatarUploaded ?? null, sec_uid: aweme.author.sec_uid,
follower_count: BigInt((aweme.author as any).follower_count || 0), uid: aweme.author.uid,
total_favorited: BigInt((aweme.author as any).total_favorited || 0), nickname: aweme.author.nickname,
unique_id: (aweme.author as any).unique_id ?? null, signature: aweme.author.signature ?? null,
short_id: (aweme.author as any).short_id ?? null, avatar_url: authorAvatarUploaded ?? null,
}, follower_count: BigInt((aweme.author as any).follower_count || 0),
update: { total_favorited: BigInt((aweme.author as any).total_favorited || 0),
uid: aweme.author.uid, unique_id: (aweme.author as any).unique_id ?? null,
nickname: aweme.author.nickname, short_id: (aweme.author as any).short_id ?? null,
signature: aweme.author.signature ?? null, },
avatar_url: authorAvatarUploaded ?? null, update: {
follower_count: BigInt((aweme.author as any).follower_count || 0), uid: aweme.author.uid,
total_favorited: BigInt((aweme.author as any).total_favorited || 0), nickname: aweme.author.nickname,
unique_id: (aweme.author as any).unique_id ?? null, signature: aweme.author.signature ?? null,
short_id: (aweme.author as any).short_id ?? null, avatar_url: authorAvatarUploaded ?? null,
follower_count: BigInt((aweme.author as any).follower_count || 0),
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
unique_id: (aweme.author as any).unique_id ?? null,
short_id: (aweme.author as any).short_id ?? null,
},
});
// Upsert ImagePost
const imagePost = await prisma.imagePost.upsert({
where: { aweme_id: aweme.aweme_id },
create: {
aweme_id: aweme.aweme_id,
desc: aweme.desc,
created_at: new Date((aweme.create_time || 0) * 1000),
share_url: aweme.share_url || "",
digg_count: BigInt(aweme.statistics?.digg_count || 0),
comment_count: BigInt(aweme.statistics?.comment_count || 0),
share_count: BigInt(aweme.statistics?.share_count || 0),
collect_count: BigInt(aweme.statistics?.collect_count || 0),
authorId: author.sec_uid,
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
music_url: uploads.musicUrl ?? null,
raw_json: rawJson ?? null, // 保存完整接口 JSON
},
update: {
desc: aweme.desc,
created_at: new Date((aweme.create_time || 0) * 1000),
share_url: aweme.share_url,
digg_count: BigInt(aweme.statistics?.digg_count || 0),
comment_count: BigInt(aweme.statistics?.comment_count || 0),
share_count: BigInt(aweme.statistics?.share_count || 0),
collect_count: BigInt(aweme.statistics?.collect_count || 0),
authorId: author.sec_uid,
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
music_url: uploads.musicUrl ?? undefined,
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
},
});
// Upsert ImageFiles按顺序
for (let i = 0; i < uploads.images.length; i++) {
const { url, width, height, video, duration } = uploads.images[i];
const durationMs =
typeof duration === "number" && Number.isFinite(duration)
? Math.max(1, Math.round(duration))
: null;
await prisma.imageFile.upsert({
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
create: {
postId: imagePost.aweme_id,
order: i,
url,
width: typeof width === "number" ? width : null,
height: typeof height === "number" ? height : null,
animated: video || null,
duration: durationMs,
},
update: {
url,
width: typeof width === "number" ? width : null,
height: typeof height === "number" ? height : null,
animated: video || null,
duration: durationMs,
},
});
}
// 评论入库:关联到 ImagePost
const comments = commentResp?.comments ?? [];
for (const c of comments) {
const origAvatar: string | null =
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
const uploadedAvatar = await uploadAvatarFromUrl(
context,
origAvatar ?? undefined,
nameHint,
);
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
const finalAvatarKey = finalAvatar ?? "";
const cu = await prisma.commentUser.upsert({
where: {
nickname_avatar_url: {
nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatarKey,
}, },
},
create: {
nickname: c.user?.nickname || "未知用户",
avatar_url: finalAvatar ?? null,
},
update: {
avatar_url: finalAvatar ?? null,
},
}); });
// Upsert ImagePost const savedComment = await prisma.comment.upsert({
const imagePost = await prisma.imagePost.upsert({ where: { cid: c.cid },
where: { aweme_id: aweme.aweme_id }, create: {
create: { cid: c.cid,
aweme_id: aweme.aweme_id, text: c.text,
desc: aweme.desc, digg_count: BigInt(c.digg_count || 0),
created_at: new Date((aweme.create_time || 0) * 1000), created_at: new Date((c.create_time || 0) * 1000),
share_url: aweme.share_url || '', imagePostId: imagePost.aweme_id,
digg_count: BigInt(aweme.statistics?.digg_count || 0), userId: cu.id,
comment_count: BigInt(aweme.statistics?.comment_count || 0), },
share_count: BigInt(aweme.statistics?.share_count || 0), update: {
collect_count: BigInt(aweme.statistics?.collect_count || 0), text: c.text,
authorId: author.sec_uid, digg_count: BigInt(c.digg_count || 0),
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []), created_at: new Date((c.create_time || 0) * 1000),
music_url: uploads.musicUrl ?? null, imagePostId: imagePost.aweme_id,
raw_json: rawJson ?? null, // 保存完整接口 JSON userId: cu.id,
}, },
update: {
desc: aweme.desc,
created_at: new Date((aweme.create_time || 0) * 1000),
share_url: aweme.share_url,
digg_count: BigInt(aweme.statistics?.digg_count || 0),
comment_count: BigInt(aweme.statistics?.comment_count || 0),
share_count: BigInt(aweme.statistics?.share_count || 0),
collect_count: BigInt(aweme.statistics?.collect_count || 0),
authorId: author.sec_uid,
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
music_url: uploads.musicUrl ?? undefined,
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
},
}); });
// Upsert ImageFiles按顺序 // 处理评论贴纸/配图上传与入库
for (let i = 0; i < uploads.images.length; i++) { try {
const { url, width, height, video } = uploads.images[i]; const sources: {
await prisma.imageFile.upsert({ url?: string | null;
where: { postId_order: { postId: imagePost.aweme_id, order: i } }, width?: number;
create: { height?: number;
postId: imagePost.aweme_id, }[] = [];
order: i, // 贴纸(当作第一张)
url, const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
width: typeof width === 'number' ? width : null, if (stickerUrl) {
height: typeof height === 'number' ? height : null, sources.push({
animated: video || null, url: stickerUrl,
}, width: c.sticker?.animate_url?.width,
update: { height: c.sticker?.animate_url?.height,
url,
width: typeof width === 'number' ? width : null,
height: typeof height === 'number' ? height : null,
animated: video || null,
},
}); });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u)
sources.push({
url: u,
width: it?.origin_url?.width as any,
height: it?.origin_url?.height as any,
});
}
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === "number" ? s.width : null,
height: typeof s.height === "number" ? s.height : null,
},
});
}
} catch (e) {
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
} }
}
// 评论入库:关联到 ImagePost return {
const comments = commentResp?.comments ?? []; aweme_id: imagePost.aweme_id,
for (const c of comments) { author_sec_uid: author.sec_uid,
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null; image_count: uploads.images.length,
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`; comment_count: comments.length,
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint); };
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
const finalAvatarKey = finalAvatar ?? '';
const cu = await prisma.commentUser.upsert({
where: {
nickname_avatar_url: {
nickname: c.user?.nickname || '未知用户',
avatar_url: finalAvatarKey,
},
},
create: {
nickname: c.user?.nickname || '未知用户',
avatar_url: finalAvatar ?? null,
},
update: {
avatar_url: finalAvatar ?? null,
},
});
const savedComment = await prisma.comment.upsert({
where: { cid: c.cid },
create: {
cid: c.cid,
text: c.text,
digg_count: BigInt(c.digg_count || 0),
created_at: new Date((c.create_time || 0) * 1000),
imagePostId: imagePost.aweme_id,
userId: cu.id,
},
update: {
text: c.text,
digg_count: BigInt(c.digg_count || 0),
created_at: new Date((c.create_time || 0) * 1000),
imagePostId: imagePost.aweme_id,
userId: cu.id,
},
});
// 处理评论贴纸/配图上传与入库
try {
const sources: { url?: string | null; width?: number; height?: number }[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
}
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
});
}
} catch (e) {
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
}
}
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };
} }

View File

@ -1,51 +1,51 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server";
import { prisma } from '@/lib/prisma' import { prisma } from "@/lib/prisma";
import { scrapeDouyin, ScrapeError } from '.'; import { scrapeDouyin, ScrapeError } from ".";
async function handleDouyinScrape(req: NextRequest) { async function handleDouyinScrape(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const videoUrl = searchParams.get('url'); const videoUrl = searchParams.get("url");
if (!videoUrl) { if (!videoUrl) {
return NextResponse.json( return NextResponse.json(
{ error: '缺少视频URL', code: 'MISSING_URL' }, { error: "缺少视频URL", code: "MISSING_URL" },
{ status: 400 } { status: 400 },
); );
}
try {
// 调用爬虫函数
const result = await scrapeDouyin(videoUrl);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
// 处理自定义的 ScrapeError
if (error instanceof ScrapeError) {
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code,
},
{ status: error.statusCode },
);
} }
try { // 处理未知错误
// 调用爬虫函数 console.error("未捕获的错误:", error);
const result = await scrapeDouyin(videoUrl); return NextResponse.json(
return NextResponse.json({ {
success: true, success: false,
data: result error: "服务器内部错误",
}); code: "INTERNAL_ERROR",
} catch (error) { },
// 处理自定义的 ScrapeError { status: 500 },
if (error instanceof ScrapeError) { );
return NextResponse.json( }
{
success: false,
error: error.message,
code: error.code
},
{ status: error.statusCode }
);
}
// 处理未知错误
console.error('未捕获的错误:', error);
return NextResponse.json(
{
success: false,
error: '服务器内部错误',
code: 'INTERNAL_ERROR'
},
{ status: 500 }
);
}
} }
export const GET = handleDouyinScrape export const GET = handleDouyinScrape;

View File

@ -17,17 +17,17 @@ interface DouyinComment {
animate_url: { animate_url: {
width: number; width: number;
height: number; height: number;
url_list: string[] url_list: string[];
} };
}, };
image_list?: { image_list?: {
origin_url:{ origin_url: {
width: number; width: number;
height: number; height: number;
url_list: string[] url_list: string[];
} };
}[] }[];
} }
/** 用户信息(精简版) */ /** 用户信息(精简版) */
@ -45,35 +45,35 @@ interface DouyinVideoDetailResponse {
} }
/** 作者信息(精简版) */ /** 作者信息(精简版) */
interface DouyinAuthor { interface DouyinAuthor {
uid: string; // 用户ID uid: string; // 用户ID
sec_uid: string; // 安全UID sec_uid: string; // 安全UID
nickname: string; // 用户昵称 nickname: string; // 用户昵称
signature: string; // 个性签名 signature: string; // 个性签名
avatar_thumb: { avatar_thumb: {
url_list: string[]; // 头像URL可取第一个 url_list: string[]; // 头像URL可取第一个
}; };
follower_count: number; // 粉丝数 follower_count: number; // 粉丝数
total_favorited: number; // 获赞总数 total_favorited: number; // 获赞总数
unique_id: string; // 抖音号 unique_id: string; // 抖音号
short_id: string; // 短ID short_id: string; // 短ID
} }
/** 视频详情 */ /** 视频详情 */
interface DouyinVideoDetail { interface DouyinVideoDetail {
aweme_id: string; // 视频ID aweme_id: string; // 视频ID
desc: string; // 视频描述 desc: string; // 视频描述
preview_title?: string; // 视频标题(有些字段中叫 preview_title preview_title?: string; // 视频标题(有些字段中叫 preview_title
duration: number; // 视频时长(毫秒) duration: number; // 视频时长(毫秒)
create_time: number; // 创建时间(时间戳) create_time: number; // 创建时间(时间戳)
share_url: string; // 视频分享链接 share_url: string; // 视频分享链接
statistics: { statistics: {
digg_count: number; // 点赞数 digg_count: number; // 点赞数
comment_count: number; // 评论数 comment_count: number; // 评论数
share_count: number; // 分享数 share_count: number; // 分享数
collect_count: number; // 收藏数 collect_count: number; // 收藏数
}; };
author: DouyinAuthor; // 作者信息 author: DouyinAuthor; // 作者信息
video: VideoPlayBasic; video: VideoPlayBasic;
tags: VideoTagBasic[]; tags: VideoTagBasic[];
} }
@ -88,9 +88,9 @@ interface VideoPlayBasic {
/** 单个清晰度变体(来自 bit_rate[*] + play_addr */ /** 单个清晰度变体(来自 bit_rate[*] + play_addr */
interface PlayVariant { interface PlayVariant {
format: string; // mp4 等 format: string; // mp4 等
FPS: number; FPS: number;
bit_rate: number; // bit_rate.bit_rate bit_rate: number; // bit_rate.bit_rate
/** 直连播放地址(最关键) */ /** 直连播放地址(最关键) */
play_addr: { play_addr: {
@ -101,7 +101,7 @@ interface PlayVariant {
data_size: number; data_size: number;
FPS: number; FPS: number;
is_bytevc1: number; // 0 or 1 is_bytevc1: number; // 0 or 1
is_h265: number; // 0 or 1 is_h265: number; // 0 or 1
}; };
} }
@ -132,7 +132,7 @@ interface DouyinImageAweme {
}; };
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid
images: DouyinImageInfo[]; // 图片列表 images: DouyinImageInfo[]; // 图片列表
music?: DouyinMusicBasic; // 背景音乐(可选) music?: DouyinMusicBasic; // 背景音乐(可选)
video_tag?: VideoTagBasic[]; // 标签 video_tag?: VideoTagBasic[]; // 标签
} }
@ -143,7 +143,7 @@ interface DouyinImageInfo {
width: number; width: number;
height: number; height: number;
video: { video: {
play_addr: { src: string }[] play_addr: { src: string }[];
} | null; // 如果是动图,会有 video 信息 } | null; // 如果是动图,会有 video 信息
} }

View File

@ -1,117 +1,175 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from "playwright";
import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { uploadFile, generateUniqueFileName } from "@/lib/minio";
import { downloadBinary } from './network'; import { downloadBinary } from "./network";
import { pickFirstUrl } from './utils'; import { pickFirstUrl } from "./utils";
import { getVideoDuration } from '@/app/api/media'; import { getVideoDuration } from "@/app/api/media";
/** /**
* MinIO退 * MinIO退
*/ */
export async function uploadAvatarFromUrl( export async function uploadAvatarFromUrl(
context: BrowserContext, context: BrowserContext,
srcUrl?: string | null, srcUrl?: string | null,
nameHint?: string, nameHint?: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!srcUrl) return undefined; if (!srcUrl) return undefined;
try { try {
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
const safeExt = ext || 'jpg'; const safeExt = ext || "jpg";
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`; const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
const fileName = generateUniqueFileName(baseName, 'douyin/avatars'); const fileName = generateUniqueFileName(baseName, "douyin/avatars");
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); const uploaded = await uploadFile(buffer, fileName, {
return uploaded; "Content-Type": contentType,
} catch (e) { });
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e); return uploaded;
return srcUrl || undefined; } catch (e) {
} console.warn(
"[avatar] 上传失败,使用原始链接:",
(e as Error)?.message || e,
);
return srcUrl || undefined;
}
} }
/** /**
* MinIO退 * MinIO退
*/ */
export async function uploadImageFromUrl( export async function uploadImageFromUrl(
context: BrowserContext, context: BrowserContext,
srcUrl?: string | null, srcUrl?: string | null,
nameHint?: string, nameHint?: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!srcUrl) return undefined; if (!srcUrl) return undefined;
try { try {
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
const safeExt = ext || 'jpg'; const safeExt = ext || "jpg";
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`; const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
const fileName = generateUniqueFileName(baseName, 'douyin/comment-images'); const fileName = generateUniqueFileName(baseName, "douyin/comment-images");
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); const uploaded = await uploadFile(buffer, fileName, {
return uploaded; "Content-Type": contentType,
} catch (e) { });
console.warn('[image] 上传失败,使用原始链接:', (e as Error)?.message || e); return uploaded;
return srcUrl || undefined; } catch (e) {
} console.warn(
"[image] 上传失败,使用原始链接:",
(e as Error)?.message || e,
);
return srcUrl || undefined;
}
} }
/** 下载图文作品的图片和音乐并上传到 MinIO */ /** 下载图文作品的图片和音乐并上传到 MinIO */
export async function handleImagePost( export async function handleImagePost(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme aweme: DouyinImageAweme,
): Promise<{ images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }> { ): Promise<{
const awemeId = aweme.aweme_id; images: {
const uploadedImages: { url: string; width?: number; height?: number; video?: string; duration?: number }[] = []; url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[];
musicUrl?: string;
}> {
const awemeId = aweme.aweme_id;
const uploadedImages: {
url: string;
width?: number;
height?: number;
video?: string;
duration?: number;
}[] = [];
// 下载图片(顺序保持) // 下载图片(顺序保持)
for (let i = 0; i < (aweme.images?.length || 0); i++) { for (let i = 0; i < (aweme.images?.length || 0); i++) {
const img = aweme.images[i]; const img = aweme.images[i];
const url = pickFirstUrl(img?.url_list); const url = pickFirstUrl(img?.url_list);
if (!url) continue; if (!url) continue;
const { buffer, contentType, ext } = await downloadBinary(context, url); const { buffer, contentType, ext } = await downloadBinary(context, url);
const safeExt = ext || 'jpg'; const safeExt = ext || "jpg";
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images'); const fileName = generateUniqueFileName(
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); `${awemeId}/${i}.${safeExt}`,
"douyin/images",
);
const uploaded = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
if (img.video?.play_addr) { if (img.video?.play_addr) {
// 如果是动图,下载 video 并上传 // 如果是动图,下载 video 并上传
const videoUrl = img.video.play_addr[0]?.src; const videoUrl = img.video.play_addr[0]?.src;
if (videoUrl) { if (videoUrl) {
try { try {
const { buffer: videoBuffer, contentType: videoContentType, ext: videoExt } = await downloadBinary(context, videoUrl); const {
const safeVideoExt = videoExt || 'mp4'; buffer: videoBuffer,
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images'); contentType: videoContentType,
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType }); ext: videoExt,
} = await downloadBinary(context, videoUrl);
const safeVideoExt = videoExt || "mp4";
const videoFileName = generateUniqueFileName(
`${awemeId}/${i}_animated.${safeVideoExt}`,
"douyin/images",
);
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, {
"Content-Type": videoContentType,
});
// 获取动图时长 // 获取动图时长
const duration = await getVideoDuration(videoBuffer); const duration = await getVideoDuration(videoBuffer);
// 将动图的 video URL 和 duration 也存储起来 // 将动图的 video URL 和 duration 也存储起来
uploadedImages.push({ uploadedImages.push({
url: uploaded, url: uploaded,
width: img?.width, width: img?.width,
height: img?.height, height: img?.height,
video: uploadedVideo, video: uploadedVideo,
duration: duration ?? undefined duration: duration ?? undefined,
}); });
if (duration) { if (duration) {
console.log(`[image] 动图 ${i} 时长: ${duration}ms`); console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
} }
} catch (e) { } catch (e) {
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); console.warn(
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); `[image] 动图视频上传失败,跳过:`,
} (e as Error)?.message || e,
} );
} else { uploadedImages.push({
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); url: uploaded,
width: img?.width,
height: img?.height,
});
} }
}
} else {
uploadedImages.push({
url: uploaded,
width: img?.width,
height: img?.height,
});
} }
}
// 下载音乐(可选) // 下载音乐(可选)
let musicUrl: string | undefined; let musicUrl: string | undefined;
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list); const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
if (audioSrc) { if (audioSrc) {
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc); const { buffer, contentType, ext } = await downloadBinary(
const safeExt = ext || 'mp3'; context,
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios'); audioSrc,
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); );
} const safeExt = ext || "mp3";
const fileName = generateUniqueFileName(
`${awemeId}.${safeExt}`,
"douyin/audios",
);
musicUrl = await uploadFile(buffer, fileName, {
"Content-Type": contentType,
});
}
return { images: uploadedImages, musicUrl }; return { images: uploadedImages, musicUrl };
} }

View File

@ -1,11 +1,11 @@
export const runtime = 'nodejs' export const runtime = "nodejs";
export function toCamelCaseKey(key: string): string { export function toCamelCaseKey(key: string): string {
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase()); return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
} }
export function toSnakeCaseKey(key: string): string { export function toSnakeCaseKey(key: string): string {
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
} }
/** /**
@ -13,52 +13,53 @@ export function toSnakeCaseKey(key: string): string {
* 访 -> camelCase -> snake_case * 访 -> camelCase -> snake_case
*/ */
export function createCamelCompatibleProxy<T extends object>(root: T): T { export function createCamelCompatibleProxy<T extends object>(root: T): T {
const seen = new WeakMap<object, any>(); const seen = new WeakMap<object, any>();
const wrap = (value: any): any => { const wrap = (value: any): any => {
if (value === null || typeof value !== 'object') return value; if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value); if (seen.has(value)) return seen.get(value);
const proxied = new Proxy(value, handler); const proxied = new Proxy(value, handler);
seen.set(value, proxied); seen.set(value, proxied);
return proxied; return proxied;
}; };
const handler: ProxyHandler<any> = { const handler: ProxyHandler<any> = {
get(target, prop, receiver) { get(target, prop, receiver) {
// 非字符串属性(如 Symbol、数字索引直接透传 // 非字符串属性(如 Symbol、数字索引直接透传
if (typeof prop !== 'string') { if (typeof prop !== "string") {
return wrap(Reflect.get(target, prop, receiver)); return wrap(Reflect.get(target, prop, receiver));
} }
const primary = prop; const primary = prop;
if (primary in target) return wrap(Reflect.get(target, primary, receiver)); if (primary in target)
return wrap(Reflect.get(target, primary, receiver));
const camel = toCamelCaseKey(primary); const camel = toCamelCaseKey(primary);
if (camel in target) return wrap(Reflect.get(target, camel, receiver)); if (camel in target) return wrap(Reflect.get(target, camel, receiver));
const snake = toSnakeCaseKey(primary); const snake = toSnakeCaseKey(primary);
if (snake in target) return wrap(Reflect.get(target, snake, receiver)); if (snake in target) return wrap(Reflect.get(target, snake, receiver));
return wrap(Reflect.get(target, prop, receiver)); return wrap(Reflect.get(target, prop, receiver));
}, },
has(target, prop) { has(target, prop) {
if (typeof prop !== 'string') return prop in target; if (typeof prop !== "string") return prop in target;
const primary = prop === 'auther' ? 'autherInfo' : prop; const primary = prop === "auther" ? "autherInfo" : prop;
return ( return (
primary in target || primary in target ||
toCamelCaseKey(primary) in target || toCamelCaseKey(primary) in target ||
toSnakeCaseKey(primary) in target toSnakeCaseKey(primary) in target
); );
} },
}; };
return wrap(root); return wrap(root);
} }
/** 选择首个可用 URL */ /** 选择首个可用 URL */
export function pickFirstUrl(list?: string[]) { export function pickFirstUrl(list?: string[]) {
return Array.isArray(list) && list.length ? list[0] : undefined; return Array.isArray(list) && list.length ? list[0] : undefined;
} }
// 别名,兼容旧命名 // 别名,兼容旧命名

View File

@ -1,80 +1,113 @@
import { execFile } from 'child_process'; import { execFile } from "child_process";
import { promises as fs } from 'fs'; import { promises as fs } from "fs";
import os from 'os'; import os from "os";
import path from 'path'; import path from "path";
import { promisify } from 'util'; import { promisify } from "util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
/** /**
* 使 ffmpeg JPEG buffer * 使 ffmpeg JPEG buffer
*/ */
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { export async function extractFirstFrame(
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg'; videoBuffer: Buffer,
const tmpDir = os.tmpdir(); ): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
const inPath = path.join(tmpDir, `${base}.mp4`); const tmpDir = os.tmpdir();
const outPath = path.join(tmpDir, `${base}.jpg`); const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const inPath = path.join(tmpDir, `${base}.mp4`);
const outPath = path.join(tmpDir, `${base}.jpg`);
try { try {
await fs.writeFile(inPath, videoBuffer); await fs.writeFile(inPath, videoBuffer);
const args = [ const args = [
'-hide_banner', "-hide_banner",
'-loglevel', 'error', "-loglevel",
'-ss', '0', "error",
'-i', inPath, "-ss",
'-frames:v', '1', "0",
'-q:v', '2', "-i",
'-f', 'image2', inPath,
'-y', "-frames:v",
outPath, "1",
]; "-q:v",
await execFileAsync(ffmpegCmd, args, { windowsHide: true }); "2",
const img = await fs.readFile(outPath); "-f",
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' }; "image2",
} catch (e: any) { "-y",
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) { outPath,
console.warn('系统未检测到 ffmpeg可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。'); ];
return null; await execFileAsync(ffmpegCmd, args, { windowsHide: true });
} const img = await fs.readFile(outPath);
throw e; return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
} finally { } catch (e: any) {
try { await fs.unlink(inPath); } catch { } if (
try { await fs.unlink(outPath); } catch { } e &&
(e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffmpeg可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
);
return null;
} }
throw e;
} finally {
try {
await fs.unlink(inPath);
} catch {}
try {
await fs.unlink(outPath);
} catch {}
}
} }
/** /**
* 使 ffprobe * 使 ffprobe
*/ */
export async function getVideoDuration(videoBuffer: Buffer): Promise<number | null> { export async function getVideoDuration(
const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe'; videoBuffer: Buffer,
const tmpDir = os.tmpdir(); ): Promise<number | null> {
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe";
const inPath = path.join(tmpDir, `${base}.mp4`); const tmpDir = os.tmpdir();
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const inPath = path.join(tmpDir, `${base}.mp4`);
try { try {
await fs.writeFile(inPath, videoBuffer); await fs.writeFile(inPath, videoBuffer);
const args = [ const args = [
'-v', 'error', "-v",
'-show_entries', 'format=duration', "error",
'-of', 'default=noprint_wrappers=1:nokey=1', "-show_entries",
inPath, "format=duration",
]; "-of",
const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true }); "default=noprint_wrappers=1:nokey=1",
const durationSeconds = parseFloat(stdout.trim()); inPath,
if (isNaN(durationSeconds)) return null; ];
return Math.round(durationSeconds * 1000); // 转换为毫秒 const { stdout } = await execFileAsync(ffprobeCmd, args, {
} catch (e: any) { windowsHide: true,
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) { });
console.warn('系统未检测到 ffprobe可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。'); const durationSeconds = parseFloat(stdout.trim());
return null; if (isNaN(durationSeconds)) return null;
} return Math.round(durationSeconds * 1000); // 转换为毫秒
console.warn(`获取视频时长失败: ${e?.message || e}`); } catch (e: any) {
return null; if (
} finally { e &&
try { await fs.unlink(inPath); } catch { } (e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffprobe可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
);
return null;
} }
console.warn(`获取视频时长失败: ${e?.message || e}`);
return null;
} finally {
try {
await fs.unlink(inPath);
} catch {}
}
} }
/** /**
@ -88,68 +121,94 @@ export async function getVideoDuration(videoBuffer: Buffer): Promise<number | nu
* ffmpeg PATH null * ffmpeg PATH null
*/ */
export async function extractAudio( export async function extractAudio(
videoBuffer: Buffer, videoBuffer: Buffer,
opts?: { format?: 'mp3' | 'aac' | 'wav'; bitrateKbps?: number } opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { ): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg'; const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const inPath = path.join(tmpDir, `${base}.mp4`); const inPath = path.join(tmpDir, `${base}.mp4`);
const format = opts?.format ?? 'mp3'; const format = opts?.format ?? "mp3";
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
// 根据目标格式设置输出路径、MIME 与编码参数 // 根据目标格式设置输出路径、MIME 与编码参数
let outPath = ''; let outPath = "";
let contentType = ''; let contentType = "";
let ext = ''; let ext = "";
let codecArgs: string[] = []; let codecArgs: string[] = [];
if (format === 'mp3') { if (format === "mp3") {
ext = 'mp3'; ext = "mp3";
contentType = 'audio/mpeg'; contentType = "audio/mpeg";
outPath = path.join(tmpDir, `${base}.${ext}`); outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = ['-c:a', 'libmp3lame', '-b:a', `${bitrate}k`]; codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`];
} else if (format === 'aac') { } else if (format === "aac") {
// 使用 m4a 容器更通用 // 使用 m4a 容器更通用
ext = 'm4a'; ext = "m4a";
contentType = 'audio/mp4'; contentType = "audio/mp4";
outPath = path.join(tmpDir, `${base}.${ext}`); outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = ['-c:a', 'aac', '-b:a', `${bitrate}k`, '-movflags', '+faststart']; codecArgs = [
} else { "-c:a",
// wav "aac",
ext = 'wav'; "-b:a",
contentType = 'audio/wav'; `${bitrate}k`,
outPath = path.join(tmpDir, `${base}.${ext}`); "-movflags",
codecArgs = ['-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2']; "+faststart",
];
} else {
// wav
ext = "wav";
contentType = "audio/wav";
outPath = path.join(tmpDir, `${base}.${ext}`);
codecArgs = [
"-f",
"wav",
"-acodec",
"pcm_s16le",
"-ar",
"44100",
"-ac",
"2",
];
}
try {
await fs.writeFile(inPath, videoBuffer);
const args = [
"-hide_banner",
"-loglevel",
"error",
"-i",
inPath,
"-vn", // 丢弃视频流
...codecArgs,
"-y",
outPath,
];
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
const audio = await fs.readFile(outPath);
return { buffer: audio, contentType, ext };
} catch (e: any) {
if (
e &&
(e.code === "ENOENT" ||
/not found|is not recognized/i.test(String(e.message)))
) {
console.warn(
"系统未检测到 ffmpeg可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
);
return null;
} }
// 一些环境可能缺少特定编码器(如 libmp3lame提示并抛出原始错误
console.warn(`提取音频失败: ${e?.message || e}`);
throw e;
} finally {
try { try {
await fs.writeFile(inPath, videoBuffer); await fs.unlink(inPath);
const args = [ } catch {}
'-hide_banner', try {
'-loglevel', 'error', if (outPath) await fs.unlink(outPath);
'-i', inPath, } catch {}
'-vn', // 丢弃视频流 }
...codecArgs,
'-y',
outPath,
];
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
const audio = await fs.readFile(outPath);
return { buffer: audio, contentType, ext };
} catch (e: any) {
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
console.warn('系统未检测到 ffmpeg可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。');
return null;
}
// 一些环境可能缺少特定编码器(如 libmp3lame提示并抛出原始错误
console.warn(`提取音频失败: ${e?.message || e}`);
throw e;
} finally {
try { await fs.unlink(inPath); } catch { }
try {
if (outPath) await fs.unlink(outPath);
} catch { }
}
} }

View File

@ -1,13 +1,16 @@
import { json } from '@/lib/json'; import { json } from "@/lib/json";
import { getFileUrl } from '@/lib/minio'; import { getFileUrl } from "@/lib/minio";
import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例 import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
export async function GET(req: Request) { export async function GET(req: Request) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const q = (searchParams.get('q') || '').trim(); const q = (searchParams.get("q") || "").trim();
const page = Math.max(1, Number(searchParams.get('page') || 1)); const page = Math.max(1, Number(searchParams.get("page") || 1));
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20))); const limit = Math.min(
50,
Math.max(1, Number(searchParams.get("limit") || 20)),
);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
if (!q) { if (!q) {
@ -24,7 +27,7 @@ export async function GET(req: Request) {
{ {
id: string; id: string;
awemeId: string; awemeId: string;
type: 'video' | 'image'; type: "video" | "image";
rank: number; rank: number;
snippet: string; snippet: string;
}[] }[]
@ -86,9 +89,11 @@ export async function GET(req: Request) {
`; `;
// 查询总数(参数化,避免注入) // 查询总数(参数化,避免注入)
const totalRows = await prisma.$queryRaw<{ const totalRows = await prisma.$queryRaw<
count: number; {
}[]>` count: number;
}[]
>`
WITH tsq AS ( WITH tsq AS (
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
) )
@ -104,61 +109,88 @@ export async function GET(req: Request) {
`; `;
// 分离视频和图文ID // 分离视频和图文ID
const videoIds = rows.filter(r => r.type === 'video').map(r => r.awemeId); const videoIds = rows
const imagePostIds = rows.filter(r => r.type === 'image').map(r => r.awemeId); .filter((r) => r.type === "video")
.map((r) => r.awemeId);
const imagePostIds = rows
.filter((r) => r.type === "image")
.map((r) => r.awemeId);
// 批量查询视频元信息 // 批量查询视频元信息
const videos = videoIds.length > 0 ? (await prisma.video.findMany({ const videos =
where: { aweme_id: { in: videoIds } }, videoIds.length > 0
select: { ? (
aweme_id: true, await prisma.video.findMany({
desc: true, where: { aweme_id: { in: videoIds } },
cover_url: true, select: {
video_url: true, aweme_id: true,
duration_ms: true, desc: true,
author: true cover_url: true,
}, video_url: true,
})).map(v => ( duration_ms: true,
{ ...v, cover_url: getFileUrl(v.cover_url || ''), author: true,
author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || '') }, },
video_url: getFileUrl(v.video_url || '') }) })
) : []; ).map((v) => ({
...v,
cover_url: getFileUrl(v.cover_url || ""),
author: {
...v.author,
avatar_url: getFileUrl(v.author.avatar_url || ""),
},
video_url: getFileUrl(v.video_url || ""),
}))
: [];
// 批量查询图文元信息 // 批量查询图文元信息
const imagePosts = imagePostIds.length > 0 ? (await prisma.imagePost.findMany({ const imagePosts =
where: { aweme_id: { in: imagePostIds } }, imagePostIds.length > 0
select: { ? (
aweme_id: true, await prisma.imagePost.findMany({
desc: true, where: { aweme_id: { in: imagePostIds } },
author: true, select: {
images: { aweme_id: true,
orderBy: { order: 'asc' }, desc: true,
take: 1, author: true,
select: { images: {
url: true, orderBy: { order: "asc" },
width: true, take: 1,
height: true, select: {
} url: true,
} width: true,
}, height: true,
})).map(ip => ({ },
...ip, },
author: { ...ip.author, avatar_url: getFileUrl(ip.author.avatar_url || '') }, },
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null, })
})) : []; ).map((ip) => ({
...ip,
author: {
...ip.author,
avatar_url: getFileUrl(ip.author.avatar_url || ""),
},
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
}))
: [];
return json({ return json({
results: rows.map(r => ({ results: rows.map((r) => ({
...r, ...r,
video: r.type === 'video' ? videos.find(v => v.aweme_id === r.awemeId) : undefined, video:
imagePost: r.type === 'image' ? imagePosts.find(ip => ip.aweme_id === r.awemeId) : undefined, r.type === "video"
? videos.find((v) => v.aweme_id === r.awemeId)
: undefined,
imagePost:
r.type === "image"
? imagePosts.find((ip) => ip.aweme_id === r.awemeId)
: undefined,
})), })),
total: totalRows?.[0]?.count ?? 0, total: totalRows?.[0]?.count ?? 0,
page, page,
limit, limit,
}); });
} catch (err) { } catch (err) {
console.error('Search error:', err); console.error("Search error:", err);
return NextResponse.json({ error: 'Search failed' }, { status: 500 }); return NextResponse.json({ error: "Search failed" }, { status: 500 });
} }
} }

View File

@ -54,7 +54,7 @@ async function transcriptAudio(audio: Buffer | string) {
response_format: zodResponseFormat(SttSchema, "stt_result"), response_format: zodResponseFormat(SttSchema, "stt_result"),
}); });
const data = completion.choices?.[0]?.message const data = completion.choices?.[0]?.message;
console.log("转写结果", data.content); console.log("转写结果", data.content);
if (!data || !data.content) { if (!data || !data.content) {
@ -74,10 +74,13 @@ export async function transcriptAweme(awemeId: string): Promise<SttResult> {
if (!aweme) { if (!aweme) {
throw new Error("Aweme not found or aweme is not a video post"); throw new Error("Aweme not found or aweme is not a video post");
} }
const vPath = aweme.video_url const vPath = aweme.video_url;
const buffer = await downloadFile(vPath); const buffer = await downloadFile(vPath);
const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 }); const audioDat = await extractAudio(buffer, {
format: "mp3",
bitrateKbps: 128,
});
if (!audioDat || !audioDat.buffer) { if (!audioDat || !audioDat.buffer) {
throw new Error("Failed to extract audio from video"); throw new Error("Failed to extract audio from video");

View File

@ -1,19 +1,22 @@
export const runtime = "nodejs"; export const runtime = "nodejs";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import type { FeedItem, FeedResponse } from '@/app/types/feed'; import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { getFileUrl } from '@/lib/minio'; import { getFileUrl } from "@/lib/minio";
import { transcriptAweme } from '.'; import { transcriptAweme } from ".";
// Contract // Contract
// Inputs: search params { before?: ISOString, limit?: number } // Inputs: search params { before?: ISOString, limit?: number }
// Output: { items: FeedItem[], nextCursor: ISOString | null } // Output: { items: FeedItem[], nextCursor: ISOString | null }
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url);
const awemeId = searchParams.get('awemeId'); const awemeId = searchParams.get("awemeId");
if (!awemeId) { if (!awemeId) {
return NextResponse.json({ error: "Missing awemeId parameter" }, { status: 400 }); return NextResponse.json(
{ error: "Missing awemeId parameter" },
{ status: 400 },
);
} }
const script = await transcriptAweme(awemeId); const script = await transcriptAweme(awemeId);
return NextResponse.json(script); return NextResponse.json(script);

View File

@ -6,7 +6,11 @@ import { FeedItem } from "@/app/types/feed";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
export default async function AuthorPage({ params }: { params: Promise<{ secUid: string }> }) { export default async function AuthorPage({
params,
}: {
params: Promise<{ secUid: string }>;
}) {
const secUid = (await params).secUid; const secUid = (await params).secUid;
const author = await prisma.author.findUnique({ const author = await prisma.author.findUnique({
@ -22,15 +26,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
const [videos, posts] = await Promise.all([ const [videos, posts] = await Promise.all([
prisma.video.findMany({ prisma.video.findMany({
where: { authorId: secUid }, where: { authorId: secUid },
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true }, include: { author: true },
}), }),
prisma.imagePost.findMany({ prisma.imagePost.findMany({
where: { authorId: secUid }, where: { authorId: secUid },
orderBy: { created_at: 'desc' }, orderBy: { created_at: "desc" },
take: limit, take: limit,
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } }, include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
}), }),
]); ]);
@ -41,11 +45,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: getFileUrl(v.video_url), video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'), cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, author: {
likes: Number(v.digg_count) nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
type: "image" as const, type: "image" as const,
@ -55,13 +63,23 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
cover_url: getFileUrl(p.images?.[0]?.url ?? null), cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null, width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null, height: p.images?.[0]?.height ?? null,
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, author: {
likes: Number(p.digg_count) nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})), })),
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)) ]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit); .slice(0, limit);
const initialCursor = initialItems.length > 0 ? new Date(initialItems[initialItems.length - 1].created_at as any).toISOString() : null; const initialCursor =
initialItems.length > 0
? new Date(
initialItems[initialItems.length - 1].created_at as any,
).toISOString()
: null;
return ( return (
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white"> <main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
@ -75,7 +93,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12"> <div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0"> <div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
<Image <Image
src={getFileUrl(author.avatar_url || 'default-avatar.png')} src={getFileUrl(author.avatar_url || "default-avatar.png")}
alt={author.nickname} alt={author.nickname}
fill fill
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800" className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
@ -86,7 +104,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
<div> <div>
<h2 className="text-2xl font-bold">{author.nickname}</h2> <h2 className="text-2xl font-bold">{author.nickname}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{author.unique_id || author.short_id || '未知'} {author.unique_id || author.short_id || "未知"}
</p> </p>
</div> </div>
@ -98,11 +116,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
<div className="flex items-center justify-center md:justify-start gap-6 text-sm"> <div className="flex items-center justify-center md:justify-start gap-6 text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-bold text-lg">{Number(author.total_favorited).toLocaleString()}</span> <span className="font-bold text-lg">
{Number(author.total_favorited).toLocaleString()}
</span>
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-bold text-lg">{Number(author.follower_count).toLocaleString()}</span> <span className="font-bold text-lg">
{Number(author.follower_count).toLocaleString()}
</span>
<span className="text-gray-500"></span> <span className="text-gray-500"></span>
</div> </div>
</div> </div>

View File

@ -1,9 +1,15 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react"; import { Play } from "lucide-react";
import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript } from "./types.ts"; import type {
AwemeData,
ImageData,
Neighbors,
VideoData,
VideoTranscript,
} from "./types.ts";
import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel"; import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel"; import { ImageCarousel } from "./components/ImageCarousel";
@ -18,7 +24,6 @@ import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation"; import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState"; import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer"; import { useVideoPlayer } from "./hooks/useVideoPlayer";
import { Prisma } from "@prisma/client";
const SEGMENT_MS = 4000; const SEGMENT_MS = 4000;
@ -28,7 +33,11 @@ interface AwemeDetailClientProps {
transcript: VideoTranscript | null; transcript: VideoTranscript | null;
} }
export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) { export default function AwemeDetailClient({
data,
neighbors,
transcript,
}: AwemeDetailClientProps) {
const router = useRouter(); const router = useRouter();
const isVideo = data.type === "video"; const isVideo = data.type === "video";
@ -44,6 +53,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const scrollerRef = useRef<HTMLDivElement | null>(null); const scrollerRef = useRef<HTMLDivElement | null>(null);
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null); const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
const pauseImageMedia = useCallback((reset = false) => {
const audioEl = audioRef.current;
if (audioEl) {
audioEl.pause();
if (reset) audioEl.currentTime = 0;
}
scrollerRef.current?.querySelectorAll("video").forEach((videoEl) => {
videoEl.pause();
if (reset) videoEl.currentTime = 0;
});
}, []);
// 图文轮播状态 // 图文轮播状态
const images = isVideo ? [] : (data as ImageData).images; const images = isVideo ? [] : (data as ImageData).images;
const imageCarouselState = useImageCarousel({ const imageCarouselState = useImageCarousel({
@ -53,7 +75,6 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors, neighbors,
volume: playerState.volume, volume: playerState.volume,
audioRef, audioRef,
scrollerRef,
setProgress: playerState.setProgress, setProgress: playerState.setProgress,
segmentMs: SEGMENT_MS, segmentMs: SEGMENT_MS,
}); });
@ -84,50 +105,32 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
return; return;
} }
if (!images?.length) return; if (!images?.length) return;
imageCarouselState.seekTo(ratio);
};
// 计算每张图片的时长 const seekImageByVisualRatio = (ratio: number) => {
const durations = images.map(img => img.duration ?? SEGMENT_MS); if (!images?.length) return;
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const targetTime = ratio * totalDuration;
// 找到目标时间对应的图片索引和进度 const clampedRatio = Math.min(1, Math.max(0, ratio));
let accumulatedTime = 0; const rawSegment = clampedRatio * images.length;
let targetIdx = 0; const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
let remainder = 0; const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
imageCarouselState.goToIndex(targetIndex, segmentProgress);
};
for (let i = 0; i < images.length; i++) { const handleControlSeek = (ratio: number) => {
if (accumulatedTime + durations[i] > targetTime) { if (isVideo) {
targetIdx = i; seekTo(ratio);
remainder = (targetTime - accumulatedTime) / durations[i]; return;
break;
}
accumulatedTime += durations[i];
if (i === images.length - 1) {
targetIdx = i;
remainder = 1;
}
} }
seekImageByVisualRatio(ratio);
imageCarouselState.idxRef.current = targetIdx;
imageCarouselState.setIdx(targetIdx);
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
// 重新计算总进度
let totalProgress = 0;
for (let i = 0; i < targetIdx; i++) {
totalProgress += 1;
}
totalProgress += remainder;
playerState.setProgress(totalProgress / images.length);
// 虚拟滚动不需要实际滚动 DOM
}; };
const togglePlay = async () => { const togglePlay = async () => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
if (v.paused) await v.play().catch(() => { }); if (v.paused) await v.play().catch(() => {});
else v.pause(); else v.pause();
return; return;
} }
@ -135,23 +138,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
if (!playerState.isPlaying) { if (!playerState.isPlaying) {
playerState.setIsPlaying(true); playerState.setIsPlaying(true);
try { try {
await el?.play().catch(() => { }); await el?.play().catch(() => {});
} catch { } } catch {}
} else { } else {
playerState.setIsPlaying(false); playerState.setIsPlaying(false);
el?.pause(); pauseImageMedia();
} }
}; };
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
if (document.body.requestFullscreen) { if (document.body.requestFullscreen) {
document.body.requestFullscreen().catch(() => { }); document.body.requestFullscreen().catch(() => {});
return; return;
} }
const vRef = videoRef.current; const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) { if (vRef && vRef.requestFullscreen) {
vRef.requestFullscreen().catch(() => { }); vRef.requestFullscreen().catch(() => {});
return; return;
} }
// @ts-ignore // @ts-ignore
@ -160,26 +163,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
vRef.webkitEnterFullscreen(); vRef.webkitEnterFullscreen();
} }
} else { } else {
document.exitFullscreen().catch(() => { }); document.exitFullscreen().catch(() => {});
} }
}; };
const prevImg = () => { const prevImg = () => {
if (!images?.length) return; if (!images?.length) return;
const next = Math.max(0, imageCarouselState.idxRef.current - 1); const next = Math.max(0, imageCarouselState.idxRef.current - 1);
imageCarouselState.idxRef.current = next; imageCarouselState.goToIndex(next);
imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now();
// 虚拟滚动不需要实际滚动 DOM
}; };
const nextImg = () => { const nextImg = () => {
if (!images?.length) return; if (!images?.length) return;
const next = Math.min(images.length - 1, imageCarouselState.idxRef.current + 1); const next = Math.min(
imageCarouselState.idxRef.current = next; images.length - 1,
imageCarouselState.setIdx(next); imageCarouselState.idxRef.current + 1,
imageCarouselState.segStartRef.current = performance.now(); );
// 虚拟滚动不需要实际滚动 DOM imageCarouselState.goToIndex(next);
}; };
const handleDownload = () => { const handleDownload = () => {
@ -223,6 +223,27 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
backgroundCanvasRef, backgroundCanvasRef,
}); });
useEffect(() => {
if (isVideo) return;
const pauseForPageLifecycle = () => {
playerState.setIsPlaying(false);
pauseImageMedia();
};
const handleVisibilityChange = () => {
if (document.hidden) pauseForPageLifecycle();
};
window.addEventListener("pagehide", pauseForPageLifecycle);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pagehide", pauseForPageLifecycle);
document.removeEventListener("visibilitychange", handleVisibilityChange);
pauseImageMedia();
};
}, [isVideo, pauseImageMedia, playerState.setIsPlaying]);
// Media Session API 集成 // Media Session API 集成
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@ -236,20 +257,28 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const album = new Date(data.created_at).toLocaleString(); const album = new Date(data.created_at).toLocaleString();
// 单一封面图:使用作品 cover_url // 单一封面图:使用作品 cover_url
const coverUrl = (data as AwemeData).cover_url as string | undefined; const coverUrl = (data as AwemeData).cover_url as string | undefined;
const coverSize = (data as AwemeData).cover_size as { w: number; h: number } | undefined; const coverSize = (data as AwemeData).cover_size as
const artwork = coverUrl ? [{ src: coverUrl, size: `${coverSize?.w || 512}x${coverSize?.h || 512}` }] : []; | { w: number; h: number }
| undefined;
const artwork = coverUrl
? [
{
src: coverUrl,
size: `${coverSize?.w || 512}x${coverSize?.h || 512}`,
},
]
: [];
try { try {
ms.metadata = new MediaMetadata({ title, artist, album, artwork }); ms.metadata = new MediaMetadata({ title, artist, album, artwork });
} catch { } } catch {}
// 更新播放状态 // 更新播放状态
try { try {
ms.playbackState = playerState.isPlaying ? "playing" : "paused"; ms.playbackState = playerState.isPlaying ? "playing" : "paused";
} catch { } } catch {}
const getImagesTotalMs = () => const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
const updatePosition = () => { const updatePosition = () => {
try { try {
@ -267,11 +296,14 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
} else if (images?.length) { } else if (images?.length) {
const totalMs = getImagesTotalMs(); const totalMs = getImagesTotalMs();
const duration = totalMs / 1000; const duration = totalMs / 1000;
const position = Math.max(0, Math.min(duration, (playerState.progress * totalMs) / 1000)); const position = Math.max(
0,
Math.min(duration, (playerState.progress * totalMs) / 1000),
);
// @ts-ignore // @ts-ignore
ms.setPositionState({ duration, position, playbackRate: 1 }); ms.setPositionState({ duration, position, playbackRate: 1 });
} }
} catch { } } catch {}
}; };
// 绑定视频事件以同步状态 // 绑定视频事件以同步状态
@ -279,11 +311,15 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const v = videoRef.current; const v = videoRef.current;
if (isVideo && v) { if (isVideo && v) {
const onPlay = () => { const onPlay = () => {
try { ms.playbackState = "playing"; } catch { } try {
ms.playbackState = "playing";
} catch {}
updatePosition(); updatePosition();
}; };
const onPause = () => { const onPause = () => {
try { ms.playbackState = "paused"; } catch { } try {
ms.playbackState = "paused";
} catch {}
updatePosition(); updatePosition();
}; };
const onTimeUpdate = () => updatePosition(); const onTimeUpdate = () => updatePosition();
@ -317,6 +353,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
data.author.avatar_url, data.author.avatar_url,
data.created_at, data.created_at,
imageCarouselState.idx, imageCarouselState.idx,
imageCarouselState.totalDurationMs,
images, images,
playerState.isPlaying, playerState.isPlaying,
playerState.progress, playerState.progress,
@ -329,16 +366,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
if (!("mediaSession" in navigator)) return; if (!("mediaSession" in navigator)) return;
const ms = (navigator as any).mediaSession as MediaSession; const ms = (navigator as any).mediaSession as MediaSession;
const getImagesTotalMs = () => const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
const handlePlay = async () => { const handlePlay = async () => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
try { await v?.play(); } catch { } try {
await v?.play();
} catch {}
} else { } else {
playerState.setIsPlaying(true); playerState.setIsPlaying(true);
try { await audioRef.current?.play(); } catch { } try {
await audioRef.current?.play();
} catch {}
} }
}; };
const handlePause = () => { const handlePause = () => {
@ -347,23 +387,30 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
v?.pause(); v?.pause();
} else { } else {
playerState.setIsPlaying(false); playerState.setIsPlaying(false);
audioRef.current?.pause(); pauseImageMedia();
} }
}; };
const handleStop = () => { const handleStop = () => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (v) { v.pause(); v.currentTime = 0; } if (v) {
v.pause();
v.currentTime = 0;
}
} else { } else {
playerState.setIsPlaying(false); playerState.setIsPlaying(false);
audioRef.current?.pause(); pauseImageMedia(true);
seekTo(0); seekTo(0);
} }
}; };
const handleSeekDelta = (deltaSec: number) => { const handleSeekDelta = (deltaSec: number) => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (v) v.currentTime = Math.max(0, Math.min(v.duration || Infinity, v.currentTime + deltaSec)); if (v)
v.currentTime = Math.max(
0,
Math.min(v.duration || Infinity, v.currentTime + deltaSec),
);
} else if (images?.length) { } else if (images?.length) {
const totalMs = getImagesTotalMs(); const totalMs = getImagesTotalMs();
if (totalMs <= 0) return; if (totalMs <= 0) return;
@ -387,8 +434,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
ms.setActionHandler("play", handlePlay); ms.setActionHandler("play", handlePlay);
ms.setActionHandler("pause", handlePause); ms.setActionHandler("pause", handlePause);
ms.setActionHandler("stop", handleStop); ms.setActionHandler("stop", handleStop);
ms.setActionHandler("seekbackward", (details: any) => handleSeekDelta(-((details?.seekOffset as number) || 10))); ms.setActionHandler("seekbackward", (details: any) =>
ms.setActionHandler("seekforward", (details: any) => handleSeekDelta((details?.seekOffset as number) || 10)); handleSeekDelta(-((details?.seekOffset as number) || 10)),
);
ms.setActionHandler("seekforward", (details: any) =>
handleSeekDelta((details?.seekOffset as number) || 10),
);
ms.setActionHandler("seekto", (details: any) => { ms.setActionHandler("seekto", (details: any) => {
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
}); });
@ -417,7 +468,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
ms.setActionHandler("seekto", null); ms.setActionHandler("seekto", null);
ms.setActionHandler("previoustrack", null); ms.setActionHandler("previoustrack", null);
ms.setActionHandler("nexttrack", null); ms.setActionHandler("nexttrack", null);
} catch { } } catch {}
}; };
}, [ }, [
isVideo, isVideo,
@ -426,6 +477,8 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors.next, neighbors.next,
router, router,
playerState.progress, playerState.progress,
pauseImageMedia,
imageCarouselState.totalDurationMs,
]); ]);
return ( return (
@ -434,29 +487,39 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
<div className="relative h-full landscape:flex landscape:flex-row"> <div className="relative h-full landscape:flex landscape:flex-row">
{/* 主媒体区域 */} {/* 主媒体区域 */}
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1"> <section
<div className="relative h-screen overflow-hidden"> ref={mediaContainerRef}
{isVideo ? ( className="relative isolate h-screen landscape:flex-1"
<VideoPlayer >
ref={videoRef} <div className="relative isolate h-screen overflow-hidden">
videoUrl={(data as VideoData).video_url} <div className="absolute inset-0 z-0">
rotation={playerState.rotation} {isVideo ? (
objectFit={playerState.objectFit} <VideoPlayer
loop={playerState.loopMode === "loop"} ref={videoRef}
onTogglePlay={togglePlay} videoUrl={(data as VideoData).video_url}
/> rotation={playerState.rotation}
) : ( objectFit={playerState.objectFit}
<ImageCarousel loop={playerState.loopMode !== "sequential"}
ref={scrollerRef} onTogglePlay={togglePlay}
images={images} />
currentIndex={imageCarouselState.idx} ) : (
onTogglePlay={togglePlay} <ImageCarousel
/> ref={scrollerRef}
)} images={images}
currentIndex={imageCarouselState.idx}
isPlaying={playerState.isPlaying}
segmentProgress={imageCarouselState.segProgress}
mediaSyncToken={imageCarouselState.mediaSyncToken}
loopMode={playerState.loopMode}
onTogglePlay={togglePlay}
onAnimatedDuration={imageCarouselState.setSegmentDuration}
/>
)}
</div>
{/* 暂停图标 */} {/* 暂停图标 */}
{!playerState.isPlaying && ( {!playerState.isPlaying && (
<div className="pointer-events-none absolute inset-0 grid place-items-center"> <div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
<div className="rounded-full bg-black/40 p-6 backdrop-blur-sm"> <div className="rounded-full bg-black/40 p-6 backdrop-blur-sm">
<Play size={64} className="text-white" fill="white" /> <Play size={64} className="text-white" fill="white" />
</div> </div>
@ -496,7 +559,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
hasTranscript={isVideo && !!transcript?.speech_detected} hasTranscript={isVideo && !!transcript?.speech_detected}
onShowTranscript={() => setTranscriptOpen(true)} onShowTranscript={() => setTranscriptOpen(true)}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
onSeek={seekTo} onSeek={handleControlSeek}
onVolumeChange={playerState.setVolume} onVolumeChange={playerState.setVolume}
onRateChange={playerState.setRate} onRateChange={playerState.setRate}
onRotationChange={playerState.setRotation} onRotationChange={playerState.setRotation}
@ -512,8 +575,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors={neighbors} neighbors={neighbors}
commentsCount={data.commentsCount} commentsCount={data.commentsCount}
likesCount={data.likesCount} likesCount={data.likesCount}
onNavigatePrev={() => neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)} onNavigatePrev={() =>
onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)} neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)
}
onNavigateNext={() =>
neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)
}
onToggleComments={() => commentState.setOpen((v) => !v)} onToggleComments={() => commentState.setOpen((v) => !v)}
/> />
</section> </section>

View File

@ -2,7 +2,10 @@ import { forwardRef } from "react";
interface BackgroundCanvasProps {} interface BackgroundCanvasProps {}
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => { export const BackgroundCanvas = forwardRef<
HTMLCanvasElement,
BackgroundCanvasProps
>((props, ref) => {
return ( return (
<canvas <canvas
ref={ref} ref={ref}

View File

@ -18,12 +18,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<header className="flex items-center gap-4 mb-5"> <header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60"> <div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? ( {author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <img
src={author.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null} ) : null}
</div> </div>
<div> <div>
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div> <div className="font-medium text-white/95 text-sm sm:text-base">
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}> {author.nickname}
</div>
<div
className="text-xs text-white/50"
title={formatAbsoluteUTC(createdAt)}
>
{formatRelativeTime(createdAt)} {formatRelativeTime(createdAt)}
</div> </div>
</div> </div>
@ -34,13 +43,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<li key={c.cid} className="flex items-start gap-3 sm:gap-4"> <li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0"> <div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? ( {c.user.avatar_url ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" /> <img
src={c.user.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null} ) : null}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span> <span className="font-medium text-white/95 text-sm">
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span> {c.user.nickname}
</span>
<span className="text-xs text-white/50">
{formatRelativeTime(c.created_at)}
</span>
</div> </div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words"> <p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} /> <CommentText text={c.text} />
@ -75,7 +92,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
</div> </div>
</li> </li>
))} ))}
{comments.length === 0 ? <li className="text-sm text-white/60"></li> : null} {comments.length === 0 ? (
<li className="text-sm text-white/60"></li>
) : null}
</ul> </ul>
{/* 图片预览灯箱 */} {/* 图片预览灯箱 */}

View File

@ -12,13 +12,23 @@ interface CommentPanelProps {
mounted: boolean; mounted: boolean;
} }
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) { export function CommentPanel({
open,
onClose,
author,
createdAt,
awemeId,
mounted,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定) // ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
const [rankParams, setRankParams] = useState<null | { seed: string; snapshot: string }>(null); const [rankParams, setRankParams] = useState<null | {
seed: string;
snapshot: string;
}>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板 // 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null); const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null); const sentinelRefLandscape = useRef<HTMLDivElement>(null);
@ -27,67 +37,81 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const loadingRef = useRef(false); const loadingRef = useRef(false);
// 加载评论 // 加载评论
const loadComments = useCallback(async (reset = false) => { const loadComments = useCallback(
if (loadingRef.current || (!reset && !hasMore)) return; async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
loadingRef.current = true; loadingRef.current = true;
setLoading(true); setLoading(true);
try { try {
const skip = reset ? 0 : comments.length; const skip = reset ? 0 : comments.length;
const query = new URLSearchParams({ const query = new URLSearchParams({
skip: String(skip), skip: String(skip),
take: String(20), take: String(20),
mode: 'ranked', mode: "ranked",
}); });
if (rankParams) { if (rankParams) {
query.set('seed', rankParams.seed); query.set("seed", rankParams.seed);
query.set('snapshot', rankParams.snapshot); query.set("snapshot", rankParams.snapshot);
} }
const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`); const response = await fetch(
const data = await response.json(); `/api/comments/${awemeId}?${query.toString()}`,
);
const data = await response.json();
// 统一做一次基于 cid 的去重,避免分页偶发重复 // 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) { if (reset) {
setComments(() => { setComments(() => {
const seen = new Set<string>(); const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => { return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false; if (seen.has(c.cid)) return false;
seen.add(c.cid); seen.add(c.cid);
return true; return true;
});
}); });
}); } else {
} else { setComments((prev) => {
setComments((prev) => { const merged = [...prev, ...(data.comments as Comment[])];
const merged = [...prev, ...(data.comments as Comment[])]; const seen = new Set<string>();
const seen = new Set<string>(); // 保留首次出现的项,既保证顺序也避免重复
// 保留首次出现的项,既保证顺序也避免重复 return merged.filter((c) => {
return merged.filter((c) => { if (seen.has(c.cid)) return false;
if (seen.has(c.cid)) return false; seen.add(c.cid);
seen.add(c.cid); return true;
return true; });
}); });
}); }
}
setTotal(data.total); setTotal(data.total);
setHasMore(data.hasMore); setHasMore(data.hasMore);
if (data.mode === 'ranked' && data.seed && data.snapshot) { if (data.mode === "ranked" && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数 // 初始化或重置时更新稳定参数
setRankParams((prev) => { setRankParams((prev) => {
if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) }; if (reset)
return prev ?? { seed: String(data.seed), snapshot: String(data.snapshot) }; return {
}); seed: String(data.seed),
} else if (reset) { snapshot: String(data.snapshot),
setRankParams(null); };
return (
prev ?? {
seed: String(data.seed),
snapshot: String(data.snapshot),
}
);
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
} }
} catch (error) { },
console.error("加载评论失败:", error); [awemeId, comments.length, hasMore],
} finally { );
setLoading(false);
loadingRef.current = false;
}
}, [awemeId, comments.length, hasMore]);
// 面板打开时加载初始评论 // 面板打开时加载初始评论
useEffect(() => { useEffect(() => {
@ -102,7 +126,10 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const observers: IntersectionObserver[] = []; const observers: IntersectionObserver[] = [];
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => { const setup = (
rootEl: HTMLDivElement | null,
targetEl: HTMLDivElement | null,
) => {
if (!rootEl || !targetEl) return; if (!rootEl || !targetEl) return;
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
@ -113,9 +140,9 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
}, },
{ {
root: rootEl, root: rootEl,
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发 rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
threshold: 0, threshold: 0,
} },
); );
io.observe(targetEl); io.observe(targetEl);
observers.push(io); observers.push(io);
@ -180,19 +207,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</div> </div>
</div> </div>
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll"> <div
<CommentList author={author} createdAt={createdAt} comments={comments} /> ref={scrollRefLandscape}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */} {/* 底部加载触发区 */}
{hasMore && ( {hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
<div ref={sentinelRefLandscape} className="h-1 w-full" />
)}
{loading && ( {loading && (
<div className="py-4 text-center text-white/60 text-sm">...</div> <div className="py-4 text-center text-white/60 text-sm">
...
</div>
)} )}
{!hasMore && comments.length > 0 && ( {!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm"></div> <div className="py-4 text-center text-white/40 text-sm">
</div>
)} )}
</div> </div>
</aside> </aside>
@ -221,19 +257,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</button> </button>
</div> </div>
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll"> <div
<CommentList author={author} createdAt={createdAt} comments={comments} /> ref={scrollRefPortrait}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */} {/* 底部加载触发区 */}
{hasMore && ( {hasMore && <div ref={sentinelRefPortrait} className="h-1 w-full" />}
<div ref={sentinelRefPortrait} className="h-1 w-full" />
)}
{loading && ( {loading && (
<div className="py-4 text-center text-white/60 text-sm">...</div> <div className="py-4 text-center text-white/60 text-sm">
...
</div>
)} )}
{!hasMore && comments.length > 0 && ( {!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm"></div> <div className="py-4 text-center text-white/40 text-sm">
</div>
)} )}
</div> </div>
</aside> </aside>

View File

@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
// 如果图片加载失败,显示原始文本 // 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none"; e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`); const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget); e.currentTarget.parentNode?.insertBefore(
textNode,
e.currentTarget,
);
}} }}
/> />
); );

View File

@ -1,78 +1,134 @@
import { forwardRef, useEffect, useRef, useState } from "react"; import {
import type { ImageData } from "../types.ts"; forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode } from "../types.ts";
interface ImageCarouselProps { interface ImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
currentIndex: number; currentIndex: number;
isPlaying: boolean;
segmentProgress: number;
mediaSyncToken: number;
loopMode: LoopMode;
onTogglePlay: () => void; onTogglePlay: () => void;
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
} }
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>( export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => { (
const [offset, setOffset] = useState(0); {
const [isTransitioning, setIsTransitioning] = useState(false); images,
const containerRef = useRef<HTMLDivElement>(null); currentIndex,
isPlaying,
segmentProgress,
mediaSyncToken,
loopMode,
onTogglePlay,
onAnimatedDuration,
},
ref,
) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map()); const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(new Set()); const activeVideoIdRef = useRef<string | null>(null);
const segmentProgressRef = useRef(segmentProgress);
const [useLightweightMode, setUseLightweightMode] = useState(false);
useEffect(() => {
segmentProgressRef.current = segmentProgress;
}, [segmentProgress]);
const syncVideoToSegmentProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number, force = false) => {
const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return;
const clampedProgress = Math.max(0, Math.min(1, progress));
const maxTime = Math.max(0, duration - 0.05);
const targetTime = Math.min(maxTime, duration * clampedProgress);
if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) {
try {
videoEl.currentTime = targetTime;
} catch {}
}
},
[],
);
useEffect(() => {
const userAgent = navigator.userAgent;
const isIOS =
/iPad|iPhone|iPod/.test(userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isSafari =
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent);
setUseLightweightMode(isIOS && isSafari);
}, []);
// 虚拟滚动:只渲染当前图片和前后各一张 // 虚拟滚动:只渲染当前图片和前后各一张
const visibleIndices = (() => { const visibleIndices = useMemo(() => {
if (useLightweightMode) return [currentIndex];
const indices: number[] = []; const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1); if (currentIndex > 0) indices.push(currentIndex - 1);
indices.push(currentIndex); indices.push(currentIndex);
if (currentIndex < images.length - 1) indices.push(currentIndex + 1); if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices; return indices;
})(); }, [currentIndex, images.length, useLightweightMode]);
// 当 currentIndex 变化时,触发滚动动画 // iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
useEffect(() => { useEffect(() => {
setIsTransitioning(true); const currentImage = images[currentIndex];
setOffset(-currentIndex * 100); if (!currentImage?.animated) {
activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => videoEl.pause());
return;
}
const timer = setTimeout(() => { const videoEl = videoRefs.current.get(currentImage.id);
setIsTransitioning(false);
}, 300); // 与 CSS transition 时间匹配
return () => clearTimeout(timer); videoRefs.current.forEach((itemVideoEl, imageId) => {
}, [currentIndex]); if (imageId !== currentImage.id || !isPlaying) {
itemVideoEl.pause();
}
});
if (videoEl) {
if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
}
if (isPlaying) {
videoEl.play().catch(() => {});
} else {
videoEl.pause();
}
}
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
// 管理动图播放:进入视口时播放一次
useEffect(() => { useEffect(() => {
const currentImage = images[currentIndex]; const currentImage = images[currentIndex];
if (!currentImage?.animated) return; if (!currentImage?.animated) return;
const videoKey = currentImage.id; const videoEl = videoRefs.current.get(currentImage.id);
const videoEl = videoRefs.current.get(videoKey); if (!videoEl) return;
if (videoEl) { syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
// 检查是否已经播放过 if (isPlaying) videoEl.play().catch(() => {});
if (!playedVideos.current.has(videoKey)) { }, [
// 重置并播放 currentIndex,
videoEl.currentTime = 0; images,
videoEl.play().catch(() => {}); isPlaying,
playedVideos.current.add(videoKey); loopMode,
} else { mediaSyncToken,
// 已播放过,重置到开头但不自动播放 syncVideoToSegmentProgress,
videoEl.currentTime = 0; ]);
videoEl.play().catch(() => {});
}
}
}, [currentIndex, images]);
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
useEffect(() => {
const currentImage = images[currentIndex];
if (currentImage?.animated) {
const videoKey = currentImage.id;
// 清除其他视频的播放记录
playedVideos.current.forEach(key => {
if (key !== videoKey) {
playedVideos.current.delete(key);
}
});
}
}, [currentIndex, images]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => { const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
if (el) { if (el) {
@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
}; };
return ( return (
<div <div ref={ref} className="relative h-full w-full overflow-hidden">
ref={ref} <div className="relative h-full w-full">
className="relative h-full w-full overflow-hidden"
>
<div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
{visibleIndices.map((i) => { {visibleIndices.map((i) => {
const img = images[i]; const img = images[i];
const isCurrent = i === currentIndex;
const animatedSrc = isCurrent ? img.animated : null;
return ( return (
<div <div
key={img.id} key={img.id}
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer" data-active={isCurrent ? "true" : undefined}
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{ style={{
transform: `translateX(${i * 100}%)`, transform: useLightweightMode
position: 'absolute', ? "translateX(0)"
left: 0, : `translateX(${(i - currentIndex) * 100}%)`,
top: 0, transition: useLightweightMode
width: '100%', ? "none"
: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0,
}} }}
onClick={onTogglePlay} onClick={onTogglePlay}
> >
{img.animated ? ( {animatedSrc ? (
<video <video
ref={(el) => handleVideoRef(el, img.id)} ref={(el) => handleVideoRef(el, img.id)}
src={img.animated} src={animatedSrc}
poster={img.url}
muted muted
playsInline playsInline
loop={loopMode === "single"}
preload={useLightweightMode ? "none" : "metadata"}
controls={false}
disablePictureInPicture
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
onLoadedMetadata={(e) => {
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration?.(
img.id,
Math.round(duration * 1000),
);
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
true,
);
}
}
}}
onCanPlay={(e) => {
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
);
}
if (isCurrent && isPlaying)
e.currentTarget.play().catch(() => {});
}}
onEnded={(e) => { onEnded={(e) => {
// 播放结束后停留在最后一帧 // 播放结束后停留在最后一帧
e.currentTarget.pause(); if (loopMode !== "single") e.currentTarget.pause();
}} }}
/> />
) : ( ) : (
<img <img
src={img.url} src={img.url}
alt={`image-${i + 1}`} alt={`image-${i + 1}`}
loading={isCurrent ? "eager" : "lazy"}
decoding="async"
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
style={{ style={{
width: img.width ? `${img.width}px` : undefined, width: img.width ? `${img.width}px` : undefined,
@ -139,7 +227,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
</div> </div>
</div> </div>
); );
} },
); );
ImageCarousel.displayName = "ImageCarousel"; ImageCarousel.displayName = "ImageCarousel";

View File

@ -8,6 +8,7 @@ import {
Minimize2, Minimize2,
Pause, Pause,
Play, Play,
Repeat,
Repeat1, Repeat1,
RotateCcw, RotateCcw,
RotateCw, RotateCw,
@ -89,8 +90,39 @@ export function MediaControls({
onDownload, onDownload,
onToggleFullscreen, onToggleFullscreen,
}: MediaControlsProps) { }: MediaControlsProps) {
const normalizedLoopMode =
isVideo && loopMode === "single" ? "loop" : loopMode;
const loopLabel = isVideo
? normalizedLoopMode === "loop"
? "循环播放"
: "顺序播放"
: loopMode === "single"
? "单页循环"
: loopMode === "loop"
? "图文循环"
: "顺序播放";
const handleLoopModeToggle = () => {
if (isVideo) {
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
return;
}
onLoopModeChange(
loopMode === "loop"
? "single"
: loopMode === "single"
? "sequential"
: "loop",
);
};
const renderLoopIcon = () => {
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
return <ArrowDownUp size={18} />;
};
return ( return (
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"> <div className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
{/* 描述行 */} {/* 描述行 */}
<div className="flex items-center gap-2.5 mb-1 pointer-events-none"> <div className="flex items-center gap-2.5 mb-1 pointer-events-none">
{author.sec_uid ? ( {author.sec_uid ? (
@ -98,16 +130,30 @@ export function MediaControls({
href={`/author/${author.sec_uid}`} href={`/author/${author.sec_uid}`}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity" className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
> >
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" /> <img
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span> src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</Link> </Link>
) : ( ) : (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" /> <img
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span> src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
{author.nickname}
</span>
</div> </div>
)} )}
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span> <span className="text-[13px] leading-tight text-white/95 drop-shadow">
·
</span>
<span <span
className="text-[11px] leading-tight text-white/95 drop-shadow" className="text-[11px] leading-tight text-white/95 drop-shadow"
title={formatAbsoluteUTC(createdAt)} title={formatAbsoluteUTC(createdAt)}
@ -150,16 +196,16 @@ export function MediaControls({
{/* 播放进度显示 - 所有设备都显示 */} {/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]"> <div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? ( {isVideo
(() => { ? (() => {
const v = videoRef?.current; const v = videoRef?.current;
const current = v?.currentTime ?? 0; const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0; const total = v?.duration ?? 0;
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--"; return total > 0
})() ? `${formatTime(current)} / ${formatTime(total)}`
) : ( : "--:-- / --:--";
`${currentIndex + 1} / ${totalSegments}` })()
)} : `${currentIndex + 1} / ${totalSegments}`}
</div> </div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */} {/* 倍速 - 中等屏幕以上显示,仅视频 */}
@ -231,21 +277,31 @@ export function MediaControls({
{/* 循环模式 - 中等屏幕以上显示 */} {/* 循环模式 - 中等屏幕以上显示 */}
<button <button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")} onClick={handleLoopModeToggle}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"} aria-label={loopLabel}
title={loopMode === "loop" ? "循环播放" : "顺序播放"} title={loopLabel}
> >
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />} {renderLoopIcon()}
</button> </button>
{/* 适配模式 - 小屏幕以上显示 */} {/* 适配模式 - 小屏幕以上显示 */}
<button <button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")} onClick={() =>
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} }
aria-label={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
title={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
> >
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />} {objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)}
</button> </button>
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */} {/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
@ -277,18 +333,28 @@ export function MediaControls({
{/* 小屏幕隐藏的适配模式 */} {/* 小屏幕隐藏的适配模式 */}
<div className="sm:hidden"> <div className="sm:hidden">
<MoreMenuItem <MoreMenuItem
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />} icon={
objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)
}
label={objectFit === "contain" ? "填充模式" : "适应模式"} label={objectFit === "contain" ? "填充模式" : "适应模式"}
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")} onClick={() =>
onObjectFitChange(
objectFit === "contain" ? "cover" : "contain",
)
}
/> />
</div> </div>
{/* 中等屏幕以下隐藏的循环模式 */} {/* 中等屏幕以下隐藏的循环模式 */}
<div className="md:hidden"> <div className="md:hidden">
<MoreMenuItem <MoreMenuItem
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />} icon={renderLoopIcon()}
label={loopMode === "loop" ? "循环播放" : "顺序播放"} label={loopLabel}
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")} onClick={handleLoopModeToggle}
/> />
</div> </div>
@ -338,13 +404,13 @@ export function MediaControls({
> >
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />} {isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
</div> </div>
{/* 图文 BGM隐藏控件仅用于播放 */} {/* 图文 BGM隐藏控件仅用于播放 */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null} {!isVideo && musicUrl ? (
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
) : null}
</div> </div>
); );
} }

View File

@ -51,9 +51,7 @@ export function MoreMenu({ children }: MoreMenuProps) {
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200" className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ minWidth: "200px" }} style={{ minWidth: "200px" }}
> >
<div className="p-2 flex flex-col gap-1"> <div className="p-2 flex flex-col gap-1">{children}</div>
{children}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,4 +1,9 @@
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react"; import {
ChevronDown,
ChevronUp,
MessageSquareText,
ThumbsUp,
} from "lucide-react";
import type { Neighbors } from "../types.ts"; import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps { interface NavigationButtonsProps {
@ -21,14 +26,13 @@ export function NavigationButtons({
return ( return (
<> <>
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10"> <div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
<button <button className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2">
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
>
<div className="grid place-items-center gap-1 drop-shadow-lg"> <div className="grid place-items-center gap-1 drop-shadow-lg">
<ThumbsUp size={40} className="" /> <ThumbsUp size={40} className="" />
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span> <span className="text-[16px] font-semibold text-white/90 drop-shadow">
{likesCount}
</span>
</div> </div>
</button> </button>
{/* 评论开关(右侧中部) */} {/* 评论开关(右侧中部) */}
@ -40,13 +44,17 @@ export function NavigationButtons({
<div className="grid place-items-center gap-1 drop-shadow-lg"> <div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" /> <MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> : {commentsCount > 0 ? (
<span className="text-[12px] font-semibold text-white/90 drop-shadow"></span> <span className="text-[16px] font-semibold text-white/90 drop-shadow">
} {commentsCount}
</span>
) : (
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
</span>
)}
</div> </div>
</button> </button>
</div> </div>
{/* 上下切换按钮(右侧胶囊形状) */} {/* 上下切换按钮(右侧胶囊形状) */}

View File

@ -4,15 +4,41 @@ interface ProgressBarProps {
} }
export function ProgressBar({ progress, onSeek }: ProgressBarProps) { export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return ( return (
<div <div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer" className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer touch-none"
onClick={(e) => { onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
onSeek((e.clientX - rect.left) / rect.width); }}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}} }}
> >
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} /> <div
className="origin-left h-full bg-white"
style={{ transform: `scaleX(${progress || 0})` }}
/>
</div> </div>
); );
} }

View File

@ -11,12 +11,35 @@ export function SegmentedProgressBar({
segmentProgress, segmentProgress,
onSeek, onSeek,
}: SegmentedProgressBarProps) { }: SegmentedProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return ( return (
<div <div
className="relative h-1.5 cursor-pointer" className="relative h-1.5 cursor-pointer touch-none"
onClick={(e) => { onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
onSeek((e.clientX - rect.left) / rect.width); }}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}} }}
> >
<div className="flex gap-1.5 h-full"> <div className="flex gap-1.5 h-full">
@ -30,7 +53,12 @@ export function SegmentedProgressBar({
aria-label={`${i + 1}`} aria-label={`${i + 1}`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden" className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
> >
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} /> <div
className="h-full origin-left bg-white"
style={{
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div> </div>
); );
})} })}

View File

@ -10,7 +10,11 @@ interface TranscriptPanelProps {
transcript: VideoTranscript | null; transcript: VideoTranscript | null;
} }
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) { export function TranscriptPanel({
open,
onClose,
transcript,
}: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false); const [copiedAll, setCopiedAll] = useState(false);

View File

@ -35,7 +35,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onClick={onTogglePlay} onClick={onTogglePlay}
/> />
); );
} },
); );
VideoPlayer.displayName = "VideoPlayer"; VideoPlayer.displayName = "VideoPlayer";

View File

@ -1,3 +1,216 @@
export const emojiList = [ export const emojiList = [
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子" "微笑",
] "色",
"发呆",
"酷拽",
"抠鼻",
"流泪",
"捂脸",
"发怒",
"呲牙",
"尬笑",
"害羞",
"调皮",
"舔屏",
"看",
"爱心",
"比心",
"赞",
"鼓掌",
"感谢",
"抱抱你",
"玫瑰",
"尴尬流汗",
"戳手手",
"星星眼",
"杀马特",
"黄脸干杯",
"抱紧自己",
"拜拜",
"热化了",
"黄脸祈祷",
"懵",
"举手",
"加功德",
"摊手",
"无语流汗",
"续火花吧",
"点火",
"哭哭",
"吐舌小狗",
"送花",
"爱心手",
"贴贴",
"灵机一动",
"耶",
"打脸",
"大笑",
"机智",
"送心",
"666",
"闭嘴",
"来看我",
"一起加油",
"哈欠",
"震惊",
"晕",
"衰",
"困",
"疑问",
"泣不成声",
"小鼓掌",
"大金牙",
"偷笑",
"石化",
"思考",
"吐血",
"可怜",
"嘘",
"撇嘴",
"笑哭",
"奸笑",
"得意",
"憨笑",
"坏笑",
"抓狂",
"泪奔",
"钱",
"恐惧",
"愉快",
"快哭了",
"翻白眼",
"互粉",
"我想静静",
"委屈",
"鄙视",
"飞吻",
"再见",
"紫薇别走",
"听歌",
"求抱抱",
"绝望的凝视",
"不失礼貌的微笑",
"不看",
"裂开",
"干饭人",
"庆祝",
"吐舌",
"呆无辜",
"白眼",
"猪头",
"冷漠",
"暗中观察",
"二哈",
"菜狗",
"黑脸",
"展开说说",
"蜜蜂狗",
"柴犬",
"摸头",
"皱眉",
"擦汗",
"红脸",
"做鬼脸",
"强",
"如花",
"吐",
"惊喜",
"敲打",
"奋斗",
"吐彩虹",
"大哭",
"嘿哈",
"惊恐",
"囧",
"难过",
"斜眼",
"阴险",
"悠闲",
"咒骂",
"吃瓜群众",
"绿帽子",
"敢怒不敢言",
"求求了",
"眼含热泪",
"叹气",
"好开心",
"不是吧",
"鞠躬",
"躺平",
"九转大肠",
"不你不想",
"一头乱麻",
"kisskiss",
"你不大行",
"噢买尬",
"宕机",
"苦涩",
"逞强落泪",
"求机位-黄脸",
"求机位3",
"点赞",
"精选",
"强壮",
"碰拳",
"OK",
"击掌",
"左上",
"握手",
"抱拳",
"勾引",
"拳头",
"弱",
"胜利",
"右边",
"左边",
"嘴唇",
"心碎",
"凋谢",
"愤怒",
"垃圾",
"啤酒",
"咖啡",
"蛋糕",
"礼物",
"撒花",
"加一",
"减一",
"okk",
"V5",
"绝",
"给力",
"红包",
"屎",
"发",
"18禁",
"炸弹",
"西瓜",
"加鸡腿",
"握爪",
"太阳",
"月亮",
"给跪了",
"蕉绿",
"扎心",
"胡瓜",
"打call",
"栓Q",
"雪花",
"圣诞树",
"平安果",
"圣诞帽",
"气球",
"烟花",
"福",
"candy",
"糖葫芦",
"鞭炮",
"元宝",
"灯笼",
"锦鲤",
"巧克力",
"戒指",
"棒棒糖",
"纸飞机",
"粽子",
];

View File

@ -23,6 +23,13 @@ export function useBackgroundCanvas({
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
if (!isVideo) {
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
return;
}
const updateCanvasSize = () => { const updateCanvasSize = () => {
canvas.width = Math.floor(window.innerWidth / 10); canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = Math.floor(window.innerHeight / 10); canvas.height = Math.floor(window.innerHeight / 10);
@ -39,40 +46,29 @@ export function useBackgroundCanvas({
const drawMediaToCanvas = () => { const drawMediaToCanvas = () => {
if (!ctx) return; if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null; const sourceElement = videoRef.current;
if (isVideo) { if (
sourceElement = videoRef.current; !sourceElement ||
} else { (sourceElement instanceof HTMLVideoElement &&
const scroller = scrollerRef.current; sourceElement.readyState < 2)
if (scroller) { )
// 虚拟滚动:查找所有图片容器,找到当前显示的那个 return;
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
for (const container of containers) {
const img = container.querySelector("img, video");
if (img && container.style.transform.includes(`${idx * 100}%`)) {
sourceElement = img as HTMLImageElement | HTMLVideoElement;
break;
}
}
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
const canvasWidth = canvas.width; const canvasWidth = canvas.width;
const canvasHeight = canvas.height; const canvasHeight = canvas.height;
const sourceWidth = const sourceWidth = sourceElement.videoWidth;
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth; const sourceHeight = sourceElement.videoHeight;
const sourceHeight =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
if (!sourceWidth || !sourceHeight) return; if (!sourceWidth || !sourceHeight) return;
const canvasRatio = canvasWidth / canvasHeight; const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight; const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number; let drawWidth: number,
drawHeight: number,
offsetX: number,
offsetY: number;
if (canvasRatio > sourceRatio) { if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth; drawWidth = canvasWidth;

View File

@ -1,8 +1,32 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types.ts"; import type { ImageData, LoopMode, Neighbors } from "../types.ts";
const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.0001;
function normalizeDuration(
duration: number | null | undefined,
fallback: number,
) {
if (
typeof duration !== "number" ||
!Number.isFinite(duration) ||
duration <= 0
) {
return fallback;
}
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
}
function sumDurations(durations: number[], end: number) {
let total = 0;
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
return total;
}
interface UseImageCarouselProps { interface UseImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
isPlaying: boolean; isPlaying: boolean;
@ -10,7 +34,6 @@ interface UseImageCarouselProps {
neighbors: Neighbors; neighbors: Neighbors;
volume: number; volume: number;
audioRef: RefObject<HTMLAudioElement | null>; audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void; setProgress: (progress: number) => void;
/** 单张图片显示时长(毫秒),默认 5000ms */ /** 单张图片显示时长(毫秒),默认 5000ms */
segmentMs?: number; segmentMs?: number;
@ -23,22 +46,186 @@ export function useImageCarousel({
neighbors, neighbors,
volume, volume,
audioRef, audioRef,
scrollerRef,
setProgress, setProgress,
segmentMs = 5000, segmentMs = 5000,
}: UseImageCarouselProps) { }: UseImageCarouselProps) {
const router = useRouter(); const router = useRouter();
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0); const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState<
Record<string, number>
>({});
const [mediaSyncToken, setMediaSyncToken] = useState(0);
const segStartRef = useRef<number | null>(null); const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0); const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const lastUiUpdateRef = useRef(0);
const segProgressRef = useRef(0);
const totalProgressRef = useRef(0);
const fallbackDuration = normalizeDuration(segmentMs, 5000);
const segmentDurations = useMemo(
() =>
images.map((img) =>
normalizeDuration(
durationOverrides[img.id] ?? img.duration,
fallbackDuration,
),
),
[durationOverrides, fallbackDuration, images],
);
const imageKey = useMemo(
() => images.map((img) => img.id).join("|"),
[images],
);
const totalDurationMs = useMemo(
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
[segmentDurations],
);
const syncProgress = useCallback(
(index: number, segmentProgress: number, force = false) => {
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
const completedMs = sumDurations(segmentDurations, index);
const totalProgress =
totalDurationMs > 0
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
: 0;
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
if (
force ||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
) {
segProgressRef.current = nextSegProgress;
setSegProgress(nextSegProgress);
}
if (
force ||
Math.abs(nextTotalProgress - totalProgressRef.current) >
PROGRESS_EPSILON
) {
totalProgressRef.current = nextTotalProgress;
setProgress(nextTotalProgress);
}
},
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
);
const setSegmentDuration = useCallback(
(imageId: string, durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const normalizedDuration = normalizeDuration(
durationMs,
fallbackDuration,
);
setDurationOverrides((current) => {
if (current[imageId] === normalizedDuration) return current;
return { ...current, [imageId]: normalizedDuration };
});
},
[fallbackDuration],
);
const goToIndex = useCallback(
(index: number, segmentProgress = 0) => {
if (!images.length) return;
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
const now = performance.now();
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now - nextSegmentProgress * segmentDuration;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, nextSegmentProgress, true);
setMediaSyncToken((token) => token + 1);
},
[fallbackDuration, images.length, segmentDurations, syncProgress],
);
const seekTo = useCallback(
(ratio: number) => {
if (!images.length || totalDurationMs <= 0) return;
const clampedRatio = Math.max(0, Math.min(1, ratio));
const targetMs = Math.min(
totalDurationMs - 1,
clampedRatio * totalDurationMs,
);
let accumulatedMs = 0;
let targetIndex = 0;
let segmentElapsedMs = 0;
for (let i = 0; i < segmentDurations.length; i++) {
const duration = segmentDurations[i] ?? fallbackDuration;
if (
targetMs < accumulatedMs + duration ||
i === segmentDurations.length - 1
) {
targetIndex = i;
segmentElapsedMs = Math.max(
0,
Math.min(duration, targetMs - accumulatedMs),
);
break;
}
accumulatedMs += duration;
}
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
const nextSegmentProgress =
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
goToIndex(targetIndex, nextSegmentProgress);
},
[
fallbackDuration,
goToIndex,
images.length,
segmentDurations,
totalDurationMs,
],
);
useEffect(() => { useEffect(() => {
idxRef.current = idx; idxRef.current = idx;
}, [idx]); }, [idx]);
useEffect(() => {
const validIds = new Set(images.map((img) => img.id));
setDurationOverrides((current) => {
let changed = false;
const next: Record<string, number> = {};
for (const [id, duration] of Object.entries(current)) {
if (validIds.has(id)) {
next[id] = duration;
} else {
changed = true;
}
}
return changed ? next : current;
});
}, [images]);
useEffect(() => {
idxRef.current = 0;
setIdx(0);
segStartRef.current = null;
segProgressRef.current = 0;
totalProgressRef.current = 0;
setSegProgress(0);
setProgress(0);
setMediaSyncToken((token) => token + 1);
}, [imageKey, setProgress]);
// BGM 控制 // BGM 控制
useEffect(() => { useEffect(() => {
const el = audioRef.current; const el = audioRef.current;
@ -51,33 +238,42 @@ export function useImageCarousel({
} }
}, [audioRef, isPlaying, volume]); }, [audioRef, isPlaying, volume]);
useEffect(() => {
return () => {
audioRef.current?.pause();
};
}, [audioRef]);
// 自动切页 // 自动切页
useEffect(() => { useEffect(() => {
if (!images?.length) return; if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
return;
if (segStartRef.current == null) segStartRef.current = performance.now(); const now = performance.now();
let lastTs = performance.now(); const currentSegmentDuration =
segmentDurations[idxRef.current] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
const tick = (ts: number) => { if (!isPlaying) return;
const tick = () => {
if (!images?.length) return; if (!images?.length) return;
if (!isPlaying) segStartRef.current! += ts - lastTs; const ts = performance.now();
lastTs = ts;
let start = segStartRef.current!; let start = segStartRef.current!;
let localIdx = idxRef.current; let localIdx = idxRef.current;
let elapsed = ts - start; let elapsed = ts - start;
let currentSegmentDuration =
// 获取当前图片的显示时长(动图使用其 duration静态图片使用 segmentMs segmentDurations[localIdx] ?? fallbackDuration;
const getCurrentSegmentDuration = (index: number) => {
const img = images[index];
return img?.duration ?? segmentMs;
};
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
while (elapsed >= currentSegmentDuration) { while (elapsed >= currentSegmentDuration) {
if (loopMode === "single") {
elapsed %= currentSegmentDuration;
break;
}
elapsed -= currentSegmentDuration; elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) { if (localIdx >= images.length - 1) {
@ -90,43 +286,59 @@ export function useImageCarousel({
localIdx = localIdx + 1; localIdx = localIdx + 1;
} }
// 更新下一张图片的时长 currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
} }
segStartRef.current = ts - elapsed; segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) { const indexChanged = localIdx !== idxRef.current;
if (indexChanged) {
idxRef.current = localIdx; idxRef.current = localIdx;
setIdx(localIdx); setIdx(localIdx);
// 虚拟滚动不需要实际滚动 DOM
} }
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration)); const localSeg = Math.max(
setSegProgress(localSeg); 0,
Math.min(1, elapsed / currentSegmentDuration),
);
// 计算总进度:已完成的图片 + 当前图片的进度 if (
let totalProgress = 0; indexChanged ||
for (let i = 0; i < localIdx; i++) { ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
totalProgress += 1; ) {
lastUiUpdateRef.current = ts;
syncProgress(localIdx, localSeg, indexChanged);
} }
totalProgress += localSeg;
setProgress(totalProgress / images.length);
rafRef.current = requestAnimationFrame(tick);
}; };
rafRef.current = requestAnimationFrame(tick); tick();
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => { return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (timerRef.current) window.clearInterval(timerRef.current);
rafRef.current = null; timerRef.current = null;
}; };
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); }, [
fallbackDuration,
images,
isPlaying,
loopMode,
neighbors?.next,
router,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return { return {
idx, idx,
setIdx, setIdx,
segProgress, segProgress,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef, segStartRef,
idxRef, idxRef,
setSegmentDuration,
goToIndex,
seekTo,
}; };
} }

View File

@ -65,7 +65,11 @@ export function useNavigation({
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return; return;
} }

View File

@ -1,19 +1,28 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types.ts"; import type { LoopMode, ObjectFit } from "../types.ts";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils"; import {
getNumberFromStorage,
getStringFromStorage,
saveToStorage,
} from "../utils";
export function usePlayerState() { export function usePlayerState() {
const [isPlaying, setIsPlaying] = useState(true); const [isPlaying, setIsPlaying] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1)); const [volume, setVolume] = useState(() =>
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1)); getNumberFromStorage("aweme_player_volume", 1),
);
const [rate, setRate] = useState(() =>
getNumberFromStorage("aweme_player_rate", 1),
);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
const [progressRestored, setProgressRestored] = useState(false); const [progressRestored, setProgressRestored] = useState(false);
const [objectFit, setObjectFit] = useState<ObjectFit>("contain"); const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
const [loopMode, setLoopMode] = useState<LoopMode>(() => { const [loopMode, setLoopMode] = useState<LoopMode>(() => {
const saved = getStringFromStorage("aweme_player_loop_mode", "loop"); const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
return saved === "sequential" ? "sequential" : "loop"; if (saved === "single" || saved === "sequential") return saved;
return "loop";
}); });
// 持久化音量 // 持久化音量

View File

@ -51,7 +51,11 @@ export function useVideoPlayer({
const now = Date.now(); const now = Date.now();
const fiveMinutes = 5 * 60 * 1000; const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) { if (
now - timestamp < fiveMinutes &&
time > 1 &&
time < v.duration - 1
) {
v.currentTime = time; v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`); console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) { } else if (now - timestamp >= fiveMinutes) {

View File

@ -5,7 +5,11 @@ import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio"; import { getFileUrl } from "@/lib/minio";
import { AwemeData, VideoTranscript } from "./types"; import { AwemeData, VideoTranscript } from "./types";
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> { export async function generateMetadata({
params,
}: {
params: Promise<{ awemeId: string }>;
}): Promise<Metadata> {
const id = (await params).awemeId; const id = (await params).awemeId;
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
@ -16,7 +20,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
prisma.imagePost.findUnique({ prisma.imagePost.findUnique({
where: { aweme_id: id }, where: { aweme_id: id },
select: { desc: true, author: { select: { nickname: true } } }, select: { desc: true, author: { select: { nickname: true } } },
}) }),
]); ]);
const data = video || post; const data = video || post;
@ -28,7 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
const desc = data.desc || "查看作品详情"; const desc = data.desc || "查看作品详情";
const author = data.author.nickname; const author = data.author.nickname;
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`; const title =
desc.length > 50
? `${desc.slice(0, 50)}... - ${author}`
: `${desc} - ${author}`;
return { return {
title, title,
@ -36,7 +43,11 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
}; };
} }
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) { export default async function AwemeDetail({
params,
}: {
params: Promise<{ awemeId: string }>;
}) {
const id = (await params).awemeId; const id = (await params).awemeId;
const [video, post] = await Promise.all([ const [video, post] = await Promise.all([
@ -47,7 +58,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
prisma.imagePost.findUnique({ prisma.imagePost.findUnique({
where: { aweme_id: id }, where: { aweme_id: id },
include: { author: true, images: { orderBy: { order: "asc" } } }, include: { author: true, images: { orderBy: { order: "asc" } } },
}) }),
]); ]);
if (!video && !post) return <main className="p-8"></main>; if (!video && !post) return <main className="p-8"></main>;
@ -68,63 +79,107 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
commentsCount, commentsCount,
author: { author: {
nickname: aweme!.author.nickname, nickname: aweme!.author.nickname,
avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'), avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
sec_uid: aweme!.author.sec_uid sec_uid: aweme!.author.sec_uid,
}, },
...(() => { ...(() => {
if (isVideo) { if (isVideo) {
const aweme = video! const aweme = video!;
return { return {
type: "video" as const, type: "video" as const,
cover_url: getFileUrl(aweme!.cover_url ?? 'default-cover.png'), cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 }, cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
duration_ms: aweme!.duration_ms, duration_ms: aweme!.duration_ms,
video_url: getFileUrl(aweme!.video_url), video_url: getFileUrl(aweme!.video_url),
width: aweme!.width ?? null, width: aweme!.width ?? null,
height: aweme!.height ?? null, height: aweme!.height ?? null,
} };
} else { } else {
const aweme = post! const aweme = post!;
return { return {
type: "image" as const, type: "image" as const,
cover_url: getFileUrl(aweme!.images[0].url ?? 'default-cover.png'), cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
cover_size: { h: aweme!.images[0].height ?? 0, w: aweme!.images[0].width ?? 0 }, cover_size: {
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: img.animated? getFileUrl(img.animated) : null })), h: aweme!.images[0].height ?? 0,
music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'), w: aweme!.images[0].width ?? 0,
},
images: aweme!.images.map((img) => ({
...img,
url: getFileUrl(img.url),
animated: img.animated ? getFileUrl(img.animated) : null,
})),
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
}; };
} }
})() })(),
} };
const transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({ const transcript: VideoTranscript | null = isVideo
where: { videoId: id }, ? await prisma.videoTranscript.findUnique({
}) : null; where: { videoId: id },
})
: null;
// Compute prev/next neighbors by created_at across videos and image posts // Compute prev/next neighbors by created_at across videos and image posts
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at); const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([ const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }), prisma.video.findFirst({
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }), where: { created_at: { gt: currentCreatedAt } },
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), orderBy: { created_at: "asc" },
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findFirst({
where: { created_at: { gt: currentCreatedAt } },
orderBy: { created_at: "asc" },
select: { aweme_id: true, created_at: true },
}),
prisma.video.findFirst({
where: { created_at: { lt: currentCreatedAt } },
orderBy: { created_at: "desc" },
select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findFirst({
where: { created_at: { lt: currentCreatedAt } },
orderBy: { created_at: "desc" },
select: { aweme_id: true, created_at: true },
}),
]); ]);
const pickPrev = (() => { const pickPrev = (() => {
const cands: { aweme_id: string; created_at: Date }[] = []; const cands: { aweme_id: string; created_at: Date }[] = [];
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at }); if (newerVideo)
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at }); cands.push({
aweme_id: newerVideo.aweme_id,
created_at: newerVideo.created_at,
});
if (newerPost)
cands.push({
aweme_id: newerPost.aweme_id,
created_at: newerPost.created_at,
});
if (cands.length === 0) return null; if (cands.length === 0) return null;
cands.sort((a, b) => +a.created_at - +b.created_at); cands.sort((a, b) => +a.created_at - +b.created_at);
return { aweme_id: cands[0].aweme_id }; return { aweme_id: cands[0].aweme_id };
})(); })();
const pickNext = (() => { const pickNext = (() => {
const cands: { aweme_id: string; created_at: Date }[] = []; const cands: { aweme_id: string; created_at: Date }[] = [];
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at }); if (olderVideo)
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at }); cands.push({
aweme_id: olderVideo.aweme_id,
created_at: olderVideo.created_at,
});
if (olderPost)
cands.push({
aweme_id: olderPost.aweme_id,
created_at: olderPost.created_at,
});
if (cands.length === 0) return null; if (cands.length === 0) return null;
cands.sort((a, b) => +b.created_at - +a.created_at); cands.sort((a, b) => +b.created_at - +a.created_at);
return { aweme_id: cands[0].aweme_id }; return { aweme_id: cands[0].aweme_id };
})(); })();
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext }; const neighbors: {
prev: { aweme_id: string } | null;
next: { aweme_id: string } | null;
} = { prev: pickPrev, next: pickNext };
return ( return (
<main className="min-h-screen w-full"> <main className="min-h-screen w-full">
@ -135,7 +190,11 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25" className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
/> />
</div> </div>
<AwemeDetailClient data={data} neighbors={neighbors} transcript={transcript} /> <AwemeDetailClient
data={data}
neighbors={neighbors}
transcript={transcript}
/>
</main> </main>
); );
} }

View File

@ -1,4 +1,8 @@
export type User = { nickname: string; avatar_url: string | null; sec_uid?: string }; export type User = {
nickname: string;
avatar_url: string | null;
sec_uid?: string;
};
export type Comment = { export type Comment = {
cid: string; cid: string;
@ -13,7 +17,7 @@ export type VideoData = {
type: "video"; type: "video";
aweme_id: string; aweme_id: string;
cover_url: string; cover_url: string;
cover_size: {w: number; h: number}; cover_size: { w: number; h: number };
desc: string; desc: string;
created_at: string | Date; created_at: string | Date;
duration_ms: number | null; duration_ms: number | null;
@ -29,10 +33,17 @@ export type ImageData = {
type: "image"; type: "image";
aweme_id: string; aweme_id: string;
cover_url: string; cover_url: string;
cover_size: {w: number; h: number}; cover_size: { w: number; h: number };
desc: string; desc: string;
created_at: string | Date; created_at: string | Date;
images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[]; images: {
id: string;
url: string;
width: number | null;
height: number | null;
animated: string | null;
duration: number | null;
}[];
music_url: string | null; music_url: string | null;
author: User; author: User;
commentsCount: number; commentsCount: number;
@ -52,5 +63,5 @@ export type Neighbors = {
next: { aweme_id: string } | null; next: { aweme_id: string } | null;
}; };
export type LoopMode = "loop" | "sequential"; export type LoopMode = "loop" | "single" | "sequential";
export type ObjectFit = "contain" | "cover"; export type ObjectFit = "contain" | "cover";

View File

@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
} }
// 处理评论文本中的表情占位符 // 处理评论文本中的表情占位符
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] { export function parseCommentText(
text: string,
): (string | { type: "emoji"; name: string })[] {
const parts: (string | { type: "emoji"; name: string })[] = []; const parts: (string | { type: "emoji"; name: string })[] = [];
const regex = /\[([^\]]+)\]/g; const regex = /\[([^\]]+)\]/g;
let lastIndex = 0; let lastIndex = 0;
@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
} }
// 从 localStorage 获取数值 // 从 localStorage 获取数值
export function getNumberFromStorage(key: string, defaultValue: number): number { export function getNumberFromStorage(
key: string,
defaultValue: number,
): number {
if (typeof window === "undefined") return defaultValue; if (typeof window === "undefined") return defaultValue;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (!saved) return defaultValue; if (!saved) return defaultValue;
@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
} }
// 从 localStorage 获取字符串 // 从 localStorage 获取字符串
export function getStringFromStorage(key: string, defaultValue: string): string { export function getStringFromStorage(
key: string,
defaultValue: string,
): string {
if (typeof window === "undefined") return defaultValue; if (typeof window === "undefined") return defaultValue;
return localStorage.getItem(key) || defaultValue; return localStorage.getItem(key) || defaultValue;
} }

View File

@ -1,9 +1,9 @@
'use client'; "use client";
import React from 'react'; import React from "react";
import Link from 'next/link'; import Link from "next/link";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from "lucide-react";
type BackButtonProps = { type BackButtonProps = {
className?: string; className?: string;
@ -18,28 +18,37 @@ type BackButtonProps = {
* - Fallback: if close fails (e.g., not opened by script), navigates to '/' * - Fallback: if close fails (e.g., not opened by script), navigates to '/'
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally * - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
*/ */
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) { export default function BackButton({
className,
ariaLabel = "返回",
hrefFallback = "/",
children,
}: BackButtonProps) {
const router = useRouter(); const router = useRouter();
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => { const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>(
// Respect modifier clicks (new tab/window) and non-left clicks (e) => {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // Respect modifier clicks (new tab/window) and non-left clicks
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
return;
e.preventDefault(); e.preventDefault();
// Try to close the window first // Try to close the window first
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.close(); window.close();
// If window.close() didn't work (window still open after a short delay), // If window.close() didn't work (window still open after a short delay),
// navigate to the fallback URL // navigate to the fallback URL
setTimeout(() => { setTimeout(() => {
if (!document.hidden) { if (!document.hidden) {
router.push(hrefFallback); router.push(hrefFallback);
} }
}, 80); }, 80);
} }
}, [router, hrefFallback]); },
[router, hrefFallback],
);
return ( return (
<Link <Link

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from 'next/link'; import Link from "next/link";
import HoverVideo from './HoverVideo'; import HoverVideo from "./HoverVideo";
import { ThumbsUp } from 'lucide-react'; import { ThumbsUp } from "lucide-react";
import type { FeedItem, FeedResponse } from '@/app/types/feed'; import type { FeedItem, FeedResponse } from "@/app/types/feed";
type Props = { type Props = {
initialItems: FeedItem[]; initialItems: FeedItem[];
@ -11,7 +11,11 @@ type Props = {
fetchUrl?: string; fetchUrl?: string;
}; };
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: Props) { export default function FeedMasonry({
initialItems,
initialCursor,
fetchUrl = "/api/feed",
}: Props) {
// 哨兵与容器 // 哨兵与容器
const [cursor, setCursor] = useState<string | null>(initialCursor); const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -22,11 +26,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4 // 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
const getColumnCount = useCallback(() => { const getColumnCount = useCallback(() => {
if (typeof window === 'undefined') return 1; if (typeof window === "undefined") return 1;
const w = window.innerWidth; const w = window.innerWidth;
if (w >= 1280) return 4; // xl if (w >= 1280) return 4; // xl
if (w >= 1024) return 3; // lg if (w >= 1024) return 3; // lg
if (w >= 640) return 2; // sm if (w >= 640) return 2; // sm
return 1; return 1;
}, []); }, []);
// 为避免 SSR 与客户端初次渲染不一致window 未定义导致服务端为 1 列,客户端首次渲染为多列), // 为避免 SSR 与客户端初次渲染不一致window 未定义导致服务端为 1 列,客户端首次渲染为多列),
@ -37,8 +41,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 挂载后立即根据当前窗口宽度更新一次列数 // 挂载后立即根据当前窗口宽度更新一次列数
setColumnCount(getColumnCount()); setColumnCount(getColumnCount());
const onResize = () => setColumnCount(getColumnCount()); const onResize = () => setColumnCount(getColumnCount());
window.addEventListener('resize', onResize); window.addEventListener("resize", onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener("resize", onResize);
}, [getColumnCount]); }, [getColumnCount]);
// 估算卡片高度(用于分配到“最短列”) // 估算卡片高度(用于分配到“最短列”)
@ -46,8 +50,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
// 媒体区域高度 // 媒体区域高度
let mediaH = 200; // fallback let mediaH = 200; // fallback
if (item.width && item.height) { if (item.width && item.height) {
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth); mediaH = Math.max(
} else if (item.type === 'video') { 80,
(Number(item.height) / Number(item.width)) * colWidth,
);
} else if (item.type === "video") {
mediaH = (9 / 16) * colWidth; // 常见视频比例 mediaH = (9 / 16) * colWidth; // 常见视频比例
} }
// 文本 + 作者栏的高度粗估 // 文本 + 作者栏的高度粗估
@ -62,12 +69,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
return cols; return cols;
}); });
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0)); const [colHeights, setColHeights] = useState<number[]>(() =>
Array.from({ length: columnCount }, () => 0),
);
// 初始化与当列数变化时重排 // 初始化与当列数变化时重排
useEffect(() => { useEffect(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0; const containerWidth = containerRef.current?.clientWidth ?? 0;
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; const colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
// 用 initialItems 重排 // 用 initialItems 重排
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
const newHeights: number[] = Array.from({ length: columnCount }, () => 0); const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
@ -90,16 +100,19 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (cursor) params.set('before', cursor); if (cursor) params.set("before", cursor);
params.set('limit', '24'); params.set("limit", "24");
const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`; const url = fetchUrl.includes("?")
const res = await fetch(url, { cache: 'no-store' }); ? `${fetchUrl}&${params.toString()}`
: `${fetchUrl}?${params.toString()}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: FeedResponse = await res.json(); const data: FeedResponse = await res.json();
// 将新数据按最短列分配 // 将新数据按最短列分配
setColumns((prevCols) => { setColumns((prevCols) => {
const containerWidth = containerRef.current?.clientWidth ?? 0; const containerWidth = containerRef.current?.clientWidth ?? 0;
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; const colWidth =
columnCount > 0 ? containerWidth / columnCount : containerWidth;
const cols = prevCols.map((c) => [...c]); const cols = prevCols.map((c) => [...c]);
const heights = [...colHeights]; const heights = [...colHeights];
for (const item of data.items) { for (const item of data.items) {
@ -117,7 +130,7 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
setCursor(data.nextCursor); setCursor(data.nextCursor);
if (!data.nextCursor || data.items.length === 0) setEnded(true); if (!data.nextCursor || data.items.length === 0) setEnded(true);
} catch (e) { } catch (e) {
console.error('fetch more feed failed', e); console.error("fetch more feed failed", e);
// 失败也不要死循环 // 失败也不要死循环
setEnded(true); setEnded(true);
} finally { } finally {
@ -128,88 +141,129 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
useEffect(() => { useEffect(() => {
const el = sentinelRef.current; const el = sentinelRef.current;
if (!el) return; if (!el) return;
const io = new IntersectionObserver((entries) => { const io = new IntersectionObserver(
const entry = entries[0]; (entries) => {
if (entry.isIntersecting) { const entry = entries[0];
fetchMore(); if (entry.isIntersecting) {
} fetchMore();
}, { rootMargin: '800px 0px 800px 0px' }); }
},
{ rootMargin: "800px 0px 800px 0px" },
);
io.observe(el); io.observe(el);
return () => io.disconnect(); return () => io.disconnect();
}, [fetchMore]); }, [fetchMore]);
const renderCard = useCallback((item: FeedItem) => ( const renderCard = useCallback(
<article key={item.aweme_id} className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"> (item: FeedItem) => (
<Link href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full"> <article
<div key={item.aweme_id}
className="relative w-full" className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }} >
<Link
href={`/aweme/${item.aweme_id}`}
target="_blank"
className="block relative w-full"
> >
{item.type === 'video' ? ( <div
<HoverVideo className="relative w-full"
videoUrl={(item as any).video_url} style={{
coverUrl={item.cover_url} aspectRatio:
className="absolute inset-0 w-full h-full" `${item.width && item.height ? `${item.width}/${item.height}` : ""}` as any,
/> }}
) : ( >
<img {item.type === "video" ? (
loading="lazy" <HoverVideo
src={item.cover_url || '/placeholder.svg'} videoUrl={(item as any).video_url}
alt={item.desc?.slice(0, 20) || 'image'} coverUrl={item.cover_url}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full"
/> />
)} ) : (
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" /> <img
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3"> loading="lazy"
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow"> src={item.cover_url || "/placeholder.svg"}
{item.desc} alt={item.desc?.slice(0, 20) || "image"}
</p> className="absolute inset-0 w-full h-full object-cover"
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800"> />
{item.type === 'video' ? '视频' : '图文'} )}
</span> <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
{item.desc}
</p>
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
{item.type === "video" ? "视频" : "图文"}
</span>
</div>
</div> </div>
</div> </Link>
</Link>
<div className="flex items-center gap-2 p-3"> <div className="flex items-center gap-2 p-3">
{item.author.sec_uid ? ( {item.author.sec_uid ? (
<Link href={`/author/${item.author.sec_uid}`} className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"> <Link
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0"> href={`/author/${item.author.sec_uid}`}
{item.author.avatar_url ? ( className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> >
) : null} <div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
{item.author.avatar_url ? (
<img
src={item.author.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null}
</div>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
{item.author.nickname}
</span>
</Link>
) : (
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
{item.author.avatar_url ? (
<img
src={item.author.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null}
</div>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
{item.author.nickname}
</span>
</div> </div>
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span> )}
</Link> <span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
) : ( {item.likes}{" "}
<div className="flex items-center gap-2 min-w-0 flex-1"> <ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0"> </span>
{item.author.avatar_url ? ( </div>
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" /> </article>
) : null} ),
</div> [],
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span> );
</div>
)}
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
{item.likes} <ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
</span>
</div>
</article>
), []);
return ( return (
<> <>
{/* Masonry按列渲染动态分配到最短列 */} {/* Masonry按列渲染动态分配到最短列 */}
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}> <div
ref={containerRef}
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
}}
>
{columns.map((col, idx) => ( {columns.map((col, idx) => (
<div key={idx} className="flex flex-col"> <div key={idx} className="flex flex-col">
{col.map((item) => renderCard(item))} {col.map((item) => renderCard(item))}
</div> </div>
))} ))}
</div> </div>
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500"> <div
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')} ref={sentinelRef}
className="h-10 flex items-center justify-center text-sm text-zinc-500"
>
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
</div> </div>
</> </>
); );

View File

@ -1,6 +1,6 @@
'use client'; "use client";
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from "react";
type HoverVideoProps = { type HoverVideoProps = {
videoUrl: string; videoUrl: string;
@ -12,7 +12,12 @@ type HoverVideoProps = {
/** /**
* *
*/ */
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) { export default function HoverVideo({
videoUrl,
coverUrl,
className,
style,
}: HoverVideoProps) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov
}, []); }, []);
return ( return (
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}> <div
className={className}
style={style}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{/* 封面始终渲染在底层 */} {/* 封面始终渲染在底层 */}
<img <img
src={coverUrl || '/placeholder.svg'} src={coverUrl || "/placeholder.svg"}
alt="cover" alt="cover"
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
draggable={false} draggable={false}
loading='lazy' loading="lazy"
/> />
{/* 仅在激活后渲染视频;初始不设置 src防止提前加载 */} {/* 仅在激活后渲染视频;初始不设置 src防止提前加载 */}

View File

@ -3,6 +3,11 @@
:root { :root {
--background: #161823; /* theme background */ --background: #161823; /* theme background */
--foreground: #ededed; --foreground: #ededed;
--font-geist-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-geist-mono:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
} }
@theme inline { @theme inline {
@ -23,11 +28,13 @@ body {
margin: 0; margin: 0;
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans);
} }
/* 滚动条隐藏 */ /* 滚动条隐藏 */
.no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar::-webkit-scrollbar {
display: none;
}
/* 搜索结果高亮标记样式 */ /* 搜索结果高亮标记样式 */
mark { mark {
@ -37,12 +44,15 @@ mark {
border-radius: 0.25rem; border-radius: 0.25rem;
font-weight: 500; font-weight: 500;
} }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } .no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.h-screen{ .h-screen {
height: 100dvh; height: 100dvh;
} }
.min-h-screen{ .min-h-screen {
min-height: 100dvh; min-height: 100dvh;
} }

View File

@ -1,17 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "抖歪 - 记录当下时代", default: "抖歪 - 记录当下时代",
@ -30,11 +19,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body <body className="antialiased">{children}</body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html> </html>
); );
} }

View File

@ -33,11 +33,15 @@ export default async function Home() {
created_at: v.created_at, created_at: v.created_at,
desc: v.desc, desc: v.desc,
video_url: getFileUrl(v.video_url), video_url: getFileUrl(v.video_url),
cover_url: getFileUrl(v.cover_url ?? ''), cover_url: getFileUrl(v.cover_url ?? ""),
width: v.width ?? null, width: v.width ?? null,
height: v.height ?? null, height: v.height ?? null,
author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, author: {
likes: Number(v.digg_count) nickname: v.author.nickname,
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
type: "image" as const, type: "image" as const,
@ -47,8 +51,12 @@ export default async function Home() {
cover_url: getFileUrl(p.images?.[0]?.url ?? null), cover_url: getFileUrl(p.images?.[0]?.url ?? null),
width: p.images?.[0]?.width ?? null, width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null, height: p.images?.[0]?.height ?? null,
author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? ''), sec_uid: p.author.sec_uid }, author: {
likes: Number(p.digg_count) nickname: p.author.nickname,
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})), })),
] ]
//.sort(() => Math.random() - 0.5) //.sort(() => Math.random() - 0.5)
@ -73,8 +81,15 @@ export default async function Home() {
<div className="w-full"> <div className="w-full">
{(() => { {(() => {
const initial = feed.slice(0, 24); const initial = feed.slice(0, 24);
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; const cursor =
return <FeedMasonry initialItems={initial} initialCursor={cursor} />; initial.length > 0
? new Date(
initial[initial.length - 1].created_at as any,
).toISOString()
: null;
return (
<FeedMasonry initialItems={initial} initialCursor={cursor} />
);
})()} })()}
</div> </div>
</div> </div>

View File

@ -9,7 +9,7 @@ import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
type SearchResultItem = { type SearchResultItem = {
id: string; id: string;
awemeId: string; awemeId: string;
type: 'video' | 'image'; type: "video" | "image";
rank: number; rank: number;
snippet: string; // 后端返回的高亮片段,已包含<mark>标签 snippet: string; // 后端返回的高亮片段,已包含<mark>标签
video?: { video?: {
@ -35,7 +35,11 @@ type SearchResultItem = {
}; };
}; };
}; };
export default function SearchClient({ initialQuery }: { initialQuery: string }) { export default function SearchClient({
initialQuery,
}: {
initialQuery: string;
}) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@ -53,7 +57,9 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
setLoading(true); setLoading(true);
setSearched(true); setSearched(true);
try { try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`); const res = await fetch(
`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`,
);
if (!res.ok) throw new Error("Search failed"); if (!res.ok) throw new Error("Search failed");
const data = await res.json(); const data = await res.json();
setResults(data.results || []); setResults(data.results || []);
@ -101,7 +107,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<ArrowLeft size={24} /> <ArrowLeft size={24} />
</button> </button>
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3"> <form
onSubmit={handleSearch}
className="flex-1 flex items-center gap-3"
>
<div className="relative flex-1"> <div className="relative flex-1">
<input <input
type="text" type="text"
@ -160,7 +169,9 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<div className="flex flex-col items-center justify-center py-20 text-white/40"> <div className="flex flex-col items-center justify-center py-20 text-white/40">
<Search size={64} className="mb-4 opacity-20" /> <Search size={64} className="mb-4 opacity-20" />
<p className="text-xl"></p> <p className="text-xl"></p>
<p className="text-sm mt-2"></p> <p className="text-sm mt-2">
</p>
</div> </div>
)} )}
@ -168,14 +179,19 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<div> <div>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h2 className="text-lg text-white/80"> <h2 className="text-lg text-white/80">
<span className="text-white font-semibold">{results.length}</span> {" "}
<span className="text-white font-semibold">
{results.length}
</span>{" "}
</h2> </h2>
</div> </div>
{/* 单列列表布局 */} {/* 单列列表布局 */}
<div className="space-y-4"> <div className="space-y-4">
{results.map((item) => { {results.map((item) => {
const content = item.type === 'video' ? item.video : item.imagePost; const content =
item.type === "video" ? item.video : item.imagePost;
if (!content) return null; if (!content) return null;
return ( return (
@ -202,12 +218,14 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{/* 类型标签 */} {/* 类型标签 */}
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded ${ <span
item.type === 'video' className={`px-2 py-0.5 text-xs rounded ${
? 'bg-blue-600/20 text-blue-300' item.type === "video"
: 'bg-purple-600/20 text-purple-300' ? "bg-blue-600/20 text-blue-300"
}`}> : "bg-purple-600/20 text-purple-300"
{item.type === 'video' ? '视频' : '图文'} }`}
>
{item.type === "video" ? "视频" : "图文"}
</span> </span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded"> <span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
<MessageSquare size={12} /> <MessageSquare size={12} />
@ -216,7 +234,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
</div> </div>
{/* 描述 */} {/* 描述 */}
<Link href={`/aweme/${content.aweme_id}`} target="_blank"> <Link
href={`/aweme/${content.aweme_id}`}
target="_blank"
>
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors"> <p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
{content.desc || "无描述"} {content.desc || "无描述"}
</p> </p>
@ -245,7 +266,7 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed" className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
dangerouslySetInnerHTML={{ __html: item.snippet }} dangerouslySetInnerHTML={{ __html: item.snippet }}
style={{ style={{
wordBreak: 'break-word', wordBreak: "break-word",
}} }}
/> />
</div> </div>

View File

@ -2,7 +2,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import SearchClient from "./SearchClient"; import SearchClient from "./SearchClient";
export default function Page({ searchParams }: { searchParams: { q?: string } }) { export default function Page({
searchParams,
}: {
searchParams: { q?: string };
}) {
const q = typeof searchParams.q === "string" ? searchParams.q : ""; const q = typeof searchParams.q === "string" ? searchParams.q : "";
return ( return (
<Suspense fallback={<div className="p-6 text-zinc-400">Loading</div>}> <Suspense fallback={<div className="p-6 text-zinc-400">Loading</div>}>

View File

@ -1,7 +1,20 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, Clipboard, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react"; import {
AlertTriangle,
CheckCircle2,
Clipboard,
Clock,
ExternalLink,
Link2,
Loader2,
PlayCircle,
Plus,
Square,
Trash2,
X,
} from "lucide-react";
type TaskStatus = "pending" | "running" | "success" | "error"; type TaskStatus = "pending" | "running" | "success" | "error";
@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
const trailing = /[)\]】>。,、!!?\s]+$/; // 去掉常见中文/英文结尾符号 const trailing = /[)\]】>。,、!!?\s]+$/; // 去掉常见中文/英文结尾符号
const cleaned = matches const cleaned = matches
.map((m) => m.replace(trailing, "")) .map((m) => m.replace(trailing, ""))
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾 .map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
// 去重 // 去重
return Array.from(new Set(cleaned)); return Array.from(new Set(cleaned));
}; };
@ -43,43 +56,58 @@ export default function TasksPage() {
}, []); }, []);
const inProgressUrls = useMemo( const inProgressUrls = useMemo(
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)), () =>
[tasks] new Set(
tasks
.filter((t) => t.status === "pending" || t.status === "running")
.map((t) => t.url),
),
[tasks],
); );
const addTasks = useCallback((urls: string[]) => { const addTasks = useCallback(
if (!urls.length) return; (urls: string[]) => {
const now = Date.now(); if (!urls.length) return;
setTasks((prev) => { const now = Date.now();
const existing = new Set(prev.map((t) => t.id)); setTasks((prev) => {
const notDuplicated = urls.filter(u => !inProgressUrls.has(u)); const existing = new Set(prev.map((t) => t.id));
const newTasks: Task[] = []; const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
for (const url of notDuplicated) { const newTasks: Task[] = [];
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; for (const url of notDuplicated) {
newTasks.push({ id, url, status: "pending" }); const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
} newTasks.push({ id, url, status: "pending" });
// 新任务添加到最前面 }
return [...newTasks, ...prev]; // 新任务添加到最前面
}); return [...newTasks, ...prev];
}, [inProgressUrls]); });
},
[inProgressUrls],
);
const handleSubmit = useCallback((e?: React.FormEvent) => { const handleSubmit = useCallback(
e?.preventDefault(); (e?: React.FormEvent) => {
const urls = extractDouyinLinks(input); e?.preventDefault();
if (!urls.length) { const urls = extractDouyinLinks(input);
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本"); if (!urls.length) {
return; alert(
} "未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本",
addTasks(urls); );
setInput(""); return;
}, [input, addTasks]); }
addTasks(urls);
setInput("");
},
[input, addTasks],
);
const handlePasteAndAdd = useCallback(async () => { const handlePasteAndAdd = useCallback(async () => {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
const urls = extractDouyinLinks(text); const urls = extractDouyinLinks(text);
if (!urls.length) { if (!urls.length) {
alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本"); alert(
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
);
return; return;
} }
addTasks(urls); addTasks(urls);
@ -94,22 +122,44 @@ export default function TasksPage() {
if (controllers.current.has(task.id)) return; if (controllers.current.has(task.id)) return;
const ctrl = new AbortController(); const ctrl = new AbortController();
controllers.current.set(task.id, ctrl); controllers.current.set(task.id, ctrl);
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t)); setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
: t,
),
);
try { try {
const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" }); const res = await fetch(
`/api/fetcher?url=${encodeURIComponent(task.url)}`,
{ signal: ctrl.signal, method: "GET" },
);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
// 使用后端返回的结构化错误信息 // 使用后端返回的结构化错误信息
const errorMsg = data?.error || `请求失败: ${res.status}`; const errorMsg = data?.error || `请求失败: ${res.status}`;
const errorCode = data?.code || 'UNKNOWN'; const errorCode = data?.code || "UNKNOWN";
throw new Error(`${errorMsg} (${errorCode})`); throw new Error(`${errorMsg} (${errorCode})`);
} }
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t)); setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "success", finishedAt: Date.now(), result: data }
: t,
),
);
} catch (err: any) { } catch (err: any) {
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err)); const msg =
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t)); err?.name === "AbortError" ? "已取消" : err?.message || String(err);
setTasks((prev) =>
prev.map((t) =>
t.id === task.id
? { ...t, status: "error", finishedAt: Date.now(), error: msg }
: t,
),
);
} finally { } finally {
controllers.current.delete(task.id); controllers.current.delete(task.id);
} }
@ -117,17 +167,17 @@ export default function TasksPage() {
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发) // 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
useEffect(() => { useEffect(() => {
const pending = tasks.filter(t => t.status === "pending"); const pending = tasks.filter((t) => t.status === "pending");
pending.forEach((t) => startTask(t)); pending.forEach((t) => startTask(t));
}, [tasks, startTask]); }, [tasks, startTask]);
// 定时器更新运行中任务的耗时显示 // 定时器更新运行中任务的耗时显示
useEffect(() => { useEffect(() => {
const hasRunningTasks = tasks.some(t => t.status === "running"); const hasRunningTasks = tasks.some((t) => t.status === "running");
if (!hasRunningTasks) return; if (!hasRunningTasks) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setTick(prev => prev + 1); setTick((prev) => prev + 1);
}, 1000); // 每秒更新一次 }, 1000); // 每秒更新一次
return () => clearInterval(timer); return () => clearInterval(timer);
@ -140,27 +190,40 @@ export default function TasksPage() {
}, []); }, []);
const retryTask = useCallback((taskId: string) => { const retryTask = useCallback((taskId: string) => {
setTasks(prev => prev.map(t => { setTasks((prev) =>
if (t.id === taskId) { prev.map((t) => {
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined }; if (t.id === taskId) {
} return {
return t; ...t,
})); status: "pending" as TaskStatus,
error: undefined,
result: undefined,
};
}
return t;
}),
);
}, []); }, []);
const clearFinished = useCallback(() => { const clearFinished = useCallback(() => {
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running")); setTasks((prev) =>
prev.filter((t) => t.status === "pending" || t.status === "running"),
);
}, []); }, []);
const toggleOpen = useCallback((id: string) => { const toggleOpen = useCallback((id: string) => {
setOpenDetails(prev => { setOpenDetails((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}) });
}, []); }, []);
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]); const extractedCount = useMemo(
() => extractDouyinLinks(input).length,
[input],
);
const formatDuration = (startTime?: number, endTime?: number) => { const formatDuration = (startTime?: number, endTime?: number) => {
if (!startTime) return ""; if (!startTime) return "";
@ -173,11 +236,31 @@ export default function TasksPage() {
}; };
const StatusBadge = ({ status }: { status: TaskStatus }) => { const StatusBadge = ({ status }: { status: TaskStatus }) => {
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; const base =
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> </span>; "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> </span>; if (status === "running")
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> </span>; return (
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> </span>; <span className={`${base} bg-indigo-500/15 text-indigo-300`}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</span>
);
if (status === "pending")
return (
<span className={`${base} bg-yellow-500/15 text-yellow-300`}>
<Clock className="h-3.5 w-3.5" />
</span>
);
if (status === "success")
return (
<span className={`${base} bg-emerald-500/15 text-emerald-300`}>
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
);
return (
<span className={`${base} bg-red-500/15 text-red-300`}>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
);
}; };
return ( return (
@ -192,11 +275,16 @@ export default function TasksPage() {
<h1 className="text-3xl font-semibold tracking-tight text-white"> <h1 className="text-3xl font-semibold tracking-tight text-white">
</h1> </h1>
<p className="mt-2 text-sm text-neutral-400"> Douyin </p> <p className="mt-2 text-sm text-neutral-400">
Douyin
</p>
</div> </div>
{/* 输入卡片 */} {/* 输入卡片 */}
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"> <form
onSubmit={handleSubmit}
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5"> <div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
<Link2 className="h-5 w-5 text-neutral-400" /> <Link2 className="h-5 w-5 text-neutral-400" />
@ -210,18 +298,31 @@ export default function TasksPage() {
/> />
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500"> <div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
<span> {extractedCount} </span> <span> {extractedCount} </span>
<span className="hidden sm:inline">Ctrl / Cmd + Enter </span> <span className="hidden sm:inline">
Ctrl / Cmd + Enter
</span>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"> <button
type="submit"
className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
<button type="button" onClick={handlePasteAndAdd} className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"> <button
type="button"
onClick={handlePasteAndAdd}
className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
>
<Clipboard className="h-4 w-4" /> <Clipboard className="h-4 w-4" />
</button> </button>
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"> <button
type="button"
onClick={clearFinished}
className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
@ -231,64 +332,91 @@ export default function TasksPage() {
<section className="mt-8"> <section className="mt-8">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-medium"></h2> <h2 className="text-lg font-medium"></h2>
<span className="text-xs text-neutral-400"> {tasks.length} </span> <span className="text-xs text-neutral-400">
{tasks.length}
</span>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
{tasks.map((t) => { {tasks.map((t) => {
const isOpen = openDetails.has(t.id); const isOpen = openDetails.has(t.id);
return ( return (
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"> <li
key={t.id}
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={t.status} /> <StatusBadge status={t.status} />
{t.status === 'running' && ( {t.status === "running" && (
<span className="text-xs text-neutral-400"> {formatDuration(t.startedAt)}</span> <span className="text-xs text-neutral-400">
{formatDuration(t.startedAt)}
</span>
)} )}
{t.status === 'success' && ( {t.status === "success" && (
<span className="text-xs text-neutral-400"> {formatDuration(t.startedAt, t.finishedAt)}</span> <span className="text-xs text-neutral-400">
{formatDuration(t.startedAt, t.finishedAt)}
</span>
)} )}
</div> </div>
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200"> <div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
<span className="truncate" title={t.url}>{t.url}</span> <span className="truncate" title={t.url}>
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer"> {t.url}
</span>
<a
className="shrink-0 text-neutral-400 hover:text-neutral-200"
href={t.url}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{t.status === 'success' && t.result?.data?.aweme_id && ( {t.status === "success" && t.result?.data?.aweme_id && (
<a <a
href={`/aweme/${t.result.data.aweme_id}`} href={`/aweme/${t.result.data.aweme_id}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600" className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
> >
<ExternalLink className="h-3.5 w-3.5"/> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
)} )}
{t.status === 'error' && ( {t.status === "error" && (
<button <button
onClick={() => retryTask(t.id)} onClick={() => retryTask(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600" className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
> >
<PlayCircle className="h-3.5 w-3.5"/> <PlayCircle className="h-3.5 w-3.5" />
</button> </button>
)} )}
{t.status === 'running' && ( {t.status === "running" && (
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"> <button
<Square className="h-3.5 w-3.5"/> onClick={() => cancelTask(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"
>
<Square className="h-3.5 w-3.5" />
</button> </button>
)} )}
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"> <button
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'} onClick={() => toggleOpen(t.id)}
className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"
>
{isOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}{" "}
{isOpen ? "收起" : "详情"}
</button> </button>
</div> </div>
</div> </div>
{/* 进度条 */} {/* 进度条 */}
{t.status === 'running' && ( {t.status === "running" && (
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800"> <div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" /> <div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
</div> </div>
@ -296,13 +424,17 @@ export default function TasksPage() {
{isOpen && ( {isOpen && (
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3"> <div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
{t.status === 'error' && t.error && ( {t.status === "error" && t.error && (
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3"> <div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-200"></div> <div className="text-sm font-medium text-red-200">
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
</div>
<div className="mt-1 text-xs text-red-300/90">
{t.error}
</div>
</div> </div>
</div> </div>
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
@ -315,34 +447,58 @@ export default function TasksPage() {
</div> </div>
</div> </div>
)} )}
{typeof t.result !== 'undefined' && ( {typeof t.result !== "undefined" && (
<div> <div>
{t.result?.data?.aweme && ( {t.result?.data?.aweme && (
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs"> <div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-400"/> <CheckCircle2 className="h-4 w-4 text-emerald-400" />
<span className="font-medium text-emerald-200"></span> <span className="font-medium text-emerald-200">
</span>
</div> </div>
<div className="space-y-1 text-neutral-300"> <div className="space-y-1 text-neutral-300">
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div> <div>
<div><span className="text-neutral-400">:</span> {t.result.data.aweme.desc || '(无)'}</div> <span className="text-neutral-400">ID:</span>{" "}
{t.result.data.aweme.aweme_id}
</div>
<div>
<span className="text-neutral-400">
:
</span>{" "}
{t.result.data.aweme.desc || "(无)"}
</div>
{t.result.data.aweme.author && ( {t.result.data.aweme.author && (
<div><span className="text-neutral-400">:</span> {t.result.data.aweme.author.nickname}</div> <div>
<span className="text-neutral-400">
:
</span>{" "}
{t.result.data.aweme.author.nickname}
</div>
)} )}
</div> </div>
</div> </div>
)} )}
<details className="group"> <details className="group">
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300"> <summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
<span className="group-open:hidden"></span><span className="hidden group-open:inline"></span> {" "}
<span className="group-open:hidden"></span>
<span className="hidden group-open:inline">
</span>
</summary> </summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre> <pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">
{JSON.stringify(t.result, null, 2)}
</pre>
</details> </details>
</div> </div>
)} )}
{typeof t.result === 'undefined' && t.status !== 'error' && ( {typeof t.result === "undefined" &&
<div className="text-xs text-neutral-400"></div> t.status !== "error" && (
)} <div className="text-xs text-neutral-400">
</div>
)}
</div> </div>
)} )}
</li> </li>

View File

@ -1,19 +1,21 @@
export type FeedItem = export type FeedItem = (
| ({ | {
type: "video"; type: "video";
video_url: string; video_url: string;
} | { }
| {
type: "image"; type: "image";
}) & { }
likes: number; ) & {
author: { nickname: string; avatar_url: string | null; sec_uid?: string }; likes: number;
aweme_id: string; author: { nickname: string; avatar_url: string | null; sec_uid?: string };
created_at: Date | string; aweme_id: string;
desc: string; created_at: Date | string;
cover_url: string | null; desc: string;
width?: number | null; cover_url: string | null;
height?: number | null; width?: number | null;
}; height?: number | null;
};
export interface FeedResponse { export interface FeedResponse {
items: FeedItem[]; items: FeedItem[];

View File

@ -8,13 +8,14 @@
"chalk": "^5.6.2", "chalk": "^5.6.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "15.5.6", "next": "15.5.7",
"openai": "^6.7.0", "openai": "^6.7.0",
"playwright": "1.56.1", "playwright": "1.56.1",
"playwright-extra": "^4.3.6", "playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"undici": "^7.16.0",
"zod": "^4.1.12", "zod": "^4.1.12",
}, },
"devDependencies": { "devDependencies": {
@ -113,23 +114,23 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="], "@next/env": ["@next/env@15.5.7", "https://registry.npmmirror.com/@next/env/-/env-15.5.7.tgz", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="], "@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
@ -503,7 +504,7 @@
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="], "next": ["next@15.5.7", "https://registry.npmmirror.com/next/-/next-15.5.7.tgz", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
@ -631,6 +632,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.16.0", "https://registry.npmmirror.com/undici/-/undici-7.16.0.tgz", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],

View File

@ -1,12 +1,12 @@
// scripts/fix-asset-urls.ts // scripts/fix-asset-urls.ts
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const FROM = 'douyin-archive/'; const FROM = "douyin-archive/";
const TO = ''; const TO = "";
function escapeForPgRegex(s: string) { function escapeForPgRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串 const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
const dryRun = false; // true: 只统计,不修改 const dryRun = false; // true: 只统计,不修改
@ -121,12 +121,14 @@ async function main() {
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2 SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
) )
`); `);
console.log('Sample after update:', sample); console.log("Sample after update:", sample);
} }
main().catch((e) => { main()
console.error(e); .catch((e) => {
process.exit(1); console.error(e);
}).finally(async () => { process.exit(1);
await prisma.$disconnect(); })
}); .finally(async () => {
await prisma.$disconnect();
});

4
global.d.ts vendored
View File

@ -1,9 +1,9 @@
declare module '*.md' { declare module "*.md" {
const content: string; const content: string;
export default content; export default content;
} }
declare module '*.txt' { declare module "*.txt" {
const content: string; const content: string;
export default content; export default content;
} }

View File

@ -1,10 +1,13 @@
// lib/json.ts // lib/json.ts
export function json(data: unknown, init?: ResponseInit) { export function json(data: unknown, init?: ResponseInit) {
return new Response( return new Response(
JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)), JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
{ {
...init, ...init,
headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) }, headers: {
} "content-type": "application/json; charset=utf-8",
...(init?.headers || {}),
},
},
); );
} }

View File

@ -14,7 +14,7 @@ import {
downloadFile, downloadFile,
fileExists, fileExists,
getFileInfo, getFileInfo,
} from './minio'; } from "./minio";
// ======================================== // ========================================
// 1. 上传文件示例 // 1. 上传文件示例
@ -25,15 +25,15 @@ import {
*/ */
async function uploadAvatar(file: File, userId: string) { async function uploadAvatar(file: File, userId: string) {
// 验证文件类型 // 验证文件类型
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
// 验证文件大小5MB // 验证文件大小5MB
const maxSize = 5 * 1024 * 1024; const maxSize = 5 * 1024 * 1024;
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大5MB'); throw new Error("文件大小超过限制最大5MB");
} }
// 生成唯一文件名,存储在 avatars 目录下 // 生成唯一文件名,存储在 avatars 目录下
@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) {
// 上传文件 // 上传文件
const url = await uploadFile(file, path, { const url = await uploadFile(file, path, {
'Content-Type': file.type, "Content-Type": file.type,
'User-Id': userId, "User-Id": userId,
}); });
return { url, path }; return { url, path };
@ -52,24 +52,24 @@ async function uploadAvatar(file: File, userId: string) {
* *
*/ */
async function uploadPostCover(file: File, postId: string) { async function uploadPostCover(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
const maxSize = 10 * 1024 * 1024; // 10MB const maxSize = 10 * 1024 * 1024; // 10MB
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大10MB'); throw new Error("文件大小超过限制最大10MB");
} }
// 按日期组织文件 // 按日期组织文件
const date = new Date(); const date = new Date();
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const path = generateUniqueFileName( const path = generateUniqueFileName(
file.name, file.name,
`posts/${year}/${month}/covers` `posts/${year}/${month}/covers`,
); );
const url = await uploadFile(file, path); const url = await uploadFile(file, path);
@ -81,23 +81,23 @@ async function uploadPostCover(file: File, postId: string) {
* *
*/ */
async function uploadPostImage(file: File, postId: string) { async function uploadPostImage(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
if (!validateFileType(file.name, allowedTypes)) { if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式'); throw new Error("不支持的图片格式");
} }
const maxSize = 5 * 1024 * 1024; // 5MB const maxSize = 5 * 1024 * 1024; // 5MB
if (!validateFileSize(file.size, maxSize)) { if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制最大5MB'); throw new Error("文件大小超过限制最大5MB");
} }
const date = new Date(); const date = new Date();
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
const path = generateUniqueFileName( const path = generateUniqueFileName(
file.name, file.name,
`posts/${year}/${month}/images` `posts/${year}/${month}/images`,
); );
const url = await uploadFile(file, path); const url = await uploadFile(file, path);
@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) {
async function deleteAvatar(avatarPath: string) { async function deleteAvatar(avatarPath: string) {
try { try {
await deleteFile(avatarPath); await deleteFile(avatarPath);
console.log('头像删除成功'); console.log("头像删除成功");
} catch (error) { } catch (error) {
console.error('删除头像失败:', error); console.error("删除头像失败:", error);
throw error; throw error;
} }
} }
@ -142,20 +142,20 @@ async function deletePostImages(postId: string) {
const files = await listFiles(`posts/`, true); const files = await listFiles(`posts/`, true);
// 过滤出该文章的图片(根据实际情况调整逻辑) // 过滤出该文章的图片(根据实际情况调整逻辑)
const postFiles = files.filter(file => const postFiles = files.filter((file) => file.name?.includes(postId));
file.name?.includes(postId)
);
// 批量删除 // 批量删除
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name); const paths = postFiles
.map((f) => f.name)
.filter((name): name is string => !!name);
if (paths.length > 0) { if (paths.length > 0) {
const { deleteFiles } = await import('./minio'); const { deleteFiles } = await import("./minio");
await deleteFiles(paths); await deleteFiles(paths);
console.log(`删除了 ${paths.length} 个文件`); console.log(`删除了 ${paths.length} 个文件`);
} }
} catch (error) { } catch (error) {
console.error('删除文章图片失败:', error); console.error("删除文章图片失败:", error);
throw error; throw error;
} }
} }
@ -171,14 +171,14 @@ async function getUserAvatars(userId: string) {
try { try {
const files = await listFiles(`avatars/${userId}/`, false); const files = await listFiles(`avatars/${userId}/`, false);
return files.map(file => ({ return files.map((file) => ({
name: file.name, name: file.name,
size: file.size, size: file.size,
lastModified: file.lastModified, lastModified: file.lastModified,
url: file.name ? getFileUrl(file.name) : null, url: file.name ? getFileUrl(file.name) : null,
})); }));
} catch (error) { } catch (error) {
console.error('获取用户头像列表失败:', error); console.error("获取用户头像列表失败:", error);
throw error; throw error;
} }
} }
@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) {
*/ */
async function getPostCoversByMonth(year: number, month: number) { async function getPostCoversByMonth(year: number, month: number) {
try { try {
const monthStr = String(month).padStart(2, '0'); const monthStr = String(month).padStart(2, "0");
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false); const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
return files.map(file => ({ return files.map((file) => ({
name: file.name, name: file.name,
size: file.size, size: file.size,
url: file.name ? getFileUrl(file.name) : null, url: file.name ? getFileUrl(file.name) : null,
})); }));
} catch (error) { } catch (error) {
console.error('获取封面列表失败:', error); console.error("获取封面列表失败:", error);
throw error; throw error;
} }
} }
@ -228,10 +228,10 @@ async function getFileDetails(path: string) {
size: info.size, size: info.size,
lastModified: info.lastModified, lastModified: info.lastModified,
etag: info.etag, etag: info.etag,
contentType: info.metaData?.['content-type'], contentType: info.metaData?.["content-type"],
}; };
} catch (error) { } catch (error) {
console.error('获取文件信息失败:', error); console.error("获取文件信息失败:", error);
throw error; throw error;
} }
} }
@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
try { try {
return await downloadFile(path); return await downloadFile(path);
} catch (error) { } catch (error) {
console.error('下载文件失败:', error); console.error("下载文件失败:", error);
throw error; throw error;
} }
} }

View File

@ -1,22 +1,22 @@
import * as Minio from 'minio'; import * as Minio from "minio";
// MinIO 客户端配置 // MinIO 客户端配置
const useSSL = process.env.MINIO_USE_SSL === 'true'; const useSSL = process.env.MINIO_USE_SSL === "true";
const port = Number(process.env.MINIO_PORT) || 9000; const port = Number(process.env.MINIO_PORT) || 9000;
// 当使用标准HTTPS端口443或HTTP端口80MinIO客户端不需要指定端口 // 当使用标准HTTPS端口443或HTTP端口80MinIO客户端不需要指定端口
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80); const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
const minioClient = new Minio.Client({ const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost', endPoint: process.env.MINIO_ENDPOINT || "localhost",
...(shouldOmitPort ? {} : { port }), ...(shouldOmitPort ? {} : { port }),
useSSL, useSSL,
accessKey: process.env.MINIO_ACCESS_KEY || '', accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || '', secretKey: process.env.MINIO_SECRET_KEY || "",
pathStyle: true, // 使用路径风格,对反向代理更友好 pathStyle: true, // 使用路径风格,对反向代理更友好
}); });
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page'; const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "home-page";
/** /**
* MinIO Bucket bucket * MinIO Bucket bucket
@ -25,30 +25,28 @@ export async function initBucket(): Promise<void> {
try { try {
const exists = await minioClient.bucketExists(BUCKET_NAME); const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) { if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1'); await minioClient.makeBucket(BUCKET_NAME, "us-east-1");
console.log(`Bucket ${BUCKET_NAME} created successfully`); console.log(`Bucket ${BUCKET_NAME} created successfully`);
} }
// 设置公共读取策略(可选) // 设置公共读取策略(可选)
const policy = { const policy = {
Version: '2012-10-17', Version: "2012-10-17",
Statement: [ Statement: [
{ {
Effect: 'Allow', Effect: "Allow",
Principal: { AWS: ['*'] }, Principal: { AWS: ["*"] },
Action: ['s3:GetObject'], Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
}, },
], ],
}; };
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy)); await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
} catch (error) { } catch (error) {
console.error('Error initializing bucket:', error); console.error("Error initializing bucket:", error);
throw error; throw error;
} }
} }
/** /**
* MinIO * MinIO
* @param file - File Buffer * @param file - File Buffer
@ -59,7 +57,7 @@ export async function initBucket(): Promise<void> {
export async function uploadFile( export async function uploadFile(
file: File | Buffer, file: File | Buffer,
path: string, path: string,
metadata?: Record<string, string> metadata?: Record<string, string>,
): Promise<string> { ): Promise<string> {
try { try {
await initBucket(); await initBucket();
@ -69,27 +67,32 @@ export async function uploadFile(
if (file instanceof File) { if (file instanceof File) {
buffer = Buffer.from(await file.arrayBuffer()); buffer = Buffer.from(await file.arrayBuffer());
contentType = file.type || 'application/octet-stream'; contentType = file.type || "application/octet-stream";
} else { } else {
buffer = file; buffer = file;
contentType = metadata?.['Content-Type'] || 'application/octet-stream'; contentType = metadata?.["Content-Type"] || "application/octet-stream";
} }
const metaData = { const metaData = {
'Content-Type': contentType, "Content-Type": contentType,
...metadata, ...metadata,
}; };
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData); await minioClient.putObject(
BUCKET_NAME,
path,
buffer,
buffer.length,
metaData,
);
return path; return path;
} catch (error) { } catch (error) {
console.error('Error uploading file:', error); console.error("Error uploading file:", error);
throw error; throw error;
} }
} }
/** /**
* 访URL * 访URL
* @param path - * @param path -
@ -112,12 +115,12 @@ export async function downloadFile(path: string): Promise<Buffer> {
const stream = await minioClient.getObject(BUCKET_NAME, path); const stream = await minioClient.getObject(BUCKET_NAME, path);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(chunk)); stream.on("data", (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on('error', reject); stream.on("error", reject);
}); });
} catch (error) { } catch (error) {
console.error('Error downloading file:', error); console.error("Error downloading file:", error);
throw error; throw error;
} }
} }
@ -127,11 +130,13 @@ export async function downloadFile(path: string): Promise<Buffer> {
* @param path - * @param path -
* @returns * @returns
*/ */
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> { export async function getFileStream(
path: string,
): Promise<NodeJS.ReadableStream> {
try { try {
return await minioClient.getObject(BUCKET_NAME, path); return await minioClient.getObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error getting file stream:', error); console.error("Error getting file stream:", error);
throw error; throw error;
} }
} }
@ -144,7 +149,7 @@ export async function deleteFile(path: string): Promise<void> {
try { try {
await minioClient.removeObject(BUCKET_NAME, path); await minioClient.removeObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error deleting file:', error); console.error("Error deleting file:", error);
throw error; throw error;
} }
} }
@ -157,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise<void> {
try { try {
await minioClient.removeObjects(BUCKET_NAME, paths); await minioClient.removeObjects(BUCKET_NAME, paths);
} catch (error) { } catch (error) {
console.error('Error deleting files:', error); console.error("Error deleting files:", error);
throw error; throw error;
} }
} }
@ -185,7 +190,7 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
try { try {
return await minioClient.statObject(BUCKET_NAME, path); return await minioClient.statObject(BUCKET_NAME, path);
} catch (error) { } catch (error) {
console.error('Error getting file info:', error); console.error("Error getting file info:", error);
throw error; throw error;
} }
} }
@ -197,24 +202,27 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
* @returns * @returns
*/ */
export async function listFiles( export async function listFiles(
prefix: string = '', prefix: string = "",
recursive: boolean = false recursive: boolean = false,
): Promise<(Minio.BucketItem & { endpoint: string })[]> { ): Promise<(Minio.BucketItem & { endpoint: string })[]> {
try { try {
const files: (Minio.BucketItem & { endpoint: string })[] = []; const files: (Minio.BucketItem & { endpoint: string })[] = [];
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive); const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('data', (obj) => { stream.on("data", (obj) => {
if (obj.name) { if (obj.name) {
files.push({ endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string }); files.push({
endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`,
...obj,
} as Minio.BucketItem & { endpoint: string });
} }
}); });
stream.on('end', () => resolve(files)); stream.on("end", () => resolve(files));
stream.on('error', reject); stream.on("error", reject);
}); });
} catch (error) { } catch (error) {
console.error('Error listing files:', error); console.error("Error listing files:", error);
throw error; throw error;
} }
} }
@ -224,17 +232,20 @@ export async function listFiles(
* @param sourcePath - * @param sourcePath -
* @param destPath - * @param destPath -
*/ */
export async function copyFile(sourcePath: string, destPath: string): Promise<void> { export async function copyFile(
sourcePath: string,
destPath: string,
): Promise<void> {
try { try {
const conds = new Minio.CopyConditions(); const conds = new Minio.CopyConditions();
await minioClient.copyObject( await minioClient.copyObject(
BUCKET_NAME, BUCKET_NAME,
destPath, destPath,
`/${BUCKET_NAME}/${sourcePath}`, `/${BUCKET_NAME}/${sourcePath}`,
conds conds,
); );
} catch (error) { } catch (error) {
console.error('Error copying file:', error); console.error("Error copying file:", error);
throw error; throw error;
} }
} }
@ -245,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
* @param prefix - : 'avatars/' 'posts/2024/' * @param prefix - : 'avatars/' 'posts/2024/'
* @returns * @returns
*/ */
export function generateUniqueFileName(originalName: string, prefix: string = ''): string { export function generateUniqueFileName(
originalName: string,
prefix: string = "",
): string {
const timestamp = Date.now(); const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8); const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split('.').pop(); const ext = originalName.split(".").pop();
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_'); const nameWithoutExt = originalName
.replace(`.${ext}`, "")
.replace(/[^a-zA-Z0-9]/g, "_");
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`; const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName; return prefix ? `${prefix.replace(/\/$/, "")}/${fileName}` : fileName;
} }
/** /**
@ -262,7 +278,7 @@ export function generateUniqueFileName(originalName: string, prefix: string = ''
* @returns * @returns
*/ */
export function getFileExtension(filename: string): string { export function getFileExtension(filename: string): string {
return filename.split('.').pop() || ''; return filename.split(".").pop() || "";
} }
/** /**
@ -271,9 +287,12 @@ export function getFileExtension(filename: string): string {
* @param allowedTypes - : ['jpg', 'png', 'gif'] * @param allowedTypes - : ['jpg', 'png', 'gif']
* @returns * @returns
*/ */
export function validateFileType(filename: string, allowedTypes: string[]): boolean { export function validateFileType(
filename: string,
allowedTypes: string[],
): boolean {
const ext = getFileExtension(filename).toLowerCase(); const ext = getFileExtension(filename).toLowerCase();
return allowedTypes.map(t => t.toLowerCase()).includes(ext); return allowedTypes.map((t) => t.toLowerCase()).includes(ext);
} }
/** /**
@ -292,10 +311,10 @@ export function validateFileSize(size: number, maxSize: number): boolean {
* @returns * @returns
*/ */
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'; if (bytes === 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;

View File

@ -1,9 +1,9 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined;
} };
export const prisma = globalForPrisma.prisma ?? new PrismaClient() export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@ -2,27 +2,29 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: [ serverExternalPackages: [
'playwright-extra', "playwright-extra",
'puppeteer-extra-plugin-stealth', "puppeteer-extra-plugin-stealth",
'puppeteer-extra-plugin', "puppeteer-extra-plugin",
], webpack: (config) => { ],
webpack: (config) => {
config.module.rules.push({ config.module.rules.push({
test: /\.(md|txt)$/i, test: /\.(md|txt)$/i,
type: 'asset/source', // 让这些文件作为纯文本注入 type: "asset/source", // 让这些文件作为纯文本注入
}); });
return config; return config;
}, turbopack: { },
turbopack: {
rules: { rules: {
'*.md': { loaders: ['raw-loader'], as: '*.js' }, "*.md": { loaders: ["raw-loader"], as: "*.js" },
'*.txt': { loaders: ['raw-loader'], as: '*.js' }, "*.txt": { loaders: ["raw-loader"], as: "*.js" },
}, },
}, },
/* config options here */ /* config options here */
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: "https",
hostname: 's3l.xn--876a.net', hostname: "s3l.xn--876a.net",
}, },
], ],
}, },

View File

@ -14,7 +14,7 @@
"chalk": "^5.6.2", "chalk": "^5.6.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "15.5.6", "next": "15.5.7",
"openai": "^6.7.0", "openai": "^6.7.0",
"playwright": "1.56.1", "playwright": "1.56.1",
"playwright-extra": "^4.3.6", "playwright-extra": "^4.3.6",

View File

@ -1,37 +1,39 @@
const path = require('path'); const path = require("path");
const dotenv = require('dotenv'); const dotenv = require("dotenv");
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1; const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? "1", 10) || 1;
const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') }); const { parsed: envFromFile = {} } = dotenv.config({
path: path.join(__dirname, ".env"),
});
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "DouyinArchive", name: "DouyinArchive",
script: 'npm', script: "npm",
args: 'run start', args: "run start",
cwd: __dirname, cwd: __dirname,
autorestart: true, autorestart: true,
restart_delay: 4000, restart_delay: 4000,
kill_timeout: 5000, kill_timeout: 5000,
instances, instances,
exec_mode: instances > 1 ? 'cluster' : 'fork', exec_mode: instances > 1 ? "cluster" : "fork",
// 注意:不要在生产环境 watch否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭 // 注意:不要在生产环境 watch否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭
watch: false, watch: false,
ignore_watch: ['.next', '.turbo', 'generated', 'node_modules', '.git'], ignore_watch: [".next", ".turbo", "generated", "node_modules", ".git"],
env: { env: {
// 明确开发环境可选项(如需) // 明确开发环境可选项(如需)
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || "development",
...envFromFile, ...envFromFile,
}, },
env_production: { env_production: {
// 关键:确保应用进程中的 NODE_ENV=production从而禁用 Next.js 的开发特性 // 关键:确保应用进程中的 NODE_ENV=production从而禁用 Next.js 的开发特性
NODE_ENV: 'production', NODE_ENV: "production",
// 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务 // 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务
WEB_CONCURRENCY: '1', WEB_CONCURRENCY: "1",
...envFromFile, ...envFromFile,
}, },
time: true time: true,
} },
] ],
}; };

View File

@ -1,4 +1,4 @@
import { createWriteStream, writeFileSync } from "node:fs"; import { createWriteStream, writeFileSync } from "node:fs";
import { initBucket } from "./lib/minio"; import { initBucket } from "./lib/minio";
initBucket() initBucket();

View File

@ -22,6 +22,12 @@
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/aweme/[awemeId]/types.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/aweme/[awemeId]/types.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }