From 040273586bf5b4d2a6e1493b3f4cd6a14660bd2c Mon Sep 17 00:00:00 2001 From: feie9454 Date: Tue, 28 Apr 2026 17:11:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=9B=BE=E6=96=87=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 4 +- .vscode/tasks.json | 44 +- README.md | 2 +- app/admin/stt/page.tsx | 24 +- app/admin/stt/videos/page.tsx | 113 +-- app/api/admin/stt/videos/route.ts | 16 +- app/api/author/[secUid]/feed/route.ts | 55 +- app/api/aweme/around/route.ts | 62 +- app/api/comments/[awemeId]/route.ts | 65 +- app/api/feed/route.ts | 46 +- app/api/fetcher/anti-anti-detector.ts | 10 +- app/api/fetcher/browser.ts | 143 ++-- app/api/fetcher/index.ts | 703 ++++++++++------- app/api/fetcher/media.ts | 21 +- app/api/fetcher/network.ts | 255 +++--- app/api/fetcher/persist.ts | 730 ++++++++++-------- app/api/fetcher/route.ts | 88 +-- app/api/fetcher/types.d.ts | 66 +- app/api/fetcher/uploader.ts | 242 +++--- app/api/fetcher/utils.ts | 75 +- app/api/media.ts | 299 ++++--- app/api/search/route.ts | 140 ++-- app/api/stt/index.ts | 9 +- app/api/stt/route.ts | 21 +- app/author/[secUid]/page.tsx | 62 +- app/aweme/[awemeId]/Client.tsx | 267 ++++--- .../[awemeId]/components/BackgroundCanvas.tsx | 5 +- .../[awemeId]/components/CommentList.tsx | 33 +- .../[awemeId]/components/CommentPanel.tsx | 203 +++-- .../[awemeId]/components/CommentText.tsx | 5 +- .../[awemeId]/components/ImageCarousel.tsx | 230 ++++-- .../[awemeId]/components/MediaControls.tsx | 134 +++- app/aweme/[awemeId]/components/MoreMenu.tsx | 4 +- .../components/NavigationButtons.tsx | 30 +- .../[awemeId]/components/ProgressBar.tsx | 34 +- .../components/SegmentedProgressBar.tsx | 36 +- .../[awemeId]/components/TranscriptPanel.tsx | 6 +- .../[awemeId]/components/VideoPlayer.tsx | 2 +- app/aweme/[awemeId]/emojis.ts | 217 +++++- .../[awemeId]/hooks/useBackgroundCanvas.ts | 44 +- app/aweme/[awemeId]/hooks/useImageCarousel.ts | 292 ++++++- app/aweme/[awemeId]/hooks/useNavigation.ts | 6 +- app/aweme/[awemeId]/hooks/usePlayerState.ts | 17 +- app/aweme/[awemeId]/hooks/useVideoPlayer.ts | 8 +- app/aweme/[awemeId]/page.tsx | 125 ++- app/aweme/[awemeId]/types.ts | 21 +- app/aweme/[awemeId]/utils.ts | 14 +- app/components/BackButton.tsx | 57 +- app/components/FeedMasonry.tsx | 222 ++++-- app/components/HoverVideo.tsx | 22 +- app/globals.css | 24 +- app/layout.tsx | 17 +- app/page.tsx | 31 +- app/search/SearchClient.tsx | 51 +- app/search/page.tsx | 6 +- app/tasks/page.tsx | 358 ++++++--- app/types/feed.ts | 28 +- bun.lock | 25 +- fix-asset-urls.ts | 24 +- global.d.ts | 4 +- lib/json.ts | 9 +- lib/minio-examples.ts | 104 +-- lib/minio.ts | 119 +-- lib/prisma.ts | 10 +- next.config.ts | 22 +- package.json | 2 +- pm2.config.cjs | 68 +- test.ts | 2 +- tsconfig.json | 8 +- 69 files changed, 3939 insertions(+), 2302 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ff30c44..78664b2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "editor.tabSize": 2 -} \ No newline at end of file + "editor.tabSize": 2 +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9cdf630..00afc2a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,27 +1,19 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "tsc-check", - "type": "shell", - "command": "node", - "args": [ - "-e", - "require('typescript').transpile('const x: number = 1;')" - ], - "problemMatcher": [ - "$tsc" - ], - "group": "build" - }, - { - "label": "tsc-check (one-off)", - "type": "shell", - "command": "node", - "args": [ - "-e", - "require('typescript').transpile('const x: number = 1;')" - ] - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "tsc-check", + "type": "shell", + "command": "node", + "args": ["-e", "require('typescript').transpile('const x: number = 1;')"], + "problemMatcher": ["$tsc"], + "group": "build" + }, + { + "label": "tsc-check (one-off)", + "type": "shell", + "command": "node", + "args": ["-e", "require('typescript').transpile('const x: number = 1;')"] + } + ] +} diff --git a/README.md b/README.md index e215bc4..2c67347 100644 --- a/README.md +++ b/README.md @@ -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. -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 diff --git a/app/admin/stt/page.tsx b/app/admin/stt/page.tsx index 272c31e..5214bff 100644 --- a/app/admin/stt/page.tsx +++ b/app/admin/stt/page.tsx @@ -1,21 +1,17 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import BackButton from '@/app/components/BackButton'; +import Link from "next/link"; +import BackButton from "@/app/components/BackButton"; export default function SttAdminPage() { return (
- +
-

- STT 管理中心 -

-

- 管理和配置视频语音转写功能 -

+

STT 管理中心

+

管理和配置视频语音转写功能

@@ -98,9 +94,7 @@ export default function SttAdminPage() {

配置转写 API、模型参数和其他高级选项

-
- 即将推出 -
+
即将推出
@@ -130,9 +124,7 @@ export default function SttAdminPage() {

查看转写使用情况、成功率、语言分布等统计数据

-
- 即将推出 -
+
即将推出
diff --git a/app/admin/stt/videos/page.tsx b/app/admin/stt/videos/page.tsx index 1bd85ef..f158877 100644 --- a/app/admin/stt/videos/page.tsx +++ b/app/admin/stt/videos/page.tsx @@ -1,8 +1,8 @@ -'use client'; +"use client"; -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import BackButton from '@/app/components/BackButton'; +import { useState, useEffect } from "react"; +import Link from "next/link"; +import BackButton from "@/app/components/BackButton"; type VideoTranscript = { id: string; @@ -34,7 +34,9 @@ export default function SttVideosPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [transcribing, setTranscribing] = useState>(new Set()); - const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all'); + const [filter, setFilter] = useState<"all" | "transcribed" | "pending">( + "all", + ); const [batchTranscribing, setBatchTranscribing] = useState(false); const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 }); @@ -45,21 +47,22 @@ export default function SttVideosPage() { const fetchVideos = async () => { try { setLoading(true); - const response = await fetch('/api/admin/stt/videos'); + const response = await fetch("/api/admin/stt/videos"); if (!response.ok) { - throw new Error('Failed to fetch videos'); + throw new Error("Failed to fetch videos"); } const data: ApiResponse = await response.json(); setVideos(data.videos); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } }; // 简单的 sleep 工具 - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); type PollOptions = { intervalMs?: number; @@ -69,11 +72,11 @@ export default function SttVideosPage() { // 轮询某个视频的转写状态直至完成或超时 const pollVideoUntilTranscribed = async ( awemeId: string, - { intervalMs = 2000, maxAttempts = 30 }: PollOptions = {} + { intervalMs = 2000, maxAttempts = 30 }: PollOptions = {}, ): Promise => { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - const resp = await fetch('/api/admin/stt/videos'); + const resp = await fetch("/api/admin/stt/videos"); if (resp.ok) { const data: ApiResponse = await resp.json(); setVideos(data.videos); @@ -91,11 +94,11 @@ export default function SttVideosPage() { }; const handleTranscribe = async (awemeId: string) => { - setTranscribing(prev => new Set(prev).add(awemeId)); + setTranscribing((prev) => new Set(prev).add(awemeId)); try { const response = await fetch(`/api/stt?awemeId=${awemeId}`); if (!response.ok) { - throw new Error('Failed to transcribe video'); + throw new Error("Failed to transcribe video"); } // 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态 const done = await pollVideoUntilTranscribed(awemeId); @@ -104,9 +107,9 @@ export default function SttVideosPage() { await fetchVideos(); } } catch (err) { - alert(err instanceof Error ? err.message : 'Transcription failed'); + alert(err instanceof Error ? err.message : "Transcription failed"); } finally { - setTranscribing(prev => { + setTranscribing((prev) => { const next = new Set(prev); next.delete(awemeId); return next; @@ -115,13 +118,17 @@ export default function SttVideosPage() { }; const handleBatchTranscribe = async () => { - const pendingVideos = videos.filter(v => v.transcript === null); + const pendingVideos = videos.filter((v) => v.transcript === null); if (pendingVideos.length === 0) { - alert('没有待转写的视频'); + alert("没有待转写的视频"); return; } - if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) { + if ( + !confirm( + `确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`, + ) + ) { return; } @@ -132,7 +139,7 @@ export default function SttVideosPage() { for (let i = 0; i < pendingVideos.length; i++) { const video = pendingVideos[i]; 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 { 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(() => { - setTranscribing(prev => { + setTranscribing((prev) => { const next = new Set(prev); next.delete(video.aweme_id); return next; @@ -160,19 +167,19 @@ export default function SttVideosPage() { await fetchVideos(); setBatchTranscribing(false); setBatchProgress({ current: 0, total: 0 }); - alert('批量转写完成!'); + alert("批量转写完成!"); }; - const filteredVideos = videos.filter(v => { - if (filter === 'transcribed') return v.transcript !== null; - if (filter === 'pending') return v.transcript === null; + const filteredVideos = videos.filter((v) => { + if (filter === "transcribed") return v.transcript !== null; + if (filter === "pending") return v.transcript === null; return true; }); const stats = { total: videos.length, - transcribed: videos.filter(v => v.transcript !== null).length, - pending: videos.filter(v => v.transcript === null).length, + transcribed: videos.filter((v) => v.transcript !== null).length, + pending: videos.filter((v) => v.transcript === null).length, }; if (loading) { @@ -212,7 +219,9 @@ export default function SttVideosPage() { {/* Header */}
-

视频转写管理

+

+ 视频转写管理 +

管理视频的语音转写状态

@@ -220,15 +229,21 @@ export default function SttVideosPage() {
总视频数
-
{stats.total}
+
+ {stats.total} +
已转写
-
{stats.transcribed}
+
+ {stats.transcribed} +
待转写
-
{stats.pending}
+
+ {stats.pending} +
@@ -237,31 +252,31 @@ export default function SttVideosPage() {
@@ -326,7 +341,7 @@ export default function SttVideosPage() { href={`/aweme/${video.aweme_id}`} className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2" > - {video.desc || '无描述'} + {video.desc || "无描述"}

@{video.author.nickname} @@ -357,7 +372,7 @@ export default function SttVideosPage() { ) : (

- {video.transcript.audio_type || '非语音'} + {video.transcript.audio_type || "非语音"} {video.transcript.non_speech_summary && ( @@ -386,7 +401,7 @@ export default function SttVideosPage() { 重新转写... ) : ( - '重新转写' + "重新转写" )} ) : ( @@ -401,7 +416,7 @@ export default function SttVideosPage() { 转写中... ) : ( - '开始转写' + "开始转写" )} )} @@ -413,9 +428,7 @@ export default function SttVideosPage() {
{filteredVideos.length === 0 && ( -
- 没有找到视频 -
+
没有找到视频
)}
diff --git a/app/api/admin/stt/videos/route.ts b/app/api/admin/stt/videos/route.ts index 6a0c7d5..26b104c 100644 --- a/app/api/admin/stt/videos/route.ts +++ b/app/api/admin/stt/videos/route.ts @@ -1,11 +1,11 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import { getFileUrl } from '@/lib/minio'; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getFileUrl } from "@/lib/minio"; export async function GET() { try { const videos = await prisma.video.findMany({ - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, take: 1000, include: { author: { @@ -29,7 +29,7 @@ export async function GET() { const formattedVideos = videos.map((v) => ({ aweme_id: v.aweme_id, desc: v.desc, - cover_url: getFileUrl(v.cover_url ?? ''), + cover_url: getFileUrl(v.cover_url ?? ""), duration_ms: v.duration_ms, author: { nickname: v.author.nickname, @@ -51,10 +51,10 @@ export async function GET() { total: videos.length, }); } catch (error) { - console.error('Failed to fetch videos:', error); + console.error("Failed to fetch videos:", error); return NextResponse.json( - { error: 'Failed to fetch videos' }, - { status: 500 } + { error: "Failed to fetch videos" }, + { status: 500 }, ); } } diff --git a/app/api/author/[secUid]/feed/route.ts b/app/api/author/[secUid]/feed/route.ts index aac8924..35a528c 100644 --- a/app/api/author/[secUid]/feed/route.ts +++ b/app/api/author/[secUid]/feed/route.ts @@ -1,15 +1,18 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import type { FeedItem, FeedResponse } from '@/app/types/feed'; -import { getFileUrl } from '@/lib/minio'; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import type { FeedItem, FeedResponse } from "@/app/types/feed"; +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 { searchParams } = new URL(req.url); - const limitParam = searchParams.get('limit'); - const beforeParam = searchParams.get('before'); + const limitParam = searchParams.get("limit"); + 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; // fetch chunk from both tables @@ -17,20 +20,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU prisma.video.findMany({ where: { authorId: secUid, - ...(before ? { created_at: { lt: before } } : {}) + ...(before ? { created_at: { lt: before } } : {}), }, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, take: limit, include: { author: true }, }), prisma.imagePost.findMany({ where: { authorId: secUid, - ...(before ? { created_at: { lt: before } } : {}) + ...(before ? { created_at: { lt: before } } : {}), }, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, 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, desc: v.desc, 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, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, - likes: Number(v.digg_count) + author: { + 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) => ({ 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), width: p.images?.[0]?.width ?? 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 }, - likes: Number(p.digg_count) + author: { + 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); - 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 }; return NextResponse.json(payload); } diff --git a/app/api/aweme/around/route.ts b/app/api/aweme/around/route.ts index 01f3266..7020807 100644 --- a/app/api/aweme/around/route.ts +++ b/app/api/aweme/around/route.ts @@ -12,8 +12,14 @@ export async function GET(req: NextRequest) { // Find current item timestamp from either table const [video, post] = await Promise.all([ - prisma.video.findUnique({ 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 } }), + prisma.video.findUnique({ + 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; @@ -52,25 +58,61 @@ export async function GET(req: NextRequest) { ]); const pickPrev = (() => { - const cands: { type: "video" | "image"; 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 }); + const cands: { + type: "video" | "image"; + 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; // nearest newer -> minimal created_at cands.sort((a, b) => +a.created_at - +b.created_at); 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 cands: { type: "video" | "image"; 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 }); + const cands: { + type: "video" | "image"; + 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; // nearest older -> maximal created_at among older cands.sort((a, b) => +b.created_at - +a.created_at); 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 }); diff --git a/app/api/comments/[awemeId]/route.ts b/app/api/comments/[awemeId]/route.ts index 3f5693f..ec521c3 100644 --- a/app/api/comments/[awemeId]/route.ts +++ b/app/api/comments/[awemeId]/route.ts @@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server"; export async function GET( request: NextRequest, - { params }: { params: Promise<{ awemeId: string }> } + { params }: { params: Promise<{ awemeId: string }> }, ) { const awemeId = (await params).awemeId; const searchParams = request.nextUrl.searchParams; @@ -13,7 +13,8 @@ export async function GET( const take = parseInt(searchParams.get("take") || "20", 10); // 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 snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定 const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24"); @@ -21,8 +22,6 @@ export async function GET( const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重 const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机) - - try { // 查找是视频还是图文 const [video, post] = await Promise.all([ @@ -41,9 +40,7 @@ export async function GET( } // 构建查询条件 - const where = video - ? { videoId: awemeId } - : { imagePostId: awemeId }; + const where = video ? { videoId: awemeId } : { imagePostId: awemeId }; // 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序 const total = await prisma.comment.count({ where }); @@ -68,7 +65,9 @@ export async function GET( ${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<{ cid: string; @@ -92,22 +91,41 @@ export async function GET( ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC OFFSET ${skip} LIMIT ${take} - ` + `, ); // 批量查询每条评论的配图/贴纸 - const cids = rows.map(r => r.cid); + const cids = rows.map((r) => r.cid); const images = cids.length ? await prisma.commentImage.findMany({ - where: { commentId: { in: cids } }, - orderBy: { order: 'asc' }, - select: { commentId: true, url: true, width: true, height: true, order: true }, - }) + where: { commentId: { in: cids } }, + orderBy: { order: "asc" }, + select: { + commentId: true, + url: true, + width: true, + height: true, + order: true, + }, + }) : []; - const group = new Map(); + const group = new Map< + string, + { + url: string; + width?: number | null; + height?: number | null; + order: number; + }[] + >(); for (const img of images) { 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); } const formattedComments = rows.map((c) => ({ @@ -117,9 +135,15 @@ export async function GET( digg_count: Number(c.digg_count), user: { 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({ @@ -134,9 +158,6 @@ export async function GET( }); } catch (error) { console.error("获取评论失败:", error); - return NextResponse.json( - { error: "获取评论失败" }, - { status: 500 } - ); + return NextResponse.json({ error: "获取评论失败" }, { status: 500 }); } } diff --git a/app/api/feed/route.ts b/app/api/feed/route.ts index 7c5a45f..1ef8ec4 100644 --- a/app/api/feed/route.ts +++ b/app/api/feed/route.ts @@ -1,7 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import type { FeedItem, FeedResponse } from '@/app/types/feed'; -import { getFileUrl } from '@/lib/minio'; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import type { FeedItem, FeedResponse } from "@/app/types/feed"; +import { getFileUrl } from "@/lib/minio"; // Contract // Inputs: search params { before?: ISOString, limit?: number } @@ -9,25 +9,25 @@ import { getFileUrl } from '@/lib/minio'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); - const limitParam = searchParams.get('limit'); - const beforeParam = searchParams.get('before'); + const limitParam = searchParams.get("limit"); + 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; // fetch chunk from both tables const [videos, posts] = await Promise.all([ prisma.video.findMany({ where: before ? { created_at: { lt: before } } : undefined, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, take: limit, include: { author: true }, }), prisma.imagePost.findMany({ where: before ? { created_at: { lt: before } } : undefined, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, 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, desc: v.desc, 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, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, - likes: Number(v.digg_count) + author: { + 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) => ({ type: "image" as const, @@ -52,13 +56,21 @@ export async function GET(req: NextRequest) { cover_url: getFileUrl(p.images?.[0]?.url ?? null), width: p.images?.[0]?.width ?? 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 }, - likes: Number(p.digg_count) + author: { + 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); - 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 }; return NextResponse.json(payload); } diff --git a/app/api/fetcher/anti-anti-detector.ts b/app/api/fetcher/anti-anti-detector.ts index 23eaf6f..96e02df 100644 --- a/app/api/fetcher/anti-anti-detector.ts +++ b/app/api/fetcher/anti-anti-detector.ts @@ -1,6 +1,6 @@ // index.js -import { chromium } from 'playwright-extra'; -import stealth from 'puppeteer-extra-plugin-stealth' +import { chromium } from "playwright-extra"; +import stealth from "puppeteer-extra-plugin-stealth"; chromium.use(stealth()); @@ -8,9 +8,9 @@ chromium.use(stealth()); const browser = await chromium.launch({ headless: false }); // 需要可视化就 false const context = await browser.newContext(); const page = await context.newPage(); - await page.goto('https://bot.sannysoft.com/'); // 常用自测页 - console.log('Title:', await page.title()); - await page.screenshot({ path: 'stealth.png', fullPage: true }); + await page.goto("https://bot.sannysoft.com/"); // 常用自测页 + console.log("Title:", await page.title()); + await page.screenshot({ path: "stealth.png", fullPage: true }); setTimeout(async () => { await browser.close(); }, 1000_000); diff --git a/app/api/fetcher/browser.ts b/app/api/fetcher/browser.ts index cfe8fbf..3218941 100644 --- a/app/api/fetcher/browser.ts +++ b/app/api/fetcher/browser.ts @@ -1,99 +1,98 @@ -export const runtime = 'nodejs' +export const runtime = "nodejs"; -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 stealth from 'puppeteer-extra-plugin-stealth' +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 stealth from "puppeteer-extra-plugin-stealth"; chromium.use(stealth()); -let contextPromise: Promise | null = null -let context: BrowserContext | null = null -let refCount = 0 -let idleCloseTimer: NodeJS.Timeout | null = null +let contextPromise: Promise | null = null; +let context: BrowserContext | null = null; +let refCount = 0; +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 { - const ctx = await chromium.launchPersistentContext( - USER_DATA_DIR, - { - headless: process.env.CHROMIUM_HEADLESS === 'true', - viewport: { - width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280), - height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080) - } - } - ) + const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, { + headless: process.env.CHROMIUM_HEADLESS === "true", + viewport: { + 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 - ctx.on('close', () => { - context = null - contextPromise = null - refCount = 0 + ctx.on("close", () => { + context = null; + contextPromise = null; + refCount = 0; if (idleCloseTimer) { - clearTimeout(idleCloseTimer) - idleCloseTimer = null + clearTimeout(idleCloseTimer); + idleCloseTimer = null; } - }) - return ctx + }); + return ctx; } export async function acquireBrowserContext(): Promise { // Cancel any pending idle close if a new consumer arrives if (idleCloseTimer) { - clearTimeout(idleCloseTimer) - idleCloseTimer = null + clearTimeout(idleCloseTimer); + idleCloseTimer = null; } if (context) { - refCount += 1 - return context + refCount += 1; + return context; } if (!contextPromise) { - contextPromise = launchContext() + contextPromise = launchContext(); } - context = await contextPromise - refCount += 1 - return context + context = await contextPromise; + refCount += 1; + return context; } -export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise { - const idleMillis = options?.idleMillis ?? 15_000 - refCount = Math.max(0, refCount - 1) +export async function releaseBrowserContext(options?: { + idleMillis?: number; +}): Promise { + 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 if (idleCloseTimer) { - clearTimeout(idleCloseTimer) - idleCloseTimer = null + clearTimeout(idleCloseTimer); + idleCloseTimer = null; } idleCloseTimer = setTimeout(async () => { try { if (context && refCount === 0) { - await context.close() + await context.close(); } } finally { - context = null - contextPromise = null - idleCloseTimer = null + context = null; + contextPromise = null; + idleCloseTimer = null; } - }, idleMillis) + }, idleMillis); } // --- Isolated context support for per-scrape independence --- -const isolatedMap = new WeakMap() +const isolatedMap = new WeakMap(); async function getSharedStorageState(): Promise { try { - const shared = await acquireBrowserContext() - const state = await shared.storageState() + const shared = await acquireBrowserContext(); + const state = await shared.storageState(); // Do not force-close immediately; keep ref-counting behavior - await releaseBrowserContext() - return state + await releaseBrowserContext(); + return state; } catch { // If shared context not available, proceed without storageState - return undefined + return undefined; } } @@ -103,37 +102,39 @@ async function getSharedStorageState(): Promise { * so you remain logged-in, but isolates network events, cache and listeners. */ export async function acquireIsolatedContext(): Promise { - const storageState = await getSharedStorageState() + const storageState = await getSharedStorageState(); const browser = await chromium.launch({ - headless: process.env.CHROMIUM_HEADLESS === 'true' - }) + headless: process.env.CHROMIUM_HEADLESS === "true", + }); const ctx = await browser.newContext({ storageState, viewport: { width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280), - height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080) - } - }) - isolatedMap.set(ctx, browser) - ctx.on('close', () => { - const b = isolatedMap.get(ctx) + height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080), + }, + }); + isolatedMap.set(ctx, browser); + ctx.on("close", () => { + const b = isolatedMap.get(ctx); if (b) { - b.close().catch(() => {}) - isolatedMap.delete(ctx) + b.close().catch(() => {}); + isolatedMap.delete(ctx); } - }) - return ctx + }); + return ctx; } -export async function releaseIsolatedContext(ctx: BrowserContext | null | undefined): Promise { - if (!ctx) return +export async function releaseIsolatedContext( + ctx: BrowserContext | null | undefined, +): Promise { + if (!ctx) return; try { - await ctx.close() + await ctx.close(); } finally { - const b = isolatedMap.get(ctx) + const b = isolatedMap.get(ctx); if (b) { - await b.close().catch(() => {}) - isolatedMap.delete(ctx) + await b.close().catch(() => {}); + isolatedMap.delete(ctx); } } } diff --git a/app/api/fetcher/index.ts b/app/api/fetcher/index.ts index 2b6420f..069cf79 100644 --- a/app/api/fetcher/index.ts +++ b/app/api/fetcher/index.ts @@ -1,22 +1,31 @@ -export const runtime = 'nodejs' +export const runtime = "nodejs"; // src/scrapeDouyin.ts -import { BrowserContext, Page, type Response } from 'playwright'; -import { chromium } from 'playwright-extra'; -import { prisma } from '@/lib/prisma'; -import { uploadFile, generateUniqueFileName } from '@/lib/minio'; -import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils'; -import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary, collectResponsesWithinTime } from '@/app/api/fetcher/network'; -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'; +import { BrowserContext, Page, type Response } from "playwright"; +import { chromium } from "playwright-extra"; +import { prisma } from "@/lib/prisma"; +import { uploadFile, generateUniqueFileName } from "@/lib/minio"; +import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils"; +import { + waitForFirstResponse, + waitForResponseWithTimeout, + safeJson, + downloadBinary, + collectResponsesWithinTime, +} from "@/app/api/fetcher/network"; +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 COMMENT_PATH = '/aweme/v1/web/comment/list/'; -const POST_PATH = '/aweme/v1/web/aweme/post/' +const DETAIL_PATH = "/aweme/v1/web/aweme/detail/"; +const COMMENT_PATH = "/aweme/v1/web/comment/list/"; +const POST_PATH = "/aweme/v1/web/aweme/post/"; /** * 滚动页面并收集评论 @@ -26,324 +35,444 @@ const POST_PATH = '/aweme/v1/web/aweme/post/' * @returns 收集到的所有评论响应 */ async function scrollAndCollectComments( - context: BrowserContext, - page: Page, - durationMs: number = 10_000 + context: BrowserContext, + page: Page, + durationMs: number = 10_000, ): Promise { - console.log(chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`)); + console.log( + chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`), + ); - // 启动评论响应收集器 - const commentResponsesPromise = collectResponsesWithinTime( - context, - (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200 && r.request().frame()?.page() === page, - durationMs - ); + // 启动评论响应收集器 + const commentResponsesPromise = collectResponsesWithinTime( + context, + (r: Response) => + r.url().includes(COMMENT_PATH) && + r.status() === 200 && + r.request().frame()?.page() === page, + durationMs, + ); - // 在指定时间内持续滚动页面 - const startTime = Date.now(); - const scrollInterval = 500; - let scrollCount = 0; - const selector = "div[data-e2e='comment-list']"; + // 在指定时间内持续滚动页面 + const startTime = Date.now(); + const scrollInterval = 500; + let scrollCount = 0; + const selector = "div[data-e2e='comment-list']"; - // 1) 等元素出现并可见 - await page.waitForSelector(selector, { state: 'visible', timeout: 5000 }); + // 1) 等元素出现并可见 + await page.waitForSelector(selector, { state: "visible", timeout: 5000 }); - // 2) 确保滚动到可见区域 - const list = page.locator(selector); - await list.scrollIntoViewIfNeeded(); + // 2) 确保滚动到可见区域 + const list = page.locator(selector); + await list.scrollIntoViewIfNeeded(); - // 3) 执行 hover(推荐用 locator 的 hover) - list.hover({ timeout: 5000 }).catch(() => { }); - while (Date.now() - startTime < durationMs - 500) { // 留 500ms 缓冲 - try { - list.hover({ timeout: 2000 }).catch(() => { }); - // 使用 Playwright 的 mouse.wheel 方法滚动 - // 每次滚动一大段距离 - // await list.hover(); - const scrollAmount = 1500; - await page.mouse.wheel(0, scrollAmount); + // 3) 执行 hover(推荐用 locator 的 hover) + list.hover({ timeout: 5000 }).catch(() => {}); + while (Date.now() - startTime < durationMs - 500) { + // 留 500ms 缓冲 + try { + list.hover({ timeout: 2000 }).catch(() => {}); + // 使用 Playwright 的 mouse.wheel 方法滚动 + // 每次滚动一大段距离 + // await list.hover(); + const scrollAmount = 1500; + await page.mouse.wheel(0, scrollAmount); - scrollCount++; - console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`)); + scrollCount++; + console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`)); - // 等待一段时间,让评论加载 - await page.waitForTimeout(scrollInterval); - - } catch (e) { - console.warn(chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`)); - } + // 等待一段时间,让评论加载 + await page.waitForTimeout(scrollInterval); + } catch (e) { + console.warn( + chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`), + ); } + } - // 等待收集器完成 - const commentResponses = await commentResponsesPromise; - console.log(chalk.green(`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`)); + // 等待收集器完成 + const commentResponses = await commentResponsesPromise; + console.log( + chalk.green( + `✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`, + ), + ); - return commentResponses; + return commentResponses; } async function readPostMem(context: BrowserContext, page: Page) { - const md = await page.evaluate(() => { - // @ts-ignore - let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1] - return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", '')) - // return {aweme: { detail: {} } }; - }).catch(() => null); + const md = await page + .evaluate(() => { + const captured = (window as any).__pace_captured__ as Array; + let data = captured.find( + (item) => typeof item[1] === "string" && item[1].includes(`"awemeId":`), + )?.[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; - if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情'); + let aweme_mem = md?.aweme?.detail as DouyinImageAweme; + if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情"); - // @ts-ignore - aweme_mem.author = aweme_mem.authorInfo - // @ts-ignore - aweme_mem.statistics = aweme_mem.stats + // @ts-ignore + aweme_mem.author = aweme_mem.authorInfo; + // @ts-ignore + aweme_mem.statistics = aweme_mem.stats; - const comments = md.comment ? createCamelCompatibleProxy(md.comment) : null; - const aweme = createCamelCompatibleProxy(aweme_mem); + const comments = md.comment + ? createCamelCompatibleProxy(md.comment) + : null; + const aweme = createCamelCompatibleProxy(aweme_mem); - return { aweme, comments } + return { aweme, comments }; } - export class ScrapeError extends Error { - constructor( - message: string, - public statusCode: number = 500, - public code?: string - ) { - super(message); - this.name = 'ScrapeError'; - } + constructor( + message: string, + public statusCode: number = 500, + public code?: string, + ) { + super(message); + this.name = "ScrapeError"; + } } export async function scrapeDouyin(url: string) { - console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...')); - let context: BrowserContext | null = await acquireIsolatedContext(); - const page = await context.newPage(); - console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`)); + console.log(chalk.blue("🚀 启动共享 Chromium 浏览器...")); + let context: BrowserContext | null = await acquireIsolatedContext(); + const page = await context.newPage(); + console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`)); - await page.addInitScript(() => { - // 建一个全局容器存捕获的数据 - (window as any).__pace_captured__ = []; + await page.addInitScript(() => { + // 建一个全局容器存捕获的数据 + (window as any).__pace_captured__ = []; - // 用 Proxy 包装一个数组,拦截 push - const captured = (window as any).__pace_captured__; - const proxyArr = new Proxy([] as any[], { - get(target, prop, receiver) { - if (prop === 'push') { - return (...items: any[]) => { - try { captured.push(...items); } catch { } - return Array.prototype.push.apply(target, items); - }; - } - return Reflect.get(target, prop, receiver); - }, - 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; + // 用 Proxy 包装一个数组,拦截 push + const captured = (window as any).__pace_captured__; + const proxyArr = new Proxy([] as any[], { + get(target, prop, receiver) { + if (prop === "push") { + return (...items: any[]) => { + try { + captured.push(...items); + } catch {} + return Array.prototype.push.apply(target, items); + }; + } + return Reflect.get(target, prop, receiver); + }, + 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; + }); + + 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 { - // 先注册“先到先得”的监听,再导航,避免漏包 - 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); + memoryData = await readPostMem(context, page); + console.log(chalk.green("✓ 从内存读取图文数据成功")); + } catch { + // 内存读取失败,稍后通过网络获取 + } - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 }); + if (!firstType && !memoryData) { + console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据")); + throw new ScrapeError( + "无法获取作品数据,可能是网络问题或作品已下架", + 404, + "NO_DATA", + ); + } - // 查找页面中是否存在 "视频不存在" 的提示 - 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'); - } + console.log( + chalk.cyan( + `📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`, + ), + ); - // 等待作品类型判定 - const firstType = await firstTypePromise; + let allComments: DouyinComment[] = []; + try { + // 开始滚动并收集评论 + const commentResponses = await scrollAndCollectComments(context, page); - // 尝试从内存读取图文数据(如果是图文作品) - let memoryData: { aweme: any; comments: DouyinCommentResponse | null } | null = null; + // 解析所有收集到的评论响应 + for (const commentRes of commentResponses) { try { - memoryData = await readPostMem(context, page); - console.log(chalk.green('✓ 从内存读取图文数据成功')); - } catch { - // 内存读取失败,稍后通过网络获取 + const commentData = await safeJson(commentRes); + if (commentData?.comments?.length) { + 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) { - console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据')); - throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA'); + // 去重评论(根据 cid) + const uniqueComments = Array.from( + 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( + firstType.response, + ); + if (!postJson?.aweme_list?.length) + throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE"); - let allComments: DouyinComment[] = []; - try { - // 开始滚动并收集评论 - const commentResponses = await scrollAndCollectComments(context, page); + 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", + ); + } - // 解析所有收集到的评论响应 - for (const commentRes of commentResponses) { - try { - const commentData = await safeJson(commentRes); - if (commentData?.comments?.length) { - allComments.push(...commentData.comments); - } - } catch (e) { - console.warn(chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`)); - } - } - } catch (error) { - console.warn(chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`)); - } + 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( + 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) - const uniqueComments = Array.from( - new Map(allComments.map(c => [c.cid, c])).values() + console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`)); + console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`)); + 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; - 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 - }; - } - - // 分支:视频 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(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(firstType.response))!; - - // 找到比特率最高的 url - const bestPlayAddr = pickBestPlayAddr( - detail?.aweme_detail?.video.bit_rate + // 提取首帧作为封面并上传 + try { + console.log(chalk.blue("🖼️ 正在提取视频封面...")); + const cover = await extractFirstFrame(buffer); + if (cover) { + const coverName = generateUniqueFileName( + `${awemeId}.jpg`, + "douyin/covers", ); - const bestVUrl = bestPlayAddr?.url_list?.[0]; - const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS - - console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`)); - console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`)); - 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.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; + 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 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('✓ 资源清理完成')); + 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 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("✓ 资源清理完成")); + } +} diff --git a/app/api/fetcher/media.ts b/app/api/fetcher/media.ts index 86e6ec3..c9b6f6f 100644 --- a/app/api/fetcher/media.ts +++ b/app/api/fetcher/media.ts @@ -1,17 +1,16 @@ -export const runtime = 'nodejs' - -import { execFile } from 'child_process'; -import { promisify } from 'util'; +export const runtime = "nodejs"; +import { execFile } from "child_process"; +import { promisify } from "util"; export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) { - if (!variants?.length) return null; + if (!variants?.length) return null; - const best = variants.reduce((best, cur) => { - const b1 = best?.bit_rate ?? -1; - const b2 = cur?.bit_rate ?? -1; - return b2 > b1 ? cur : best; - }); + const best = variants.reduce((best, cur) => { + const b1 = best?.bit_rate ?? -1; + const b2 = cur?.bit_rate ?? -1; + return b2 > b1 ? cur : best; + }); - return best?.play_addr ?? null; + return best?.play_addr ?? null; } diff --git a/app/api/fetcher/network.ts b/app/api/fetcher/network.ts index 959505e..edb5fbe 100644 --- a/app/api/fetcher/network.ts +++ b/app/api/fetcher/network.ts @@ -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(res: Response): Promise { - const ctype = res.headers()['content-type'] || ''; - if (ctype.includes('application/json')) { - return (await res.json()) as T; - } - const t = await res.text(); - try { - return JSON.parse(t) as T; - } catch { - return null; - } + const ctype = res.headers()["content-type"] || ""; + if (ctype.includes("application/json")) { + return (await res.json()) as T; + } + const t = await res.text(); + try { + return JSON.parse(t) as T; + } catch { + return null; + } } /** @@ -21,30 +21,31 @@ export async function safeJson(res: Response): Promise { * - referrer 使用链接本身 */ export async function downloadBinary( - context: BrowserContext, - url: string, + context: BrowserContext, + url: string, ): Promise<{ buffer: Buffer; contentType: string; ext: string }> { - console.log('下载:', url); + console.log("下载:", url); - const headers = { - referer: 'https://www.douyin.com/', - } as Record; + const headers = { + referer: "https://www.douyin.com/", + } as Record; - const res = await context.request.get(url, { - headers, - maxRedirects: 3, - timeout: 240_000, - failOnStatusCode: true, - }); + const res = await context.request.get(url, { + headers, + maxRedirects: 3, + timeout: 240_000, + failOnStatusCode: true, + }); - if (!res.ok()) { - throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`); - } + if (!res.ok()) { + throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`); + } - const buffer = await res.body(); - const contentType = res.headers()['content-type'] || 'application/octet-stream'; - const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin'; - return { buffer, contentType, ext }; + const buffer = await res.body(); + const contentType = + res.headers()["content-type"] || "application/octet-stream"; + const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin"; + return { buffer, contentType, ext }; } /** @@ -52,46 +53,46 @@ export async function downloadBinary( * - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。 */ export function waitForFirstResponse( - context: BrowserContext, - candidates: { key: string; test: (r: Response) => boolean }[], - timeoutMs = 20_000 + context: BrowserContext, + candidates: { key: string; test: (r: Response) => boolean }[], + timeoutMs = 20_000, ): Promise<{ key: string; response: Response } | null> { - return new Promise((resolve) => { - let resolved = false; - let timer: NodeJS.Timeout | undefined; + return new Promise((resolve) => { + let resolved = false; + let timer: NodeJS.Timeout | undefined; - const handler = (res: Response) => { - if (resolved) return; - for (const c of candidates) { - try { - if (c.test(res)) { - resolved = true; - cleanup(); - resolve({ key: c.key, response: res }); - return; - } - } catch { - // 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 handler = (res: Response) => { + if (resolved) return; + for (const c of candidates) { + try { + if (c.test(res)) { + resolved = true; + cleanup(); + resolve({ key: c.key, response: res }); + return; + } + } catch { + // 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); + } + }); } /** @@ -99,76 +100,76 @@ export function waitForFirstResponse( * 用于评论等需要滚动加载的数据 */ export function collectResponsesWithinTime( - context: BrowserContext, - predicate: (r: Response) => boolean, - durationMs: number + context: BrowserContext, + predicate: (r: Response) => boolean, + durationMs: number, ): Promise { - return new Promise((resolve) => { - const collected: Response[] = []; - const seenUrls = new Set(); - let timer: NodeJS.Timeout | undefined; + return new Promise((resolve) => { + const collected: Response[] = []; + const seenUrls = new Set(); + let timer: NodeJS.Timeout | undefined; - const handler = (res: Response) => { - try { - if (predicate(res)) { - // 使用 URL 去重,避免重复收集同一个请求 - const url = res.url(); - if (!seenUrls.has(url)) { - seenUrls.add(url); - collected.push(res); - } - } - } catch { - // ignore predicate errors - } - }; + const handler = (res: Response) => { + try { + if (predicate(res)) { + // 使用 URL 去重,避免重复收集同一个请求 + const url = res.url(); + if (!seenUrls.has(url)) { + seenUrls.add(url); + collected.push(res); + } + } + } catch { + // ignore predicate errors + } + }; - const cleanup = () => { - context.off('response', handler); - if (timer) clearTimeout(timer); - }; + const cleanup = () => { + context.off("response", handler); + if (timer) clearTimeout(timer); + }; - context.on('response', handler); - timer = setTimeout(() => { - cleanup(); - resolve(collected); - }, durationMs); - }); + context.on("response", handler); + timer = setTimeout(() => { + cleanup(); + resolve(collected); + }, durationMs); + }); } /** * 等待符合条件的单个 Response,带短超时;用于评论等"可有可无"的数据。 */ export function waitForResponseWithTimeout( - context: BrowserContext, - predicate: (r: Response) => boolean, - timeoutMs = 5_000 + context: BrowserContext, + predicate: (r: Response) => boolean, + timeoutMs = 5_000, ): Promise { - return new Promise((resolve, reject) => { - let timer: NodeJS.Timeout | undefined; + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; - const handler = (res: Response) => { - try { - if (predicate(res)) { - cleanup(); - 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); + const handler = (res: Response) => { + try { + if (predicate(res)) { + cleanup(); + 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); + } + }); } diff --git a/app/api/fetcher/persist.ts b/app/api/fetcher/persist.ts index 53ff6cd..c31aab0 100644 --- a/app/api/fetcher/persist.ts +++ b/app/api/fetcher/persist.ts @@ -1,359 +1,427 @@ -import type { BrowserContext } from 'playwright'; -import { prisma } from '@/lib/prisma'; -import { uploadAvatarFromUrl, uploadImageFromUrl } from './uploader'; -import { firstUrl } from './utils'; +import type { BrowserContext } from "playwright"; +import { prisma } from "@/lib/prisma"; +import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader"; +import { firstUrl } from "./utils"; export async function saveToDB( - context: BrowserContext, - detailResp: DouyinVideoDetailResponse, - commentResp: DouyinCommentResponse, - videoUrl?: string, - width?: number, - height?: number, - coverUrl?: string, - fps?: number + context: BrowserContext, + detailResp: DouyinVideoDetailResponse, + commentResp: DouyinCommentResponse, + videoUrl?: string, + width?: number, + height?: number, + coverUrl?: string, + fps?: number, ) { - if (!detailResp?.aweme_detail) throw new Error('视频详情为空'); - const d = detailResp.aweme_detail; + if (!detailResp?.aweme_detail) throw new Error("视频详情为空"); + const d = detailResp.aweme_detail; - // 1) Upsert Author - const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list); - const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`); - const author = await prisma.author.upsert({ - where: { sec_uid: d.author.sec_uid }, - create: { - sec_uid: d.author.sec_uid, - uid: d.author.uid, - nickname: d.author.nickname, - signature: d.author.signature ?? 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, - }, - update: { - uid: d.author.uid, - nickname: d.author.nickname, - signature: d.author.signature ?? 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, + // 1) Upsert Author + const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list); + const authorAvatarUploaded = await uploadAvatarFromUrl( + context, + authorAvatarSrc, + `authors/${d.author.sec_uid}`, + ); + const author = await prisma.author.upsert({ + where: { sec_uid: d.author.sec_uid }, + create: { + sec_uid: d.author.sec_uid, + uid: d.author.uid, + nickname: d.author.nickname, + signature: d.author.signature ?? 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, + }, + update: { + uid: d.author.uid, + nickname: d.author.nickname, + signature: d.author.signature ?? 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 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 - }, + 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), + videoId: video.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), + videoId: video.aweme_id, + userId: cu.id, + }, }); - // 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, - }, + // 处理评论贴纸/配图上传与入库 + 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, + }); + } - 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), - videoId: video.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), - videoId: video.aweme_id, - userId: cu.id, - }, + 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, + }, }); - - // 处理评论贴纸/配图上传与入库 - 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); - } + } + } 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( - context: BrowserContext, - aweme: DouyinImageAweme, - commentResp: DouyinCommentResponse, - uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, - rawJson?: any + context: BrowserContext, + aweme: DouyinImageAweme, + commentResp: DouyinCommentResponse, + uploads: { + 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(与视频一致) - const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list); - const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`); - const author = await prisma.author.upsert({ - where: { sec_uid: aweme.author.sec_uid }, - create: { - sec_uid: aweme.author.sec_uid, - uid: aweme.author.uid, - nickname: aweme.author.nickname, - signature: aweme.author.signature ?? 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, - }, - update: { - uid: aweme.author.uid, - nickname: aweme.author.nickname, - signature: aweme.author.signature ?? 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 Author(与视频一致) + const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list); + const authorAvatarUploaded = await uploadAvatarFromUrl( + context, + authorAvatarSrc, + `authors/${aweme.author.sec_uid}`, + ); + const author = await prisma.author.upsert({ + where: { sec_uid: aweme.author.sec_uid }, + create: { + sec_uid: aweme.author.sec_uid, + uid: aweme.author.uid, + nickname: aweme.author.nickname, + signature: aweme.author.signature ?? 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, + }, + update: { + uid: aweme.author.uid, + nickname: aweme.author.nickname, + signature: aweme.author.signature ?? 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 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 - }, + 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, + }, }); - // Upsert ImageFiles(按顺序) - for (let i = 0; i < uploads.images.length; i++) { - const { url, width, height, video } = uploads.images[i]; - 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, - }, - update: { - url, - width: typeof width === 'number' ? width : null, - height: typeof height === 'number' ? height : null, - animated: video || null, - }, + // 处理评论贴纸/配图上传与入库 + 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); } + } - // 评论入库:关联到 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, - }, - }); - - 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 }; + return { + aweme_id: imagePost.aweme_id, + author_sec_uid: author.sec_uid, + image_count: uploads.images.length, + comment_count: comments.length, + }; } diff --git a/app/api/fetcher/route.ts b/app/api/fetcher/route.ts index 7c22959..98d9609 100644 --- a/app/api/fetcher/route.ts +++ b/app/api/fetcher/route.ts @@ -1,51 +1,51 @@ -export const runtime = 'nodejs' +export const runtime = "nodejs"; -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { scrapeDouyin, ScrapeError } from '.'; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { scrapeDouyin, ScrapeError } from "."; async function handleDouyinScrape(req: NextRequest) { - const { searchParams } = new URL(req.url); - const videoUrl = searchParams.get('url'); - - if (!videoUrl) { - return NextResponse.json( - { error: '缺少视频URL', code: 'MISSING_URL' }, - { status: 400 } - ); + const { searchParams } = new URL(req.url); + const videoUrl = searchParams.get("url"); + + if (!videoUrl) { + return NextResponse.json( + { error: "缺少视频URL", code: "MISSING_URL" }, + { 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 { - // 调用爬虫函数 - 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 } - ); - } - - // 处理未知错误 - console.error('未捕获的错误:', error); - return NextResponse.json( - { - success: false, - error: '服务器内部错误', - code: 'INTERNAL_ERROR' - }, - { status: 500 } - ); - } + // 处理未知错误 + console.error("未捕获的错误:", error); + return NextResponse.json( + { + success: false, + error: "服务器内部错误", + code: "INTERNAL_ERROR", + }, + { status: 500 }, + ); + } } -export const GET = handleDouyinScrape +export const GET = handleDouyinScrape; diff --git a/app/api/fetcher/types.d.ts b/app/api/fetcher/types.d.ts index 896cc86..ddda09a 100644 --- a/app/api/fetcher/types.d.ts +++ b/app/api/fetcher/types.d.ts @@ -17,17 +17,17 @@ interface DouyinComment { animate_url: { width: number; height: number; - url_list: string[] - } - }, + url_list: string[]; + }; + }; image_list?: { - origin_url:{ + origin_url: { width: number; height: number; - url_list: string[] - } - }[] + url_list: string[]; + }; + }[]; } /** 用户信息(精简版) */ @@ -45,35 +45,35 @@ interface DouyinVideoDetailResponse { } /** 作者信息(精简版) */ interface DouyinAuthor { - uid: string; // 用户ID - sec_uid: string; // 安全UID - nickname: string; // 用户昵称 - signature: string; // 个性签名 + uid: string; // 用户ID + sec_uid: string; // 安全UID + nickname: string; // 用户昵称 + signature: string; // 个性签名 avatar_thumb: { - url_list: string[]; // 头像URL(可取第一个) + url_list: string[]; // 头像URL(可取第一个) }; - follower_count: number; // 粉丝数 - total_favorited: number; // 获赞总数 - unique_id: string; // 抖音号 - short_id: string; // 短ID + follower_count: number; // 粉丝数 + total_favorited: number; // 获赞总数 + unique_id: string; // 抖音号 + short_id: string; // 短ID } /** 视频详情 */ interface DouyinVideoDetail { - aweme_id: string; // 视频ID - desc: string; // 视频描述 - preview_title?: string; // 视频标题(有些字段中叫 preview_title) - duration: number; // 视频时长(毫秒) - create_time: number; // 创建时间(时间戳) - share_url: string; // 视频分享链接 + aweme_id: string; // 视频ID + desc: string; // 视频描述 + preview_title?: string; // 视频标题(有些字段中叫 preview_title) + duration: number; // 视频时长(毫秒) + create_time: number; // 创建时间(时间戳) + share_url: string; // 视频分享链接 statistics: { - digg_count: number; // 点赞数 - comment_count: number; // 评论数 - share_count: number; // 分享数 - collect_count: number; // 收藏数 + digg_count: number; // 点赞数 + comment_count: number; // 评论数 + share_count: number; // 分享数 + collect_count: number; // 收藏数 }; - author: DouyinAuthor; // 作者信息 + author: DouyinAuthor; // 作者信息 video: VideoPlayBasic; tags: VideoTagBasic[]; } @@ -88,9 +88,9 @@ interface VideoPlayBasic { /** 单个清晰度变体(来自 bit_rate[*] + play_addr) */ interface PlayVariant { - format: string; // mp4 等 + format: string; // mp4 等 FPS: number; - bit_rate: number; // bit_rate.bit_rate + bit_rate: number; // bit_rate.bit_rate /** 直连播放地址(最关键) */ play_addr: { @@ -101,7 +101,7 @@ interface PlayVariant { data_size: number; FPS: number; 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) images: DouyinImageInfo[]; // 图片列表 - music?: DouyinMusicBasic; // 背景音乐(可选) + music?: DouyinMusicBasic; // 背景音乐(可选) video_tag?: VideoTagBasic[]; // 标签 } @@ -143,7 +143,7 @@ interface DouyinImageInfo { width: number; height: number; video: { - play_addr: { src: string }[] + play_addr: { src: string }[]; } | null; // 如果是动图,会有 video 信息 } @@ -157,4 +157,4 @@ interface DouyinMusicBasic { uri?: string; url_list: string[]; // 真实可下载地址 }; -} \ No newline at end of file +} diff --git a/app/api/fetcher/uploader.ts b/app/api/fetcher/uploader.ts index 68a261c..512a4f3 100644 --- a/app/api/fetcher/uploader.ts +++ b/app/api/fetcher/uploader.ts @@ -1,117 +1,175 @@ -export const runtime = 'nodejs' +export const runtime = "nodejs"; -import type { BrowserContext } from 'playwright'; -import { uploadFile, generateUniqueFileName } from '@/lib/minio'; -import { downloadBinary } from './network'; -import { pickFirstUrl } from './utils'; -import { getVideoDuration } from '@/app/api/media'; +import type { BrowserContext } from "playwright"; +import { uploadFile, generateUniqueFileName } from "@/lib/minio"; +import { downloadBinary } from "./network"; +import { pickFirstUrl } from "./utils"; +import { getVideoDuration } from "@/app/api/media"; /** * 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。 */ export async function uploadAvatarFromUrl( - context: BrowserContext, - srcUrl?: string | null, - nameHint?: string, + context: BrowserContext, + srcUrl?: string | null, + nameHint?: string, ): Promise { - if (!srcUrl) return undefined; - try { - const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); - const safeExt = ext || 'jpg'; - const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`; - const fileName = generateUniqueFileName(baseName, 'douyin/avatars'); - const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); - return uploaded; - } catch (e) { - console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e); - return srcUrl || undefined; - } + if (!srcUrl) return undefined; + try { + const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); + const safeExt = ext || "jpg"; + const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`; + const fileName = generateUniqueFileName(baseName, "douyin/avatars"); + const uploaded = await uploadFile(buffer, fileName, { + "Content-Type": contentType, + }); + return uploaded; + } catch (e) { + console.warn( + "[avatar] 上传失败,使用原始链接:", + (e as Error)?.message || e, + ); + return srcUrl || undefined; + } } /** * 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。 */ export async function uploadImageFromUrl( - context: BrowserContext, - srcUrl?: string | null, - nameHint?: string, + context: BrowserContext, + srcUrl?: string | null, + nameHint?: string, ): Promise { - if (!srcUrl) return undefined; - try { - const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); - const safeExt = ext || 'jpg'; - const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`; - const fileName = generateUniqueFileName(baseName, 'douyin/comment-images'); - const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); - return uploaded; - } catch (e) { - console.warn('[image] 上传失败,使用原始链接:', (e as Error)?.message || e); - return srcUrl || undefined; - } + if (!srcUrl) return undefined; + try { + const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); + const safeExt = ext || "jpg"; + const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`; + const fileName = generateUniqueFileName(baseName, "douyin/comment-images"); + const uploaded = await uploadFile(buffer, fileName, { + "Content-Type": contentType, + }); + return uploaded; + } catch (e) { + console.warn( + "[image] 上传失败,使用原始链接:", + (e as Error)?.message || e, + ); + return srcUrl || undefined; + } } /** 下载图文作品的图片和音乐并上传到 MinIO */ export async function handleImagePost( - context: BrowserContext, - aweme: DouyinImageAweme -): Promise<{ images: { 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 }[] = []; + context: BrowserContext, + aweme: DouyinImageAweme, +): Promise<{ + images: { + 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++) { - const img = aweme.images[i]; - const url = pickFirstUrl(img?.url_list); - if (!url) continue; - const { buffer, contentType, ext } = await downloadBinary(context, url); - const safeExt = ext || 'jpg'; - const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images'); - const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); + // 下载图片(顺序保持) + for (let i = 0; i < (aweme.images?.length || 0); i++) { + const img = aweme.images[i]; + const url = pickFirstUrl(img?.url_list); + if (!url) continue; + const { buffer, contentType, ext } = await downloadBinary(context, url); + const safeExt = ext || "jpg"; + const fileName = generateUniqueFileName( + `${awemeId}/${i}.${safeExt}`, + "douyin/images", + ); + const uploaded = await uploadFile(buffer, fileName, { + "Content-Type": contentType, + }); - if (img.video?.play_addr) { - // 如果是动图,下载 video 并上传 - const videoUrl = img.video.play_addr[0]?.src; - if (videoUrl) { - try { - const { buffer: videoBuffer, contentType: 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); - - // 将动图的 video URL 和 duration 也存储起来 - uploadedImages.push({ - url: uploaded, - width: img?.width, - height: img?.height, - video: uploadedVideo, - duration: duration ?? undefined - }); - - if (duration) { - console.log(`[image] 动图 ${i} 时长: ${duration}ms`); - } - } catch (e) { - console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); - uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); - } - } - } else { - uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); + if (img.video?.play_addr) { + // 如果是动图,下载 video 并上传 + const videoUrl = img.video.play_addr[0]?.src; + if (videoUrl) { + try { + const { + buffer: videoBuffer, + contentType: 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); + + // 将动图的 video URL 和 duration 也存储起来 + uploadedImages.push({ + url: uploaded, + width: img?.width, + height: img?.height, + video: uploadedVideo, + duration: duration ?? undefined, + }); + + if (duration) { + console.log(`[image] 动图 ${i} 时长: ${duration}ms`); + } + } catch (e) { + console.warn( + `[image] 动图视频上传失败,跳过:`, + (e as Error)?.message || e, + ); + uploadedImages.push({ + url: uploaded, + width: img?.width, + height: img?.height, + }); } + } + } else { + uploadedImages.push({ + url: uploaded, + width: img?.width, + height: img?.height, + }); } + } - // 下载音乐(可选) - let musicUrl: string | undefined; - const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list); - if (audioSrc) { - const { buffer, contentType, ext } = await downloadBinary(context, audioSrc); - const safeExt = ext || 'mp3'; - const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios'); - musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); - } + // 下载音乐(可选) + let musicUrl: string | undefined; + const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list); + if (audioSrc) { + const { buffer, contentType, ext } = await downloadBinary( + context, + audioSrc, + ); + 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 }; } diff --git a/app/api/fetcher/utils.ts b/app/api/fetcher/utils.ts index 737fc14..7816227 100644 --- a/app/api/fetcher/utils.ts +++ b/app/api/fetcher/utils.ts @@ -1,11 +1,11 @@ -export const runtime = 'nodejs' +export const runtime = "nodejs"; 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 { - 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 */ export function createCamelCompatibleProxy(root: T): T { - const seen = new WeakMap(); + const seen = new WeakMap(); - const wrap = (value: any): any => { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return seen.get(value); - const proxied = new Proxy(value, handler); - seen.set(value, proxied); - return proxied; - }; + const wrap = (value: any): any => { + if (value === null || typeof value !== "object") return value; + if (seen.has(value)) return seen.get(value); + const proxied = new Proxy(value, handler); + seen.set(value, proxied); + return proxied; + }; - const handler: ProxyHandler = { - get(target, prop, receiver) { - // 非字符串属性(如 Symbol、数字索引)直接透传 - if (typeof prop !== 'string') { - return wrap(Reflect.get(target, prop, receiver)); - } + const handler: ProxyHandler = { + get(target, prop, receiver) { + // 非字符串属性(如 Symbol、数字索引)直接透传 + if (typeof prop !== "string") { + 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); - if (camel in target) return wrap(Reflect.get(target, camel, receiver)); + const camel = toCamelCaseKey(primary); + if (camel in target) return wrap(Reflect.get(target, camel, receiver)); - const snake = toSnakeCaseKey(primary); - if (snake in target) return wrap(Reflect.get(target, snake, receiver)); + const snake = toSnakeCaseKey(primary); + if (snake in target) return wrap(Reflect.get(target, snake, receiver)); - return wrap(Reflect.get(target, prop, receiver)); - }, - has(target, prop) { - if (typeof prop !== 'string') return prop in target; - const primary = prop === 'auther' ? 'autherInfo' : prop; - return ( - primary in target || - toCamelCaseKey(primary) in target || - toSnakeCaseKey(primary) in target - ); - } - }; + return wrap(Reflect.get(target, prop, receiver)); + }, + has(target, prop) { + if (typeof prop !== "string") return prop in target; + const primary = prop === "auther" ? "autherInfo" : prop; + return ( + primary in target || + toCamelCaseKey(primary) in target || + toSnakeCaseKey(primary) in target + ); + }, + }; - return wrap(root); + return wrap(root); } /** 选择首个可用 URL */ export function pickFirstUrl(list?: string[]) { - return Array.isArray(list) && list.length ? list[0] : undefined; + return Array.isArray(list) && list.length ? list[0] : undefined; } // 别名,兼容旧命名 diff --git a/app/api/media.ts b/app/api/media.ts index fddc778..a8786c9 100644 --- a/app/api/media.ts +++ b/app/api/media.ts @@ -1,80 +1,113 @@ -import { execFile } from 'child_process'; -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; -import { promisify } from 'util'; +import { execFile } from "child_process"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; const execFileAsync = promisify(execFile); /** * 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer */ -export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { - const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg'; - const tmpDir = os.tmpdir(); - 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`); +export async function extractFirstFrame( + videoBuffer: Buffer, +): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> { + const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg"; + const tmpDir = os.tmpdir(); + 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 { - await fs.writeFile(inPath, videoBuffer); - const args = [ - '-hide_banner', - '-loglevel', 'error', - '-ss', '0', - '-i', inPath, - '-frames:v', '1', - '-q:v', '2', - '-f', 'image2', - '-y', - outPath, - ]; - await execFileAsync(ffmpegCmd, args, { windowsHide: true }); - const img = await fs.readFile(outPath); - return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' }; - } 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; - } - throw e; - } finally { - try { await fs.unlink(inPath); } catch { } - try { await fs.unlink(outPath); } catch { } + try { + await fs.writeFile(inPath, videoBuffer); + const args = [ + "-hide_banner", + "-loglevel", + "error", + "-ss", + "0", + "-i", + inPath, + "-frames:v", + "1", + "-q:v", + "2", + "-f", + "image2", + "-y", + outPath, + ]; + await execFileAsync(ffmpegCmd, args, { windowsHide: true }); + const img = await fs.readFile(outPath); + return { buffer: img, contentType: "image/jpeg", ext: "jpg" }; + } 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; } + throw e; + } finally { + try { + await fs.unlink(inPath); + } catch {} + try { + await fs.unlink(outPath); + } catch {} + } } /** * 使用 ffprobe 获取视频时长(毫秒) */ -export async function getVideoDuration(videoBuffer: Buffer): Promise { - const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe'; - const tmpDir = os.tmpdir(); - const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const inPath = path.join(tmpDir, `${base}.mp4`); +export async function getVideoDuration( + videoBuffer: Buffer, +): Promise { + const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe"; + const tmpDir = os.tmpdir(); + const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const inPath = path.join(tmpDir, `${base}.mp4`); - try { - await fs.writeFile(inPath, videoBuffer); - const args = [ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', - inPath, - ]; - const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true }); - const durationSeconds = parseFloat(stdout.trim()); - if (isNaN(durationSeconds)) return null; - return Math.round(durationSeconds * 1000); // 转换为毫秒 - } catch (e: any) { - if (e && (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 { } + try { + await fs.writeFile(inPath, videoBuffer); + const args = [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + inPath, + ]; + const { stdout } = await execFileAsync(ffprobeCmd, args, { + windowsHide: true, + }); + const durationSeconds = parseFloat(stdout.trim()); + if (isNaN(durationSeconds)) return null; + return Math.round(durationSeconds * 1000); // 转换为毫秒 + } catch (e: any) { + if ( + e && + (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 { - const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg'; - const tmpDir = os.tmpdir(); - const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const inPath = path.join(tmpDir, `${base}.mp4`); + const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg"; + const tmpDir = os.tmpdir(); + const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const inPath = path.join(tmpDir, `${base}.mp4`); - const format = opts?.format ?? 'mp3'; - const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps + const format = opts?.format ?? "mp3"; + const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps - // 根据目标格式设置输出路径、MIME 与编码参数 - let outPath = ''; - let contentType = ''; - let ext = ''; - let codecArgs: string[] = []; + // 根据目标格式设置输出路径、MIME 与编码参数 + let outPath = ""; + let contentType = ""; + let ext = ""; + let codecArgs: string[] = []; - if (format === 'mp3') { - ext = 'mp3'; - contentType = 'audio/mpeg'; - outPath = path.join(tmpDir, `${base}.${ext}`); - codecArgs = ['-c:a', 'libmp3lame', '-b:a', `${bitrate}k`]; - } else if (format === 'aac') { - // 使用 m4a 容器更通用 - ext = 'm4a'; - contentType = 'audio/mp4'; - outPath = path.join(tmpDir, `${base}.${ext}`); - codecArgs = ['-c:a', 'aac', '-b:a', `${bitrate}k`, '-movflags', '+faststart']; - } else { - // wav - ext = 'wav'; - contentType = 'audio/wav'; - outPath = path.join(tmpDir, `${base}.${ext}`); - codecArgs = ['-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2']; + if (format === "mp3") { + ext = "mp3"; + contentType = "audio/mpeg"; + outPath = path.join(tmpDir, `${base}.${ext}`); + codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`]; + } else if (format === "aac") { + // 使用 m4a 容器更通用 + ext = "m4a"; + contentType = "audio/mp4"; + outPath = path.join(tmpDir, `${base}.${ext}`); + codecArgs = [ + "-c:a", + "aac", + "-b:a", + `${bitrate}k`, + "-movflags", + "+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 { - 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 { await fs.unlink(inPath); } catch { } - try { - if (outPath) await fs.unlink(outPath); - } catch { } - } + await fs.unlink(inPath); + } catch {} + try { + if (outPath) await fs.unlink(outPath); + } catch {} + } } diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 4208bf0..4bc728c 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,13 +1,16 @@ -import { json } from '@/lib/json'; -import { getFileUrl } from '@/lib/minio'; -import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例 -import { NextResponse } from 'next/server'; +import { json } from "@/lib/json"; +import { getFileUrl } from "@/lib/minio"; +import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例 +import { NextResponse } from "next/server"; export async function GET(req: Request) { const { searchParams } = new URL(req.url); - const q = (searchParams.get('q') || '').trim(); - const page = Math.max(1, Number(searchParams.get('page') || 1)); - const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20))); + const q = (searchParams.get("q") || "").trim(); + const page = Math.max(1, Number(searchParams.get("page") || 1)); + const limit = Math.min( + 50, + Math.max(1, Number(searchParams.get("limit") || 20)), + ); const offset = (page - 1) * limit; if (!q) { @@ -24,7 +27,7 @@ export async function GET(req: Request) { { id: string; awemeId: string; - type: 'video' | 'image'; + type: "video" | "image"; rank: number; snippet: string; }[] @@ -86,9 +89,11 @@ export async function GET(req: Request) { `; // 查询总数(参数化,避免注入) - const totalRows = await prisma.$queryRaw<{ - count: number; - }[]>` + const totalRows = await prisma.$queryRaw< + { + count: number; + }[] + >` WITH tsq AS ( SELECT websearch_to_tsquery('zhcfg', ${q}) AS query ) @@ -104,61 +109,88 @@ export async function GET(req: Request) { `; // 分离视频和图文ID - const videoIds = rows.filter(r => r.type === 'video').map(r => r.awemeId); - const imagePostIds = rows.filter(r => r.type === 'image').map(r => r.awemeId); + const videoIds = rows + .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({ - where: { aweme_id: { in: videoIds } }, - select: { - aweme_id: true, - desc: true, - cover_url: true, - video_url: true, - duration_ms: true, - author: true - }, - })).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 videos = + videoIds.length > 0 + ? ( + await prisma.video.findMany({ + where: { aweme_id: { in: videoIds } }, + select: { + aweme_id: true, + desc: true, + cover_url: true, + video_url: true, + duration_ms: true, + author: true, + }, + }) + ).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({ - where: { aweme_id: { in: imagePostIds } }, - select: { - aweme_id: true, - desc: true, - author: true, - images: { - orderBy: { order: 'asc' }, - take: 1, - 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, - })) : []; + const imagePosts = + imagePostIds.length > 0 + ? ( + await prisma.imagePost.findMany({ + where: { aweme_id: { in: imagePostIds } }, + select: { + aweme_id: true, + desc: true, + author: true, + images: { + orderBy: { order: "asc" }, + take: 1, + 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, + })) + : []; return json({ - results: rows.map(r => ({ + results: rows.map((r) => ({ ...r, - video: 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, + video: + 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, page, limit, }); } catch (err) { - console.error('Search error:', err); - return NextResponse.json({ error: 'Search failed' }, { status: 500 }); + console.error("Search error:", err); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); } } diff --git a/app/api/stt/index.ts b/app/api/stt/index.ts index 667dae6..4c05c7f 100644 --- a/app/api/stt/index.ts +++ b/app/api/stt/index.ts @@ -54,7 +54,7 @@ async function transcriptAudio(audio: Buffer | string) { response_format: zodResponseFormat(SttSchema, "stt_result"), }); - const data = completion.choices?.[0]?.message + const data = completion.choices?.[0]?.message; console.log("转写结果", data.content); if (!data || !data.content) { @@ -74,10 +74,13 @@ export async function transcriptAweme(awemeId: string): Promise { if (!aweme) { 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 audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 }); + const audioDat = await extractAudio(buffer, { + format: "mp3", + bitrateKbps: 128, + }); if (!audioDat || !audioDat.buffer) { throw new Error("Failed to extract audio from video"); diff --git a/app/api/stt/route.ts b/app/api/stt/route.ts index 811a24b..40ca475 100644 --- a/app/api/stt/route.ts +++ b/app/api/stt/route.ts @@ -1,20 +1,23 @@ export const runtime = "nodejs"; -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; -import type { FeedItem, FeedResponse } from '@/app/types/feed'; -import { getFileUrl } from '@/lib/minio'; -import { transcriptAweme } from '.'; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import type { FeedItem, FeedResponse } from "@/app/types/feed"; +import { getFileUrl } from "@/lib/minio"; +import { transcriptAweme } from "."; // Contract // Inputs: search params { before?: ISOString, limit?: number } // Output: { items: FeedItem[], nextCursor: ISOString | null } export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url) - const awemeId = searchParams.get('awemeId'); + const { searchParams } = new URL(req.url); + const awemeId = searchParams.get("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); return NextResponse.json(script); -} \ No newline at end of file +} diff --git a/app/author/[secUid]/page.tsx b/app/author/[secUid]/page.tsx index aae069d..435fc51 100644 --- a/app/author/[secUid]/page.tsx +++ b/app/author/[secUid]/page.tsx @@ -6,7 +6,11 @@ import { FeedItem } from "@/app/types/feed"; import { notFound } from "next/navigation"; 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 author = await prisma.author.findUnique({ @@ -22,15 +26,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid: const [videos, posts] = await Promise.all([ prisma.video.findMany({ where: { authorId: secUid }, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, take: limit, include: { author: true }, }), prisma.imagePost.findMany({ where: { authorId: secUid }, - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, 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, desc: v.desc, 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, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, - likes: Number(v.digg_count) + author: { + 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) => ({ 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), width: p.images?.[0]?.width ?? 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 }, - likes: Number(p.digg_count) + author: { + 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); - 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 (
@@ -75,21 +93,21 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
{author.nickname}
- +

{author.nickname}

- 抖音号:{author.unique_id || author.short_id || '未知'} + 抖音号:{author.unique_id || author.short_id || "未知"}

- + {author.signature && (

{author.signature} @@ -98,11 +116,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:

- {Number(author.total_favorited).toLocaleString()} + + {Number(author.total_favorited).toLocaleString()} + 获赞
- {Number(author.follower_count).toLocaleString()} + + {Number(author.follower_count).toLocaleString()} + 粉丝
@@ -111,9 +133,9 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:

作品

-
diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 790220d..901b1d7 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,9 +1,15 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Pause, Play } from "lucide-react"; -import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript } from "./types.ts"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Play } from "lucide-react"; +import type { + AwemeData, + ImageData, + Neighbors, + VideoData, + VideoTranscript, +} from "./types.ts"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ImageCarousel } from "./components/ImageCarousel"; @@ -18,7 +24,6 @@ import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; -import { Prisma } from "@prisma/client"; const SEGMENT_MS = 4000; @@ -28,7 +33,11 @@ interface AwemeDetailClientProps { transcript: VideoTranscript | null; } -export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) { +export default function AwemeDetailClient({ + data, + neighbors, + transcript, +}: AwemeDetailClientProps) { const router = useRouter(); const isVideo = data.type === "video"; @@ -44,6 +53,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme const scrollerRef = useRef(null); const backgroundCanvasRef = useRef(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 imageCarouselState = useImageCarousel({ @@ -53,7 +75,6 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme neighbors, volume: playerState.volume, audioRef, - scrollerRef, setProgress: playerState.setProgress, segmentMs: SEGMENT_MS, }); @@ -84,50 +105,32 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme return; } if (!images?.length) return; + imageCarouselState.seekTo(ratio); + }; - // 计算每张图片的时长 - const durations = images.map(img => img.duration ?? SEGMENT_MS); - const totalDuration = durations.reduce((sum, d) => sum + d, 0); - const targetTime = ratio * totalDuration; + const seekImageByVisualRatio = (ratio: number) => { + if (!images?.length) return; - // 找到目标时间对应的图片索引和进度 - let accumulatedTime = 0; - let targetIdx = 0; - let remainder = 0; + const clampedRatio = Math.min(1, Math.max(0, ratio)); + const rawSegment = clampedRatio * images.length; + const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment)); + const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex)); + imageCarouselState.goToIndex(targetIndex, segmentProgress); + }; - for (let i = 0; i < images.length; i++) { - if (accumulatedTime + durations[i] > targetTime) { - targetIdx = i; - remainder = (targetTime - accumulatedTime) / durations[i]; - break; - } - accumulatedTime += durations[i]; - if (i === images.length - 1) { - targetIdx = i; - remainder = 1; - } + const handleControlSeek = (ratio: number) => { + if (isVideo) { + seekTo(ratio); + return; } - - 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 + seekImageByVisualRatio(ratio); }; const togglePlay = async () => { if (isVideo) { const v = videoRef.current; if (!v) return; - if (v.paused) await v.play().catch(() => { }); + if (v.paused) await v.play().catch(() => {}); else v.pause(); return; } @@ -135,23 +138,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme if (!playerState.isPlaying) { playerState.setIsPlaying(true); try { - await el?.play().catch(() => { }); - } catch { } + await el?.play().catch(() => {}); + } catch {} } else { playerState.setIsPlaying(false); - el?.pause(); + pauseImageMedia(); } }; const toggleFullscreen = () => { if (!document.fullscreenElement) { if (document.body.requestFullscreen) { - document.body.requestFullscreen().catch(() => { }); + document.body.requestFullscreen().catch(() => {}); return; } const vRef = videoRef.current; if (vRef && vRef.requestFullscreen) { - vRef.requestFullscreen().catch(() => { }); + vRef.requestFullscreen().catch(() => {}); return; } // @ts-ignore @@ -160,26 +163,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme vRef.webkitEnterFullscreen(); } } else { - document.exitFullscreen().catch(() => { }); + document.exitFullscreen().catch(() => {}); } }; const prevImg = () => { if (!images?.length) return; const next = Math.max(0, imageCarouselState.idxRef.current - 1); - imageCarouselState.idxRef.current = next; - imageCarouselState.setIdx(next); - imageCarouselState.segStartRef.current = performance.now(); - // 虚拟滚动不需要实际滚动 DOM + imageCarouselState.goToIndex(next); }; const nextImg = () => { if (!images?.length) return; - const next = Math.min(images.length - 1, imageCarouselState.idxRef.current + 1); - imageCarouselState.idxRef.current = next; - imageCarouselState.setIdx(next); - imageCarouselState.segStartRef.current = performance.now(); - // 虚拟滚动不需要实际滚动 DOM + const next = Math.min( + images.length - 1, + imageCarouselState.idxRef.current + 1, + ); + imageCarouselState.goToIndex(next); }; const handleDownload = () => { @@ -223,6 +223,27 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme 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 集成 useEffect(() => { 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(); // 单一封面图:使用作品 cover_url const coverUrl = (data as AwemeData).cover_url as string | undefined; - const coverSize = (data as AwemeData).cover_size as { w: number; h: number } | undefined; - const artwork = coverUrl ? [{ src: coverUrl, size: `${coverSize?.w || 512}x${coverSize?.h || 512}` }] : []; + const coverSize = (data as AwemeData).cover_size as + | { w: number; h: number } + | undefined; + const artwork = coverUrl + ? [ + { + src: coverUrl, + size: `${coverSize?.w || 512}x${coverSize?.h || 512}`, + }, + ] + : []; try { ms.metadata = new MediaMetadata({ title, artist, album, artwork }); - } catch { } + } catch {} // 更新播放状态 try { ms.playbackState = playerState.isPlaying ? "playing" : "paused"; - } catch { } + } catch {} - const getImagesTotalMs = () => - (images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0); + const getImagesTotalMs = () => imageCarouselState.totalDurationMs; const updatePosition = () => { try { @@ -267,11 +296,14 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme } else if (images?.length) { const totalMs = getImagesTotalMs(); 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 ms.setPositionState({ duration, position, playbackRate: 1 }); } - } catch { } + } catch {} }; // 绑定视频事件以同步状态 @@ -279,15 +311,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme const v = videoRef.current; if (isVideo && v) { const onPlay = () => { - try { ms.playbackState = "playing"; } catch { } + try { + ms.playbackState = "playing"; + } catch {} updatePosition(); }; const onPause = () => { - try { ms.playbackState = "paused"; } catch { } + try { + ms.playbackState = "paused"; + } catch {} updatePosition(); }; const onTimeUpdate = () => updatePosition(); - + v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); v.addEventListener("timeupdate", onTimeUpdate); @@ -317,6 +353,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme data.author.avatar_url, data.created_at, imageCarouselState.idx, + imageCarouselState.totalDurationMs, images, playerState.isPlaying, playerState.progress, @@ -329,16 +366,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme if (!("mediaSession" in navigator)) return; const ms = (navigator as any).mediaSession as MediaSession; - const getImagesTotalMs = () => - (images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0); + const getImagesTotalMs = () => imageCarouselState.totalDurationMs; const handlePlay = async () => { if (isVideo) { const v = videoRef.current; - try { await v?.play(); } catch { } + try { + await v?.play(); + } catch {} } else { playerState.setIsPlaying(true); - try { await audioRef.current?.play(); } catch { } + try { + await audioRef.current?.play(); + } catch {} } }; const handlePause = () => { @@ -347,23 +387,30 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme v?.pause(); } else { playerState.setIsPlaying(false); - audioRef.current?.pause(); + pauseImageMedia(); } }; const handleStop = () => { if (isVideo) { const v = videoRef.current; - if (v) { v.pause(); v.currentTime = 0; } + if (v) { + v.pause(); + v.currentTime = 0; + } } else { playerState.setIsPlaying(false); - audioRef.current?.pause(); + pauseImageMedia(true); seekTo(0); } }; const handleSeekDelta = (deltaSec: number) => { if (isVideo) { 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) { const totalMs = getImagesTotalMs(); if (totalMs <= 0) return; @@ -387,8 +434,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme ms.setActionHandler("play", handlePlay); ms.setActionHandler("pause", handlePause); ms.setActionHandler("stop", handleStop); - ms.setActionHandler("seekbackward", (details: any) => handleSeekDelta(-((details?.seekOffset as number) || 10))); - ms.setActionHandler("seekforward", (details: any) => handleSeekDelta((details?.seekOffset as number) || 10)); + ms.setActionHandler("seekbackward", (details: any) => + handleSeekDelta(-((details?.seekOffset as number) || 10)), + ); + ms.setActionHandler("seekforward", (details: any) => + handleSeekDelta((details?.seekOffset as number) || 10), + ); ms.setActionHandler("seekto", (details: any) => { 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("previoustrack", null); ms.setActionHandler("nexttrack", null); - } catch { } + } catch {} }; }, [ isVideo, @@ -426,6 +477,8 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme neighbors.next, router, playerState.progress, + pauseImageMedia, + imageCarouselState.totalDurationMs, ]); return ( @@ -434,29 +487,39 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
{/* 主媒体区域 */} -
-
- {isVideo ? ( - - ) : ( - - )} +
+
+
+ {isVideo ? ( + + ) : ( + + )} +
{/* 暂停图标 */} {!playerState.isPlaying && ( -
+
@@ -496,7 +559,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme hasTranscript={isVideo && !!transcript?.speech_detected} onShowTranscript={() => setTranscriptOpen(true)} onTogglePlay={togglePlay} - onSeek={seekTo} + onSeek={handleControlSeek} onVolumeChange={playerState.setVolume} onRateChange={playerState.setRate} onRotationChange={playerState.setRotation} @@ -512,8 +575,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme neighbors={neighbors} commentsCount={data.commentsCount} likesCount={data.likesCount} - onNavigatePrev={() => neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)} - onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)} + onNavigatePrev={() => + neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`) + } + onNavigateNext={() => + neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`) + } onToggleComments={() => commentState.setOpen((v) => !v)} />
diff --git a/app/aweme/[awemeId]/components/BackgroundCanvas.tsx b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx index befddfe..8fcc2de 100644 --- a/app/aweme/[awemeId]/components/BackgroundCanvas.tsx +++ b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx @@ -2,7 +2,10 @@ import { forwardRef } from "react"; interface BackgroundCanvasProps {} -export const BackgroundCanvas = forwardRef((props, ref) => { +export const BackgroundCanvas = forwardRef< + HTMLCanvasElement, + BackgroundCanvasProps +>((props, ref) => { return (
{author.avatar_url ? ( - avatar + avatar ) : null}
-
{author.nickname}
-
+
+ {author.nickname} +
+
发布于 {formatRelativeTime(createdAt)}
@@ -34,13 +43,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
  • {c.user.avatar_url ? ( - avatar + avatar ) : null}
    - {c.user.nickname} - {formatRelativeTime(c.created_at)} + + {c.user.nickname} + + + {formatRelativeTime(c.created_at)} +

    @@ -75,7 +92,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {

  • ))} - {comments.length === 0 ?
  • 暂无评论
  • : null} + {comments.length === 0 ? ( +
  • 暂无评论
  • + ) : null} {/* 图片预览灯箱 */} diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx index ae56542..2e5ff7c 100644 --- a/app/aweme/[awemeId]/components/CommentPanel.tsx +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -12,13 +12,23 @@ interface CommentPanelProps { 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([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); // ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定) - const [rankParams, setRankParams] = useState(null); + const [rankParams, setRankParams] = useState(null); // 两套滚动容器与哨兵,分别对应横屏与竖屏面板 const scrollRefLandscape = useRef(null); const sentinelRefLandscape = useRef(null); @@ -27,67 +37,81 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte const loadingRef = useRef(false); // 加载评论 - const loadComments = useCallback(async (reset = false) => { - if (loadingRef.current || (!reset && !hasMore)) return; - - loadingRef.current = true; - setLoading(true); + const loadComments = useCallback( + async (reset = false) => { + if (loadingRef.current || (!reset && !hasMore)) return; - try { - const skip = reset ? 0 : comments.length; - const query = new URLSearchParams({ - skip: String(skip), - take: String(20), - mode: 'ranked', - }); - if (rankParams) { - query.set('seed', rankParams.seed); - query.set('snapshot', rankParams.snapshot); - } - const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`); - const data = await response.json(); + loadingRef.current = true; + setLoading(true); - // 统一做一次基于 cid 的去重,避免分页偶发重复 - if (reset) { - setComments(() => { - const seen = new Set(); - return (data.comments as Comment[]).filter((c) => { - if (seen.has(c.cid)) return false; - seen.add(c.cid); - return true; + try { + const skip = reset ? 0 : comments.length; + const query = new URLSearchParams({ + skip: String(skip), + take: String(20), + mode: "ranked", + }); + if (rankParams) { + query.set("seed", rankParams.seed); + query.set("snapshot", rankParams.snapshot); + } + const response = await fetch( + `/api/comments/${awemeId}?${query.toString()}`, + ); + const data = await response.json(); + + // 统一做一次基于 cid 的去重,避免分页偶发重复 + if (reset) { + setComments(() => { + const seen = new Set(); + return (data.comments as Comment[]).filter((c) => { + if (seen.has(c.cid)) return false; + seen.add(c.cid); + return true; + }); }); - }); - } else { - setComments((prev) => { - const merged = [...prev, ...(data.comments as Comment[])]; - const seen = new Set(); - // 保留首次出现的项,既保证顺序也避免重复 - return merged.filter((c) => { - if (seen.has(c.cid)) return false; - seen.add(c.cid); - return true; + } else { + setComments((prev) => { + const merged = [...prev, ...(data.comments as Comment[])]; + const seen = new Set(); + // 保留首次出现的项,既保证顺序也避免重复 + return merged.filter((c) => { + if (seen.has(c.cid)) return false; + seen.add(c.cid); + return true; + }); }); - }); + } + + setTotal(data.total); + setHasMore(data.hasMore); + if (data.mode === "ranked" && data.seed && data.snapshot) { + // 初始化或重置时更新稳定参数 + setRankParams((prev) => { + if (reset) + return { + seed: String(data.seed), + snapshot: String(data.snapshot), + }; + 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; } - - setTotal(data.total); - setHasMore(data.hasMore); - if (data.mode === 'ranked' && data.seed && data.snapshot) { - // 初始化或重置时更新稳定参数 - setRankParams((prev) => { - if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) }; - 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; - } - }, [awemeId, comments.length, hasMore]); + }, + [awemeId, comments.length, hasMore], + ); // 面板打开时加载初始评论 useEffect(() => { @@ -102,7 +126,10 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte const observers: IntersectionObserver[] = []; - const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => { + const setup = ( + rootEl: HTMLDivElement | null, + targetEl: HTMLDivElement | null, + ) => { if (!rootEl || !targetEl) return; const io = new IntersectionObserver( (entries) => { @@ -113,9 +140,9 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte }, { root: rootEl, - rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发 + rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发 threshold: 0, - } + }, ); io.observe(targetEl); observers.push(io); @@ -180,19 +207,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
    -
    - - +
    + + {/* 底部加载触发区 */} - {hasMore && ( -
    - )} - + {hasMore &&
    } + {loading && ( -
    加载中...
    +
    + 加载中... +
    )} {!hasMore && comments.length > 0 && ( -
    没有更多评论了
    +
    + 没有更多评论了 +
    )}
    @@ -221,19 +257,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
    -
    - - +
    + + {/* 底部加载触发区 */} - {hasMore && ( -
    - )} - + {hasMore &&
    } + {loading && ( -
    加载中...
    +
    + 加载中... +
    )} {!hasMore && comments.length > 0 && ( -
    没有更多评论了
    +
    + 没有更多评论了 +
    )}
    diff --git a/app/aweme/[awemeId]/components/CommentText.tsx b/app/aweme/[awemeId]/components/CommentText.tsx index 50b264e..5c2b363 100644 --- a/app/aweme/[awemeId]/components/CommentText.tsx +++ b/app/aweme/[awemeId]/components/CommentText.tsx @@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) { // 如果图片加载失败,显示原始文本 e.currentTarget.style.display = "none"; const textNode = document.createTextNode(`[${part.name}]`); - e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget); + e.currentTarget.parentNode?.insertBefore( + textNode, + e.currentTarget, + ); }} /> ); diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index bd5c215..de54fc4 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -1,78 +1,134 @@ -import { forwardRef, useEffect, useRef, useState } from "react"; -import type { ImageData } from "../types.ts"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ImageData, LoopMode } from "../types.ts"; interface ImageCarouselProps { images: ImageData["images"]; currentIndex: number; + isPlaying: boolean; + segmentProgress: number; + mediaSyncToken: number; + loopMode: LoopMode; onTogglePlay: () => void; + onAnimatedDuration?: (imageId: string, durationMs: number) => void; } export const ImageCarousel = forwardRef( - ({ images, currentIndex, onTogglePlay }, ref) => { - const [offset, setOffset] = useState(0); - const [isTransitioning, setIsTransitioning] = useState(false); - const containerRef = useRef(null); + ( + { + images, + currentIndex, + isPlaying, + segmentProgress, + mediaSyncToken, + loopMode, + onTogglePlay, + onAnimatedDuration, + }, + ref, + ) => { const videoRefs = useRef>(new Map()); - const playedVideos = useRef>(new Set()); + const activeVideoIdRef = useRef(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[] = []; if (currentIndex > 0) indices.push(currentIndex - 1); indices.push(currentIndex); if (currentIndex < images.length - 1) indices.push(currentIndex + 1); return indices; - })(); + }, [currentIndex, images.length, useLightweightMode]); - // 当 currentIndex 变化时,触发滚动动画 + // iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。 useEffect(() => { - setIsTransitioning(true); - setOffset(-currentIndex * 100); + const currentImage = images[currentIndex]; + if (!currentImage?.animated) { + activeVideoIdRef.current = null; + videoRefs.current.forEach((videoEl) => videoEl.pause()); + return; + } - const timer = setTimeout(() => { - setIsTransitioning(false); - }, 300); // 与 CSS transition 时间匹配 + const videoEl = videoRefs.current.get(currentImage.id); - return () => clearTimeout(timer); - }, [currentIndex]); + videoRefs.current.forEach((itemVideoEl, imageId) => { + 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(() => { const currentImage = images[currentIndex]; if (!currentImage?.animated) return; - const videoKey = currentImage.id; - const videoEl = videoRefs.current.get(videoKey); - - if (videoEl) { - // 检查是否已经播放过 - if (!playedVideos.current.has(videoKey)) { - // 重置并播放 - videoEl.currentTime = 0; - videoEl.play().catch(() => {}); - playedVideos.current.add(videoKey); - } else { - // 已播放过,重置到开头但不自动播放 - videoEl.currentTime = 0; - videoEl.play().catch(() => {}); - } - } - }, [currentIndex, images]); + const videoEl = videoRefs.current.get(currentImage.id); + if (!videoEl) return; - // 当切换到其他图片时,清除已播放标记(切回来会重新播放) - 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]); + syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true); + if (isPlaying) videoEl.play().catch(() => {}); + }, [ + currentIndex, + images, + isPlaying, + loopMode, + mediaSyncToken, + syncVideoToSegmentProgress, + ]); const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => { if (el) { @@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef( }; return ( -
    -
    +
    +
    {visibleIndices.map((i) => { const img = images[i]; + const isCurrent = i === currentIndex; + const animatedSrc = isCurrent ? img.animated : null; return (
    - {img.animated ? ( + {animatedSrc ? (
    ); - } + }, ); ImageCarousel.displayName = "ImageCarousel"; diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 6234356..6a51884 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -8,6 +8,7 @@ import { Minimize2, Pause, Play, + Repeat, Repeat1, RotateCcw, RotateCw, @@ -89,25 +90,70 @@ export function MediaControls({ onDownload, onToggleFullscreen, }: 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 ; + if (normalizedLoopMode === "loop") return ; + return ; + }; + return ( -
    +
    {/* 描述行 */}
    {author.sec_uid ? ( - - - {author.nickname} + + + {author.nickname} + ) : (
    - - {author.nickname} + + + {author.nickname} +
    )} - · + + · + - {isVideo ? ( - (() => { - const v = videoRef?.current; - const current = v?.currentTime ?? 0; - const total = v?.duration ?? 0; - return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--"; - })() - ) : ( - `${currentIndex + 1} / ${totalSegments}` - )} + {isVideo + ? (() => { + const v = videoRef?.current; + const current = v?.currentTime ?? 0; + const total = v?.duration ?? 0; + return total > 0 + ? `${formatTime(current)} / ${formatTime(total)}` + : "--:-- / --:--"; + })() + : `${currentIndex + 1} / ${totalSegments}`}
    {/* 倍速 - 中等屏幕以上显示,仅视频 */} @@ -231,21 +277,31 @@ export function MediaControls({ {/* 循环模式 - 中等屏幕以上显示 */} {/* 适配模式 - 小屏幕以上显示 */} {/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */} @@ -277,18 +333,28 @@ export function MediaControls({ {/* 小屏幕隐藏的适配模式 */}
    : } + icon={ + objectFit === "contain" ? ( + + ) : ( + + ) + } label={objectFit === "contain" ? "填充模式" : "适应模式"} - onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")} + onClick={() => + onObjectFitChange( + objectFit === "contain" ? "cover" : "contain", + ) + } />
    {/* 中等屏幕以下隐藏的循环模式 */}
    : } - label={loopMode === "loop" ? "循环播放" : "顺序播放"} - onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")} + icon={renderLoopIcon()} + label={loopLabel} + onClick={handleLoopModeToggle} />
    @@ -338,13 +404,13 @@ export function MediaControls({ > {isFullscreen ? : } - -
    {/* 图文 BGM(隐藏控件,仅用于播放) */} - {!isVideo && musicUrl ?
    ); } diff --git a/app/aweme/[awemeId]/components/MoreMenu.tsx b/app/aweme/[awemeId]/components/MoreMenu.tsx index ec8b00d..2b08eff 100644 --- a/app/aweme/[awemeId]/components/MoreMenu.tsx +++ b/app/aweme/[awemeId]/components/MoreMenu.tsx @@ -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" style={{ minWidth: "200px" }} > -
    - {children} -
    +
    {children}
    )}
    diff --git a/app/aweme/[awemeId]/components/NavigationButtons.tsx b/app/aweme/[awemeId]/components/NavigationButtons.tsx index c767d30..a838eff 100644 --- a/app/aweme/[awemeId]/components/NavigationButtons.tsx +++ b/app/aweme/[awemeId]/components/NavigationButtons.tsx @@ -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"; interface NavigationButtonsProps { @@ -21,14 +26,13 @@ export function NavigationButtons({ return ( <>
    - {/* 评论开关(右侧中部) */} @@ -40,13 +44,17 @@ export function NavigationButtons({
    - {commentsCount > 0 ? {commentsCount} : - 暂无评论 - } - + {commentsCount > 0 ? ( + + {commentsCount} + + ) : ( + + 暂无评论 + + )}
    -
    {/* 上下切换按钮(右侧胶囊形状) */} diff --git a/app/aweme/[awemeId]/components/ProgressBar.tsx b/app/aweme/[awemeId]/components/ProgressBar.tsx index a5af462..e20ff56 100644 --- a/app/aweme/[awemeId]/components/ProgressBar.tsx +++ b/app/aweme/[awemeId]/components/ProgressBar.tsx @@ -4,15 +4,41 @@ interface ProgressBarProps { } export function ProgressBar({ progress, onSeek }: ProgressBarProps) { + const seekFromClientX = (clientX: number, element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + onSeek((clientX - rect.left) / rect.width); + }; + return (
    { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - onSeek((e.clientX - rect.left) / rect.width); + seekFromClientX(e.clientX, e.currentTarget as HTMLElement); + }} + 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); + } }} > -
    +
    ); } diff --git a/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx b/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx index 1fa416d..c3e131d 100644 --- a/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx +++ b/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx @@ -11,12 +11,35 @@ export function SegmentedProgressBar({ segmentProgress, onSeek, }: SegmentedProgressBarProps) { + const seekFromClientX = (clientX: number, element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + onSeek((clientX - rect.left) / rect.width); + }; + return (
    { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - onSeek((e.clientX - rect.left) / rect.width); + seekFromClientX(e.clientX, e.currentTarget as HTMLElement); + }} + 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); + } }} >
    @@ -30,7 +53,12 @@ export function SegmentedProgressBar({ aria-label={`第 ${i + 1} 段`} className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden" > -
    +
    ); })} diff --git a/app/aweme/[awemeId]/components/TranscriptPanel.tsx b/app/aweme/[awemeId]/components/TranscriptPanel.tsx index 33fdc0c..e2cb36b 100644 --- a/app/aweme/[awemeId]/components/TranscriptPanel.tsx +++ b/app/aweme/[awemeId]/components/TranscriptPanel.tsx @@ -10,7 +10,11 @@ interface TranscriptPanelProps { transcript: VideoTranscript | null; } -export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) { +export function TranscriptPanel({ + open, + onClose, + transcript, +}: TranscriptPanelProps) { const [copiedIndex, setCopiedIndex] = useState(null); const [copiedAll, setCopiedAll] = useState(false); diff --git a/app/aweme/[awemeId]/components/VideoPlayer.tsx b/app/aweme/[awemeId]/components/VideoPlayer.tsx index 8afb009..1abce9f 100644 --- a/app/aweme/[awemeId]/components/VideoPlayer.tsx +++ b/app/aweme/[awemeId]/components/VideoPlayer.tsx @@ -35,7 +35,7 @@ export const VideoPlayer = forwardRef( onClick={onTogglePlay} /> ); - } + }, ); VideoPlayer.displayName = "VideoPlayer"; diff --git a/app/aweme/[awemeId]/emojis.ts b/app/aweme/[awemeId]/emojis.ts index f716652..2adf2c3 100644 --- a/app/aweme/[awemeId]/emojis.ts +++ b/app/aweme/[awemeId]/emojis.ts @@ -1,3 +1,216 @@ export const emojiList = [ - "微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子" -] \ No newline at end of file + "微笑", + "色", + "发呆", + "酷拽", + "抠鼻", + "流泪", + "捂脸", + "发怒", + "呲牙", + "尬笑", + "害羞", + "调皮", + "舔屏", + "看", + "爱心", + "比心", + "赞", + "鼓掌", + "感谢", + "抱抱你", + "玫瑰", + "尴尬流汗", + "戳手手", + "星星眼", + "杀马特", + "黄脸干杯", + "抱紧自己", + "拜拜", + "热化了", + "黄脸祈祷", + "懵", + "举手", + "加功德", + "摊手", + "无语流汗", + "续火花吧", + "点火", + "哭哭", + "吐舌小狗", + "送花", + "爱心手", + "贴贴", + "灵机一动", + "耶", + "打脸", + "大笑", + "机智", + "送心", + "666", + "闭嘴", + "来看我", + "一起加油", + "哈欠", + "震惊", + "晕", + "衰", + "困", + "疑问", + "泣不成声", + "小鼓掌", + "大金牙", + "偷笑", + "石化", + "思考", + "吐血", + "可怜", + "嘘", + "撇嘴", + "笑哭", + "奸笑", + "得意", + "憨笑", + "坏笑", + "抓狂", + "泪奔", + "钱", + "恐惧", + "愉快", + "快哭了", + "翻白眼", + "互粉", + "我想静静", + "委屈", + "鄙视", + "飞吻", + "再见", + "紫薇别走", + "听歌", + "求抱抱", + "绝望的凝视", + "不失礼貌的微笑", + "不看", + "裂开", + "干饭人", + "庆祝", + "吐舌", + "呆无辜", + "白眼", + "猪头", + "冷漠", + "暗中观察", + "二哈", + "菜狗", + "黑脸", + "展开说说", + "蜜蜂狗", + "柴犬", + "摸头", + "皱眉", + "擦汗", + "红脸", + "做鬼脸", + "强", + "如花", + "吐", + "惊喜", + "敲打", + "奋斗", + "吐彩虹", + "大哭", + "嘿哈", + "惊恐", + "囧", + "难过", + "斜眼", + "阴险", + "悠闲", + "咒骂", + "吃瓜群众", + "绿帽子", + "敢怒不敢言", + "求求了", + "眼含热泪", + "叹气", + "好开心", + "不是吧", + "鞠躬", + "躺平", + "九转大肠", + "不你不想", + "一头乱麻", + "kisskiss", + "你不大行", + "噢买尬", + "宕机", + "苦涩", + "逞强落泪", + "求机位-黄脸", + "求机位3", + "点赞", + "精选", + "强壮", + "碰拳", + "OK", + "击掌", + "左上", + "握手", + "抱拳", + "勾引", + "拳头", + "弱", + "胜利", + "右边", + "左边", + "嘴唇", + "心碎", + "凋谢", + "愤怒", + "垃圾", + "啤酒", + "咖啡", + "蛋糕", + "礼物", + "撒花", + "加一", + "减一", + "okk", + "V5", + "绝", + "给力", + "红包", + "屎", + "发", + "18禁", + "炸弹", + "西瓜", + "加鸡腿", + "握爪", + "太阳", + "月亮", + "给跪了", + "蕉绿", + "扎心", + "胡瓜", + "打call", + "栓Q", + "雪花", + "圣诞树", + "平安果", + "圣诞帽", + "气球", + "烟花", + "福", + "candy", + "糖葫芦", + "鞭炮", + "元宝", + "灯笼", + "锦鲤", + "巧克力", + "戒指", + "棒棒糖", + "纸飞机", + "粽子", +]; diff --git a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts index 77b9e24..9593e8c 100644 --- a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts +++ b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts @@ -23,6 +23,13 @@ export function useBackgroundCanvas({ const ctx = canvas.getContext("2d"); if (!ctx) return; + if (!isVideo) { + canvas.width = 1; + canvas.height = 1; + ctx.clearRect(0, 0, 1, 1); + return; + } + const updateCanvasSize = () => { canvas.width = Math.floor(window.innerWidth / 10); canvas.height = Math.floor(window.innerHeight / 10); @@ -39,40 +46,29 @@ export function useBackgroundCanvas({ const drawMediaToCanvas = () => { if (!ctx) return; - let sourceElement: HTMLVideoElement | HTMLImageElement | null = null; + const sourceElement = videoRef.current; - if (isVideo) { - sourceElement = videoRef.current; - } else { - const scroller = scrollerRef.current; - if (scroller) { - // 虚拟滚动:查找所有图片容器,找到当前显示的那个 - const containers = scroller.querySelectorAll('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; + if ( + !sourceElement || + (sourceElement instanceof HTMLVideoElement && + sourceElement.readyState < 2) + ) + return; const canvasWidth = canvas.width; const canvasHeight = canvas.height; - const sourceWidth = - sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth; - const sourceHeight = - sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight; + const sourceWidth = sourceElement.videoWidth; + const sourceHeight = sourceElement.videoHeight; if (!sourceWidth || !sourceHeight) return; const canvasRatio = canvasWidth / canvasHeight; 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) { drawWidth = canvasWidth; diff --git a/app/aweme/[awemeId]/hooks/useImageCarousel.ts b/app/aweme/[awemeId]/hooks/useImageCarousel.ts index fd4a178..3ffcba6 100644 --- a/app/aweme/[awemeId]/hooks/useImageCarousel.ts +++ b/app/aweme/[awemeId]/hooks/useImageCarousel.ts @@ -1,8 +1,32 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; import { useRouter } from "next/navigation"; 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 { images: ImageData["images"]; isPlaying: boolean; @@ -10,7 +34,6 @@ interface UseImageCarouselProps { neighbors: Neighbors; volume: number; audioRef: RefObject; - scrollerRef: RefObject; setProgress: (progress: number) => void; /** 单张图片显示时长(毫秒),默认 5000ms */ segmentMs?: number; @@ -23,22 +46,186 @@ export function useImageCarousel({ neighbors, volume, audioRef, - scrollerRef, setProgress, segmentMs = 5000, }: UseImageCarouselProps) { const router = useRouter(); const [idx, setIdx] = useState(0); const [segProgress, setSegProgress] = useState(0); - + const [durationOverrides, setDurationOverrides] = useState< + Record + >({}); + const [mediaSyncToken, setMediaSyncToken] = useState(0); + const segStartRef = useRef(null); const idxRef = useRef(0); - const rafRef = useRef(null); + const timerRef = useRef(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(() => { idxRef.current = idx; }, [idx]); + useEffect(() => { + const validIds = new Set(images.map((img) => img.id)); + setDurationOverrides((current) => { + let changed = false; + const next: Record = {}; + + 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 控制 useEffect(() => { const el = audioRef.current; @@ -51,33 +238,42 @@ export function useImageCarousel({ } }, [audioRef, isPlaying, volume]); + useEffect(() => { + return () => { + audioRef.current?.pause(); + }; + }, [audioRef]); + // 自动切页 useEffect(() => { - if (!images?.length) return; + if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) + return; - if (segStartRef.current == null) segStartRef.current = performance.now(); - let lastTs = performance.now(); + const now = 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 (!isPlaying) segStartRef.current! += ts - lastTs; - lastTs = ts; + const ts = performance.now(); let start = segStartRef.current!; let localIdx = idxRef.current; let elapsed = ts - start; - - // 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs) - const getCurrentSegmentDuration = (index: number) => { - const img = images[index]; - return img?.duration ?? segmentMs; - }; - - let currentSegmentDuration = getCurrentSegmentDuration(localIdx); - + let currentSegmentDuration = + segmentDurations[localIdx] ?? fallbackDuration; + while (elapsed >= currentSegmentDuration) { + if (loopMode === "single") { + elapsed %= currentSegmentDuration; + break; + } + elapsed -= currentSegmentDuration; if (localIdx >= images.length - 1) { @@ -89,44 +285,60 @@ export function useImageCarousel({ } else { localIdx = localIdx + 1; } - - // 更新下一张图片的时长 - currentSegmentDuration = getCurrentSegmentDuration(localIdx); + + currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration; } segStartRef.current = ts - elapsed; - if (localIdx !== idxRef.current) { + const indexChanged = localIdx !== idxRef.current; + if (indexChanged) { idxRef.current = localIdx; setIdx(localIdx); - // 虚拟滚动不需要实际滚动 DOM } - const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration)); - setSegProgress(localSeg); - - // 计算总进度:已完成的图片 + 当前图片的进度 - let totalProgress = 0; - for (let i = 0; i < localIdx; i++) { - totalProgress += 1; - } - totalProgress += localSeg; - setProgress(totalProgress / images.length); + const localSeg = Math.max( + 0, + Math.min(1, elapsed / currentSegmentDuration), + ); - rafRef.current = requestAnimationFrame(tick); + if ( + indexChanged || + ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS + ) { + lastUiUpdateRef.current = ts; + syncProgress(localIdx, localSeg, indexChanged); + } }; - rafRef.current = requestAnimationFrame(tick); + tick(); + timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS); return () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = null; + if (timerRef.current) window.clearInterval(timerRef.current); + timerRef.current = null; }; - }, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); + }, [ + fallbackDuration, + images, + isPlaying, + loopMode, + neighbors?.next, + router, + segmentDurations, + syncProgress, + totalDurationMs, + ]); return { idx, setIdx, segProgress, + segmentDurations, + totalDurationMs, + mediaSyncToken, segStartRef, idxRef, + setSegmentDuration, + goToIndex, + seekTo, }; } diff --git a/app/aweme/[awemeId]/hooks/useNavigation.ts b/app/aweme/[awemeId]/hooks/useNavigation.ts index a36341c..25ae50d 100644 --- a/app/aweme/[awemeId]/hooks/useNavigation.ts +++ b/app/aweme/[awemeId]/hooks/useNavigation.ts @@ -65,7 +65,11 @@ export function useNavigation({ useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { 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; } diff --git a/app/aweme/[awemeId]/hooks/usePlayerState.ts b/app/aweme/[awemeId]/hooks/usePlayerState.ts index 476bcd6..953b128 100644 --- a/app/aweme/[awemeId]/hooks/usePlayerState.ts +++ b/app/aweme/[awemeId]/hooks/usePlayerState.ts @@ -1,19 +1,28 @@ import { useEffect, useState } from "react"; import type { LoopMode, ObjectFit } from "../types.ts"; -import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils"; +import { + getNumberFromStorage, + getStringFromStorage, + saveToStorage, +} from "../utils"; export function usePlayerState() { const [isPlaying, setIsPlaying] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); - const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1)); - const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1)); + const [volume, setVolume] = useState(() => + getNumberFromStorage("aweme_player_volume", 1), + ); + const [rate, setRate] = useState(() => + getNumberFromStorage("aweme_player_rate", 1), + ); const [progress, setProgress] = useState(0); const [rotation, setRotation] = useState(0); const [progressRestored, setProgressRestored] = useState(false); const [objectFit, setObjectFit] = useState("contain"); const [loopMode, setLoopMode] = useState(() => { const saved = getStringFromStorage("aweme_player_loop_mode", "loop"); - return saved === "sequential" ? "sequential" : "loop"; + if (saved === "single" || saved === "sequential") return saved; + return "loop"; }); // 持久化音量 diff --git a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts index 96f1e15..39defba 100644 --- a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts +++ b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts @@ -51,7 +51,11 @@ export function useVideoPlayer({ const now = Date.now(); 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; console.log(`恢复播放进度: ${Math.round(time)}s`); } else if (now - timestamp >= fiveMinutes) { @@ -125,7 +129,7 @@ export function useVideoPlayer({ v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); v.addEventListener("ended", onEnded); - + return () => { v.removeEventListener("timeupdate", onTime); v.removeEventListener("loadedmetadata", onTime); diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 5c19eab..58ed4eb 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -5,7 +5,11 @@ import type { Metadata } from "next"; import { getFileUrl } from "@/lib/minio"; import { AwemeData, VideoTranscript } from "./types"; -export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ awemeId: string }>; +}): Promise { const id = (await params).awemeId; const [video, post] = await Promise.all([ @@ -16,7 +20,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId: prisma.imagePost.findUnique({ where: { aweme_id: id }, select: { desc: true, author: { select: { nickname: true } } }, - }) + }), ]); const data = video || post; @@ -28,7 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId: const desc = data.desc || "查看作品详情"; 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 { 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 [video, post] = await Promise.all([ @@ -47,7 +58,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI prisma.imagePost.findUnique({ where: { aweme_id: id }, include: { author: true, images: { orderBy: { order: "asc" } } }, - }) + }), ]); if (!video && !post) return
    找不到该作品
    ; @@ -66,65 +77,109 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI created_at: aweme!.created_at, likesCount: Number(aweme!.digg_count), commentsCount, - author: { - nickname: aweme!.author.nickname, - avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'), - sec_uid: aweme!.author.sec_uid + author: { + nickname: aweme!.author.nickname, + avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"), + sec_uid: aweme!.author.sec_uid, }, ...(() => { if (isVideo) { - const aweme = video! + const aweme = video!; return { 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 }, duration_ms: aweme!.duration_ms, video_url: getFileUrl(aweme!.video_url), width: aweme!.width ?? null, height: aweme!.height ?? null, - } + }; } else { - const aweme = post! + const aweme = post!; return { type: "image" as const, - cover_url: getFileUrl(aweme!.images[0].url ?? 'default-cover.png'), - cover_size: { h: aweme!.images[0].height ?? 0, 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'), + cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"), + cover_size: { + h: aweme!.images[0].height ?? 0, + 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({ - where: { videoId: id }, - }) : null; + const transcript: VideoTranscript | null = isVideo + ? await prisma.videoTranscript.findUnique({ + where: { videoId: id }, + }) + : null; // 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([ - prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, 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 } }), + prisma.video.findFirst({ + where: { created_at: { gt: currentCreatedAt } }, + orderBy: { created_at: "asc" }, + 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 cands: { aweme_id: string; created_at: Date }[] = []; - if (newerVideo) 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 (newerVideo) + 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; cands.sort((a, b) => +a.created_at - +b.created_at); return { aweme_id: cands[0].aweme_id }; })(); const pickNext = (() => { const cands: { aweme_id: string; created_at: Date }[] = []; - if (olderVideo) 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 (olderVideo) + 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; cands.sort((a, b) => +b.created_at - +a.created_at); 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 (
    @@ -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" />
    - +
    ); } diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 9b14304..51f8e7a 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -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 = { cid: string; @@ -13,7 +17,7 @@ export type VideoData = { type: "video"; aweme_id: string; cover_url: string; - cover_size: {w: number; h: number}; + cover_size: { w: number; h: number }; desc: string; created_at: string | Date; duration_ms: number | null; @@ -29,10 +33,17 @@ export type ImageData = { type: "image"; aweme_id: string; cover_url: string; - cover_size: {w: number; h: number}; + cover_size: { w: number; h: number }; desc: string; 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; author: User; commentsCount: number; @@ -52,5 +63,5 @@ export type Neighbors = { next: { aweme_id: string } | null; }; -export type LoopMode = "loop" | "sequential"; +export type LoopMode = "loop" | "single" | "sequential"; export type ObjectFit = "contain" | "cover"; diff --git a/app/aweme/[awemeId]/utils.ts b/app/aweme/[awemeId]/utils.ts index c471fa8..23c0b83 100644 --- a/app/aweme/[awemeId]/utils.ts +++ b/app/aweme/[awemeId]/utils.ts @@ -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 regex = /\[([^\]]+)\]/g; let lastIndex = 0; @@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name: } // 从 localStorage 获取数值 -export function getNumberFromStorage(key: string, defaultValue: number): number { +export function getNumberFromStorage( + key: string, + defaultValue: number, +): number { if (typeof window === "undefined") return defaultValue; const saved = localStorage.getItem(key); if (!saved) return defaultValue; @@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number } // 从 localStorage 获取字符串 -export function getStringFromStorage(key: string, defaultValue: string): string { +export function getStringFromStorage( + key: string, + defaultValue: string, +): string { if (typeof window === "undefined") return defaultValue; return localStorage.getItem(key) || defaultValue; } diff --git a/app/components/BackButton.tsx b/app/components/BackButton.tsx index a1f3015..a767ce3 100644 --- a/app/components/BackButton.tsx +++ b/app/components/BackButton.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import React from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { ArrowLeft } from 'lucide-react'; +import React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; type BackButtonProps = { className?: string; @@ -18,28 +18,37 @@ type BackButtonProps = { * - Fallback: if close fails (e.g., not opened by script), navigates to '/' * - Uses 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 onClick = React.useCallback>((e) => { - // Respect modifier clicks (new tab/window) and non-left clicks - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const onClick = React.useCallback>( + (e) => { + // 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(); - - // Try to close the window first - if (typeof window !== 'undefined') { - window.close(); - - // If window.close() didn't work (window still open after a short delay), - // navigate to the fallback URL - setTimeout(() => { - if (!document.hidden) { - router.push(hrefFallback); - } - }, 80); - } - }, [router, hrefFallback]); + e.preventDefault(); + + // Try to close the window first + if (typeof window !== "undefined") { + window.close(); + + // If window.close() didn't work (window still open after a short delay), + // navigate to the fallback URL + setTimeout(() => { + if (!document.hidden) { + router.push(hrefFallback); + } + }, 80); + } + }, + [router, hrefFallback], + ); return ( (initialCursor); const [loading, setLoading] = useState(false); @@ -22,11 +26,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ // 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4 const getColumnCount = useCallback(() => { - if (typeof window === 'undefined') return 1; + if (typeof window === "undefined") return 1; const w = window.innerWidth; if (w >= 1280) return 4; // xl if (w >= 1024) return 3; // lg - if (w >= 640) return 2; // sm + if (w >= 640) return 2; // sm return 1; }, []); // 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列), @@ -37,8 +41,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ // 挂载后立即根据当前窗口宽度更新一次列数 setColumnCount(getColumnCount()); const onResize = () => setColumnCount(getColumnCount()); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); }, [getColumnCount]); // 估算卡片高度(用于分配到“最短列”) @@ -46,8 +50,11 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ // 媒体区域高度 let mediaH = 200; // fallback if (item.width && item.height) { - mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth); - } else if (item.type === 'video') { + mediaH = Math.max( + 80, + (Number(item.height) / Number(item.width)) * colWidth, + ); + } else if (item.type === "video") { mediaH = (9 / 16) * colWidth; // 常见视频比例 } // 文本 + 作者栏的高度粗估 @@ -62,12 +69,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []); return cols; }); - const [colHeights, setColHeights] = useState(() => Array.from({ length: columnCount }, () => 0)); + const [colHeights, setColHeights] = useState(() => + Array.from({ length: columnCount }, () => 0), + ); // 初始化与当列数变化时重排 useEffect(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; - const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; + const colWidth = + columnCount > 0 ? containerWidth / columnCount : containerWidth; // 用 initialItems 重排 const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []); const newHeights: number[] = Array.from({ length: columnCount }, () => 0); @@ -90,16 +100,19 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ setLoading(true); try { const params = new URLSearchParams(); - if (cursor) params.set('before', cursor); - params.set('limit', '24'); - const url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`; - const res = await fetch(url, { cache: 'no-store' }); + if (cursor) params.set("before", cursor); + params.set("limit", "24"); + const url = fetchUrl.includes("?") + ? `${fetchUrl}&${params.toString()}` + : `${fetchUrl}?${params.toString()}`; + const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: FeedResponse = await res.json(); // 将新数据按最短列分配 setColumns((prevCols) => { 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 heights = [...colHeights]; for (const item of data.items) { @@ -117,7 +130,7 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ setCursor(data.nextCursor); if (!data.nextCursor || data.items.length === 0) setEnded(true); } catch (e) { - console.error('fetch more feed failed', e); + console.error("fetch more feed failed", e); // 失败也不要死循环 setEnded(true); } finally { @@ -128,88 +141,129 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/ useEffect(() => { const el = sentinelRef.current; if (!el) return; - const io = new IntersectionObserver((entries) => { - const entry = entries[0]; - if (entry.isIntersecting) { - fetchMore(); - } - }, { rootMargin: '800px 0px 800px 0px' }); + const io = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting) { + fetchMore(); + } + }, + { rootMargin: "800px 0px 800px 0px" }, + ); io.observe(el); return () => io.disconnect(); }, [fetchMore]); - const renderCard = useCallback((item: FeedItem) => ( -
    - -
    ( +
    + - {item.type === 'video' ? ( - - ) : ( - {item.desc?.slice(0, - )} -
    -
    -

    - {item.desc} -

    - - {item.type === 'video' ? '视频' : '图文'} - +
    + {item.type === "video" ? ( + + ) : ( + {item.desc?.slice(0, + )} +
    +
    +

    + {item.desc} +

    + + {item.type === "video" ? "视频" : "图文"} + +
    -
    - + -
    - {item.author.sec_uid ? ( - -
    - {item.author.avatar_url ? ( - avatar - ) : null} +
    + {item.author.sec_uid ? ( + +
    + {item.author.avatar_url ? ( + avatar + ) : null} +
    + + {item.author.nickname} + + + ) : ( +
    +
    + {item.author.avatar_url ? ( + avatar + ) : null} +
    + + {item.author.nickname} +
    - {item.author.nickname} - - ) : ( -
    -
    - {item.author.avatar_url ? ( - avatar - ) : null} -
    - {item.author.nickname} -
    - )} - - {item.likes} - -
    -
    - ), []); + )} + + {item.likes}{" "} + + +
    +
    + ), + [], + ); return ( <> {/* Masonry(按列渲染,动态分配到最短列) */} -
    +
    {columns.map((col, idx) => (
    {col.map((item) => renderCard(item))}
    ))}
    -
    - {ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')} +
    + {ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
    ); diff --git a/app/components/HoverVideo.tsx b/app/components/HoverVideo.tsx index 88dce38..991a347 100644 --- a/app/components/HoverVideo.tsx +++ b/app/components/HoverVideo.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from "react"; type HoverVideoProps = { 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 videoRef = useRef(null); @@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov }, []); return ( -
    +
    {/* 封面始终渲染在底层 */} cover {/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */} diff --git a/app/globals.css b/app/globals.css index 965dac9..f51cba7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,6 +3,11 @@ :root { --background: #161823; /* theme background */ --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 { @@ -23,11 +28,13 @@ body { margin: 0; background: var(--background); 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 { @@ -37,12 +44,15 @@ mark { border-radius: 0.25rem; 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; } -.min-h-screen{ +.min-h-screen { min-height: 100dvh; -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 04de63b..57f518b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,17 +1,6 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; 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 = { title: { default: "抖歪 - 记录当下时代", @@ -30,11 +19,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); } diff --git a/app/page.tsx b/app/page.tsx index 47f3d42..462f32b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -33,11 +33,15 @@ export default async function Home() { created_at: v.created_at, desc: v.desc, video_url: getFileUrl(v.video_url), - cover_url: getFileUrl(v.cover_url ?? ''), + cover_url: getFileUrl(v.cover_url ?? ""), width: v.width ?? null, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid }, - likes: Number(v.digg_count) + author: { + 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) => ({ type: "image" as const, @@ -47,8 +51,12 @@ export default async function Home() { cover_url: getFileUrl(p.images?.[0]?.url ?? null), width: p.images?.[0]?.width ?? 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 }, - likes: Number(p.digg_count) + author: { + 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) @@ -65,7 +73,7 @@ export default async function Home() {

    Douyin Archive

    - +
    @@ -73,8 +81,15 @@ export default async function Home() {
    {(() => { const initial = feed.slice(0, 24); - const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; - return ; + const cursor = + initial.length > 0 + ? new Date( + initial[initial.length - 1].created_at as any, + ).toISOString() + : null; + return ( + + ); })()}
    diff --git a/app/search/SearchClient.tsx b/app/search/SearchClient.tsx index 4fbb1cf..1defd57 100644 --- a/app/search/SearchClient.tsx +++ b/app/search/SearchClient.tsx @@ -9,7 +9,7 @@ import { Search, ArrowLeft, X, MessageSquare } from "lucide-react"; type SearchResultItem = { id: string; awemeId: string; - type: 'video' | 'image'; + type: "video" | "image"; rank: number; snippet: string; // 后端返回的高亮片段,已包含标签 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 router = useRouter(); @@ -53,7 +57,9 @@ export default function SearchClient({ initialQuery }: { initialQuery: string }) setLoading(true); setSearched(true); 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"); const data = await res.json(); setResults(data.results || []); @@ -101,7 +107,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string }) -
    +

    输入关键词开始搜索

    -

    支持搜索视频描述、图文描述及语音转写内容

    +

    + 支持搜索视频描述、图文描述及语音转写内容 +

    )} @@ -168,14 +179,19 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })

    - 找到 {results.length} 个结果 + 找到{" "} + + {results.length} + {" "} + 个结果

    {/* 单列列表布局 */}
    {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; return ( @@ -202,12 +218,14 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
    {/* 类型标签 */}
    - - {item.type === 'video' ? '视频' : '图文'} + + {item.type === "video" ? "视频" : "图文"} @@ -216,7 +234,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
    {/* 描述 */} - +

    {content.desc || "无描述"}

    @@ -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" dangerouslySetInnerHTML={{ __html: item.snippet }} style={{ - wordBreak: 'break-word', + wordBreak: "break-word", }} />
    diff --git a/app/search/page.tsx b/app/search/page.tsx index 184116c..f4286a2 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -2,7 +2,11 @@ import { Suspense } from "react"; 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 : ""; return ( Loading…
    }> diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 19005d3..18d4119 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -1,7 +1,20 @@ "use client"; 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"; @@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => { const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号 const cleaned = matches .map((m) => m.replace(trailing, "")) - .map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾 + .map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾 // 去重 return Array.from(new Set(cleaned)); }; @@ -43,43 +56,58 @@ export default function TasksPage() { }, []); 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[]) => { - if (!urls.length) return; - const now = Date.now(); - setTasks((prev) => { - const existing = new Set(prev.map((t) => t.id)); - const notDuplicated = urls.filter(u => !inProgressUrls.has(u)); - const newTasks: Task[] = []; - for (const url of notDuplicated) { - const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; - newTasks.push({ id, url, status: "pending" }); - } - // 新任务添加到最前面 - return [...newTasks, ...prev]; - }); - }, [inProgressUrls]); + const addTasks = useCallback( + (urls: string[]) => { + if (!urls.length) return; + const now = Date.now(); + setTasks((prev) => { + const existing = new Set(prev.map((t) => t.id)); + const notDuplicated = urls.filter((u) => !inProgressUrls.has(u)); + const newTasks: Task[] = []; + for (const url of notDuplicated) { + const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; + newTasks.push({ id, url, status: "pending" }); + } + // 新任务添加到最前面 + return [...newTasks, ...prev]; + }); + }, + [inProgressUrls], + ); - const handleSubmit = useCallback((e?: React.FormEvent) => { - e?.preventDefault(); - const urls = extractDouyinLinks(input); - if (!urls.length) { - alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本"); - return; - } - addTasks(urls); - setInput(""); - }, [input, addTasks]); + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + const urls = extractDouyinLinks(input); + if (!urls.length) { + alert( + "未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本", + ); + return; + } + addTasks(urls); + setInput(""); + }, + [input, addTasks], + ); const handlePasteAndAdd = useCallback(async () => { try { const text = await navigator.clipboard.readText(); const urls = extractDouyinLinks(text); if (!urls.length) { - alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本"); + alert( + "剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本", + ); return; } addTasks(urls); @@ -94,22 +122,44 @@ export default function TasksPage() { if (controllers.current.has(task.id)) return; const ctrl = new AbortController(); 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 { - 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); - + if (!res.ok) { // 使用后端返回的结构化错误信息 const errorMsg = data?.error || `请求失败: ${res.status}`; - const errorCode = data?.code || 'UNKNOWN'; + const errorCode = data?.code || "UNKNOWN"; 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) { - const msg = 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)); + const msg = + 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 { controllers.current.delete(task.id); } @@ -117,17 +167,17 @@ export default function TasksPage() { // 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发) useEffect(() => { - const pending = tasks.filter(t => t.status === "pending"); + const pending = tasks.filter((t) => t.status === "pending"); pending.forEach((t) => startTask(t)); }, [tasks, startTask]); // 定时器更新运行中任务的耗时显示 useEffect(() => { - const hasRunningTasks = tasks.some(t => t.status === "running"); + const hasRunningTasks = tasks.some((t) => t.status === "running"); if (!hasRunningTasks) return; const timer = setInterval(() => { - setTick(prev => prev + 1); + setTick((prev) => prev + 1); }, 1000); // 每秒更新一次 return () => clearInterval(timer); @@ -140,27 +190,40 @@ export default function TasksPage() { }, []); const retryTask = useCallback((taskId: string) => { - setTasks(prev => prev.map(t => { - if (t.id === taskId) { - return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined }; - } - return t; - })); + setTasks((prev) => + prev.map((t) => { + if (t.id === taskId) { + return { + ...t, + status: "pending" as TaskStatus, + error: undefined, + result: undefined, + }; + } + return t; + }), + ); }, []); 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) => { - setOpenDetails(prev => { + setOpenDetails((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; - }) + }); }, []); - const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]); + const extractedCount = useMemo( + () => extractDouyinLinks(input).length, + [input], + ); const formatDuration = (startTime?: number, endTime?: number) => { if (!startTime) return ""; @@ -173,11 +236,31 @@ export default function TasksPage() { }; const StatusBadge = ({ status }: { status: TaskStatus }) => { - const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; - if (status === 'running') return 进行中; - if (status === 'pending') return 待开始; - if (status === 'success') return 完成; - return 失败; + const base = + "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; + if (status === "running") + return ( + + 进行中 + + ); + if (status === "pending") + return ( + + 待开始 + + ); + if (status === "success") + return ( + + 完成 + + ); + return ( + + 失败 + + ); }; return ( @@ -192,11 +275,16 @@ export default function TasksPage() {

    抖音采集任务

    -

    粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。

    +

    + 粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。 +

    {/* 输入卡片 */} - +
    @@ -210,18 +298,31 @@ export default function TasksPage() { />
    已提取 {extractedCount} 个短链 - 快捷键:Ctrl / Cmd + Enter 提交 + + 快捷键:Ctrl / Cmd + Enter 提交 +
    - - -
    @@ -231,64 +332,91 @@ export default function TasksPage() {

    进行中的任务

    - 共 {tasks.length} 个 + + 共 {tasks.length} 个 +
      {tasks.map((t) => { const isOpen = openDetails.has(t.id); return ( -
    • +
    • - {t.status === 'running' && ( - 已耗时 {formatDuration(t.startedAt)} + {t.status === "running" && ( + + 已耗时 {formatDuration(t.startedAt)} + )} - {t.status === 'success' && ( - 用时 {formatDuration(t.startedAt, t.finishedAt)} + {t.status === "success" && ( + + 用时 {formatDuration(t.startedAt, t.finishedAt)} + )}
      - {t.status === 'success' && t.result?.data?.aweme_id && ( - - 查看作品 + 查看作品 )} - {t.status === 'error' && ( - )} - {t.status === 'running' && ( - )} -
      {/* 进度条 */} - {t.status === 'running' && ( + {t.status === "running" && (
      @@ -296,13 +424,17 @@ export default function TasksPage() { {isOpen && (
      - {t.status === 'error' && t.error && ( + {t.status === "error" && t.error && (
      - +
      -
      爬取失败
      -
      {t.error}
      +
      + 爬取失败 +
      +
      + {t.error} +
      @@ -315,34 +447,58 @@ export default function TasksPage() {
      )} - {typeof t.result !== 'undefined' && ( + {typeof t.result !== "undefined" && (
      {t.result?.data?.aweme && (
      - - 作品信息 + + + 作品信息 +
      -
      ID: {t.result.data.aweme.aweme_id}
      -
      描述: {t.result.data.aweme.desc || '(无)'}
      +
      + ID:{" "} + {t.result.data.aweme.aweme_id} +
      +
      + + 描述: + {" "} + {t.result.data.aweme.desc || "(无)"} +
      {t.result.data.aweme.author && ( -
      作者: {t.result.data.aweme.author.nickname}
      +
      + + 作者: + {" "} + {t.result.data.aweme.author.nickname} +
      )}
      )}
      - 查看完整响应数据 + 查看完整响应数据{" "} + + + ▼ + -
      {JSON.stringify(t.result, null, 2)}
      +
      +                              {JSON.stringify(t.result, null, 2)}
      +                            
      )} - {typeof t.result === 'undefined' && t.status !== 'error' && ( -
      暂无输出
      - )} + {typeof t.result === "undefined" && + t.status !== "error" && ( +
      + 暂无输出 +
      + )}
      )}
    • diff --git a/app/types/feed.ts b/app/types/feed.ts index 2505ed1..2483c31 100644 --- a/app/types/feed.ts +++ b/app/types/feed.ts @@ -1,19 +1,21 @@ -export type FeedItem = - | ({ +export type FeedItem = ( + | { type: "video"; video_url: string; - } | { + } + | { type: "image"; - }) & { - likes: number; - author: { nickname: string; avatar_url: string | null; sec_uid?: string }; - aweme_id: string; - created_at: Date | string; - desc: string; - cover_url: string | null; - width?: number | null; - height?: number | null; - }; + } +) & { + likes: number; + author: { nickname: string; avatar_url: string | null; sec_uid?: string }; + aweme_id: string; + created_at: Date | string; + desc: string; + cover_url: string | null; + width?: number | null; + height?: number | null; +}; export interface FeedResponse { items: FeedItem[]; diff --git a/bun.lock b/bun.lock index 70d6fff..a5b45e0 100644 --- a/bun.lock +++ b/bun.lock @@ -8,13 +8,14 @@ "chalk": "^5.6.2", "lucide-react": "^0.546.0", "minio": "^8.0.6", - "next": "15.5.6", + "next": "15.5.7", "openai": "^6.7.0", "playwright": "1.56.1", "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "19.1.0", "react-dom": "19.1.0", + "undici": "^7.16.0", "zod": "^4.1.12", }, "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=="], - "@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=="], @@ -503,7 +504,7 @@ "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=="], @@ -631,6 +632,8 @@ "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=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], diff --git a/fix-asset-urls.ts b/fix-asset-urls.ts index 934163d..8ce9406 100644 --- a/fix-asset-urls.ts +++ b/fix-asset-urls.ts @@ -1,12 +1,12 @@ // scripts/fix-asset-urls.ts -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); -const FROM = 'douyin-archive/'; -const TO = ''; +const FROM = "douyin-archive/"; +const TO = ""; function escapeForPgRegex(s: string) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串 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 ) `); - console.log('Sample after update:', sample); + console.log("Sample after update:", sample); } -main().catch((e) => { - console.error(e); - process.exit(1); -}).finally(async () => { - await prisma.$disconnect(); -}); +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/global.d.ts b/global.d.ts index 495cce9..8ba3728 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,9 +1,9 @@ -declare module '*.md' { +declare module "*.md" { const content: string; export default content; } -declare module '*.txt' { +declare module "*.txt" { const content: string; export default content; } diff --git a/lib/json.ts b/lib/json.ts index 4d00a00..4c3dfe1 100644 --- a/lib/json.ts +++ b/lib/json.ts @@ -1,10 +1,13 @@ // lib/json.ts export function json(data: unknown, init?: ResponseInit) { 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, - headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) }, - } + headers: { + "content-type": "application/json; charset=utf-8", + ...(init?.headers || {}), + }, + }, ); } diff --git a/lib/minio-examples.ts b/lib/minio-examples.ts index 8c8ce3d..0a24c3b 100644 --- a/lib/minio-examples.ts +++ b/lib/minio-examples.ts @@ -14,7 +14,7 @@ import { downloadFile, fileExists, getFileInfo, -} from './minio'; +} from "./minio"; // ======================================== // 1. 上传文件示例 @@ -25,15 +25,15 @@ import { */ 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)) { - throw new Error('不支持的图片格式'); + throw new Error("不支持的图片格式"); } // 验证文件大小(5MB) const maxSize = 5 * 1024 * 1024; if (!validateFileSize(file.size, maxSize)) { - throw new Error('文件大小超过限制(最大5MB)'); + throw new Error("文件大小超过限制(最大5MB)"); } // 生成唯一文件名,存储在 avatars 目录下 @@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) { // 上传文件 const url = await uploadFile(file, path, { - 'Content-Type': file.type, - 'User-Id': userId, + "Content-Type": file.type, + "User-Id": userId, }); return { url, path }; @@ -52,28 +52,28 @@ async function uploadAvatar(file: File, userId: 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)) { - throw new Error('不支持的图片格式'); + throw new Error("不支持的图片格式"); } const maxSize = 10 * 1024 * 1024; // 10MB if (!validateFileSize(file.size, maxSize)) { - throw new Error('文件大小超过限制(最大10MB)'); + throw new Error("文件大小超过限制(最大10MB)"); } // 按日期组织文件 const date = new Date(); 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( file.name, - `posts/${year}/${month}/covers` + `posts/${year}/${month}/covers`, ); const url = await uploadFile(file, path); - + return { url, path }; } @@ -81,27 +81,27 @@ async function uploadPostCover(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)) { - throw new Error('不支持的图片格式'); + throw new Error("不支持的图片格式"); } const maxSize = 5 * 1024 * 1024; // 5MB if (!validateFileSize(file.size, maxSize)) { - throw new Error('文件大小超过限制(最大5MB)'); + throw new Error("文件大小超过限制(最大5MB)"); } const date = new Date(); 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( file.name, - `posts/${year}/${month}/images` + `posts/${year}/${month}/images`, ); const url = await uploadFile(file, path); - + return { url, path }; } @@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) { async function deleteAvatar(avatarPath: string) { try { await deleteFile(avatarPath); - console.log('头像删除成功'); + console.log("头像删除成功"); } catch (error) { - console.error('删除头像失败:', error); + console.error("删除头像失败:", error); throw error; } } @@ -140,22 +140,22 @@ async function deletePostImages(postId: string) { try { // 列出文章相关的所有图片 const files = await listFiles(`posts/`, true); - + // 过滤出该文章的图片(根据实际情况调整逻辑) - const postFiles = files.filter(file => - file.name?.includes(postId) - ); + const postFiles = files.filter((file) => 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) { - const { deleteFiles } = await import('./minio'); + const { deleteFiles } = await import("./minio"); await deleteFiles(paths); console.log(`删除了 ${paths.length} 个文件`); } } catch (error) { - console.error('删除文章图片失败:', error); + console.error("删除文章图片失败:", error); throw error; } } @@ -170,15 +170,15 @@ async function deletePostImages(postId: string) { async function getUserAvatars(userId: string) { try { const files = await listFiles(`avatars/${userId}/`, false); - - return files.map(file => ({ + + return files.map((file) => ({ name: file.name, size: file.size, lastModified: file.lastModified, url: file.name ? getFileUrl(file.name) : null, })); } catch (error) { - console.error('获取用户头像列表失败:', error); + console.error("获取用户头像列表失败:", error); throw error; } } @@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) { */ async function getPostCoversByMonth(year: number, month: number) { try { - const monthStr = String(month).padStart(2, '0'); + const monthStr = String(month).padStart(2, "0"); const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false); - - return files.map(file => ({ + + return files.map((file) => ({ name: file.name, size: file.size, url: file.name ? getFileUrl(file.name) : null, })); } catch (error) { - console.error('获取封面列表失败:', error); + console.error("获取封面列表失败:", error); throw error; } } @@ -223,15 +223,15 @@ async function checkAvatarExists(avatarPath: string): Promise { async function getFileDetails(path: string) { try { const info = await getFileInfo(path); - + return { size: info.size, lastModified: info.lastModified, etag: info.etag, - contentType: info.metaData?.['content-type'], + contentType: info.metaData?.["content-type"], }; } catch (error) { - console.error('获取文件信息失败:', error); + console.error("获取文件信息失败:", error); throw error; } } @@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise { try { return await downloadFile(path); } catch (error) { - console.error('下载文件失败:', error); + console.error("下载文件失败:", error); throw error; } } @@ -258,42 +258,42 @@ async function downloadFileToBuffer(path: string): Promise { /** * Next.js API Route 示例:上传文件 - * + * * 使用方法: - * + * * // app/api/upload/route.ts * import { uploadFile, generateUniqueFileName } from '@/lib/minio'; - * + * * export async function POST(request: Request) { * const formData = await request.formData(); * const file = formData.get('file') as File; - * + * * if (!file) { * return Response.json({ error: '没有文件' }, { status: 400 }); * } - * + * * const path = generateUniqueFileName(file.name, 'uploads'); * const url = await uploadFile(file, path); - * + * * return Response.json({ url, path }); * } */ /** * Next.js API Route 示例:删除文件 - * + * * // app/api/delete/route.ts * import { deleteFile } from '@/lib/minio'; - * + * * export async function DELETE(request: Request) { * const { path } = await request.json(); - * + * * if (!path) { * return Response.json({ error: '缺少文件路径' }, { status: 400 }); * } - * + * * await deleteFile(path); - * + * * return Response.json({ success: true }); * } */ diff --git a/lib/minio.ts b/lib/minio.ts index da0d88a..6e6cb41 100644 --- a/lib/minio.ts +++ b/lib/minio.ts @@ -1,22 +1,22 @@ -import * as Minio from 'minio'; +import * as Minio from "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; // 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口 const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80); const minioClient = new Minio.Client({ - endPoint: process.env.MINIO_ENDPOINT || 'localhost', + endPoint: process.env.MINIO_ENDPOINT || "localhost", ...(shouldOmitPort ? {} : { port }), useSSL, - accessKey: process.env.MINIO_ACCESS_KEY || '', - secretKey: process.env.MINIO_SECRET_KEY || '', + accessKey: process.env.MINIO_ACCESS_KEY || "", + secretKey: process.env.MINIO_SECRET_KEY || "", 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 存在) @@ -25,41 +25,39 @@ export async function initBucket(): Promise { try { const exists = await minioClient.bucketExists(BUCKET_NAME); 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`); } // 设置公共读取策略(可选) const policy = { - Version: '2012-10-17', + Version: "2012-10-17", Statement: [ { - Effect: 'Allow', - Principal: { AWS: ['*'] }, - Action: ['s3:GetObject'], + Effect: "Allow", + Principal: { AWS: ["*"] }, + Action: ["s3:GetObject"], Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], }, ], }; await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy)); } catch (error) { - console.error('Error initializing bucket:', error); + console.error("Error initializing bucket:", error); throw error; } } - - /** * 上传文件到 MinIO * @param file - File 对象或 Buffer * @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png') * @param metadata - 可选的元数据 - * @returns 文件 path = args.path + * @returns 文件 path = args.path */ export async function uploadFile( file: File | Buffer, path: string, - metadata?: Record + metadata?: Record, ): Promise { try { await initBucket(); @@ -69,27 +67,32 @@ export async function uploadFile( if (file instanceof File) { buffer = Buffer.from(await file.arrayBuffer()); - contentType = file.type || 'application/octet-stream'; + contentType = file.type || "application/octet-stream"; } else { buffer = file; - contentType = metadata?.['Content-Type'] || 'application/octet-stream'; + contentType = metadata?.["Content-Type"] || "application/octet-stream"; } const metaData = { - 'Content-Type': contentType, + "Content-Type": contentType, ...metadata, }; - await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData); + await minioClient.putObject( + BUCKET_NAME, + path, + buffer, + buffer.length, + metaData, + ); return path; } catch (error) { - console.error('Error uploading file:', error); + console.error("Error uploading file:", error); throw error; } } - /** * 获取文件的公共访问URL * @param path - 文件路径 @@ -112,12 +115,12 @@ export async function downloadFile(path: string): Promise { const stream = await minioClient.getObject(BUCKET_NAME, path); return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(chunk)); - stream.on('end', () => resolve(Buffer.concat(chunks))); - stream.on('error', reject); + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); }); } catch (error) { - console.error('Error downloading file:', error); + console.error("Error downloading file:", error); throw error; } } @@ -127,11 +130,13 @@ export async function downloadFile(path: string): Promise { * @param path - 文件路径 * @returns 文件流 */ -export async function getFileStream(path: string): Promise { +export async function getFileStream( + path: string, +): Promise { try { return await minioClient.getObject(BUCKET_NAME, path); } catch (error) { - console.error('Error getting file stream:', error); + console.error("Error getting file stream:", error); throw error; } } @@ -144,7 +149,7 @@ export async function deleteFile(path: string): Promise { try { await minioClient.removeObject(BUCKET_NAME, path); } catch (error) { - console.error('Error deleting file:', error); + console.error("Error deleting file:", error); throw error; } } @@ -157,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise { try { await minioClient.removeObjects(BUCKET_NAME, paths); } catch (error) { - console.error('Error deleting files:', error); + console.error("Error deleting files:", error); throw error; } } @@ -185,7 +190,7 @@ export async function getFileInfo(path: string): Promise { try { return await minioClient.statObject(BUCKET_NAME, path); } catch (error) { - console.error('Error getting file info:', error); + console.error("Error getting file info:", error); throw error; } } @@ -197,24 +202,27 @@ export async function getFileInfo(path: string): Promise { * @returns 文件列表 */ export async function listFiles( - prefix: string = '', - recursive: boolean = false + prefix: string = "", + recursive: boolean = false, ): Promise<(Minio.BucketItem & { endpoint: string })[]> { try { const files: (Minio.BucketItem & { endpoint: string })[] = []; const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive); return new Promise((resolve, reject) => { - stream.on('data', (obj) => { + stream.on("data", (obj) => { 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('error', reject); + stream.on("end", () => resolve(files)); + stream.on("error", reject); }); } catch (error) { - console.error('Error listing files:', error); + console.error("Error listing files:", error); throw error; } } @@ -224,17 +232,20 @@ export async function listFiles( * @param sourcePath - 源文件路径 * @param destPath - 目标文件路径 */ -export async function copyFile(sourcePath: string, destPath: string): Promise { +export async function copyFile( + sourcePath: string, + destPath: string, +): Promise { try { const conds = new Minio.CopyConditions(); await minioClient.copyObject( BUCKET_NAME, destPath, `/${BUCKET_NAME}/${sourcePath}`, - conds + conds, ); } catch (error) { - console.error('Error copying file:', error); + console.error("Error copying file:", error); throw error; } } @@ -245,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise 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 格式化后的文件大小 */ export function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; + if (bytes === 0) return "0 B"; 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)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; diff --git a/lib/prisma.ts b/lib/prisma.ts index af2a01e..ef41cd7 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -1,9 +1,9 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from "@prisma/client"; 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; diff --git a/next.config.ts b/next.config.ts index 03e32f7..40d8aa7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,27 +2,29 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { serverExternalPackages: [ - 'playwright-extra', - 'puppeteer-extra-plugin-stealth', - 'puppeteer-extra-plugin', - ], webpack: (config) => { + "playwright-extra", + "puppeteer-extra-plugin-stealth", + "puppeteer-extra-plugin", + ], + webpack: (config) => { config.module.rules.push({ test: /\.(md|txt)$/i, - type: 'asset/source', // 让这些文件作为纯文本注入 + type: "asset/source", // 让这些文件作为纯文本注入 }); return config; - }, turbopack: { + }, + turbopack: { rules: { - '*.md': { loaders: ['raw-loader'], as: '*.js' }, - '*.txt': { loaders: ['raw-loader'], as: '*.js' }, + "*.md": { loaders: ["raw-loader"], as: "*.js" }, + "*.txt": { loaders: ["raw-loader"], as: "*.js" }, }, }, /* config options here */ images: { remotePatterns: [ { - protocol: 'https', - hostname: 's3l.xn--876a.net', + protocol: "https", + hostname: "s3l.xn--876a.net", }, ], }, diff --git a/package.json b/package.json index c0d79e1..bab48d4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "chalk": "^5.6.2", "lucide-react": "^0.546.0", "minio": "^8.0.6", - "next": "15.5.6", + "next": "15.5.7", "openai": "^6.7.0", "playwright": "1.56.1", "playwright-extra": "^4.3.6", diff --git a/pm2.config.cjs b/pm2.config.cjs index 788a76f..a60e179 100644 --- a/pm2.config.cjs +++ b/pm2.config.cjs @@ -1,37 +1,39 @@ -const path = require('path'); -const dotenv = require('dotenv'); +const path = require("path"); +const dotenv = require("dotenv"); -const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1; -const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') }); +const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? "1", 10) || 1; +const { parsed: envFromFile = {} } = dotenv.config({ + path: path.join(__dirname, ".env"), +}); module.exports = { - apps: [ - { - name: "DouyinArchive", - script: 'npm', - args: 'run start', - cwd: __dirname, - autorestart: true, - restart_delay: 4000, - kill_timeout: 5000, - instances, - exec_mode: instances > 1 ? 'cluster' : 'fork', - // 注意:不要在生产环境 watch,否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭 - watch: false, - ignore_watch: ['.next', '.turbo', 'generated', 'node_modules', '.git'], - env: { - // 明确开发环境可选项(如需) - NODE_ENV: process.env.NODE_ENV || 'development', - ...envFromFile, - }, - env_production: { - // 关键:确保应用进程中的 NODE_ENV=production,从而禁用 Next.js 的开发特性 - NODE_ENV: 'production', - // 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务 - WEB_CONCURRENCY: '1', - ...envFromFile, - }, - time: true - } - ] + apps: [ + { + name: "DouyinArchive", + script: "npm", + args: "run start", + cwd: __dirname, + autorestart: true, + restart_delay: 4000, + kill_timeout: 5000, + instances, + exec_mode: instances > 1 ? "cluster" : "fork", + // 注意:不要在生产环境 watch,否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭 + watch: false, + ignore_watch: [".next", ".turbo", "generated", "node_modules", ".git"], + env: { + // 明确开发环境可选项(如需) + NODE_ENV: process.env.NODE_ENV || "development", + ...envFromFile, + }, + env_production: { + // 关键:确保应用进程中的 NODE_ENV=production,从而禁用 Next.js 的开发特性 + NODE_ENV: "production", + // 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务 + WEB_CONCURRENCY: "1", + ...envFromFile, + }, + time: true, + }, + ], }; diff --git a/test.ts b/test.ts index a502589..3d0c93f 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,4 @@ import { createWriteStream, writeFileSync } from "node:fs"; import { initBucket } from "./lib/minio"; -initBucket() \ No newline at end of file +initBucket(); diff --git a/tsconfig.json b/tsconfig.json index 062d8d9..612f77e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }