diff --git a/app/api/fetcher/browser.ts b/app/api/fetcher/browser.ts index 3f5386e..fb30247 100644 --- a/app/api/fetcher/browser.ts +++ b/app/api/fetcher/browser.ts @@ -9,10 +9,18 @@ let refCount = 0 let idleCloseTimer: NodeJS.Timeout | null = null const USER_DATA_DIR = 'chrome-profile/douyin' -const DEFAULT_OPTIONS = { headless: true } as const async function launchContext(): Promise { - const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, DEFAULT_OPTIONS) + const ctx = await chromium.launchPersistentContext( + USER_DATA_DIR, + { + headless: Boolean(process.env.CHROMIUM_HEADLESS ?? 'false'), + 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 diff --git a/app/api/fetcher/index.ts b/app/api/fetcher/index.ts index d067686..a555552 100644 --- a/app/api/fetcher/index.ts +++ b/app/api/fetcher/index.ts @@ -84,11 +84,11 @@ export async function scrapeDouyin(url: string) { const firstTypePromise = waitForFirstResponse(context, [ { key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 }, { key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 }, - ], 9_000); // 整体 9s 兜底超时,不逐个等待 + ], 40_000); // 评论只做短时“有就用、没有不等”的监听 const commentPromise = waitForResponseWithTimeout( - context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 8_000 + context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 40_000 ).catch(() => null); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); diff --git a/app/api/fetcher/persist.ts b/app/api/fetcher/persist.ts index 70512c5..57a38b9 100644 --- a/app/api/fetcher/persist.ts +++ b/app/api/fetcher/persist.ts @@ -138,7 +138,7 @@ export async function saveImagePostToDB( context: BrowserContext, aweme: DouyinImageAweme, commentResp: DouyinCommentResponse, - uploads: { images: { url: string; width?: number; height?: number }[]; musicUrl?: string }, + uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, rawJson?: any ) { if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); @@ -205,7 +205,7 @@ export async function saveImagePostToDB( // Upsert ImageFiles(按顺序) for (let i = 0; i < uploads.images.length; i++) { - const { url, width, height } = uploads.images[i]; + const { url, width, height, video } = uploads.images[i]; await prisma.imageFile.upsert({ where: { postId_order: { postId: imagePost.aweme_id, order: i } }, create: { @@ -214,11 +214,13 @@ export async function saveImagePostToDB( 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, }, }); } diff --git a/app/api/fetcher/types.d.ts b/app/api/fetcher/types.d.ts index 404c151..b3a8442 100644 --- a/app/api/fetcher/types.d.ts +++ b/app/api/fetcher/types.d.ts @@ -126,6 +126,9 @@ interface DouyinImageInfo { download_url_list?: string[]; // 可能带水印 width: number; height: number; + video: { + play_addr: { src: string }[] + } | null; // 如果是动图,会有 video 信息 } /** 音乐基本信息(精简) */ diff --git a/app/api/fetcher/uploader.ts b/app/api/fetcher/uploader.ts index e25166a..4aa18e6 100644 --- a/app/api/fetcher/uploader.ts +++ b/app/api/fetcher/uploader.ts @@ -31,7 +31,7 @@ export async function handleImagePost( aweme: DouyinImageAweme ): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> { const awemeId = aweme.aweme_id; - const uploadedImages: { url: string; width?: number; height?: number }[] = []; + const uploadedImages: { url: string; width?: number; height?: number, video?: string }[] = []; // 下载图片(顺序保持) for (let i = 0; i < (aweme.images?.length || 0); i++) { @@ -42,7 +42,25 @@ export async function handleImagePost( const safeExt = ext || 'jpg'; const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images'); const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); - 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 }); + // 将动图的 video URL 也存储起来 + uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height, video: uploadedVideo }); + } catch (e) { + console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); + } + } + } else { + uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); + } } // 下载音乐(可选) diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index bebfc53..e63ec82 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -18,7 +18,7 @@ import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; -const SEGMENT_MS = 5000; +const SEGMENT_MS = 4000; interface AwemeDetailClientProps { data: AwemeData; @@ -51,6 +51,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient audioRef, scrollerRef, setProgress: playerState.setProgress, + segmentMs: SEGMENT_MS, }); // 视频播放器 hooks diff --git a/app/aweme/[awemeId]/components/CommentList.tsx b/app/aweme/[awemeId]/components/CommentList.tsx index 7fa42ab..0ce58cd 100644 --- a/app/aweme/[awemeId]/components/CommentList.tsx +++ b/app/aweme/[awemeId]/components/CommentList.tsx @@ -1,6 +1,6 @@ import { ThumbsUp } from "lucide-react"; import type { Comment, User } from "../types"; -import { formatRelativeTime } from "../utils"; +import { formatRelativeTime, formatAbsoluteUTC } from "../utils"; import { CommentText } from "./CommentText"; interface CommentListProps { @@ -20,7 +20,7 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
{author.nickname}
-
+
发布于 {formatRelativeTime(createdAt)}
diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index 20fc976..7ad1bc0 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -19,16 +19,17 @@ export const ImageCarousel = forwardRef( key={img.id} className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer" onClick={onTogglePlay} - > - {`image-${i + >{ + img.animated ?
))} diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 05bdb1e..9f5b837 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import type { RefObject } from "react"; import type { LoopMode, ObjectFit, User } from "../types"; -import { formatRelativeTime, formatTime } from "../utils"; +import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { ProgressBar } from "./ProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar"; @@ -90,7 +90,7 @@ export function MediaControls({ · {formatRelativeTime(createdAt)} diff --git a/app/aweme/[awemeId]/hooks/useImageCarousel.ts b/app/aweme/[awemeId]/hooks/useImageCarousel.ts index 8a13ec7..5816be0 100644 --- a/app/aweme/[awemeId]/hooks/useImageCarousel.ts +++ b/app/aweme/[awemeId]/hooks/useImageCarousel.ts @@ -3,8 +3,6 @@ import type { RefObject } from "react"; import { useRouter } from "next/navigation"; import type { ImageData, LoopMode, Neighbors } from "../types"; -const SEGMENT_MS = 5000; - interface UseImageCarouselProps { images: ImageData["images"]; isPlaying: boolean; @@ -14,6 +12,8 @@ interface UseImageCarouselProps { audioRef: RefObject; scrollerRef: RefObject; setProgress: (progress: number) => void; + /** 单张图片显示时长(毫秒),默认 5000ms */ + segmentMs?: number; } export function useImageCarousel({ @@ -25,6 +25,7 @@ export function useImageCarousel({ audioRef, scrollerRef, setProgress, + segmentMs = 5000, }: UseImageCarouselProps) { const router = useRouter(); const [idx, setIdx] = useState(0); @@ -67,8 +68,8 @@ export function useImageCarousel({ let localIdx = idxRef.current; let elapsed = ts - start; - while (elapsed >= SEGMENT_MS) { - elapsed -= SEGMENT_MS; + while (elapsed >= segmentMs) { + elapsed -= segmentMs; if (localIdx >= images.length - 1) { if (loopMode === "sequential" && neighbors?.next) { @@ -89,7 +90,7 @@ export function useImageCarousel({ if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" }); } - const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS)); + const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs)); setSegProgress(localSeg); setProgress((localIdx + localSeg) / images.length); @@ -101,7 +102,7 @@ export function useImageCarousel({ if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; }; - }, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress]); + }, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); return { idx, diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 64777d4..c75565b 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -83,7 +83,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI aweme_id: post!.aweme_id, desc: post!.desc, created_at: post!.created_at, - images: post!.images.map((i) => ({ id: i.id, url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })), + images: post!.images, music_url: post!.music_url, author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url }, comments: post!.comments.map((c) => ({ diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 2781cfa..20bc6fc 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -26,7 +26,7 @@ export type ImageData = { aweme_id: string; desc: string; created_at: string | Date; - images: { id: string; url: string; width?: number; height?: number }[]; + images: { id: string; url: string; width?: number; height?: number, animated?: string }[]; music_url?: string | null; author: User; comments: Comment[]; diff --git a/app/aweme/[awemeId]/utils.ts b/app/aweme/[awemeId]/utils.ts index 21abf2c..c471fa8 100644 --- a/app/aweme/[awemeId]/utils.ts +++ b/app/aweme/[awemeId]/utils.ts @@ -70,3 +70,16 @@ export function saveToStorage(key: string, value: string | number): void { if (typeof window === "undefined") return; localStorage.setItem(key, value.toString()); } + +// 稳定的绝对时间格式(与 locale 无关),用于避免 SSR/CSR 水合不一致 +// 输出示例:2025-10-20 08:23:05 UTC +export function formatAbsoluteUTC(date: string | Date): string { + const d = new Date(date); + const Y = d.getUTCFullYear(); + const M = String(d.getUTCMonth() + 1).padStart(2, "0"); + const D = String(d.getUTCDate()).padStart(2, "0"); + const h = String(d.getUTCHours()).padStart(2, "0"); + const m = String(d.getUTCMinutes()).padStart(2, "0"); + const s = String(d.getUTCSeconds()).padStart(2, "0"); + return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`; +} diff --git a/prisma/migrations/20251022131006_add_post_animation/migration.sql b/prisma/migrations/20251022131006_add_post_animation/migration.sql new file mode 100644 index 0000000..0c3d8a0 --- /dev/null +++ b/prisma/migrations/20251022131006_add_post_animation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ImageFile" ADD COLUMN "animated" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d3182d..d70abbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -143,6 +143,8 @@ model ImageFile { width Int? height Int? + animated String? // 如果是动图,存储 video 格式的 URL + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt