Compare commits
2 Commits
9b45c4e3e8
...
200283b21b
| Author | SHA1 | Date | |
|---|---|---|---|
| 200283b21b | |||
| 9d1598a5ab |
9
.vscode/tasks.json
vendored
@ -13,6 +13,15 @@
|
|||||||
"$tsc"
|
"$tsc"
|
||||||
],
|
],
|
||||||
"group": "build"
|
"group": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tsc-check (one-off)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"-e",
|
||||||
|
"require('typescript').transpile('const x: number = 1;')"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
77
app/api/aweme/around/route.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// GET /api/aweme/around?awemeId=xxxx
|
||||||
|
// Response: { prev?: { type: 'video'|'image', aweme_id: string, created_at: string }, next?: { ... } }
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const awemeId = searchParams.get("awemeId");
|
||||||
|
if (!awemeId) {
|
||||||
|
return NextResponse.json({ error: "missing awemeId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current item timestamp from either table
|
||||||
|
const [video, post] = await Promise.all([
|
||||||
|
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const current = video ?? post;
|
||||||
|
if (!current) {
|
||||||
|
return NextResponse.json({ error: "aweme not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = current.created_at as unknown as Date;
|
||||||
|
|
||||||
|
// Newer than current (prev in a desc-ordered feed): pick the nearest newer by created_at
|
||||||
|
const [newerVideo, newerPost] = await Promise.all([
|
||||||
|
prisma.video.findFirst({
|
||||||
|
where: { created_at: { gt: createdAt } },
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findFirst({
|
||||||
|
where: { created_at: { gt: createdAt } },
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Older than current (next in a desc-ordered feed): pick the nearest older by created_at
|
||||||
|
const [olderVideo, olderPost] = await Promise.all([
|
||||||
|
prisma.video.findFirst({
|
||||||
|
where: { created_at: { lt: createdAt } },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findFirst({
|
||||||
|
where: { created_at: { lt: createdAt } },
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: { aweme_id: true, created_at: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pickPrev = (() => {
|
||||||
|
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
||||||
|
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
||||||
|
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
||||||
|
if (cands.length === 0) return undefined;
|
||||||
|
// nearest newer -> minimal created_at
|
||||||
|
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||||
|
const h = cands[0];
|
||||||
|
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const pickNext = (() => {
|
||||||
|
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
||||||
|
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
||||||
|
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
||||||
|
if (cands.length === 0) return undefined;
|
||||||
|
// nearest older -> maximal created_at among older
|
||||||
|
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||||
|
const h = cands[0];
|
||||||
|
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
||||||
|
})();
|
||||||
|
|
||||||
|
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -14,11 +15,67 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
Maximize2,
|
||||||
|
Minimize,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
type User = { nickname: string; avatar_url: string | null };
|
type User = { nickname: string; avatar_url: string | null };
|
||||||
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
|
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
|
||||||
|
|
||||||
|
// 处理评论文本中的表情占位符
|
||||||
|
function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
|
||||||
|
const parts: (string | { type: "emoji"; name: string })[] = [];
|
||||||
|
const regex = /\[([^\]]+)\]/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
// 添加表情前的文本
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
// 添加表情
|
||||||
|
parts.push({ type: "emoji", name: match[1] });
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余文本
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染评论文本(包含表情)
|
||||||
|
function CommentText({ text }: { text: string }) {
|
||||||
|
const parts = parseCommentText(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, idx) => {
|
||||||
|
if (typeof part === "string") {
|
||||||
|
return <span key={idx}>{part}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={`/emojis/${part.name}.webp`}
|
||||||
|
alt={part.name}
|
||||||
|
className="inline-block w-5 h-5 align-text-bottom mx-0.5"
|
||||||
|
onError={(e) => {
|
||||||
|
// 如果图片加载失败,显示原始文本
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
const textNode = document.createTextNode(`[${part.name}]`);
|
||||||
|
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type VideoData = {
|
type VideoData = {
|
||||||
type: "video";
|
type: "video";
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
@ -45,26 +102,53 @@ type ImageData = {
|
|||||||
|
|
||||||
const SEGMENT_MS = 5000; // 图文每段 5s
|
const SEGMENT_MS = 5000; // 图文每段 5s
|
||||||
|
|
||||||
export default function AwemeDetailClient(props: { data: VideoData | ImageData }) {
|
type Neighbors = { prev: { aweme_id: string } | null; next: { aweme_id: string } | null };
|
||||||
const { data } = props;
|
|
||||||
|
export default function AwemeDetailClient(props: { data: VideoData | ImageData; neighbors?: Neighbors }) {
|
||||||
|
const { data, neighbors } = props;
|
||||||
const isVideo = data.type === "video";
|
const isVideo = data.type === "video";
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// ====== 布局 & 评论 ======
|
// ====== 布局 & 评论 ======
|
||||||
const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏)
|
const [open, setOpen] = useState(() => {
|
||||||
|
// 从 localStorage 读取评论区状态,默认 false
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const saved = localStorage.getItem("aweme_player_comments_open");
|
||||||
|
if (!saved) return false;
|
||||||
|
return saved === "true";
|
||||||
|
}); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏)
|
||||||
const comments = useMemo(() => data.comments ?? [], [data]);
|
const comments = useMemo(() => data.comments ?? [], [data]);
|
||||||
|
|
||||||
// ====== 媒体引用 ======
|
// ====== 媒体引用 ======
|
||||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const wheelCooldownRef = useRef<number>(0);
|
||||||
|
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
// ====== 统一控制状态 ======
|
// ====== 统一控制状态 ======
|
||||||
const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换
|
const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量
|
const [volume, setVolume] = useState(() => {
|
||||||
const [rate, setRate] = useState(1); // 仅视频使用
|
// 从 localStorage 读取音量,默认 1
|
||||||
|
if (typeof window === "undefined") return 1;
|
||||||
|
const saved = localStorage.getItem("aweme_player_volume");
|
||||||
|
if (!saved) return 1;
|
||||||
|
const parsed = parseFloat(saved);
|
||||||
|
return Number.isNaN(parsed) ? 1 : Math.max(0, Math.min(1, parsed));
|
||||||
|
});
|
||||||
|
const [rate, setRate] = useState(() => {
|
||||||
|
// 从 localStorage 读取倍速,默认 1
|
||||||
|
if (typeof window === "undefined") return 1;
|
||||||
|
const saved = localStorage.getItem("aweme_player_rate");
|
||||||
|
if (!saved) return 1;
|
||||||
|
const parsed = parseFloat(saved);
|
||||||
|
return Number.isNaN(parsed) ? 1 : parsed;
|
||||||
|
});
|
||||||
const [progress, setProgress] = useState(0); // 0..1 总进度
|
const [progress, setProgress] = useState(0); // 0..1 总进度
|
||||||
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
||||||
|
const [progressRestored, setProgressRestored] = useState(false); // 标记进度是否已恢复
|
||||||
|
const [objectFit, setObjectFit] = useState<"contain" | "cover">("contain"); // 媒体显示模式
|
||||||
|
|
||||||
// ====== 图文专用(分段) ======
|
// ====== 图文专用(分段) ======
|
||||||
const images = (data as any).images as ImageData["images"] | undefined;
|
const images = (data as any).images as ImageData["images"] | undefined;
|
||||||
@ -80,6 +164,104 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
|
|
||||||
useEffect(() => { idxRef.current = idx; }, [idx]);
|
useEffect(() => { idxRef.current = idx; }, [idx]);
|
||||||
|
|
||||||
|
// ====== 持久化音量到 localStorage ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem("aweme_player_volume", volume.toString());
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// ====== 持久化倍速到 localStorage ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem("aweme_player_rate", rate.toString());
|
||||||
|
}, [rate]);
|
||||||
|
|
||||||
|
// ====== 持久化评论区状态到 localStorage ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem("aweme_player_comments_open", open.toString());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ====== 恢复视频播放进度(带有效期) ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideo || progressRestored) return;
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
if (progressRestored) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = `aweme_progress_${data.aweme_id}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (!saved) {
|
||||||
|
setProgressRestored(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { time, timestamp } = JSON.parse(saved);
|
||||||
|
const now = Date.now();
|
||||||
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// 检查是否在 5 分钟有效期内
|
||||||
|
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
|
||||||
|
v.currentTime = time;
|
||||||
|
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
||||||
|
} else if (now - timestamp >= fiveMinutes) {
|
||||||
|
// 过期则清除
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("恢复播放进度失败", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgressRestored(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (v.readyState >= 1) {
|
||||||
|
// 元数据已加载
|
||||||
|
onLoadedMetadata();
|
||||||
|
} else {
|
||||||
|
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||||
|
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
|
}
|
||||||
|
}, [isVideo, data.aweme_id, progressRestored]);
|
||||||
|
|
||||||
|
// ====== 实时保存视频播放进度到 localStorage ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideo) return;
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
|
||||||
|
const saveProgress = () => {
|
||||||
|
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = `aweme_progress_${data.aweme_id}`;
|
||||||
|
const value = JSON.stringify({
|
||||||
|
time: v.currentTime,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("保存播放进度失败", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每 2 秒保存一次进度
|
||||||
|
const interval = setInterval(saveProgress, 2000);
|
||||||
|
|
||||||
|
// 页面卸载时也保存一次
|
||||||
|
const onBeforeUnload = () => saveProgress();
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
saveProgress(); // 组件卸载时保存
|
||||||
|
};
|
||||||
|
}, [isVideo, data.aweme_id]);
|
||||||
|
|
||||||
// ====== 视频:进度/播放/倍速/音量 ======
|
// ====== 视频:进度/播放/倍速/音量 ======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo) return;
|
if (!isVideo) return;
|
||||||
@ -143,6 +325,30 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ====== 监听浏览器返回事件,尝试关闭页面 ======
|
||||||
|
useEffect(() => {
|
||||||
|
// 在 history 中添加一个状态,用于拦截返回事件
|
||||||
|
window.history.pushState({ interceptBack: true }, "");
|
||||||
|
|
||||||
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
|
// 尝试关闭窗口
|
||||||
|
window.close();
|
||||||
|
|
||||||
|
// 如果关闭失败(100ms 后页面仍可见),则导航到首页
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
// ====== 图文:自动切页(消除“闪回”)======
|
// ====== 图文:自动切页(消除“闪回”)======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVideo || !images?.length) return;
|
if (isVideo || !images?.length) return;
|
||||||
@ -241,14 +447,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (v.paused) await v.play();
|
if (v.paused) await v.play().catch(() => { });
|
||||||
else v.pause();
|
else v.pause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const el = audioRef.current;
|
const el = audioRef.current;
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
try { await el?.play(); } catch { }
|
try { await el?.play().catch(() => { }); } catch { }
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
el?.pause();
|
el?.pause();
|
||||||
@ -256,10 +462,8 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const el = mediaContainerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
el.requestFullscreen().catch(() => { });
|
document.body.requestFullscreen().catch(() => { });
|
||||||
} else {
|
} else {
|
||||||
document.exitFullscreen().catch(() => { });
|
document.exitFullscreen().catch(() => { });
|
||||||
}
|
}
|
||||||
@ -303,8 +507,132 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
: "landscape:w-0",
|
: "landscape:w-0",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
|
// ====== 预取上/下一条路由,提高切换流畅度 ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (!neighbors) return;
|
||||||
|
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
|
||||||
|
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
|
||||||
|
}, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]);
|
||||||
|
|
||||||
|
// ====== 鼠标滚轮切换上一条/下一条(纵向滚动) ======
|
||||||
|
useEffect(() => {
|
||||||
|
const el = mediaContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
// 避免缩放/横向滚动干扰
|
||||||
|
if (e.ctrlKey) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms
|
||||||
|
const dy = e.deltaY;
|
||||||
|
if (Math.abs(dy) < 40) return; // 过滤轻微滚轮
|
||||||
|
|
||||||
|
// 有上一条/下一条才拦截默认行为
|
||||||
|
if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dy > 0 && neighbors?.next) {
|
||||||
|
wheelCooldownRef.current = now;
|
||||||
|
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||||
|
} else if (dy < 0 && neighbors?.prev) {
|
||||||
|
wheelCooldownRef.current = now;
|
||||||
|
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 需非被动监听以便 preventDefault
|
||||||
|
el.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener("wheel", onWheel as any);
|
||||||
|
}, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]);
|
||||||
|
|
||||||
|
// ====== 动态模糊背景:每 0.2s 截取当前媒体内容绘制到背景 canvas ======
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = backgroundCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 更新 canvas 尺寸以匹配视口
|
||||||
|
const updateCanvasSize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
updateCanvasSize();
|
||||||
|
window.addEventListener("resize", updateCanvasSize);
|
||||||
|
|
||||||
|
// 绘制媒体内容到 canvas(cover 策略)
|
||||||
|
const drawMediaToCanvas = () => {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
|
||||||
|
|
||||||
|
// 获取当前媒体元素
|
||||||
|
if (isVideo) {
|
||||||
|
sourceElement = videoRef.current;
|
||||||
|
} else {
|
||||||
|
// 对于图文,获取当前显示的图片
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
if (scroller) {
|
||||||
|
const currentImgContainer = scroller.children[idx] as HTMLElement;
|
||||||
|
if (currentImgContainer) {
|
||||||
|
sourceElement = currentImgContainer.querySelector("img");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWidth = canvas.width;
|
||||||
|
const canvasHeight = canvas.height;
|
||||||
|
const sourceWidth = sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
||||||
|
const sourceHeight = sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
||||||
|
|
||||||
|
if (!sourceWidth || !sourceHeight) return;
|
||||||
|
|
||||||
|
// 计算 cover 模式的尺寸和位置
|
||||||
|
const canvasRatio = canvasWidth / canvasHeight;
|
||||||
|
const sourceRatio = sourceWidth / sourceHeight;
|
||||||
|
|
||||||
|
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
|
||||||
|
|
||||||
|
if (canvasRatio > sourceRatio) {
|
||||||
|
// canvas 更宽,按宽度填充
|
||||||
|
drawWidth = canvasWidth;
|
||||||
|
drawHeight = canvasWidth / sourceRatio;
|
||||||
|
offsetX = 0;
|
||||||
|
offsetY = (canvasHeight - drawHeight) / 2;
|
||||||
|
} else {
|
||||||
|
// canvas 更高,按高度填充
|
||||||
|
drawHeight = canvasHeight;
|
||||||
|
drawWidth = canvasHeight * sourceRatio;
|
||||||
|
offsetX = (canvasWidth - drawWidth) / 2;
|
||||||
|
offsetY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空画布并绘制
|
||||||
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = setInterval(drawMediaToCanvas, 20);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
window.removeEventListener("resize", updateCanvasSize);
|
||||||
|
};
|
||||||
|
}, [isVideo, idx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full">
|
<div className="h-screen w-full">
|
||||||
|
{/* 动态模糊背景 canvas */}
|
||||||
|
<canvas
|
||||||
|
ref={backgroundCanvasRef}
|
||||||
|
className="fixed inset-0 w-full h-full -z-10"
|
||||||
|
style={{ filter: "blur(40px)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
||||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||||
{/* 主媒体区域 */}
|
{/* 主媒体区域 */}
|
||||||
@ -317,11 +645,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={(data as VideoData).video_url}
|
src={(data as VideoData).video_url}
|
||||||
className={[
|
className={[
|
||||||
// 旋转 0/180:充满容器盒子,用 object-contain;
|
// 旋转 0/180:充满容器盒子;
|
||||||
// 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏
|
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
||||||
rotation % 180 === 0
|
rotation % 180 === 0
|
||||||
? "absolute inset-0 h-full w-full object-contain bg-black/70"
|
? `absolute inset-0 h-full w-full object-${objectFit} bg-black/70 cursor-pointer`
|
||||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70",
|
: `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-${objectFit} bg-black/70 cursor-pointer`,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
transform:
|
transform:
|
||||||
@ -332,7 +660,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
}}
|
}}
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop onClick={togglePlay}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
@ -347,7 +675,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={img.url} alt="image" className="absolute inset-0 w-full h-full object-contain" />
|
<img src={img.url} alt="image" className={`absolute inset-0 w-full h-full object-${objectFit} cursor-pointer`} onClick={togglePlay} draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -506,6 +834,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||||
|
onClick={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))}
|
||||||
|
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||||
|
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||||
|
>
|
||||||
|
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
@ -584,7 +920,9 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
|||||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
||||||
<span className="text-xs text-white/50">{new Date(c.created_at).toLocaleString()}</span>
|
<span className="text-xs text-white/50">{new Date(c.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">{c.text}</p>
|
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
||||||
|
<CommentText text={c.text} />
|
||||||
|
</p>
|
||||||
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
|
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
|
||||||
<ThumbsUp size={14} />
|
<ThumbsUp size={14} />
|
||||||
<span>{c.digg_count}</span>
|
<span>{c.digg_count}</span>
|
||||||
|
|||||||
3
app/aweme/[awemeId]/emojis.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const emojiList = [
|
||||||
|
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
|
||||||
|
]
|
||||||
@ -63,6 +63,32 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Compute prev/next neighbors by created_at across videos and image posts
|
||||||
|
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date;
|
||||||
|
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||||
|
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
||||||
|
]);
|
||||||
|
const pickPrev = (() => {
|
||||||
|
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||||
|
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
||||||
|
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
||||||
|
if (cands.length === 0) return null;
|
||||||
|
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||||
|
return { aweme_id: cands[0].aweme_id };
|
||||||
|
})();
|
||||||
|
const pickNext = (() => {
|
||||||
|
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||||
|
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
||||||
|
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
||||||
|
if (cands.length === 0) return null;
|
||||||
|
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||||
|
return { aweme_id: cands[0].aweme_id };
|
||||||
|
})();
|
||||||
|
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full overflow-hidden">
|
<main className="min-h-screen w-full overflow-hidden">
|
||||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||||
@ -72,7 +98,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
|||||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AwemeDetailClient data={data as any} />
|
<AwemeDetailClient data={data as any} neighbors={neighbors as any} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ type BackButtonProps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* BackButton
|
* BackButton
|
||||||
* - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters)
|
* - Primary: attempts to close the current window/tab (window.close())
|
||||||
* - Fallback: if no history entry exists (e.g., opened directly), navigates to '/'
|
* - Fallback: if close fails (e.g., not opened by script), navigates to '/'
|
||||||
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
||||||
*/
|
*/
|
||||||
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) {
|
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) {
|
||||||
@ -25,13 +25,21 @@ export default function BackButton({ className, ariaLabel = '返回', hrefFallba
|
|||||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
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
|
e.preventDefault();
|
||||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
|
||||||
e.preventDefault();
|
// Try to close the window first
|
||||||
router.back();
|
if (typeof window !== 'undefined') {
|
||||||
|
window.close();
|
||||||
|
|
||||||
|
// If window.close() didn't work (window still open after a short delay),
|
||||||
|
// navigate to the fallback URL
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
router.push(hrefFallback);
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
}
|
}
|
||||||
// else: allow default <Link href> to navigate to fallback
|
}, [router, hrefFallback]);
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -133,7 +133,7 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
|||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
const renderCard = useCallback((item: FeedItem) => (
|
const renderCard = useCallback((item: FeedItem) => (
|
||||||
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} className="mb-4 block group">
|
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group">
|
||||||
<article className="relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 group-hover:-translate-y-1">
|
<article className="relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 group-hover:-translate-y-1">
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
|
|||||||
71
app/fetcher/browser.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { chromium, type BrowserContext } from 'playwright'
|
||||||
|
|
||||||
|
// A simple singleton manager for a persistent Chromium context with ref-counting.
|
||||||
|
// Prevents concurrent tasks from closing the shared window prematurely.
|
||||||
|
|
||||||
|
let contextPromise: Promise<BrowserContext> | null = null
|
||||||
|
let context: BrowserContext | null = null
|
||||||
|
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<BrowserContext> {
|
||||||
|
const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, DEFAULT_OPTIONS)
|
||||||
|
// When the context is closed externally, reset manager state
|
||||||
|
ctx.on('close', () => {
|
||||||
|
context = null
|
||||||
|
contextPromise = null
|
||||||
|
refCount = 0
|
||||||
|
if (idleCloseTimer) {
|
||||||
|
clearTimeout(idleCloseTimer)
|
||||||
|
idleCloseTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
||||||
|
// Cancel any pending idle close if a new consumer arrives
|
||||||
|
if (idleCloseTimer) {
|
||||||
|
clearTimeout(idleCloseTimer)
|
||||||
|
idleCloseTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
refCount += 1
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contextPromise) {
|
||||||
|
contextPromise = launchContext()
|
||||||
|
}
|
||||||
|
context = await contextPromise
|
||||||
|
refCount += 1
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> {
|
||||||
|
const idleMillis = options?.idleMillis ?? 15_000
|
||||||
|
refCount = Math.max(0, refCount - 1)
|
||||||
|
|
||||||
|
if (refCount > 0 || !context) return
|
||||||
|
|
||||||
|
// Delay the close to allow bursty workloads to reuse the context
|
||||||
|
if (idleCloseTimer) {
|
||||||
|
clearTimeout(idleCloseTimer)
|
||||||
|
idleCloseTimer = null
|
||||||
|
}
|
||||||
|
idleCloseTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (context && refCount === 0) {
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context = null
|
||||||
|
contextPromise = null
|
||||||
|
idleCloseTimer = null
|
||||||
|
}
|
||||||
|
}, idleMillis)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// src/scrapeDouyin.ts
|
// src/scrapeDouyin.ts
|
||||||
import { chromium, type Response } from 'playwright';
|
import { BrowserContext, Page, chromium, type Response } from 'playwright';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||||
import { createCamelCompatibleProxy } from '@/app/fetcher/utils';
|
import { createCamelCompatibleProxy } from '@/app/fetcher/utils';
|
||||||
@ -7,16 +7,50 @@ import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBin
|
|||||||
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media';
|
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media';
|
||||||
import { handleImagePost } from '@/app/fetcher/uploader';
|
import { handleImagePost } from '@/app/fetcher/uploader';
|
||||||
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist';
|
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { acquireBrowserContext, releaseBrowserContext } from '@/app/fetcher/browser';
|
||||||
|
|
||||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
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 });
|
async function readPostMem(context: BrowserContext, page: Page) {
|
||||||
|
const md = await page.evaluate(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||||
|
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||||
|
// return {aweme: { detail: {} } };
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
||||||
|
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
aweme_mem.author = aweme_mem.authorInfo
|
||||||
|
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null;
|
||||||
|
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||||
|
|
||||||
|
return { aweme, comments }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ScrapeError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public statusCode: number = 500,
|
||||||
|
public code?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ScrapeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeDouyin(url: string) {
|
||||||
|
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...'));
|
||||||
|
|
||||||
|
const context = await acquireBrowserContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
// 建一个全局容器存捕获的数据
|
// 建一个全局容器存捕获的数据
|
||||||
@ -41,8 +75,6 @@ export async function scrapeDouyin(url: string) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 把 self/window 上的同名队列都指向我们的 proxy
|
|
||||||
// 有些站点用 self,有些用 window
|
|
||||||
(self as any).__pace_f = proxyArr;
|
(self as any).__pace_f = proxyArr;
|
||||||
(window as any).__pace_f = proxyArr;
|
(window as any).__pace_f = proxyArr;
|
||||||
});
|
});
|
||||||
@ -52,119 +84,163 @@ 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 },
|
||||||
], 20_000); // 整体 20s 兜底超时,不逐个等待
|
], 9_000); // 整体 9s 兜底超时,不逐个等待
|
||||||
|
|
||||||
// 评论只做短时“有就用、没有不等”的监听
|
// 评论只做短时“有就用、没有不等”的监听
|
||||||
const commentPromise = waitForResponseWithTimeout(
|
const commentPromise = waitForResponseWithTimeout(
|
||||||
context,
|
context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 8_000
|
||||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200,
|
|
||||||
8_000
|
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
|
|
||||||
const firstType = await firstTypePromise; // { key, response } | null
|
// 查找页面中是否存在 "视频不存在" 的提示
|
||||||
const commentRes = await commentPromise; // Response | null
|
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
|
||||||
|
if (isNotFound) {
|
||||||
|
console.error(chalk.red('✗ 视频不存在或已被删除'));
|
||||||
|
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
if (!firstType) {
|
try {
|
||||||
console.warn('无法判定作品类型(未捕获详情或图文接口)');
|
// 优先尝试从内存读取图文数据
|
||||||
const md = await page.evaluate(() => {
|
let { aweme, comments } = await readPostMem(context, page);
|
||||||
// @ts-ignore
|
console.log(chalk.green('✓ 从内存读取图文数据成功'));
|
||||||
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<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
|
||||||
|
|
||||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
const uploads = await handleImagePost(context, aweme);
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
if (!comments) {
|
||||||
|
console.warn(chalk.yellow('⚠ 从内存读取评论数据失败,尝试从网络请求获取评论数据'));
|
||||||
|
|
||||||
|
const commentRes = await commentPromise;
|
||||||
|
comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
||||||
|
if (!comments) {
|
||||||
|
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
||||||
|
comments = { comments: [], total: 0, status_code: 0 };
|
||||||
|
} else {
|
||||||
|
console.log(chalk.green('✓ 从网络请求获取评论数据成功'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(chalk.green('✓ 从内存读取评论数据成功'));
|
||||||
|
}
|
||||||
|
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
||||||
|
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
||||||
return { type: "image", ...saved };
|
return { type: "image", ...saved };
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentRes = await commentPromise;
|
||||||
|
const firstType = await firstTypePromise;
|
||||||
|
|
||||||
|
if (!firstType) {
|
||||||
|
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
|
||||||
|
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`));
|
||||||
|
|
||||||
|
let comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
||||||
|
if (!comments) {
|
||||||
|
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
||||||
|
comments = { comments: [], total: 0, status_code: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||||
if (firstType.key === 'post') {
|
if (firstType.key === 'post') {
|
||||||
// 图文作品
|
// 图文作品
|
||||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
||||||
if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空');
|
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
|
||||||
|
|
||||||
const currentURL = page.url();
|
const currentURL = page.url();
|
||||||
const target_aweme_id = currentURL.split('/').at(-1);
|
const target_aweme_id = currentURL.split('/').at(-1);
|
||||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
||||||
if (!aweme) {
|
if (!aweme) {
|
||||||
console.warn(`图文作品响应中未找到对应作品,look for aweme_id=${target_aweme_id}, have ${postJson.aweme_list.map(pt => pt.aweme_id).join(', ')}`);
|
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
|
||||||
// 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<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
const uploads = await handleImagePost(context, aweme);
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
|
||||||
|
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
||||||
return { type: "image", ...saved };
|
return { type: "image", ...saved };
|
||||||
} else if (firstType.key === 'detail') {
|
} else if (firstType.key === 'detail') {
|
||||||
// 视频作品
|
// 视频作品
|
||||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
||||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
|
||||||
|
|
||||||
// 找到比特率最高的 url
|
// 找到比特率最高的 url
|
||||||
const bestPlayAddr = pickBestPlayAddr(
|
const bestPlayAddr = pickBestPlayAddr(
|
||||||
detail?.aweme_detail?.video.bit_rate
|
detail?.aweme_detail?.video.bit_rate
|
||||||
);
|
);
|
||||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||||
|
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
||||||
|
|
||||||
console.log('Best video URL:', bestVUrl);
|
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
||||||
|
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`));
|
||||||
|
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
||||||
|
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`));
|
||||||
|
}
|
||||||
|
|
||||||
// 下载视频并上传至 MinIO,获取外链
|
// 下载视频并上传至 MinIO,获取外链
|
||||||
let uploadedUrl: string | undefined;
|
let uploadedUrl: string | undefined;
|
||||||
let coverUrl: string | undefined;
|
let coverUrl: string | undefined;
|
||||||
if (bestVUrl && detail?.aweme_detail) {
|
if (bestVUrl && detail?.aweme_detail) {
|
||||||
|
console.log(chalk.blue('⬇️ 正在下载视频...'));
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
||||||
const awemeId = detail.aweme_detail.aweme_id;
|
const awemeId = detail.aweme_detail.aweme_id;
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
||||||
|
|
||||||
|
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
|
||||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||||
console.log('Uploaded to MinIO:', uploadedUrl);
|
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
|
||||||
|
|
||||||
// 提取首帧作为封面并上传
|
// 提取首帧作为封面并上传
|
||||||
try {
|
try {
|
||||||
|
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
|
||||||
const cover = await extractFirstFrame(buffer);
|
const cover = await extractFirstFrame(buffer);
|
||||||
if (cover) {
|
if (cover) {
|
||||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
||||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
||||||
console.log('Cover uploaded to MinIO:', coverUrl);
|
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Extract first frame failed, skip cover:', (e as Error)?.message || e);
|
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl);
|
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
|
||||||
|
console.log(chalk.green.bold('✓ 视频作品保存成功'));
|
||||||
return { type: "video", ...saved };
|
return { type: "video", ...saved };
|
||||||
} else {
|
} else {
|
||||||
throw new Error('无法判定作品类型(未命中详情或图文接口)');
|
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果是我们自定义的错误,直接抛出
|
||||||
|
if (error instanceof ScrapeError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理其他类型的错误
|
||||||
|
const errMsg = (error as Error)?.message || String(error);
|
||||||
|
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
||||||
|
|
||||||
|
// 根据错误类型返回不同的状态码
|
||||||
|
if (errMsg.includes('timeout') || errMsg.includes('超时')) {
|
||||||
|
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT');
|
||||||
|
}
|
||||||
|
if (errMsg.includes('页面内存数据中未找到作品详情')) {
|
||||||
|
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED');
|
||||||
|
}
|
||||||
|
if (errMsg.includes('net::')) {
|
||||||
|
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认服务器错误
|
||||||
|
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
console.log(chalk.gray('🧹 清理资源...'));
|
||||||
await browser.close();
|
try { await page.close({ runBeforeUnload: true }); } catch {}
|
||||||
|
// 仅释放共享上下文的引用,不直接关闭窗口
|
||||||
|
await releaseBrowserContext();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
console.log(chalk.gray('✓ 资源清理完成'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export async function downloadBinary(
|
|||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
url: string
|
url: string
|
||||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||||
console.log('Download bin:', url);
|
console.log('下载:', url);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
referer: url,
|
referer: url,
|
||||||
|
|||||||
@ -10,7 +10,8 @@ export async function saveToDB(
|
|||||||
videoUrl?: string,
|
videoUrl?: string,
|
||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
coverUrl?: string
|
coverUrl?: string,
|
||||||
|
fps?: number
|
||||||
) {
|
) {
|
||||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
||||||
const d = detailResp.aweme_detail;
|
const d = detailResp.aweme_detail;
|
||||||
@ -63,6 +64,8 @@ export async function saveToDB(
|
|||||||
width: width ?? null,
|
width: width ?? null,
|
||||||
height: height ?? null,
|
height: height ?? null,
|
||||||
cover_url: coverUrl ?? null,
|
cover_url: coverUrl ?? null,
|
||||||
|
fps: fps ?? null,
|
||||||
|
raw_json: detailResp as any, // 保存完整接口 JSON
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
desc: d.desc,
|
desc: d.desc,
|
||||||
@ -79,6 +82,8 @@ export async function saveToDB(
|
|||||||
...(width ? { width } : {}),
|
...(width ? { width } : {}),
|
||||||
...(height ? { height } : {}),
|
...(height ? { height } : {}),
|
||||||
...(coverUrl ? { cover_url: coverUrl } : {}),
|
...(coverUrl ? { cover_url: coverUrl } : {}),
|
||||||
|
...(fps ? { fps } : {}),
|
||||||
|
raw_json: detailResp as any, // 更新完整接口 JSON
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,7 +138,8 @@ 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 }[]; musicUrl?: string },
|
||||||
|
rawJson?: any
|
||||||
) {
|
) {
|
||||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
||||||
|
|
||||||
@ -180,6 +186,7 @@ export async function saveImagePostToDB(
|
|||||||
authorId: author.sec_uid,
|
authorId: author.sec_uid,
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||||
music_url: uploads.musicUrl ?? null,
|
music_url: uploads.musicUrl ?? null,
|
||||||
|
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
desc: aweme.desc,
|
desc: aweme.desc,
|
||||||
@ -192,6 +199,7 @@ export async function saveImagePostToDB(
|
|||||||
authorId: author.sec_uid,
|
authorId: author.sec_uid,
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||||
music_url: uploads.musicUrl ?? undefined,
|
music_url: uploads.musicUrl ?? undefined,
|
||||||
|
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,49 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { scrapeDouyin } from '.';
|
import { scrapeDouyin, ScrapeError } from '.';
|
||||||
|
|
||||||
async function handleDouyinScrape(req: NextRequest) {
|
async function handleDouyinScrape(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const videoUrl = searchParams.get('url');
|
const videoUrl = searchParams.get('url');
|
||||||
|
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
return NextResponse.json({ error: '缺少视频URL' }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: '缺少视频URL', code: 'MISSING_URL' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用爬虫函数
|
try {
|
||||||
const result = await scrapeDouyin(videoUrl);
|
// 调用爬虫函数
|
||||||
return NextResponse.json(result);
|
const result = await scrapeDouyin(videoUrl);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 处理自定义的 ScrapeError
|
||||||
|
if (error instanceof ScrapeError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
code: error.code
|
||||||
|
},
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理未知错误
|
||||||
|
console.error('未捕获的错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '服务器内部错误',
|
||||||
|
code: 'INTERNAL_ERROR'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handleDouyinScrape
|
export const GET = handleDouyinScrape
|
||||||
|
|||||||
3
app/fetcher/types.d.ts
vendored
@ -83,6 +83,9 @@ interface PlayVariant {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
data_size: number;
|
data_size: number;
|
||||||
|
FPS: number;
|
||||||
|
is_bytevc1: number; // 0 or 1
|
||||||
|
is_h265: number; // 0 or 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export default function TasksPage() {
|
|||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const controllers = useRef<Map<string, AbortController>>(new Map());
|
const controllers = useRef<Map<string, AbortController>>(new Map());
|
||||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||||
|
const [, setTick] = useState(0); // 用于强制更新计时显示
|
||||||
|
|
||||||
const inProgressUrls = useMemo(
|
const inProgressUrls = useMemo(
|
||||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||||||
@ -44,12 +45,13 @@ export default function TasksPage() {
|
|||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
const existing = new Set(prev.map((t) => t.id));
|
const existing = new Set(prev.map((t) => t.id));
|
||||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
||||||
const next: Task[] = [...prev];
|
const newTasks: Task[] = [];
|
||||||
for (const url of notDuplicated) {
|
for (const url of notDuplicated) {
|
||||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
next.push({ id, url, status: "pending" });
|
newTasks.push({ id, url, status: "pending" });
|
||||||
}
|
}
|
||||||
return next;
|
// 新任务添加到最前面
|
||||||
|
return [...newTasks, ...prev];
|
||||||
});
|
});
|
||||||
}, [inProgressUrls]);
|
}, [inProgressUrls]);
|
||||||
|
|
||||||
@ -69,14 +71,18 @@ export default function TasksPage() {
|
|||||||
if (controllers.current.has(task.id)) return;
|
if (controllers.current.has(task.id)) return;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
controllers.current.set(task.id, ctrl);
|
controllers.current.set(task.id, ctrl);
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now() } : t));
|
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
// 使用后端返回的结构化错误信息
|
||||||
throw new Error(text || `请求失败: ${res.status}`);
|
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||||||
|
const errorCode = data?.code || 'UNKNOWN';
|
||||||
|
throw new Error(`${errorMsg} (${errorCode})`);
|
||||||
}
|
}
|
||||||
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));
|
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
||||||
@ -92,12 +98,33 @@ export default function TasksPage() {
|
|||||||
pending.forEach((t) => startTask(t));
|
pending.forEach((t) => startTask(t));
|
||||||
}, [tasks, startTask]);
|
}, [tasks, startTask]);
|
||||||
|
|
||||||
|
// 定时器更新运行中任务的耗时显示
|
||||||
|
useEffect(() => {
|
||||||
|
const hasRunningTasks = tasks.some(t => t.status === "running");
|
||||||
|
if (!hasRunningTasks) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTick(prev => prev + 1);
|
||||||
|
}, 1000); // 每秒更新一次
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
const cancelTask = useCallback((id: string) => {
|
const cancelTask = useCallback((id: string) => {
|
||||||
const ctrl = controllers.current.get(id);
|
const ctrl = controllers.current.get(id);
|
||||||
if (ctrl) ctrl.abort();
|
if (ctrl) ctrl.abort();
|
||||||
controllers.current.delete(id);
|
controllers.current.delete(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const retryTask = useCallback((taskId: string) => {
|
||||||
|
setTasks(prev => prev.map(t => {
|
||||||
|
if (t.id === taskId) {
|
||||||
|
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearFinished = useCallback(() => {
|
const clearFinished = useCallback(() => {
|
||||||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
||||||
}, []);
|
}, []);
|
||||||
@ -203,7 +230,25 @@ export default function TasksPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 space-x-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{t.status === 'success' && t.result?.data?.aweme_id && (
|
||||||
|
<a
|
||||||
|
href={`/aweme/${t.result.data.aweme_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5"/> 查看作品
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{t.status === 'error' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryTask(t.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-3.5 w-3.5"/> 重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{t.status === 'running' && (
|
{t.status === 'running' && (
|
||||||
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
||||||
<Square className="h-3.5 w-3.5"/> 取消
|
<Square className="h-3.5 w-3.5"/> 取消
|
||||||
@ -224,13 +269,49 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||||
{t.status === 'error' && (
|
{t.status === 'error' && t.error && (
|
||||||
<div className="mb-2 inline-flex items-center gap-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200">
|
<div className="mb-3 space-y-2">
|
||||||
<AlertTriangle className="h-4 w-4"/> {t.error}
|
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-red-200">爬取失败</div>
|
||||||
|
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-400">
|
||||||
|
<strong>常见原因:</strong>
|
||||||
|
<ul className="ml-4 mt-1 list-disc space-y-0.5">
|
||||||
|
<li>视频已被删除或设为私密</li>
|
||||||
|
<li>网络连接问题或请求超时</li>
|
||||||
|
<li>抖音接口限流或反爬虫</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result !== 'undefined' && (
|
{typeof t.result !== 'undefined' && (
|
||||||
<pre className="max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
<div>
|
||||||
|
{t.result?.data?.aweme && (
|
||||||
|
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-400"/>
|
||||||
|
<span className="font-medium text-emerald-200">作品信息</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-neutral-300">
|
||||||
|
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div>
|
||||||
|
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</div>
|
||||||
|
{t.result.data.aweme.author && (
|
||||||
|
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
||||||
|
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span>
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
<div className="text-xs text-neutral-400">暂无输出</div>
|
||||||
|
|||||||
3
bun.lock
@ -5,6 +5,7 @@
|
|||||||
"name": "douyin-archive",
|
"name": "douyin-archive",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.3",
|
"@prisma/client": "^6.16.3",
|
||||||
|
"chalk": "^5.6.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "15.5.6",
|
"next": "15.5.6",
|
||||||
@ -199,6 +200,8 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.3",
|
"@prisma/client": "^6.16.3",
|
||||||
|
"chalk": "^5.6.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "15.5.6",
|
"next": "15.5.6",
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ImagePost" ADD COLUMN "raw_json" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Video" ADD COLUMN "fps" INTEGER,
|
||||||
|
ADD COLUMN "raw_json" JSONB;
|
||||||
@ -42,6 +42,9 @@ model Video {
|
|||||||
width Int?
|
width Int?
|
||||||
height Int?
|
height Int?
|
||||||
|
|
||||||
|
// 视频帧率
|
||||||
|
fps Int?
|
||||||
|
|
||||||
// 视频封面(首帧提取后上传到 MinIO 的外链)
|
// 视频封面(首帧提取后上传到 MinIO 的外链)
|
||||||
cover_url String?
|
cover_url String?
|
||||||
|
|
||||||
@ -53,6 +56,9 @@ model Video {
|
|||||||
tags String[] // 视频标签列表
|
tags String[] // 视频标签列表
|
||||||
video_url String // 视频文件 URL
|
video_url String // 视频文件 URL
|
||||||
|
|
||||||
|
// 保存完整的接口原始 JSON 数据(用于备份和后续分析)
|
||||||
|
raw_json Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -117,6 +123,9 @@ model ImagePost {
|
|||||||
images ImageFile[]
|
images ImageFile[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
|
// 保存完整的接口原始 JSON 数据(用于备份和后续分析)
|
||||||
|
raw_json Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
BIN
public/emojis/18禁.webp
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/emojis/666.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/emojis/OK.webp
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/emojis/V5.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/emojis/candy.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/emojis/kisskiss.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/okk.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/emojis/一头乱麻.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/emojis/一起加油.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/emojis/不你不想.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/emojis/不失礼貌的微笑.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/不是吧.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/不看.webp
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/emojis/举手.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/emojis/九转大肠.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/emojis/二哈.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/emojis/互粉.webp
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/emojis/你不大行.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/做鬼脸.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/偷笑.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/emojis/元宝.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/emojis/再见.webp
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/emojis/冷漠.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/emojis/凋谢.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/emojis/减一.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/emojis/击掌.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/emojis/加一.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/emojis/加功德.webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public/emojis/加鸡腿.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/emojis/勾引.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/emojis/发.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/emojis/发呆.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/emojis/发怒.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/emojis/可怜.webp
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/emojis/右边.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/emojis/叹气.webp
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/emojis/吃瓜群众.webp
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/emojis/吐.webp
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/emojis/吐彩虹.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/吐舌.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/emojis/吐舌小狗.webp
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/emojis/吐血.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/emojis/听歌.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/emojis/呆无辜.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/呲牙.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/emojis/咒骂.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/咖啡.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/emojis/哈欠.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/emojis/哭哭.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/emojis/啤酒.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/emojis/嘘.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/嘴唇.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/emojis/嘿哈.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/emojis/噢买尬.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/emojis/囧.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/emojis/困.webp
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/emojis/圣诞帽.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/emojis/圣诞树.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/emojis/坏笑.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/emojis/垃圾.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/emojis/大哭.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/emojis/大笑.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/emojis/大金牙.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/emojis/太阳.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/emojis/奋斗.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/奸笑.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/emojis/好开心.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/emojis/如花.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/emojis/委屈.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/emojis/宕机.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/emojis/害羞.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/emojis/小鼓掌.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/尬笑.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/emojis/尴尬流汗.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/emojis/屎.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/emojis/展开说说.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/emojis/左上.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/emojis/左边.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/emojis/巧克力.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/emojis/干饭人.webp
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/emojis/平安果.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/emojis/庆祝.webp
Normal file
|
After Width: | Height: | Size: 8.8 KiB |