diff --git a/app/api/stt/index.ts b/app/api/stt/index.ts new file mode 100644 index 0000000..b4106bb --- /dev/null +++ b/app/api/stt/index.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import OpenAI from "openai"; +import prompt from "./prompt.md"; +import { prisma } from "@/lib/prisma"; + +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_API_BASE_URL, +}); + +async function transcriptAudio(audio: Buffer | string) { + if (typeof audio === "string") { + audio = fs.readFileSync(audio); + } + const base64Audio = Buffer.from(audio).toString("base64"); + const response = await client.chat.completions.create({ + model: "gemini-2.5-flash-lite", + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: prompt, + }, + { + type: "input_audio", + input_audio: { + data: base64Audio, + format: "wav", + }, + }, + ], + }, + ], + }); + + console.log(response.choices[0].message.content); +} + +async function transcriptAweme(awemeId: string) { + const aweme = await prisma.video.findUnique({ + where: { aweme_id: awemeId }, + }); + if (!aweme) { + throw new Error("Aweme not found or aweme is not a video post"); + } + +} diff --git a/app/api/stt/prompt.md b/app/api/stt/prompt.md new file mode 100644 index 0000000..f23aa09 --- /dev/null +++ b/app/api/stt/prompt.md @@ -0,0 +1,51 @@ +你将接收一段音频。请完成: +A.语音活动检测(VAD)与声源分类; +B.条件式处理: +- 若包含可辨识的人类发言:** 进行转录 **(保留原语言,不翻译),并尽可能给出说话人分离与时间戳; +- 若不包含人类发言:** 不转录 **,仅返回音频类型与简要描述。 +C.严格输出为下方 JSON,字段不得缺失或额外编造。听不清处用“[听不清]”。 + +** 输出 JSON Schema(示例)** + +```json +{ + "speech_detected": true, + "language": "zh-CN", + "audio_type": null, + "background": "music | ambience | none | unknown", + "transcript": [ + { + "start": 0.00, + "end": 3.42, + "text": "大家好,我是……" + }, + { + "start": 3.50, + "end": 6.10, + "text": "欢迎来到今天的节目。" + } + ], + "non_speech_summary": null, +} +``` + > +** 当无发言时返回:** + +```json +{ + "speech_detected": false, + "language": null, + "audio_type": "music | ambience | animal | mechanical | other", + "background": "none", + "transcript": [], + "non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。", +} +``` + +** 规则补充 ** + +* 只要存在可理解的人类发言(即便有音乐 / 噪声),就执行转录,并在 `background` 标注“music / ambience”。 +* 不要将唱词 / 哼唱视为“发言”;若仅有人声演唱且无口语发言,视为 ** 音乐 **。 +* 不要臆测未听清内容;不要添加与音频无关的信息。 +* 时间单位统一为秒,保留两位小数。 +* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。 diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 7dca2fc..3f0f53d 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useRef } from "react"; import { Pause, Play } from "lucide-react"; -import type { AwemeData, ImageData, Neighbors, VideoData } from "./types"; +import type { AwemeData, ImageData, Neighbors, VideoData } from "./types.ts"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ImageCarousel } from "./components/ImageCarousel"; @@ -41,7 +41,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient const backgroundCanvasRef = useRef(null); // 图文轮播状态 - const images = isVideo ? [] : (data as ImageData).images; + const images = isVideo ? [] : (data as ImageData).images; const imageCarouselState = useImageCarousel({ images, isPlaying: playerState.isPlaying, @@ -80,17 +80,17 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient return; } if (!images?.length) return; - + // 计算每张图片的时长 const durations = images.map(img => img.duration ?? SEGMENT_MS); const totalDuration = durations.reduce((sum, d) => sum + d, 0); const targetTime = ratio * totalDuration; - + // 找到目标时间对应的图片索引和进度 let accumulatedTime = 0; let targetIdx = 0; let remainder = 0; - + for (let i = 0; i < images.length; i++) { if (accumulatedTime + durations[i] > targetTime) { targetIdx = i; @@ -107,7 +107,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient 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++) { @@ -115,7 +115,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient } totalProgress += remainder; playerState.setProgress(totalProgress / images.length); - + // 虚拟滚动不需要实际滚动 DOM }; @@ -123,7 +123,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient 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; } @@ -131,8 +131,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient if (!playerState.isPlaying) { playerState.setIsPlaying(true); try { - await el?.play().catch(() => {}); - } catch {} + await el?.play().catch(() => { }); + } catch { } } else { playerState.setIsPlaying(false); el?.pause(); @@ -142,12 +142,12 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient 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 @@ -156,7 +156,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient vRef.webkitEnterFullscreen(); } } else { - document.exitFullscreen().catch(() => {}); + document.exitFullscreen().catch(() => { }); } }; diff --git a/app/aweme/[awemeId]/components/CommentList.tsx b/app/aweme/[awemeId]/components/CommentList.tsx index 14630bf..04773b6 100644 --- a/app/aweme/[awemeId]/components/CommentList.tsx +++ b/app/aweme/[awemeId]/components/CommentList.tsx @@ -1,6 +1,6 @@ import { ThumbsUp, X } from "lucide-react"; import { useState } from "react"; -import type { Comment, User } from "../types"; +import type { Comment, User } from "../types.ts"; import { formatRelativeTime, formatAbsoluteUTC } from "../utils"; import { CommentText } from "./CommentText"; diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx index e312eec..ae56542 100644 --- a/app/aweme/[awemeId]/components/CommentPanel.tsx +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -1,6 +1,6 @@ import { X } from "lucide-react"; import { useState, useEffect, useRef, useCallback } from "react"; -import type { Comment, User } from "../types"; +import type { Comment, User } from "../types.ts"; import { CommentList } from "./CommentList"; interface CommentPanelProps { diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index ef1689a..bd5c215 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -1,5 +1,5 @@ import { forwardRef, useEffect, useRef, useState } from "react"; -import type { ImageData } from "../types"; +import type { ImageData } from "../types.ts"; interface ImageCarouselProps { images: ImageData["images"]; diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 9f5b837..2998565 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -14,7 +14,7 @@ import { VolumeX, } from "lucide-react"; import type { RefObject } from "react"; -import type { LoopMode, ObjectFit, User } from "../types"; +import type { LoopMode, ObjectFit, User } from "../types.ts"; import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { ProgressBar } from "./ProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar"; diff --git a/app/aweme/[awemeId]/components/NavigationButtons.tsx b/app/aweme/[awemeId]/components/NavigationButtons.tsx index 5120aad..c767d30 100644 --- a/app/aweme/[awemeId]/components/NavigationButtons.tsx +++ b/app/aweme/[awemeId]/components/NavigationButtons.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react"; -import type { Neighbors } from "../types"; +import type { Neighbors } from "../types.ts"; interface NavigationButtonsProps { neighbors: Neighbors; diff --git a/app/aweme/[awemeId]/components/VideoPlayer.tsx b/app/aweme/[awemeId]/components/VideoPlayer.tsx index 9e8bf3b..8afb009 100644 --- a/app/aweme/[awemeId]/components/VideoPlayer.tsx +++ b/app/aweme/[awemeId]/components/VideoPlayer.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import type { ObjectFit } from "../types"; +import type { ObjectFit } from "../types.ts"; interface VideoPlayerProps { videoUrl: string; diff --git a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts index e442e7e..77b9e24 100644 --- a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts +++ b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts @@ -89,7 +89,7 @@ export function useBackgroundCanvas({ ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); }; - const intervalId = setInterval(drawMediaToCanvas, 20); + const intervalId = setInterval(drawMediaToCanvas, 34); return () => { clearInterval(intervalId); diff --git a/app/aweme/[awemeId]/hooks/useImageCarousel.ts b/app/aweme/[awemeId]/hooks/useImageCarousel.ts index ee06f72..fd4a178 100644 --- a/app/aweme/[awemeId]/hooks/useImageCarousel.ts +++ b/app/aweme/[awemeId]/hooks/useImageCarousel.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { RefObject } from "react"; import { useRouter } from "next/navigation"; -import type { ImageData, LoopMode, Neighbors } from "../types"; +import type { ImageData, LoopMode, Neighbors } from "../types.ts"; interface UseImageCarouselProps { images: ImageData["images"]; diff --git a/app/aweme/[awemeId]/hooks/useNavigation.ts b/app/aweme/[awemeId]/hooks/useNavigation.ts index 0397278..a36341c 100644 --- a/app/aweme/[awemeId]/hooks/useNavigation.ts +++ b/app/aweme/[awemeId]/hooks/useNavigation.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import type { RefObject } from "react"; import { useRouter } from "next/navigation"; -import type { Neighbors } from "../types"; +import type { Neighbors } from "../types.ts"; interface UseNavigationProps { neighbors: Neighbors; diff --git a/app/aweme/[awemeId]/hooks/usePlayerState.ts b/app/aweme/[awemeId]/hooks/usePlayerState.ts index 9441b7b..476bcd6 100644 --- a/app/aweme/[awemeId]/hooks/usePlayerState.ts +++ b/app/aweme/[awemeId]/hooks/usePlayerState.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { LoopMode, ObjectFit } from "../types"; +import type { LoopMode, ObjectFit } from "../types.ts"; import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils"; export function usePlayerState() { diff --git a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts index bbf53a7..96f1e15 100644 --- a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts +++ b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import type { RefObject } from "react"; import { useRouter } from "next/navigation"; -import type { LoopMode, Neighbors } from "../types"; +import type { LoopMode, Neighbors } from "../types.ts"; interface UseVideoPlayerProps { awemeId: string; diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index d6faba0..176cd56 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -2,14 +2,8 @@ import { prisma } from "@/lib/prisma"; import BackButton from "@/app/components/BackButton"; import AwemeDetailClient from "./Client"; import type { Metadata } from "next"; - -function ms(v?: number | null) { - if (!v) return ""; - const s = Math.round(v / 1000); - const m = Math.floor(s / 60); - const r = s % 60; - return `${m}:${r.toString().padStart(2, "0")}`; -} +import { getFileUrl } from "@/lib/minio"; +import { AwemeData } from "./types"; export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise { const id = (await params).awemeId; @@ -59,40 +53,45 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI if (!video && !post) return
找不到该作品
; const isVideo = !!video; - + // 获取评论总数 const commentsCount = await prisma.comment.count({ where: isVideo ? { videoId: id } : { imagePostId: id }, }); - const data = isVideo - ? { - type: "video" as const, - aweme_id: video!.aweme_id, - desc: video!.desc, - created_at: video!.created_at, - duration_ms: video!.duration_ms, - video_url: video!.video_url, - width: video!.width ?? null, - height: video!.height ?? null, - author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url }, - commentsCount, - likesCount: Number(video!.digg_count), + const aweme = isVideo ? video : post; + + const data: AwemeData = { + aweme_id: aweme!.aweme_id, + desc: aweme!.desc, + 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') }, + ...(() => { + if (isVideo) { + const aweme = video! + return { + type: "video" as const, + duration_ms: aweme!.duration_ms, + video_url: aweme!.video_url, + width: aweme!.width ?? null, + height: aweme!.height ?? null, + } + } else { + const aweme = post! + return { + type: "image" as const, + images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url) })), + music_url: aweme!.music_url, + }; } - : { - type: "image" as const, - aweme_id: post!.aweme_id, - desc: post!.desc, - created_at: post!.created_at, - images: post!.images, - music_url: post!.music_url, - author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url }, - commentsCount, - likesCount: Number(post!.digg_count), - }; + })() + } + // Compute prev/next neighbors by created_at across videos and image posts - const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date; + 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 } }), @@ -101,16 +100,16 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI ]); const pickPrev = (() => { const cands: { aweme_id: string; created_at: Date }[] = []; - if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date }); - if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as 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 (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 as unknown as Date }); - if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as 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 (cands.length === 0) return null; cands.sort((a, b) => +b.created_at - +a.created_at); return { aweme_id: cands[0].aweme_id }; @@ -126,7 +125,7 @@ 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 9e444c9..804874a 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -14,10 +14,10 @@ export type VideoData = { aweme_id: string; desc: string; created_at: string | Date; - duration_ms?: number | null; + duration_ms: number | null; video_url: string; - width?: number | null; - height?: number | null; + width: number | null; + height: number | null; author: User; commentsCount: number; likesCount: number; @@ -28,8 +28,8 @@ export type ImageData = { aweme_id: string; desc: string; created_at: string | Date; - images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[]; - music_url?: string | 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; likesCount: number; diff --git a/app/components/FeedMasonry.tsx b/app/components/FeedMasonry.tsx index 9df0bc9..1954bdb 100644 --- a/app/components/FeedMasonry.tsx +++ b/app/components/FeedMasonry.tsx @@ -28,9 +28,13 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) { if (w >= 640) return 2; // sm return 1; }, []); - const [columnCount, setColumnCount] = useState(getColumnCount()); + // 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列), + // 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。 + const [columnCount, setColumnCount] = useState(1); useEffect(() => { + // 挂载后立即根据当前窗口宽度更新一次列数 + setColumnCount(getColumnCount()); const onResize = () => setColumnCount(getColumnCount()); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); diff --git a/app/page.tsx b/app/page.tsx index deb2af9..762abb0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma"; import FeedMasonry from "./components/FeedMasonry"; import type { FeedItem } from "./types/feed"; import type { Metadata } from "next"; +import { getFileUrl } from "@/lib/minio"; export const metadata: Metadata = { title: "作品集 - 抖歪", @@ -28,11 +29,11 @@ export default async function Home() { aweme_id: v.aweme_id, created_at: v.created_at, desc: v.desc, - video_url: v.video_url, - cover_url: v.cover_url ?? null, + video_url: getFileUrl(v.video_url), + cover_url: getFileUrl(v.cover_url ?? ''), width: v.width ?? null, height: v.height ?? null, - author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null }, + author: { nickname: v.author.nickname, avatar_url: getFileUrl(v.author.avatar_url ?? '') }, likes: Number(v.digg_count) })), ...posts.map((p) => ({ @@ -40,10 +41,10 @@ export default async function Home() { aweme_id: p.aweme_id, created_at: p.created_at, desc: p.desc, - cover_url: p.images?.[0]?.url ?? null, + 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: p.author.avatar_url ?? null }, + author: { nickname: p.author.nickname, avatar_url: getFileUrl(p.author.avatar_url ?? '') }, likes: Number(p.digg_count) })), ] diff --git a/bun.lock b/bun.lock index 9c73b68..2897ef1 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "lucide-react": "^0.546.0", "minio": "^8.0.6", "next": "15.5.6", + "openai": "^6.7.0", "playwright": "1.56.1", "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", @@ -402,6 +403,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@6.7.0", "https://registry.npmmirror.com/openai/-/openai-6.7.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/fix-asset-urls.ts b/fix-asset-urls.ts new file mode 100644 index 0000000..934163d --- /dev/null +++ b/fix-asset-urls.ts @@ -0,0 +1,132 @@ +// scripts/fix-asset-urls.ts +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +const FROM = 'douyin-archive/'; +const TO = ''; + +function escapeForPgRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串 +const dryRun = false; // true: 只统计,不修改 + +async function main() { + if (dryRun) { + const rows = await prisma.$queryRawUnsafe(` + WITH c AS ( + SELECT 'Author.avatar_url' AS col, COUNT(*) AS n FROM "Author" WHERE avatar_url LIKE '${FROM}%' + UNION ALL + SELECT 'CommentUser.avatar_url' , COUNT(*) FROM "CommentUser" WHERE avatar_url LIKE '${FROM}%' + UNION ALL + SELECT 'CommentImage.url' , COUNT(*) FROM "CommentImage" WHERE url LIKE '${FROM}%' + UNION ALL + SELECT 'Video.cover_url' , COUNT(*) FROM "Video" WHERE cover_url LIKE '${FROM}%' + UNION ALL + SELECT 'Video.video_url' , COUNT(*) FROM "Video" WHERE video_url LIKE '${FROM}%' + UNION ALL + SELECT 'ImageFile.url' , COUNT(*) FROM "ImageFile" WHERE url LIKE '${FROM}%' + UNION ALL + SELECT 'ImageFile.animated' , COUNT(*) FROM "ImageFile" WHERE animated LIKE '${FROM}%' + UNION ALL + SELECT 'ImagePost.music_url' , COUNT(*) FROM "ImagePost" WHERE music_url LIKE '${FROM}%' + UNION ALL + SELECT 'Video.raw_json' , COUNT(*) FROM "Video" WHERE raw_json::text LIKE '%${FROM}%' + UNION ALL + SELECT 'ImagePost.raw_json' , COUNT(*) FROM "ImagePost" WHERE raw_json::text LIKE '%${FROM}%' + ) + SELECT * FROM c ORDER BY col; + `); + console.table(rows); + return; + } + + await prisma.$transaction(async (tx) => { + // CommentUser.avatar_url + await tx.$executeRawUnsafe(` + UPDATE "CommentUser" + SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}') + WHERE avatar_url LIKE '${FROM}%' + `); + + // CommentImage.url + await tx.$executeRawUnsafe(` + UPDATE "CommentImage" + SET url = regexp_replace(url, '${FROM_RE}', '${TO}') + WHERE url LIKE '${FROM}%' + `); + + // Author.avatar_url + await tx.$executeRawUnsafe(` + UPDATE "Author" + SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}') + WHERE avatar_url LIKE '${FROM}%' + `); + + // Video.cover_url + await tx.$executeRawUnsafe(` + UPDATE "Video" + SET cover_url = regexp_replace(cover_url, '${FROM_RE}', '${TO}') + WHERE cover_url LIKE '${FROM}%' + `); + + // Video.video_url + await tx.$executeRawUnsafe(` + UPDATE "Video" + SET video_url = regexp_replace(video_url, '${FROM_RE}', '${TO}') + WHERE video_url LIKE '${FROM}%' + `); + + // ImageFile.url + await tx.$executeRawUnsafe(` + UPDATE "ImageFile" + SET url = regexp_replace(url, '${FROM_RE}', '${TO}') + WHERE url LIKE '${FROM}%' + `); + + // ImageFile.animated + await tx.$executeRawUnsafe(` + UPDATE "ImageFile" + SET animated = regexp_replace(animated, '${FROM_RE}', '${TO}') + WHERE animated LIKE '${FROM}%' + `); + + // ImagePost.music_url + await tx.$executeRawUnsafe(` + UPDATE "ImagePost" + SET music_url = regexp_replace(music_url, '${FROM_RE}', '${TO}') + WHERE music_url LIKE '${FROM}%' + `); + + // (可选)raw_json:简单文本整体替换;若想更精细可用递归 JSONB 方法 + await tx.$executeRawUnsafe(` + UPDATE "Video" + SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}')) + WHERE raw_json::text LIKE '%${FROM}%' + `); + await tx.$executeRawUnsafe(` + UPDATE "ImagePost" + SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}')) + WHERE raw_json::text LIKE '%${FROM}%' + `); + }); + + // 快速抽样 + const sample = await prisma.$queryRawUnsafe(` + ( + SELECT 'CommentImage.url' AS col, url AS v FROM "CommentImage" WHERE url LIKE '${TO}%' LIMIT 2 + ) UNION ALL ( + SELECT 'CommentUser.avatar_url', avatar_url FROM "CommentUser" WHERE avatar_url LIKE '${TO}%' LIMIT 2 + ) UNION ALL ( + SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2 + ) + `); + console.log('Sample after update:', sample); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}).finally(async () => { + await prisma.$disconnect(); +}); diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..495cce9 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,9 @@ +declare module '*.md' { + const content: string; + export default content; +} + +declare module '*.txt' { + const content: string; + export default content; +} diff --git a/lib/minio.ts b/lib/minio.ts index ba5fd32..da0d88a 100644 --- a/lib/minio.ts +++ b/lib/minio.ts @@ -54,7 +54,7 @@ export async function initBucket(): Promise { * @param file - File 对象或 Buffer * @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png') * @param metadata - 可选的元数据 - * @returns 文件的访问URL + * @returns 文件 path = args.path */ export async function uploadFile( file: File | Buffer, @@ -82,7 +82,7 @@ export async function uploadFile( await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData); - return getFileUrl(path); + return path; } catch (error) { console.error('Error uploading file:', error); throw error; @@ -96,29 +96,11 @@ export async function uploadFile( * @returns 文件的访问URL */ export function getFileUrl(path: string): string { - const endpoint = process.env.MINIO_REAL_DOMAIN; + const endpoint = process.env.MINIO_PUBLIC_DOMAIN; return `${endpoint}/${BUCKET_NAME}/${path}`; } -/** - * 获取文件的临时访问URL(带签名,用于私有文件) - * @param path - 文件路径 - * @param expirySeconds - 过期时间(秒),默认7天 - * @returns 临时访问URL - */ -export async function getPresignedUrl( - path: string, - expirySeconds: number = 7 * 24 * 60 * 60 -): Promise { - try { - return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds); - } catch (error) { - console.error('Error generating presigned URL:', error); - throw error; - } -} - /** * 下载文件 * @param path - 文件路径 @@ -225,7 +207,7 @@ export async function listFiles( return new Promise((resolve, reject) => { stream.on('data', (obj) => { if (obj.name) { - files.push({ endpoint: `${process.env.MINIO_REAL_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)); @@ -323,7 +305,6 @@ export default { initBucket, uploadFile, getFileUrl, - getPresignedUrl, downloadFile, getFileStream, deleteFile, diff --git a/next.config.ts b/next.config.ts index 76c0948..075f4e9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,7 +5,13 @@ const nextConfig: NextConfig = { 'playwright-extra', 'puppeteer-extra-plugin-stealth', 'puppeteer-extra-plugin', - ], + ], webpack: (config) => { + config.module.rules.push({ + test: /\.(md|txt)$/i, + type: 'asset/source', // 让这些文件作为纯文本注入 + }); + return config; + }, /* config options here */ }; diff --git a/package.json b/package.json index 70fc9ff..b6041b4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lucide-react": "^0.546.0", "minio": "^8.0.6", "next": "15.5.6", + "openai": "^6.7.0", "playwright": "1.56.1", "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", diff --git a/pm2.config.cjs b/pm2.config.cjs index 1589d5c..788a76f 100644 --- a/pm2.config.cjs +++ b/pm2.config.cjs @@ -8,22 +8,28 @@ module.exports = { apps: [ { name: "DouyinArchive", - script: 'index.ts', + script: 'npm', + args: 'run start', cwd: __dirname, autorestart: true, restart_delay: 4000, kill_timeout: 5000, instances, exec_mode: instances > 1 ? 'cluster' : 'fork', - watch: process.env.NODE_ENV !== 'production', - ignore_watch: ['generated', 'node_modules', '.git'], + // 注意:不要在生产环境 watch,否则 Next.js 写入 .next 会触发重启风暴,导致 Playwright 进程被提前关闭 + watch: false, + ignore_watch: ['.next', '.turbo', 'generated', 'node_modules', '.git'], env: { + // 明确开发环境可选项(如需) + NODE_ENV: process.env.NODE_ENV || 'development', ...envFromFile, - NODE_ENV: 'development' }, env_production: { + // 关键:确保应用进程中的 NODE_ENV=production,从而禁用 Next.js 的开发特性 + NODE_ENV: 'production', + // 为避免多个实例同时拉起共享浏览器,默认单实例;如需并发,请改为独立浏览器服务 + WEB_CONCURRENCY: '1', ...envFromFile, - NODE_ENV: 'production' }, time: true } diff --git a/test.ts b/test.ts index 7c03d43..a502589 100644 --- a/test.ts +++ b/test.ts @@ -1,36 +1,4 @@ import { createWriteStream, writeFileSync } from "node:fs"; - -initBucket() - -const response = await fetch("https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097", { - "headers": { - "accept": "*/*", - "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", - "sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"", - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": "\"Windows\"", - "sec-fetch-dest": "video", - "sec-fetch-mode": "no-cors", - "sec-fetch-site": "same-origin" - }, - "referrer": "https://v3-web.douyinvod.com/95ee4b6a1f064d04653f506024e3d493/68f4daca/video/tos/cn/tos-cn-ve-15c000-ce/oojxjQ2XsXPPgPniGM4kz8M3BSEahiGNcA0AI/?a=6383\\u0026ch=26\\u0026cr=3\\u0026dr=0\\u0026lr=all\\u0026cd=0%7C0%7C0%7C3\\u0026cv=1\\u0026br=6662\\u0026bt=6662\\u0026cs=2\\u0026ds=10\\u0026ft=khyHAB1UiiuGzJrZ~~OC~49Zyo3nOz7HQNaLpMyC6LZjrKQ2B22E1J6kcKb2oPd.o~\\u0026mime_type=video_mp4\\u0026qs=15\\u0026rc=ODNoPGk2MzRnNDg7NDdnNUBpM3ZzbXA5cjNyNjMzbGkzNEAzMy41MzU1XzMxNTY2Ll9jYSNmYy1tMmQ0b2xhLS1kLWJzcw%3D%3D\\u0026btag=c0000e00028000\\u0026cquery=100w_100B_100x_100z_100o\\u0026dy_q=1760866258\\u0026feature_id=10cf95ef75b4f3e7eac623e4ea0ea691\\u0026l=20251019173058DF8022814E036CA4C097", - "body": null, - "method": "GET", - "mode": "cors", - "credentials": "omit" -}); - -import { pipeline, Readable } from 'stream'; -import { promisify } from 'util'; import { initBucket } from "./lib/minio"; -const streamPipeline = promisify(pipeline); -if(!response.body) { - throw new Error("No response body"); -} -// 将 Web ReadableStream 转换为 Node.js Readable -const nodeStream = Readable.fromWeb(response.body as any); -const contentLength = response.headers.get('content-length'); -await uploadFileStream(nodeStream, 'test-video.mp4', Number(contentLength)); - -export { }; +initBucket() \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d8b9323..062d8d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/aweme/[awemeId]/types.ts"], "exclude": ["node_modules"] }