From 9b45c4e3e87cadab1994b443f25cdbff5ccda277 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Mon, 20 Oct 2025 13:06:06 +0800 Subject: [PATCH] init --- .gitignore | 44 + .vscode/tasks.json | 18 + README.md | 36 + app/api/feed/route.ts | 64 + app/aweme/[awemeId]/Client.tsx | 602 ++++++ app/aweme/[awemeId]/page.tsx | 80 + app/components/BackButton.tsx | 46 + app/components/FeedMasonry.tsx | 195 ++ app/components/HoverVideo.tsx | 66 + app/favicon.ico | Bin 0 -> 25931 bytes app/fetcher/index.ts | 170 ++ app/fetcher/media.ts | 57 + app/fetcher/network.ts | 130 ++ app/fetcher/persist.ts | 263 +++ app/fetcher/route.ts | 17 + app/fetcher/types.d.ts | 138 ++ app/fetcher/uploader.ts | 59 + app/fetcher/utils.ts | 63 + app/globals.css | 31 + app/layout.tsx | 34 + app/page.tsx | 60 + app/tasks/page.tsx | 257 +++ app/types/feed.ts | 21 + biome.json | 37 + bun.lock | 452 +++++ lib/minio-examples.ts | 317 +++ lib/minio.ts | 340 ++++ lib/prisma.ts | 9 + next.config.ts | 7 + package-lock.json | 1787 +++++++++++++++++ package.json | 31 + pm2.config.cjs | 31 + postcss.config.mjs | 5 + .../20251019082632_init/migration.sql | 84 + .../migration.sql | 9 + .../migration.sql | 50 + .../20251019113302_add/migration.sql | 15 + .../20251019120440_add/migration.sql | 3 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 142 ++ public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + test.ts | 36 + tsconfig.json | 27 + 48 files changed, 5843 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 app/api/feed/route.ts create mode 100644 app/aweme/[awemeId]/Client.tsx create mode 100644 app/aweme/[awemeId]/page.tsx create mode 100644 app/components/BackButton.tsx create mode 100644 app/components/FeedMasonry.tsx create mode 100644 app/components/HoverVideo.tsx create mode 100644 app/favicon.ico create mode 100644 app/fetcher/index.ts create mode 100644 app/fetcher/media.ts create mode 100644 app/fetcher/network.ts create mode 100644 app/fetcher/persist.ts create mode 100644 app/fetcher/route.ts create mode 100644 app/fetcher/types.d.ts create mode 100644 app/fetcher/uploader.ts create mode 100644 app/fetcher/utils.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/tasks/page.tsx create mode 100644 app/types/feed.ts create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 lib/minio-examples.ts create mode 100644 lib/minio.ts create mode 100644 lib/prisma.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pm2.config.cjs create mode 100644 postcss.config.mjs create mode 100644 prisma/migrations/20251019082632_init/migration.sql create mode 100644 prisma/migrations/20251019101440_add_video_url_and_tags/migration.sql create mode 100644 prisma/migrations/20251019111904_add_image_post_and_image_file_models/migration.sql create mode 100644 prisma/migrations/20251019113302_add/migration.sql create mode 100644 prisma/migrations/20251019120440_add/migration.sql create mode 100644 prisma/migrations/20251019140306_add_video_cover_url/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5a2413 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma +chrome-profile \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9fbee17 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +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. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/api/feed/route.ts b/app/api/feed/route.ts new file mode 100644 index 0000000..606e9c8 --- /dev/null +++ b/app/api/feed/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import type { FeedItem, FeedResponse } from '@/app/types/feed'; + +// 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 limitParam = searchParams.get('limit'); + const beforeParam = searchParams.get('before'); + + 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' }, + take: limit, + include: { author: true }, + }), + prisma.imagePost.findMany({ + where: before ? { created_at: { lt: before } } : undefined, + orderBy: { created_at: 'desc' }, + take: limit, + include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } }, + }), + ]); + + const merged: FeedItem[] = [ + ...videos.map((v) => ({ + type: 'video' as const, + aweme_id: v.aweme_id, + created_at: v.created_at, + desc: v.desc, + video_url: v.video_url, + cover_url: v.cover_url ?? null, + width: v.width ?? null, + height: v.height ?? null, + author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null }, + likes: Number(v.digg_count), + })), + ...posts.map((p) => ({ + type: 'image' as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: 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 }, + likes: Number(p.digg_count), + })), + ] + .sort((a, b) => +new Date(b.created_at as any) - +new Date(a.created_at as any)) + .slice(0, limit); + + 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/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx new file mode 100644 index 0000000..c8bf178 --- /dev/null +++ b/app/aweme/[awemeId]/Client.tsx @@ -0,0 +1,602 @@ +"use client"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Pause, + Play, + Volume2, + VolumeX, + Maximize, + Minimize2, + MessageSquare, + ThumbsUp, + MessageSquareText, + RotateCcw, + RotateCw, +} from "lucide-react"; + +type User = { nickname: string; avatar_url: string | null }; +type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User }; + +type VideoData = { + type: "video"; + aweme_id: string; + desc: string; + created_at: string | Date; + duration_ms?: number | null; + video_url: string; + width?: number | null; + height?: number | null; + author: User; + comments: Comment[]; +}; + +type ImageData = { + type: "image"; + aweme_id: string; + desc: string; + created_at: string | Date; + images: { id: string; url: string; width?: number; height?: number }[]; + music_url?: string | null; + author: User; + comments: Comment[]; +}; + +const SEGMENT_MS = 5000; // 图文每段 5s + +export default function AwemeDetailClient(props: { data: VideoData | ImageData }) { + const { data } = props; + const isVideo = data.type === "video"; + + // ====== 布局 & 评论 ====== + const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) + const comments = useMemo(() => data.comments ?? [], [data]); + + // ====== 媒体引用 ====== + const mediaContainerRef = useRef(null); + const videoRef = useRef(null); + const audioRef = useRef(null); + + // ====== 统一控制状态 ====== + const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换 + const [isFullscreen, setIsFullscreen] = useState(false); + const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量 + const [rate, setRate] = useState(1); // 仅视频使用 + const [progress, setProgress] = useState(0); // 0..1 总进度 + const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270 + + // ====== 图文专用(分段) ====== + const images = (data as any).images as ImageData["images"] | undefined; + const totalSegments = images?.length ?? 0; + const [idx, setIdx] = useState(0); // 当前图片索引 + const scrollerRef = useRef(null); + + // 用 ref 解决“闪回” + const segStartRef = useRef(null); // 当前段开始时间戳 + const idxRef = useRef(0); + const rafRef = useRef(null); + const [segProgress, setSegProgress] = useState(0); // 段内 0..1 + + useEffect(() => { idxRef.current = idx; }, [idx]); + + // ====== 视频:进度/播放/倍速/音量 ====== + useEffect(() => { + if (!isVideo) return; + const v = videoRef.current; + if (!v) return; + + const onTime = () => { + if (!v.duration || Number.isNaN(v.duration)) return; + setProgress(v.currentTime / v.duration); + }; + const onPlay = () => setIsPlaying(true); + const onPause = () => setIsPlaying(false); + + v.addEventListener("timeupdate", onTime); + v.addEventListener("loadedmetadata", onTime); + v.addEventListener("play", onPlay); + v.addEventListener("pause", onPause); + return () => { + v.removeEventListener("timeupdate", onTime); + v.removeEventListener("loadedmetadata", onTime); + v.removeEventListener("play", onPlay); + v.removeEventListener("pause", onPause); + }; + }, [isVideo]); + + useEffect(() => { + if (!isVideo) return; + const v = videoRef.current; + if (v) v.volume = volume; + }, [volume, isVideo]); + + useEffect(() => { + if (!isVideo) return; + const v = videoRef.current; + if (v) v.playbackRate = rate; + }, [rate, isVideo]); + + // ====== 图文:BGM & 初次自动播放尝试 ====== + useEffect(() => { + if (isVideo) return; + const el = audioRef.current; + if (!el) return; + el.volume = volume; + if (isPlaying) { + el.play().catch(() => {/* 被策略阻止无妨,用户点播放即可 */ }); + } else { + el.pause(); + } + }, [isVideo]); // 初次挂载 + + useEffect(() => { + if (isVideo) return; + const el = audioRef.current; + if (el) el.volume = volume; + }, [volume, isVideo]); + + // ====== 全屏状态监听 ====== + useEffect(() => { + const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener("fullscreenchange", onFsChange); + return () => document.removeEventListener("fullscreenchange", onFsChange); + }, []); + + // ====== 图文:自动切页(消除“闪回”)====== + useEffect(() => { + if (isVideo || !images?.length) return; + + if (segStartRef.current == null) segStartRef.current = performance.now(); + + const tick = (ts: number) => { + if (!images?.length) return; + + let start = segStartRef.current!; + let localIdx = idxRef.current; + + // 暂停时只更新 UI,不推进时间 + if (!isPlaying) { + const elapsed = Math.max(0, ts - start); + const localSeg = Math.min(1, elapsed / SEGMENT_MS); + setSegProgress(localSeg); + setProgress((localIdx + localSeg) / images.length); + rafRef.current = requestAnimationFrame(tick); + return; + } + + // 前进时间:处理跨多段情况(极少见,但更稳妥) + let elapsed = ts - start; + while (elapsed >= SEGMENT_MS) { + elapsed -= SEGMENT_MS; + localIdx = (localIdx + 1) % images.length; + } + segStartRef.current = ts - elapsed; + + if (localIdx !== idxRef.current) { + idxRef.current = localIdx; + setIdx(localIdx); + const el = scrollerRef.current; + if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" }); + } + + const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS)); + setSegProgress(localSeg); + setProgress((localIdx + localSeg) / images.length); + + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + }, [isVideo, images?.length, isPlaying]); + + // 横向滚动同步 idx(且重置段起点) + useEffect(() => { + const el = scrollerRef.current; + if (!el) return; + const onScroll = () => { + const i = Math.round(el.scrollLeft / el.clientWidth); + if (i !== idxRef.current) { + idxRef.current = i; + setIdx(i); + segStartRef.current = performance.now(); + setSegProgress(0); + setProgress(images && images.length ? i / images.length : 0); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [images?.length]); + + // ====== 统一操作 ====== + const seekTo = (ratio: number) => { + ratio = Math.min(1, Math.max(0, ratio)); + if (isVideo) { + const v = videoRef.current; + if (!v || !v.duration) return; + v.currentTime = v.duration * ratio; + return; + } + if (!images?.length) return; + const total = images.length; + const exact = ratio * total; + const targetIdx = Math.min(total - 1, Math.floor(exact)); + const remainder = exact - targetIdx; + + idxRef.current = targetIdx; + setIdx(targetIdx); + segStartRef.current = performance.now() - remainder * SEGMENT_MS; + setSegProgress(remainder); + setProgress((targetIdx + remainder) / total); + + const el = scrollerRef.current; + if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" }); + }; + + const togglePlay = async () => { + if (isVideo) { + const v = videoRef.current; + if (!v) return; + if (v.paused) await v.play(); + else v.pause(); + return; + } + const el = audioRef.current; + if (!isPlaying) { + setIsPlaying(true); + try { await el?.play(); } catch { } + } else { + setIsPlaying(false); + el?.pause(); + } + }; + + const toggleFullscreen = () => { + const el = mediaContainerRef.current; + if (!el) return; + if (!document.fullscreenElement) { + el.requestFullscreen().catch(() => { }); + } else { + document.exitFullscreen().catch(() => { }); + } + }; + + const prevImg = () => { + if (!images?.length) return; + const next = Math.max(0, idxRef.current - 1); + idxRef.current = next; + setIdx(next); + segStartRef.current = performance.now(); + setSegProgress(0); + const el = scrollerRef.current; + if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); + }; + + const nextImg = () => { + if (!images?.length) return; + const next = Math.min(images.length - 1, idxRef.current + 1); + idxRef.current = next; + setIdx(next); + segStartRef.current = performance.now(); + setSegProgress(0); + const el = scrollerRef.current; + if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); + }; + + // ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind) + const asideClasses = [ + "z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white", + // 竖屏:bottom sheet,从下向上弹出 + "portrait:fixed portrait:inset-x-0 portrait:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]", + "portrait:transition-transform portrait:duration-200 portrait:ease-out", + open ? "portrait:translate-y-0" : "portrait:translate-y-full", + "portrait:border-t portrait:border-white/10", + // 横屏:并排分栏,宽度过渡 + "landscape:relative landscape:h-full landscape:overflow-hidden", + "landscape:transition-[width] landscape:duration-200 landscape:ease-out", + open + ? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10" + : "landscape:w-0", + ].join(" "); + + return ( +
+ {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */} +
+ {/* 主媒体区域 */} +
+
+ {isVideo ? ( +
+ + {/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */} + +
+
+ ); +} diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx new file mode 100644 index 0000000..eed8ff4 --- /dev/null +++ b/app/aweme/[awemeId]/page.tsx @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/prisma"; +import BackButton from "@/app/components/BackButton"; +import AwemeDetailClient from "./Client"; + +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")}`; +} + +export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) { + const id = (await params).awemeId; + + const [video, post] = await Promise.all([ + prisma.video.findUnique({ + where: { aweme_id: id }, + include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } }, + }), + prisma.imagePost.findUnique({ + where: { aweme_id: id }, + include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } }, + }) + ]); + + if (!video && !post) return
找不到该作品
; + + const isVideo = !!video; + 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 }, + comments: video!.comments.map((c) => ({ + cid: c.cid, + text: c.text, + created_at: c.created_at, + digg_count: c.digg_count, + user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url }, + })), + } + : { + type: "image" as const, + 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 })), + music_url: post!.music_url, + author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url }, + comments: post!.comments.map((c) => ({ + cid: c.cid, + text: c.text, + created_at: c.created_at, + digg_count: c.digg_count, + user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url }, + })), + }; + + return ( +
+ {/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */} +
+ +
+ +
+ ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/components/BackButton.tsx b/app/components/BackButton.tsx new file mode 100644 index 0000000..64e8caa --- /dev/null +++ b/app/components/BackButton.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; + +type BackButtonProps = { + className?: string; + ariaLabel?: string; + hrefFallback?: string; // default '/' + children?: React.ReactNode; // custom icon/content; defaults to ArrowLeft +}; + +/** + * BackButton + * - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters) + * - Fallback: if no history entry exists (e.g., opened directly), 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) { + 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; + + // Prefer SPA back when we have some history to go back to + if (typeof window !== 'undefined' && window.history.length > 1) { + e.preventDefault(); + router.back(); + } + // else: allow default to navigate to fallback + }, [router]); + + return ( + + {children ?? } + + ); +} diff --git a/app/components/FeedMasonry.tsx b/app/components/FeedMasonry.tsx new file mode 100644 index 0000000..d48ad0a --- /dev/null +++ b/app/components/FeedMasonry.tsx @@ -0,0 +1,195 @@ +"use client"; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import HoverVideo from './HoverVideo'; +import { ThumbsUp } from 'lucide-react'; +import type { FeedItem, FeedResponse } from '@/app/types/feed'; + +type Props = { + initialItems: FeedItem[]; + initialCursor: string | null; +}; + +export default function FeedMasonry({ initialItems, initialCursor }: Props) { + // 哨兵与容器 + const [cursor, setCursor] = useState(initialCursor); + const [loading, setLoading] = useState(false); + const [ended, setEnded] = useState(!initialCursor); + + const sentinelRef = useRef(null); + const containerRef = useRef(null); + + // 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4 + const getColumnCount = useCallback(() => { + 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 + return 1; + }, []); + const [columnCount, setColumnCount] = useState(getColumnCount()); + + useEffect(() => { + const onResize = () => setColumnCount(getColumnCount()); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [getColumnCount]); + + // 估算卡片高度(用于分配到“最短列”) + const estimateItemHeight = useCallback((item: FeedItem, colWidth: number) => { + // 媒体区域高度 + 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 = (9 / 16) * colWidth; // 常见视频比例 + } + // 文本 + 作者栏的高度粗估 + const textH = 48; // 标题+标签区域 + const authorH = 48; // 作者行 + const gap = 16; // 卡片内边距/间隙 + return mediaH + textH + authorH + gap; + }, []); + + // 维护列数据与列高 + const [columns, setColumns] = useState(() => { + const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []); + return cols; + }); + const [colHeights, setColHeights] = useState(() => Array.from({ length: columnCount }, () => 0)); + + // 初始化与当列数变化时重排 + useEffect(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth; + // 用 initialItems 重排 + const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []); + const newHeights: number[] = Array.from({ length: columnCount }, () => 0); + for (const item of initialItems) { + // 找最短列 + let minIdx = 0; + for (let i = 1; i < columnCount; i++) { + if (newHeights[i] < newHeights[minIdx]) minIdx = i; + } + newCols[minIdx].push(item); + newHeights[minIdx] += estimateItemHeight(item, colWidth || 300); + } + setColumns(newCols); + setColHeights(newHeights); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnCount]); + + const fetchMore = useCallback(async () => { + if (loading || ended) return; + setLoading(true); + try { + const params = new URLSearchParams(); + if (cursor) params.set('before', cursor); + params.set('limit', '24'); + const res = await fetch(`/api/feed?${params.toString()}`, { 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 cols = prevCols.map((c) => [...c]); + const heights = [...colHeights]; + for (const item of data.items) { + // 找当前最短列 + let minIdx = 0; + for (let i = 1; i < columnCount; i++) { + if (heights[i] < heights[minIdx]) minIdx = i; + } + cols[minIdx].push(item); + heights[minIdx] += estimateItemHeight(item, colWidth || 300); + } + setColHeights(heights); + return cols; + }); + setCursor(data.nextCursor); + if (!data.nextCursor || data.items.length === 0) setEnded(true); + } catch (e) { + console.error('fetch more feed failed', e); + // 失败也不要死循环 + setEnded(true); + } finally { + setLoading(false); + } + }, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]); + + 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' }); + 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.author.avatar_url ? ( + avatar + ) : null} +
+ {item.author.nickname} + {item.likes} +
+
+ + ), []); + + return ( + <> + {/* Masonry(按列渲染,动态分配到最短列) */} +
+ {columns.map((col, idx) => ( +
+ {col.map((item) => renderCard(item))} +
+ ))} +
+
+ {ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')} +
+ + ); +} diff --git a/app/components/HoverVideo.tsx b/app/components/HoverVideo.tsx new file mode 100644 index 0000000..88dce38 --- /dev/null +++ b/app/components/HoverVideo.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React, { useCallback, useRef, useState } from 'react'; + +type HoverVideoProps = { + videoUrl: string; + coverUrl?: string | null; + className?: string; + style?: React.CSSProperties; +}; + +/** + * 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。 + */ +export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) { + const [active, setActive] = useState(false); + const videoRef = useRef(null); + + const onEnter = useCallback(() => { + setActive(true); + // 播放在下一帧触发,避免 ref 尚未赋值 + requestAnimationFrame(() => { + const v = videoRef.current; + if (!v) return; + v.play().catch(() => {}); + }); + }, [videoUrl]); + + const onLeave = useCallback(() => { + const v = videoRef.current; + if (v) { + try { + v.pause(); + v.load(); + } catch {} + } + setActive(false); + }, []); + + return ( +
+ {/* 封面始终渲染在底层 */} + cover + + {/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */} + {active ? ( +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/fetcher/index.ts b/app/fetcher/index.ts new file mode 100644 index 0000000..48e4a56 --- /dev/null +++ b/app/fetcher/index.ts @@ -0,0 +1,170 @@ +// src/scrapeDouyin.ts +import { chromium, type Response } from 'playwright'; +import { prisma } from '@/lib/prisma'; +import { uploadFile, generateUniqueFileName } from '@/lib/minio'; +import { createCamelCompatibleProxy } from '@/app/fetcher/utils'; +import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/fetcher/network'; +import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media'; +import { handleImagePost } from '@/app/fetcher/uploader'; +import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist'; + +const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; +const COMMENT_PATH = '/aweme/v1/web/comment/list/'; +const POST_PATH = '/aweme/v1/web/aweme/post/' +export async function scrapeDouyin(url: string) { + const browser = await chromium.launch({ headless: true }); + console.log("Launch chromium"); + + const context = await chromium.launchPersistentContext('chrome-profile/douyin', { headless: true }); + const page = await context.newPage(); + + 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/window 上的同名队列都指向我们的 proxy + // 有些站点用 self,有些用 window + (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 }, + { key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 }, + ], 20_000); // 整体 20s 兜底超时,不逐个等待 + + // 评论只做短时“有就用、没有不等”的监听 + const commentPromise = waitForResponseWithTimeout( + context, + (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, + 8_000 + ).catch(() => null); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + + const firstType = await firstTypePromise; // { key, response } | null + const commentRes = await commentPromise; // Response | null + + if (!firstType) { + console.warn('无法判定作品类型(未捕获详情或图文接口)'); + 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: {} } }; + }); + let aweme_mem = md.aweme.detail as DouyinImageAweme; + if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情'); + + //@ts-ignore + aweme_mem.author = aweme_mem.authorInfo + const comments = commentRes ? (await safeJson(commentRes))! : { comments: [], total: 0, status_code: 0 }; + + const aweme = createCamelCompatibleProxy(aweme_mem); + + const uploads = await handleImagePost(context, aweme); + const saved = await saveImagePostToDB(context, aweme, comments, uploads); + return { type: "image", ...saved }; + } + + // 分支:视频 or 图文(两者只会有一个命中,先到先得) + if (firstType.key === 'post') { + // 图文作品 + const postJson = await safeJson(firstType.response); + if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空'); + + 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) { + console.warn(`图文作品响应中未找到对应作品,look for aweme_id=${target_aweme_id}, have ${postJson.aweme_list.map(pt => pt.aweme_id).join(', ')}`); + // Try read from memory + // await new Promise(resolve => setTimeout(resolve, 1000000)); + 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: {} } }; + }); + aweme = md.aweme.detail as DouyinImageAweme; + } + // console.log(aweme); + // await new Promise(resolve => setTimeout(resolve, 1000000)); + console.log(aweme); + + + const comments = commentRes ? (await safeJson(commentRes))! : { comments: [], total: 0, status_code: 0 }; + + const uploads = await handleImagePost(context, aweme); + const saved = await saveImagePostToDB(context, aweme, comments, uploads); + return { type: "image", ...saved }; + } else if (firstType.key === 'detail') { + // 视频作品 + const detail = (await safeJson(firstType.response))!; + const comments = commentRes ? (await safeJson(commentRes))! : { comments: [], total: 0, status_code: 0 }; + + // 找到比特率最高的 url + const bestPlayAddr = pickBestPlayAddr( + detail?.aweme_detail?.video.bit_rate + ); + const bestVUrl = bestPlayAddr?.url_list?.[0]; + + console.log('Best video URL:', bestVUrl); + + // 下载视频并上传至 MinIO,获取外链 + let uploadedUrl: string | undefined; + let coverUrl: string | undefined; + if (bestVUrl && detail?.aweme_detail) { + const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl); + const awemeId = detail.aweme_detail.aweme_id; + const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos'); + uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType }); + console.log('Uploaded to MinIO:', uploadedUrl); + + // 提取首帧作为封面并上传 + try { + 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('Cover uploaded to MinIO:', coverUrl); + } + } catch (e) { + console.warn('Extract first frame failed, skip cover:', (e as Error)?.message || e); + } + } + + const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl); + return { type: "video", ...saved }; + } else { + throw new Error('无法判定作品类型(未命中详情或图文接口)'); + } + } finally { + await context.close(); + await browser.close(); + await prisma.$disconnect(); + } +} + diff --git a/app/fetcher/media.ts b/app/fetcher/media.ts new file mode 100644 index 0000000..aae0d02 --- /dev/null +++ b/app/fetcher/media.ts @@ -0,0 +1,57 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export function pickBestPlayAddr(variants: PlayVariant[] | undefined | 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; + }); + + return best?.play_addr ?? null; +} + +/** + * 使用 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`); + + 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 { } + } +} diff --git a/app/fetcher/network.ts b/app/fetcher/network.ts new file mode 100644 index 0000000..a510695 --- /dev/null +++ b/app/fetcher/network.ts @@ -0,0 +1,130 @@ +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; + } +} + +/** + * 使用 Playwright 的 APIRequestContext 下载二进制内容 + * - 使用指定 headers 模拟浏览器请求 + * - referrer 使用链接本身 + */ +export async function downloadBinary( + context: BrowserContext, + url: string +): Promise<{ buffer: Buffer; contentType: string; ext: string }> { + console.log('Download bin:', url); + + const headers = { + referer: url, + } as Record; + + 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()}`); + } + + 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 }; +} + +/** + * 在多个候选匹配中“先到先得”地返回首个命中的 Response。 + * - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。 + */ +export function waitForFirstResponse( + 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; + + 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); + } + }); +} + +/** + * 等待符合条件的单个 Response,带短超时;用于评论等“可有可无”的数据。 + */ +export function waitForResponseWithTimeout( + context: BrowserContext, + predicate: (r: Response) => boolean, + timeoutMs = 5_000 +): Promise { + 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); + } + }); +} diff --git a/app/fetcher/persist.ts b/app/fetcher/persist.ts new file mode 100644 index 0000000..9dd27db --- /dev/null +++ b/app/fetcher/persist.ts @@ -0,0 +1,263 @@ +import type { BrowserContext } from 'playwright'; +import { prisma } from '@/lib/prisma'; +import { uploadAvatarFromUrl } from './uploader'; +import { firstUrl } from './utils'; + +export async function saveToDB( + context: BrowserContext, + detailResp: DouyinVideoDetailResponse, + commentResp: DouyinCommentResponse, + videoUrl?: string, + width?: number, + height?: number, + coverUrl?: string +) { + 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, + }, + }); + + // 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, + }, + 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 } : {}), + }, + }); + + // 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, + }, + }); + + 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, + }, + }); + } + + 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 }[]; musicUrl?: string } +) { + 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 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, + }, + 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, + }, + }); + + // Upsert ImageFiles(按顺序) + for (let i = 0; i < uploads.images.length; i++) { + const { url, width, height } = 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, + }, + update: { + url, + width: typeof width === 'number' ? width : null, + height: typeof height === 'number' ? height : null, + }, + }); + } + + // 评论入库:关联到 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, + }, + }); + + 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, + }, + }); + } + + 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/fetcher/route.ts b/app/fetcher/route.ts new file mode 100644 index 0000000..f46cead --- /dev/null +++ b/app/fetcher/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { scrapeDouyin } from '.'; + +async function handleDouyinScrape(req: NextRequest) { + const { searchParams } = new URL(req.url); + const videoUrl = searchParams.get('url'); + if (!videoUrl) { + return NextResponse.json({ error: '缺少视频URL' }, { status: 400 }); + } + + // 调用爬虫函数 + const result = await scrapeDouyin(videoUrl); + return NextResponse.json(result); +} + +export const GET = handleDouyinScrape diff --git a/app/fetcher/types.d.ts b/app/fetcher/types.d.ts new file mode 100644 index 0000000..15fff6c --- /dev/null +++ b/app/fetcher/types.d.ts @@ -0,0 +1,138 @@ +/** 抖音评论响应(精简版) */ +interface DouyinCommentResponse { + status_code: number; + comments: DouyinComment[]; + total: number; +} + +/** 单条评论(精简版) */ +interface DouyinComment { + cid: string; + text: string; // 评论内容 + digg_count: number; // 点赞数 + create_time: number; // 创建时间(时间戳) + user: DouyinUser; // 评论用户 +} + +/** 用户信息(精简版) */ +interface DouyinUser { + nickname: string; // 用户昵称 + avatar_thumb: { + url_list: string[]; // 头像链接(可取第一个) + }; +} + +/** 抖音视频详情响应(精简版) */ +interface DouyinVideoDetailResponse { + status_code: number; + aweme_detail: DouyinVideoDetail; +} +/** 作者信息(精简版) */ +interface DouyinAuthor { + uid: string; // 用户ID + sec_uid: string; // 安全UID + nickname: string; // 用户昵称 + signature: string; // 个性签名 + avatar_thumb: { + url_list: string[]; // 头像URL(可取第一个) + }; + 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; // 视频分享链接 + + statistics: { + digg_count: number; // 点赞数 + comment_count: number; // 评论数 + share_count: number; // 分享数 + collect_count: number; // 收藏数 + }; + + author: DouyinAuthor; // 作者信息 + video: VideoPlayBasic; + tags: VideoTagBasic[]; +} + +/** 视频播放信息 */ +interface VideoPlayBasic { + /** 多种清晰度/编码的可播放变体(选其一即可) */ + bit_rate: PlayVariant[]; + /** 大缩略图(如果需要封面) */ + big_thumb_url?: string; +} + +/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */ +interface PlayVariant { + format: string; // mp4 等 + FPS: number; + bit_rate: number; // bit_rate.bit_rate + + /** 直连播放地址(最关键) */ + play_addr: { + uri: string; + url_list: string[]; + width: number; + height: number; + data_size: number; + }; +} + +/** 视频标签(精简) */ +interface VideoTagBasic { + tag_id: number; + tag_name: string; + level?: number; +} + +/** 图文作品列表响应(POST_PATH) */ +interface DouyinPostListResponse { + status_code: number; + aweme_list: DouyinImageAweme[]; +} + +/** 图文作品(精简必要字段) */ +interface DouyinImageAweme { + aweme_id: string; + desc: string; + create_time: number; // 秒 + share_url: string; + statistics: { + digg_count: number; + comment_count: number; + share_count: number; + collect_count: number; + }; + author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid) + images: DouyinImageInfo[]; // 图片列表 + music?: DouyinMusicBasic; // 背景音乐(可选) + video_tag?: VideoTagBasic[]; // 标签 +} + +/** 图文作品的单张图片信息(精简) */ +interface DouyinImageInfo { + url_list: string[]; // 多种格式(webp/jpeg) + download_url_list?: string[]; // 可能带水印 + width: number; + height: number; +} + +/** 音乐基本信息(精简) */ +interface DouyinMusicBasic { + id?: number | string; + title?: string; + author?: string; + album?: string; + play_url: { + uri?: string; + url_list: string[]; // 真实可下载地址 + }; +} \ No newline at end of file diff --git a/app/fetcher/uploader.ts b/app/fetcher/uploader.ts new file mode 100644 index 0000000..e25166a --- /dev/null +++ b/app/fetcher/uploader.ts @@ -0,0 +1,59 @@ +import type { BrowserContext } from 'playwright'; +import { uploadFile, generateUniqueFileName } from '@/lib/minio'; +import { downloadBinary } from './network'; +import { pickFirstUrl } from './utils'; + +/** + * 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。 + */ +export async function uploadAvatarFromUrl( + 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; + } +} + +/** 下载图文作品的图片和音乐并上传到 MinIO */ +export async function handleImagePost( + context: BrowserContext, + 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 }[] = []; + + // 下载图片(顺序保持) + 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 }); + 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 }); + } + + return { images: uploadedImages, musicUrl }; +} diff --git a/app/fetcher/utils.ts b/app/fetcher/utils.ts new file mode 100644 index 0000000..172a118 --- /dev/null +++ b/app/fetcher/utils.ts @@ -0,0 +1,63 @@ +export function toCamelCaseKey(key: string): string { + 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()}`); +} + +/** + * 创建一个代理对象,使得读取属性时同时兼容 snake_case 与 camelCase 键名。 + * 访问顺序:原名 -> camelCase -> snake_case + */ +export function createCamelCompatibleProxy(root: T): T { + 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 handler: ProxyHandler = { + get(target, prop, receiver) { + // 非字符串属性(如 Symbol、数字索引)直接透传 + if (typeof prop !== 'string') { + return wrap(Reflect.get(target, prop, receiver)); + } + + const primary = prop; + + 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 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(root); +} + +/** 选择首个可用 URL */ +export function pickFirstUrl(list?: string[]) { + return Array.isArray(list) && list.length ? list[0] : undefined; +} + +// 别名,兼容旧命名 +export const firstUrl = pickFirstUrl; diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b8bcd52 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,31 @@ +@import "tailwindcss"; + +:root { + --background: #161823; /* theme background */ + --foreground: #ededed; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #161823; + --foreground: #ededed; + } +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} + +/* 滚动条隐藏 */ +.no-scrollbar::-webkit-scrollbar { display: none; } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +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: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..e1371e4 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,60 @@ +import { prisma } from "@/lib/prisma"; +import FeedMasonry from "./components/FeedMasonry"; +import type { FeedItem } from "./types/feed"; + +export default async function Home() { + const [videos, posts] = await Promise.all([ + prisma.video.findMany({ + orderBy: { created_at: "desc" }, + take: 60, + include: { author: true }, + }), + prisma.imagePost.findMany({ + orderBy: { created_at: "desc" }, + take: 60, + include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } }, + }), + ]); + + const feed: FeedItem[] = [ + ...videos.map((v) => ({ + type: "video" as const, + aweme_id: v.aweme_id, + created_at: v.created_at, + desc: v.desc, + video_url: v.video_url, + cover_url: v.cover_url ?? null, + width: v.width ?? null, + height: v.height ?? null, + author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null }, + likes: Number(v.digg_count) + })), + ...posts.map((p) => ({ + type: "image" as const, + aweme_id: p.aweme_id, + created_at: p.created_at, + desc: p.desc, + cover_url: 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 }, + likes: Number(p.digg_count) + })), + ] + //.sort(() => Math.random() - 0.5) + .sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)); + + return ( +
+

+ 作品集 +

+ + {(() => { + const initial = feed.slice(0, 24); + const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null; + return ; + })()} +
+ ); +} diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx new file mode 100644 index 0000000..cf6a0e0 --- /dev/null +++ b/app/tasks/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react"; + +type TaskStatus = "pending" | "running" | "success" | "error"; + +type Task = { + id: string; + url: string; + status: TaskStatus; + startedAt?: number; + finishedAt?: number; + error?: string; + result?: any; +}; + +const extractDouyinLinks = (text: string): string[] => { + if (!text) return []; + const regex = /https?:\/\/v\.douyin\.com\/\S+/g; // 粗略匹配直到空白 + const matches = text.match(regex) ?? []; + const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号 + const cleaned = matches + .map((m) => m.replace(trailing, "")) + .map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾 + // 去重 + return Array.from(new Set(cleaned)); +}; + +export default function TasksPage() { + const [input, setInput] = useState(""); + const [tasks, setTasks] = useState([]); + const controllers = useRef>(new Map()); + const [openDetails, setOpenDetails] = useState>(new Set()); + + const inProgressUrls = useMemo( + () => 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 next: Task[] = [...prev]; + for (const url of notDuplicated) { + const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; + next.push({ id, url, status: "pending" }); + } + return next; + }); + }, [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 startTask = useCallback(async (task: Task) => { + // 若已存在控制器,避免重复启动 + 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() } : t)); + try { + const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `请求失败: ${res.status}`); + } + const data = await res.json().catch(() => undefined); + 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)); + } finally { + controllers.current.delete(task.id); + } + }, []); + + // 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发) + useEffect(() => { + const pending = tasks.filter(t => t.status === "pending"); + pending.forEach((t) => startTask(t)); + }, [tasks, startTask]); + + const cancelTask = useCallback((id: string) => { + const ctrl = controllers.current.get(id); + if (ctrl) ctrl.abort(); + controllers.current.delete(id); + }, []); + + const clearFinished = useCallback(() => { + setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running")); + }, []); + + const toggleOpen = useCallback((id: string) => { + setOpenDetails(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }) + }, []); + + const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]); + + const formatDuration = (t?: number) => { + if (!t) return ""; + const secs = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (secs < 60) return `${secs}s`; + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}m ${s}s`; + }; + + 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 失败; + }; + + return ( +
+ {/* 背景渐变(明确置于内容层后面) */} +
+
+
+ +
+
+

+ 抖音采集任务 +

+

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

+
+ + {/* 输入卡片 */} +
+
+
+ +
+
+