添加动图支持,优化图片和视频上传逻辑,调整超时设置,增强评论时间格式化
This commit is contained in:
parent
fe9bc8fd6c
commit
77bc2c5775
@ -9,10 +9,18 @@ let refCount = 0
|
|||||||
let idleCloseTimer: NodeJS.Timeout | null = null
|
let idleCloseTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const USER_DATA_DIR = 'chrome-profile/douyin'
|
const USER_DATA_DIR = 'chrome-profile/douyin'
|
||||||
const DEFAULT_OPTIONS = { headless: true } as const
|
|
||||||
|
|
||||||
async function launchContext(): Promise<BrowserContext> {
|
async function launchContext(): Promise<BrowserContext> {
|
||||||
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
|
// When the context is closed externally, reset manager state
|
||||||
ctx.on('close', () => {
|
ctx.on('close', () => {
|
||||||
context = null
|
context = null
|
||||||
|
|||||||
@ -84,11 +84,11 @@ export async function scrapeDouyin(url: string) {
|
|||||||
const firstTypePromise = waitForFirstResponse(context, [
|
const firstTypePromise = waitForFirstResponse(context, [
|
||||||
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 },
|
{ 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 },
|
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 },
|
||||||
], 9_000); // 整体 9s 兜底超时,不逐个等待
|
], 40_000);
|
||||||
|
|
||||||
// 评论只做短时“有就用、没有不等”的监听
|
// 评论只做短时“有就用、没有不等”的监听
|
||||||
const commentPromise = waitForResponseWithTimeout(
|
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);
|
).catch(() => null);
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
|
|||||||
@ -138,7 +138,7 @@ export async function saveImagePostToDB(
|
|||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
aweme: DouyinImageAweme,
|
aweme: DouyinImageAweme,
|
||||||
commentResp: DouyinCommentResponse,
|
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
|
rawJson?: any
|
||||||
) {
|
) {
|
||||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
||||||
@ -205,7 +205,7 @@ export async function saveImagePostToDB(
|
|||||||
|
|
||||||
// Upsert ImageFiles(按顺序)
|
// Upsert ImageFiles(按顺序)
|
||||||
for (let i = 0; i < uploads.images.length; i++) {
|
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({
|
await prisma.imageFile.upsert({
|
||||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||||
create: {
|
create: {
|
||||||
@ -214,11 +214,13 @@ export async function saveImagePostToDB(
|
|||||||
url,
|
url,
|
||||||
width: typeof width === 'number' ? width : null,
|
width: typeof width === 'number' ? width : null,
|
||||||
height: typeof height === 'number' ? height : null,
|
height: typeof height === 'number' ? height : null,
|
||||||
|
animated: video || null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
url,
|
url,
|
||||||
width: typeof width === 'number' ? width : null,
|
width: typeof width === 'number' ? width : null,
|
||||||
height: typeof height === 'number' ? height : null,
|
height: typeof height === 'number' ? height : null,
|
||||||
|
animated: video || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
3
app/api/fetcher/types.d.ts
vendored
3
app/api/fetcher/types.d.ts
vendored
@ -126,6 +126,9 @@ interface DouyinImageInfo {
|
|||||||
download_url_list?: string[]; // 可能带水印
|
download_url_list?: string[]; // 可能带水印
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
video: {
|
||||||
|
play_addr: { src: string }[]
|
||||||
|
} | null; // 如果是动图,会有 video 信息
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 音乐基本信息(精简) */
|
/** 音乐基本信息(精简) */
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export async function handleImagePost(
|
|||||||
aweme: DouyinImageAweme
|
aweme: DouyinImageAweme
|
||||||
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> {
|
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> {
|
||||||
const awemeId = aweme.aweme_id;
|
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++) {
|
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||||
@ -42,7 +42,25 @@ export async function handleImagePost(
|
|||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || 'jpg';
|
||||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载音乐(可选)
|
// 下载音乐(可选)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import { useNavigation } from "./hooks/useNavigation";
|
|||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||||
|
|
||||||
const SEGMENT_MS = 5000;
|
const SEGMENT_MS = 4000;
|
||||||
|
|
||||||
interface AwemeDetailClientProps {
|
interface AwemeDetailClientProps {
|
||||||
data: AwemeData;
|
data: AwemeData;
|
||||||
@ -51,6 +51,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
|||||||
audioRef,
|
audioRef,
|
||||||
scrollerRef,
|
scrollerRef,
|
||||||
setProgress: playerState.setProgress,
|
setProgress: playerState.setProgress,
|
||||||
|
segmentMs: SEGMENT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 视频播放器 hooks
|
// 视频播放器 hooks
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ThumbsUp } from "lucide-react";
|
import { ThumbsUp } from "lucide-react";
|
||||||
import type { Comment, User } from "../types";
|
import type { Comment, User } from "../types";
|
||||||
import { formatRelativeTime } from "../utils";
|
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
|
||||||
import { CommentText } from "./CommentText";
|
import { CommentText } from "./CommentText";
|
||||||
|
|
||||||
interface CommentListProps {
|
interface CommentListProps {
|
||||||
@ -20,7 +20,7 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
|
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
|
||||||
<div className="text-xs text-white/50" title={new Date(createdAt).toLocaleString()}>
|
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
|
||||||
发布于 {formatRelativeTime(createdAt)}
|
发布于 {formatRelativeTime(createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,16 +19,17 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
|
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
>
|
>{
|
||||||
<img
|
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={`image-${i + 1}`}
|
alt={`image-${i + 1}`}
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: img.width ? `${img.width}px` : undefined,
|
width: img.width ? `${img.width}px` : undefined,
|
||||||
height: img.height ? `${img.height}px` : undefined,
|
height: img.height ? `${img.height}px` : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type { LoopMode, ObjectFit, User } from "../types";
|
import type { LoopMode, ObjectFit, User } from "../types";
|
||||||
import { formatRelativeTime, formatTime } from "../utils";
|
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ export function MediaControls({
|
|||||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
||||||
title={new Date(createdAt).toLocaleString()}
|
title={formatAbsoluteUTC(createdAt)}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(createdAt)}
|
{formatRelativeTime(createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import type { RefObject } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ImageData, LoopMode, Neighbors } from "../types";
|
import type { ImageData, LoopMode, Neighbors } from "../types";
|
||||||
|
|
||||||
const SEGMENT_MS = 5000;
|
|
||||||
|
|
||||||
interface UseImageCarouselProps {
|
interface UseImageCarouselProps {
|
||||||
images: ImageData["images"];
|
images: ImageData["images"];
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@ -14,6 +12,8 @@ interface UseImageCarouselProps {
|
|||||||
audioRef: RefObject<HTMLAudioElement | null>;
|
audioRef: RefObject<HTMLAudioElement | null>;
|
||||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||||
setProgress: (progress: number) => void;
|
setProgress: (progress: number) => void;
|
||||||
|
/** 单张图片显示时长(毫秒),默认 5000ms */
|
||||||
|
segmentMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useImageCarousel({
|
export function useImageCarousel({
|
||||||
@ -25,6 +25,7 @@ export function useImageCarousel({
|
|||||||
audioRef,
|
audioRef,
|
||||||
scrollerRef,
|
scrollerRef,
|
||||||
setProgress,
|
setProgress,
|
||||||
|
segmentMs = 5000,
|
||||||
}: UseImageCarouselProps) {
|
}: UseImageCarouselProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
@ -67,8 +68,8 @@ export function useImageCarousel({
|
|||||||
let localIdx = idxRef.current;
|
let localIdx = idxRef.current;
|
||||||
|
|
||||||
let elapsed = ts - start;
|
let elapsed = ts - start;
|
||||||
while (elapsed >= SEGMENT_MS) {
|
while (elapsed >= segmentMs) {
|
||||||
elapsed -= SEGMENT_MS;
|
elapsed -= segmentMs;
|
||||||
|
|
||||||
if (localIdx >= images.length - 1) {
|
if (localIdx >= images.length - 1) {
|
||||||
if (loopMode === "sequential" && neighbors?.next) {
|
if (loopMode === "sequential" && neighbors?.next) {
|
||||||
@ -89,7 +90,7 @@ export function useImageCarousel({
|
|||||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
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);
|
setSegProgress(localSeg);
|
||||||
setProgress((localIdx + localSeg) / images.length);
|
setProgress((localIdx + localSeg) / images.length);
|
||||||
|
|
||||||
@ -101,7 +102,7 @@ export function useImageCarousel({
|
|||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = null;
|
rafRef.current = null;
|
||||||
};
|
};
|
||||||
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress]);
|
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idx,
|
idx,
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
aweme_id: post!.aweme_id,
|
aweme_id: post!.aweme_id,
|
||||||
desc: post!.desc,
|
desc: post!.desc,
|
||||||
created_at: post!.created_at,
|
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,
|
music_url: post!.music_url,
|
||||||
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
||||||
comments: post!.comments.map((c) => ({
|
comments: post!.comments.map((c) => ({
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export type ImageData = {
|
|||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
created_at: string | Date;
|
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;
|
music_url?: string | null;
|
||||||
author: User;
|
author: User;
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
|||||||
@ -70,3 +70,16 @@ export function saveToStorage(key: string, value: string | number): void {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(key, value.toString());
|
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`;
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ImageFile" ADD COLUMN "animated" TEXT;
|
||||||
@ -143,6 +143,8 @@ model ImageFile {
|
|||||||
width Int?
|
width Int?
|
||||||
height Int?
|
height Int?
|
||||||
|
|
||||||
|
animated String? // 如果是动图,存储 video 格式的 URL
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user