Compare commits

..

3 Commits

Author SHA1 Message Date
f94ef73518 添加详情页的点赞计数,添加网站图标 2025-10-23 14:22:12 +08:00
5a5eba19e4 反爬虫优化,评论图片支持 2025-10-23 13:25:51 +08:00
590b26c420 优化动图 2025-10-23 10:30:43 +08:00
34 changed files with 1395 additions and 174 deletions

View File

@ -0,0 +1,175 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ awemeId: string }> }
) {
const awemeId = (await params).awemeId;
const searchParams = request.nextUrl.searchParams;
const skip = parseInt(searchParams.get("skip") || "0", 10);
const take = parseInt(searchParams.get("take") || "20", 10);
const mode = (searchParams.get("mode") || "").toLowerCase();
// ranked 模式参数(均为可选,提供合理默认值)
const seed = searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
const snapshotIso = searchParams.get("snapshot");
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
const wPop = parseFloat(searchParams.get("wPop") || "1"); // 热度权重
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
try {
// 查找是视频还是图文
const [video, post] = await Promise.all([
prisma.video.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: awemeId },
select: { aweme_id: true },
}),
]);
if (!video && !post) {
return NextResponse.json({ error: "作品不存在" }, { status: 404 });
}
// 构建查询条件
const where = video
? { videoId: awemeId }
: { imagePostId: awemeId };
// ranked 模式:按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
if (mode === "ranked") {
const total = await prisma.comment.count({ where });
// 计算稳定随机扰动:基于 (cid, seed) 的 md5 -> 取前4字节 -> 映射到 [0,1)
const jitterExpr = Prisma.sql`(
(
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 0)::double precision * 16777216.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 1)::double precision * 65536.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 2)::double precision * 256.0 +
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 3)::double precision
) / 4294967295.0
)`;
const snapshotTs = snapshot.toISOString();
// popularity: ln(1 + digg_count)
// time decay: exp(- age_hours / halfLifeHours),使用固定 snapshot 基准保证会话内稳定
const scoreExpr = Prisma.sql`(
${wPop} * ln(1 + c."digg_count"::numeric) +
${wTime} * exp(- (extract(epoch from (${snapshotTs}::timestamptz - c."created_at")) / 3600.0) / ${halfLifeHours}) +
${wJit} * ${jitterExpr}
)`;
const whereField = (await video) ? Prisma.sql`c."videoId"` : Prisma.sql`c."imagePostId"`;
const rows: Array<{
cid: string;
text: string;
created_at: Date;
digg_count: bigint;
nickname: string;
avatar_url: string | null;
}> = await prisma.$queryRaw(
Prisma.sql`
SELECT
c."cid",
c."text",
c."created_at",
c."digg_count",
u."nickname",
u."avatar_url"
FROM "Comment" c
JOIN "CommentUser" u ON u."id" = c."userId"
WHERE ${whereField} = ${awemeId}
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
OFFSET ${skip}
LIMIT ${take}
`
);
// 批量查询每条评论的配图/贴纸
const cids = rows.map(r => r.cid);
const images = cids.length
? await prisma.commentImage.findMany({
where: { commentId: { in: cids } },
orderBy: { order: 'asc' },
select: { commentId: true, url: true, width: true, height: true, order: true },
})
: [];
const group = new Map<string, { url: string; width?: number | null; height?: number | null; order: number }[]>();
for (const img of images) {
const arr = group.get(img.commentId) || [];
arr.push({ url: img.url, width: img.width, height: img.height, order: img.order });
group.set(img.commentId, arr);
}
const formattedComments = rows.map((c) => ({
cid: c.cid,
text: c.text,
created_at: c.created_at,
digg_count: Number(c.digg_count),
user: {
nickname: c.nickname,
avatar_url: c.avatar_url || undefined,
},
images: (group.get(c.cid) || []).sort((a,b)=>a.order-b.order).map(i=>({ url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
}));
return NextResponse.json({
comments: formattedComments,
total,
hasMore: skip + take < total,
// 回传用于稳定排序的参数,前端可在后续请求中透传
seed,
snapshot: snapshotTs,
weights: { wPop, wTime, wJit, halfLifeHours },
mode: "ranked",
});
}
// 默认模式:获取评论总数和分页数据(使用稳定且不可变的排序,避免因 digg_count 变化导致翻页重复/遗漏)
const [total, comments] = await Promise.all([
prisma.comment.count({ where }),
prisma.comment.findMany({
where,
orderBy: [
{ created_at: "desc" },
{ cid: "asc" },
],
include: { user: true, images: { orderBy: { order: 'asc' }, select: { url: true, width: true, height: true } } },
skip,
take,
}),
]);
const formattedComments = comments.map((c) => ({
cid: c.cid,
text: c.text,
created_at: c.created_at,
digg_count: Number(c.digg_count),
user: {
nickname: c.user.nickname,
avatar_url: c.user.avatar_url,
},
images: (c.images || []).map(i=>({ url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
}));
return NextResponse.json({
comments: formattedComments,
total,
hasMore: skip + take < total,
});
} catch (error) {
console.error("获取评论失败:", error);
return NextResponse.json(
{ error: "获取评论失败" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,17 @@
// index.js
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth'
chromium.use(stealth());
(async () => {
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://bot.sannysoft.com/'); // 常用自测页
console.log('Title:', await page.title());
await page.screenshot({ path: 'stealth.png', fullPage: true });
setTimeout(async () => {
await browser.close();
}, 1000_000);
})();

View File

@ -1,20 +1,22 @@
import { chromium, type BrowserContext } from 'playwright'
export const runtime = 'nodejs'
// A simple singleton manager for a persistent Chromium context with ref-counting.
// Prevents concurrent tasks from closing the shared window prematurely.
import { type Browser, type BrowserContext } from 'playwright'
import { chromium } from 'playwright-extra' // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
import stealth from 'puppeteer-extra-plugin-stealth'
chromium.use(stealth());
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 USER_DATA_DIR = process.env.USER_DATA_DIR ?? 'chrome-profile/douyin'
async function launchContext(): Promise<BrowserContext> {
const ctx = await chromium.launchPersistentContext(
USER_DATA_DIR,
{
headless: Boolean(process.env.CHROMIUM_HEADLESS ?? 'false'),
headless: process.env.CHROMIUM_HEADLESS === 'true',
viewport: {
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
@ -77,3 +79,61 @@ export async function releaseBrowserContext(options?: { idleMillis?: number }):
}
}, idleMillis)
}
// --- Isolated context support for per-scrape independence ---
const isolatedMap = new WeakMap<BrowserContext, Browser>()
async function getSharedStorageState(): Promise<any | undefined> {
try {
const shared = await acquireBrowserContext()
const state = await shared.storageState()
// Do not force-close immediately; keep ref-counting behavior
await releaseBrowserContext()
return state
} catch {
// If shared context not available, proceed without storageState
return undefined
}
}
/**
* Create a brand-new, independent Browser + Context for a single scrape.
* Attempts to import cookies/localStorage from the shared persistent profile
* so you remain logged-in, but isolates network events, cache and listeners.
*/
export async function acquireIsolatedContext(): Promise<BrowserContext> {
const storageState = await getSharedStorageState()
const browser = await chromium.launch({
headless: process.env.CHROMIUM_HEADLESS === 'true'
})
const ctx = await browser.newContext({
storageState,
viewport: {
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
}
})
isolatedMap.set(ctx, browser)
ctx.on('close', () => {
const b = isolatedMap.get(ctx)
if (b) {
b.close().catch(() => {})
isolatedMap.delete(ctx)
}
})
return ctx
}
export async function releaseIsolatedContext(ctx: BrowserContext | null | undefined): Promise<void> {
if (!ctx) return
try {
await ctx.close()
} finally {
const b = isolatedMap.get(ctx)
if (b) {
await b.close().catch(() => {})
isolatedMap.delete(ctx)
}
}
}

View File

@ -1,19 +1,84 @@
export const runtime = 'nodejs'
// src/scrapeDouyin.ts
import { BrowserContext, Page, chromium, type Response } from 'playwright';
import { BrowserContext, Page, type Response } from 'playwright';
import { chromium } from 'playwright-extra';
import { prisma } from '@/lib/prisma';
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils';
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/api/fetcher/network';
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary, collectResponsesWithinTime } from '@/app/api/fetcher/network';
import { pickBestPlayAddr, extractFirstFrame } from '@/app/api/fetcher/media';
import { handleImagePost } from '@/app/api/fetcher/uploader';
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
import chalk from 'chalk';
import { acquireBrowserContext, releaseBrowserContext } from '@/app/api/fetcher/browser';
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser';
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
const POST_PATH = '/aweme/v1/web/aweme/post/'
/**
*
* @param context
* @param page
* @param durationMs
* @returns
*/
async function scrollAndCollectComments(
context: BrowserContext,
page: Page,
durationMs: number = 10_000
): Promise<Response[]> {
console.log(chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`));
// 启动评论响应收集器
const commentResponsesPromise = collectResponsesWithinTime(
context,
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200 && r.request().frame()?.page() === page,
durationMs
);
// 在指定时间内持续滚动页面
const startTime = Date.now();
const scrollInterval = 500;
let scrollCount = 0;
const selector = "div[data-e2e='comment-list']";
// 1) 等元素出现并可见
await page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
// 2) 确保滚动到可见区域
const list = page.locator(selector);
await list.scrollIntoViewIfNeeded();
// 3) 执行 hover推荐用 locator 的 hover
list.hover({ timeout: 5000 }).catch(() => { });
while (Date.now() - startTime < durationMs - 500) { // 留 500ms 缓冲
try {
list.hover({ timeout: 2000 }).catch(() => { });
// 使用 Playwright 的 mouse.wheel 方法滚动
// 每次滚动一大段距离
// await list.hover();
const scrollAmount = 1500;
await page.mouse.wheel(0, scrollAmount);
scrollCount++;
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
// 等待一段时间,让评论加载
await page.waitForTimeout(scrollInterval);
} catch (e) {
console.warn(chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`));
}
}
// 等待收集器完成
const commentResponses = await commentResponsesPromise;
console.log(chalk.green(`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`));
return commentResponses;
}
async function readPostMem(context: BrowserContext, page: Page) {
const md = await page.evaluate(() => {
// @ts-ignore
@ -22,11 +87,16 @@ async function readPostMem(context: BrowserContext, page: Page) {
// return {aweme: { detail: {} } };
}).catch(() => null);
// await new Promise((res) => setTimeout(res, 1000000));
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
// @ts-ignore
aweme_mem.author = aweme_mem.authorInfo
// @ts-ignore
aweme_mem.statistics = aweme_mem.stats
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null;
const aweme = createCamelCompatibleProxy(aweme_mem);
@ -47,8 +117,7 @@ export class ScrapeError extends Error {
export async function scrapeDouyin(url: string) {
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...'));
const context = await acquireBrowserContext();
let context: BrowserContext | null = await acquireIsolatedContext();
const page = await context.newPage();
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
@ -82,16 +151,11 @@ export async function scrapeDouyin(url: string) {
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 },
], 40_000);
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
], 10_000);
// 评论只做短时“有就用、没有不等”的监听
const commentPromise = waitForResponseWithTimeout(
context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 40_000
).catch(() => null);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
// 查找页面中是否存在 "视频不存在" 的提示
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
@ -100,52 +164,88 @@ export async function scrapeDouyin(url: string) {
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
}
try {
// 优先尝试从内存读取图文数据
let { aweme, comments } = await readPostMem(context, page);
console.log(chalk.green('✓ 从内存读取图文数据成功'));
const uploads = await handleImagePost(context, aweme);
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 };
} catch {
}
const commentRes = await commentPromise;
// 等待作品类型判定
const firstType = await firstTypePromise;
if (!firstType) {
// 尝试从内存读取图文数据(如果是图文作品)
let memoryData: { aweme: any; comments: DouyinCommentResponse | null } | null = null;
try {
memoryData = await readPostMem(context, page);
console.log(chalk.green('✓ 从内存读取图文数据成功'));
} catch {
// 内存读取失败,稍后通过网络获取
}
if (!firstType && !memoryData) {
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
}
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`));
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType?.key === 'post' || memoryData ? '图文' : '视频')}`));
let comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
if (!comments) {
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
comments = { comments: [], total: 0, status_code: 0 };
let allComments: DouyinComment[] = [];
try {
// 开始滚动并收集评论
const commentResponses = await scrollAndCollectComments(context, page);
// 解析所有收集到的评论响应
for (const commentRes of commentResponses) {
try {
const commentData = await safeJson<DouyinCommentResponse>(commentRes);
if (commentData?.comments?.length) {
allComments.push(...commentData.comments);
}
} catch (e) {
console.warn(chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`));
}
}
} catch (error) {
console.warn(chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`));
}
// 去重评论(根据 cid
const uniqueComments = Array.from(
new Map(allComments.map(c => [c.cid, c])).values()
);
console.log(chalk.green(`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length}`));
// 如果从内存读取到了评论,合并进来作为兜底
let comments: DouyinCommentResponse;
if (memoryData?.comments?.comments?.length) {
console.log(chalk.blue(`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`));
const memComments = memoryData.comments.comments;
const mergedMap = new Map(uniqueComments.map(c => [c.cid, c]));
for (const c of memComments) {
if (!mergedMap.has(c.cid)) {
mergedMap.set(c.cid, c);
}
}
comments = {
comments: Array.from(mergedMap.values()),
total: mergedMap.size,
status_code: 0
};
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
} else {
comments = {
comments: uniqueComments,
total: uniqueComments.length,
status_code: 0
};
}
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
if (firstType.key === 'post') {
// 图文作品
// 优先处理内存数据(图文)
if (memoryData) {
const aweme = memoryData.aweme;
const uploads = await handleImagePost(context, aweme);
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
console.log(chalk.green.bold('✓ 图文作品保存成功'));
return { type: "image", ...saved };
} else if (firstType?.key === 'post') {
// 图文作品(网络)
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
@ -161,7 +261,7 @@ export async function scrapeDouyin(url: string) {
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
console.log(chalk.green.bold('✓ 图文作品保存成功'));
return { type: "image", ...saved };
} else if (firstType.key === 'detail') {
} else if (firstType?.key === 'detail') {
// 视频作品
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
@ -236,9 +336,9 @@ export async function scrapeDouyin(url: string) {
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
} finally {
console.log(chalk.gray('🧹 清理资源...'));
try { await page.close({ runBeforeUnload: true }); } catch {}
// 仅释放共享上下文的引用,不直接关闭窗口
await releaseBrowserContext();
try { await page.close({ runBeforeUnload: true }); } catch { }
// 关闭本次任务的隔离上下文与浏览器
await releaseIsolatedContext(context);
await prisma.$disconnect();
console.log(chalk.gray('✓ 资源清理完成'));
}

View File

@ -1,3 +1,5 @@
export const runtime = 'nodejs'
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
@ -55,3 +57,36 @@ export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer:
try { await fs.unlink(outPath); } catch { }
}
}
/**
* 使 ffprobe
*/
export async function getVideoDuration(videoBuffer: Buffer): Promise<number | null> {
const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe';
const tmpDir = os.tmpdir();
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const inPath = path.join(tmpDir, `${base}.mp4`);
try {
await fs.writeFile(inPath, videoBuffer);
const args = [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inPath,
];
const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true });
const durationSeconds = parseFloat(stdout.trim());
if (isNaN(durationSeconds)) return null;
return Math.round(durationSeconds * 1000); // 转换为毫秒
} catch (e: any) {
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
console.warn('系统未检测到 ffprobe可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。');
return null;
}
console.warn(`获取视频时长失败: ${e?.message || e}`);
return null;
} finally {
try { await fs.unlink(inPath); } catch { }
}
}

View File

@ -1,3 +1,5 @@
export const runtime = 'nodejs'
import type { BrowserContext, Response } from 'playwright';
export async function safeJson<T>(res: Response): Promise<T | null> {
@ -20,12 +22,12 @@ export async function safeJson<T>(res: Response): Promise<T | null> {
*/
export async function downloadBinary(
context: BrowserContext,
url: string
url: string,
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
console.log('下载:', url);
const headers = {
referer: url,
referer: 'https://www.douyin.com/',
} as Record<string, string>;
const res = await context.request.get(url, {
@ -93,7 +95,49 @@ export function waitForFirstResponse(
}
/**
* Response
* Response
*
*/
export function collectResponsesWithinTime(
context: BrowserContext,
predicate: (r: Response) => boolean,
durationMs: number
): Promise<Response[]> {
return new Promise((resolve) => {
const collected: Response[] = [];
const seenUrls = new Set<string>();
let timer: NodeJS.Timeout | undefined;
const handler = (res: Response) => {
try {
if (predicate(res)) {
// 使用 URL 去重,避免重复收集同一个请求
const url = res.url();
if (!seenUrls.has(url)) {
seenUrls.add(url);
collected.push(res);
}
}
} catch {
// ignore predicate errors
}
};
const cleanup = () => {
context.off('response', handler);
if (timer) clearTimeout(timer);
};
context.on('response', handler);
timer = setTimeout(() => {
cleanup();
resolve(collected);
}, durationMs);
});
}
/**
* Response"可有可无"
*/
export function waitForResponseWithTimeout(
context: BrowserContext,

View File

@ -1,6 +1,6 @@
import type { BrowserContext } from 'playwright';
import { prisma } from '@/lib/prisma';
import { uploadAvatarFromUrl } from './uploader';
import { uploadAvatarFromUrl, uploadImageFromUrl } from './uploader';
import { firstUrl } from './utils';
export async function saveToDB(
@ -111,7 +111,7 @@ export async function saveToDB(
},
});
await prisma.comment.upsert({
const savedComment = await prisma.comment.upsert({
where: { cid: c.cid },
create: {
cid: c.cid,
@ -129,6 +129,49 @@ export async function saveToDB(
userId: cu.id,
},
});
// 处理评论贴纸/配图上传与入库
try {
const sources: { url?: string | null; width?: number; height?: number }[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
}
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
});
}
} catch (e) {
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
}
}
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
@ -249,7 +292,7 @@ export async function saveImagePostToDB(
},
});
await prisma.comment.upsert({
const savedComment = await prisma.comment.upsert({
where: { cid: c.cid },
create: {
cid: c.cid,
@ -267,6 +310,49 @@ export async function saveImagePostToDB(
userId: cu.id,
},
});
// 处理评论贴纸/配图上传与入库
try {
const sources: { url?: string | null; width?: number; height?: number }[] = [];
// 贴纸(当作第一张)
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
if (stickerUrl) {
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
}
// 配图列表
const imgs = c.image_list || [];
for (const it of imgs) {
const u = firstUrl(it?.origin_url?.url_list);
if (u) sources.push({ url: u, width: it?.origin_url?.width as any, height: it?.origin_url?.height as any });
}
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const uploaded = await uploadImageFromUrl(
context,
s.url ?? undefined,
`comments/${c.cid}/${i}`,
);
if (!uploaded) continue;
await prisma.commentImage.upsert({
where: { commentId_order: { commentId: savedComment.cid, order: i } },
create: {
commentId: savedComment.cid,
order: i,
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
update: {
url: uploaded,
width: typeof s.width === 'number' ? s.width : null,
height: typeof s.height === 'number' ? s.height : null,
},
});
}
} catch (e) {
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
}
}
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };

View File

@ -1,3 +1,5 @@
export const runtime = 'nodejs'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { scrapeDouyin, ScrapeError } from '.';

View File

@ -12,6 +12,22 @@ interface DouyinComment {
digg_count: number; // 点赞数
create_time: number; // 创建时间(时间戳)
user: DouyinUser; // 评论用户
sticker?: {
id: number;
animate_url: {
width: number;
height: number;
url_list: string[]
}
},
image_list?: {
origin_url:{
width: number;
height: number;
url_list: string[]
}
}[]
}
/** 用户信息(精简版) */

View File

@ -1,7 +1,10 @@
export const runtime = 'nodejs'
import type { BrowserContext } from 'playwright';
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
import { downloadBinary } from './network';
import { pickFirstUrl } from './utils';
import { getVideoDuration } from './media';
/**
* MinIO退
@ -9,7 +12,7 @@ import { pickFirstUrl } from './utils';
export async function uploadAvatarFromUrl(
context: BrowserContext,
srcUrl?: string | null,
nameHint?: string
nameHint?: string,
): Promise<string | undefined> {
if (!srcUrl) return undefined;
try {
@ -25,13 +28,35 @@ export async function uploadAvatarFromUrl(
}
}
/**
* MinIO退
*/
export async function uploadImageFromUrl(
context: BrowserContext,
srcUrl?: string | null,
nameHint?: string,
): Promise<string | undefined> {
if (!srcUrl) return undefined;
try {
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
const safeExt = ext || 'jpg';
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
const fileName = generateUniqueFileName(baseName, 'douyin/comment-images');
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
return uploaded;
} catch (e) {
console.warn('[image] 上传失败,使用原始链接:', (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 }> {
): Promise<{ images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }> {
const awemeId = aweme.aweme_id;
const uploadedImages: { url: string; width?: number; height?: number, video?: string }[] = [];
const uploadedImages: { url: string; width?: number; height?: number; video?: string; duration?: number }[] = [];
// 下载图片(顺序保持)
for (let i = 0; i < (aweme.images?.length || 0); i++) {
@ -52,10 +77,25 @@ export async function handleImagePost(
const safeVideoExt = videoExt || 'mp4';
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images');
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType });
// 将动图的 video URL 也存储起来
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height, video: uploadedVideo });
// 获取动图时长
const duration = await getVideoDuration(videoBuffer);
// 将动图的 video URL 和 duration 也存储起来
uploadedImages.push({
url: uploaded,
width: img?.width,
height: img?.height,
video: uploadedVideo,
duration: duration ?? undefined
});
if (duration) {
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
}
} catch (e) {
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e);
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
}
}
} else {

View File

@ -1,3 +1,5 @@
export const runtime = 'nodejs'
export function toCamelCaseKey(key: string): string {
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
}

View File

@ -80,18 +80,43 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
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;
// 计算每张图片的时长
const durations = images.map(img => img.duration ?? SEGMENT_MS);
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const targetTime = ratio * totalDuration;
// 找到目标时间对应的图片索引和进度
let accumulatedTime = 0;
let targetIdx = 0;
let remainder = 0;
for (let i = 0; i < images.length; i++) {
if (accumulatedTime + durations[i] > targetTime) {
targetIdx = i;
remainder = (targetTime - accumulatedTime) / durations[i];
break;
}
accumulatedTime += durations[i];
if (i === images.length - 1) {
targetIdx = i;
remainder = 1;
}
}
imageCarouselState.idxRef.current = targetIdx;
imageCarouselState.setIdx(targetIdx);
imageCarouselState.segStartRef.current = performance.now() - remainder * SEGMENT_MS;
playerState.setProgress((targetIdx + remainder) / total);
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
const el = scrollerRef.current;
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" });
// 重新计算总进度
let totalProgress = 0;
for (let i = 0; i < targetIdx; i++) {
totalProgress += 1;
}
totalProgress += remainder;
playerState.setProgress(totalProgress / images.length);
// 虚拟滚动不需要实际滚动 DOM
};
const togglePlay = async () => {
@ -141,8 +166,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now();
const el = scrollerRef.current;
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
// 虚拟滚动不需要实际滚动 DOM
};
const nextImg = () => {
@ -151,8 +175,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now();
const el = scrollerRef.current;
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
// 虚拟滚动不需要实际滚动 DOM
};
const handleDownload = () => {
@ -276,7 +299,8 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
{/* 导航按钮 */}
<NavigationButtons
neighbors={neighbors}
commentsCount={data.comments.length}
commentsCount={data.commentsCount}
likesCount={data.likesCount}
onNavigatePrev={() => neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)}
onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)}
onToggleComments={() => commentState.setOpen((v) => !v)}
@ -289,7 +313,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
onClose={() => commentState.setOpen(false)}
author={data.author}
createdAt={data.created_at}
comments={data.comments}
awemeId={data.aweme_id}
mounted={commentState.mounted}
/>
</div>

View File

@ -1,4 +1,5 @@
import { ThumbsUp } from "lucide-react";
import { ThumbsUp, X } from "lucide-react";
import { useState } from "react";
import type { Comment, User } from "../types";
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
import { CommentText } from "./CommentText";
@ -10,6 +11,8 @@ interface CommentListProps {
}
export function CommentList({ author, createdAt, comments }: CommentListProps) {
const [previewImage, setPreviewImage] = useState<string | null>(null);
return (
<>
<header className="flex items-center gap-4 mb-5">
@ -42,6 +45,29 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
</p>
{c.images && c.images.length > 0 ? (
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
{c.images.map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setPreviewImage(img.url)}
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
title="点击预览大图"
>
{/* 以真实比例显示:利用 width/height 属性提供固有尺寸CSS 设定宽度 100% 高度自适应 */}
<img
src={img.url}
alt="comment-image"
width={img.width}
height={img.height}
className="w-full h-auto"
loading="lazy"
/>
</button>
))}
</div>
) : null}
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} />
<span>{c.digg_count}</span>
@ -51,6 +77,32 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
))}
{comments.length === 0 ? <li className="text-sm text-white/60"></li> : null}
</ul>
{/* 图片预览灯箱 */}
{previewImage && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
>
{/* 关闭按钮 */}
<button
type="button"
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center"
onClick={() => setPreviewImage(null)}
aria-label="关闭预览"
>
<X size={20} />
</button>
{/* 大图 */}
<img
src={previewImage}
alt="preview"
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
);
}

View File

@ -1,4 +1,5 @@
import { X } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { Comment, User } from "../types";
import { CommentList } from "./CommentList";
@ -7,13 +8,155 @@ interface CommentPanelProps {
onClose: () => void;
author: User;
createdAt: string | Date;
comments: Comment[];
awemeId: string;
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) {
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
const [rankParams, setRankParams] = useState<null | { seed: string; snapshot: string }>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
const scrollRefPortrait = useRef<HTMLDivElement>(null);
const sentinelRefPortrait = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
// 加载评论
const loadComments = useCallback(async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
loadingRef.current = true;
setLoading(true);
try {
const skip = reset ? 0 : comments.length;
const query = new URLSearchParams({
skip: String(skip),
take: String(20),
mode: 'ranked',
});
if (rankParams) {
query.set('seed', rankParams.seed);
query.set('snapshot', rankParams.snapshot);
}
const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`);
const data = await response.json();
// 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) {
setComments(() => {
const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
} else {
setComments((prev) => {
const merged = [...prev, ...(data.comments as Comment[])];
const seen = new Set<string>();
// 保留首次出现的项,既保证顺序也避免重复
return merged.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
}
setTotal(data.total);
setHasMore(data.hasMore);
if (data.mode === 'ranked' && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数
setRankParams((prev) => {
if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) };
return prev ?? { seed: String(data.seed), snapshot: String(data.snapshot) };
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [awemeId, comments.length, hasMore]);
// 面板打开时加载初始评论
useEffect(() => {
if (open && comments.length === 0) {
loadComments(true);
}
}, [open]);
// Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听)
useEffect(() => {
if (!open) return;
const observers: IntersectionObserver[] = [];
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => {
if (!rootEl || !targetEl) return;
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !loadingRef.current && hasMore) {
loadComments();
}
},
{
root: rootEl,
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发
threshold: 0,
}
);
io.observe(targetEl);
observers.push(io);
};
setup(scrollRefLandscape.current, sentinelRefLandscape.current);
setup(scrollRefPortrait.current, sentinelRefPortrait.current);
return () => {
for (const io of observers) io.disconnect();
};
}, [open, hasMore, loadComments]);
return (
<>
<style jsx global>{`
/* 自定义滚动条样式 */
.comment-scroll::-webkit-scrollbar {
width: 6px;
}
.comment-scroll::-webkit-scrollbar-track {
background: transparent;
}
.comment-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.comment-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Firefox */
.comment-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
`}</style>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
@ -32,11 +175,25 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
>
<X size={18} />
</button>
<div className="text-white font-semibold"> {comments.length > 0 ? `(${comments.length})` : ""}</div>
<div className="text-white font-semibold">
{total > 0 ? `(${total})` : ""}
</div>
</div>
<div className="p-3 overflow-auto">
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
{/* 底部加载触发区 */}
{hasMore && (
<div ref={sentinelRefLandscape} className="h-1 w-full" />
)}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">...</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm"></div>
)}
</div>
</aside>
@ -52,7 +209,9 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold"> {comments.length > 0 ? `(${comments.length})` : ""}</div>
<div className="text-white font-semibold">
{total > 0 ? `(${total})` : ""}
</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
@ -62,8 +221,20 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount
</button>
</div>
<div className="p-3 overflow-auto">
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
{/* 底部加载触发区 */}
{hasMore && (
<div ref={sentinelRefPortrait} className="h-1 w-full" />
)}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">...</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm"></div>
)}
</div>
</aside>
</>

View File

@ -1,4 +1,4 @@
import { forwardRef } from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types";
interface ImageCarouselProps {
@ -9,29 +9,134 @@ interface ImageCarouselProps {
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
const [offset, setOffset] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(new Set());
// 虚拟滚动:只渲染当前图片和前后各一张
const visibleIndices = (() => {
const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1);
indices.push(currentIndex);
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices;
})();
// 当 currentIndex 变化时,触发滚动动画
useEffect(() => {
setIsTransitioning(true);
setOffset(-currentIndex * 100);
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 300); // 与 CSS transition 时间匹配
return () => clearTimeout(timer);
}, [currentIndex]);
// 管理动图播放:进入视口时播放一次
useEffect(() => {
const currentImage = images[currentIndex];
if (!currentImage?.animated) return;
const videoKey = currentImage.id;
const videoEl = videoRefs.current.get(videoKey);
if (videoEl) {
// 检查是否已经播放过
if (!playedVideos.current.has(videoKey)) {
// 重置并播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
playedVideos.current.add(videoKey);
} else {
// 已播放过,重置到开头但不自动播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
}
}
}, [currentIndex, images]);
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
useEffect(() => {
const currentImage = images[currentIndex];
if (currentImage?.animated) {
const videoKey = currentImage.id;
// 清除其他视频的播放记录
playedVideos.current.forEach(key => {
if (key !== videoKey) {
playedVideos.current.delete(key);
}
});
}
}, [currentIndex, images]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
if (el) {
videoRefs.current.set(imageId, el);
} else {
videoRefs.current.delete(imageId);
}
};
return (
<div
ref={ref}
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
className="relative h-full w-full overflow-hidden"
>
{images.map((img, i) => (
<div
key={img.id}
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
onClick={onTogglePlay}
>{
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img
src={img.url}
alt={`image-${i + 1}`}
className="max-w-full max-h-full object-contain"
<div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
{visibleIndices.map((i) => {
const img = images[i];
return (
<div
key={img.id}
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
transform: `translateX(${i * 100}%)`,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
}}
/>
}
</div>
))}
onClick={onTogglePlay}
>
{img.animated ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
src={img.animated}
muted
playsInline
className="max-w-full max-h-full object-contain"
onEnded={(e) => {
// 播放结束后停留在最后一帧
e.currentTarget.pause();
}}
/>
) : (
<img
src={img.url}
alt={`image-${i + 1}`}
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
/>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@ -21,7 +21,7 @@ export function ImageNavigationButtons({
{/* 左侧按钮 */}
<button
className={`
absolute left-4 top-7/16 -translate-y-1/2 z-20
absolute left-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center
@ -38,7 +38,7 @@ export function ImageNavigationButtons({
{/* 右侧按钮 */}
<button
className={`
absolute right-4 top-7/16 -translate-y-1/2 z-20
absolute right-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center

View File

@ -1,9 +1,10 @@
import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react";
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
import type { Neighbors } from "../types";
interface NavigationButtonsProps {
neighbors: Neighbors;
commentsCount: number;
likesCount: number;
onNavigatePrev: () => void;
onNavigateNext: () => void;
onToggleComments: () => void;
@ -12,30 +13,44 @@ interface NavigationButtonsProps {
export function NavigationButtons({
neighbors,
commentsCount,
likesCount,
onNavigatePrev,
onNavigateNext,
onToggleComments,
}: NavigationButtonsProps) {
return (
<>
{/* 评论开关(右侧中部) */}
<button
className="z-10 grid place-items-center w-[54px] h-[54px] rounded-full absolute right-4 top-2/3 -translate-y-1/2 cursor-pointer"
onClick={onToggleComments}
aria-label="切换评论"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
<button
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<ThumbsUp size={40} className="" />
{commentsCount > 0 ? <span className="text-[18px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow"></span>
}
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span>
</div>
</button>
</div>
</button>
{/* 评论开关(右侧中部) */}
<button
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
onClick={onToggleComments}
aria-label="切换评论"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow"></span>
}
</div>
</button>
</div>
{/* 上下切换按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10">
<div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
<button
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"

View File

@ -46,9 +46,14 @@ export function useBackgroundCanvas({
} else {
const scroller = scrollerRef.current;
if (scroller) {
const currentImgContainer = scroller.children[idx] as HTMLElement;
if (currentImgContainer) {
sourceElement = currentImgContainer.querySelector("img");
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
for (const container of containers) {
const img = container.querySelector("img, video");
if (img && container.style.transform.includes(`${idx * 100}%`)) {
sourceElement = img as HTMLImageElement | HTMLVideoElement;
break;
}
}
}
}

View File

@ -68,8 +68,17 @@ export function useImageCarousel({
let localIdx = idxRef.current;
let elapsed = ts - start;
while (elapsed >= segmentMs) {
elapsed -= segmentMs;
// 获取当前图片的显示时长(动图使用其 duration静态图片使用 segmentMs
const getCurrentSegmentDuration = (index: number) => {
const img = images[index];
return img?.duration ?? segmentMs;
};
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
while (elapsed >= currentSegmentDuration) {
elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
@ -80,19 +89,28 @@ export function useImageCarousel({
} else {
localIdx = localIdx + 1;
}
// 更新下一张图片的时长
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
}
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" });
// 虚拟滚动不需要实际滚动 DOM
}
const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs));
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
setSegProgress(localSeg);
setProgress((localIdx + localSeg) / images.length);
// 计算总进度:已完成的图片 + 当前图片的进度
let totalProgress = 0;
for (let i = 0; i < localIdx; i++) {
totalProgress += 1;
}
totalProgress += localSeg;
setProgress(totalProgress / images.length);
rafRef.current = requestAnimationFrame(tick);
};
@ -102,7 +120,7 @@ export function useImageCarousel({
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
return {
idx,

View File

@ -48,17 +48,23 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
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 } },
include: { author: true },
}),
prisma.imagePost.findUnique({
where: { aweme_id: id },
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
include: { author: true, images: { orderBy: { order: "asc" } } },
})
]);
if (!video && !post) return <main className="p-8"></main>;
const isVideo = !!video;
// 获取评论总数
const commentsCount = await prisma.comment.count({
where: isVideo ? { videoId: id } : { imagePostId: id },
});
const data = isVideo
? {
type: "video" as const,
@ -70,13 +76,8 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
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 },
})),
commentsCount,
likesCount: Number(video!.digg_count),
}
: {
type: "image" as const,
@ -86,13 +87,8 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
images: post!.images,
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 },
})),
commentsCount,
likesCount: Number(post!.digg_count),
};
// Compute prev/next neighbors by created_at across videos and image posts

View File

@ -6,6 +6,7 @@ export type Comment = {
digg_count: number;
created_at: string | Date;
user: User;
images?: { url: string; width?: number; height?: number }[];
};
export type VideoData = {
@ -18,7 +19,8 @@ export type VideoData = {
width?: number | null;
height?: number | null;
author: User;
comments: Comment[];
commentsCount: number;
likesCount: number;
};
export type ImageData = {
@ -26,10 +28,11 @@ export type ImageData = {
aweme_id: string;
desc: string;
created_at: string | Date;
images: { id: string; url: string; width?: number; height?: number, animated?: string }[];
images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[];
music_url?: string | null;
author: User;
comments: Comment[];
commentsCount: number;
likesCount: number;
};
export type AwemeData = VideoData | ImageData;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -18,6 +18,9 @@ export const metadata: Metadata = {
template: "%s - 抖歪",
},
description: "记录当下时代的精彩瞬间",
icons: {
icon: "/favicon.png",
},
};
export default function RootLayout({

View File

@ -1,7 +1,7 @@
"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";
import { AlertTriangle, CheckCircle2, Clipboard, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
type TaskStatus = "pending" | "running" | "success" | "error";
@ -74,6 +74,21 @@ export default function TasksPage() {
setInput("");
}, [input, addTasks]);
const handlePasteAndAdd = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
const urls = extractDouyinLinks(text);
if (!urls.length) {
alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本");
return;
}
addTasks(urls);
} catch (err) {
alert("无法读取剪贴板内容,请检查浏览器权限设置");
console.error("Clipboard read error:", err);
}
}, [addTasks]);
const startTask = useCallback(async (task: Task) => {
// 若已存在控制器,避免重复启动
if (controllers.current.has(task.id)) return;
@ -147,9 +162,10 @@ export default function TasksPage() {
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));
const formatDuration = (startTime?: number, endTime?: number) => {
if (!startTime) return "";
const end = endTime || Date.now();
const secs = Math.max(0, Math.round((end - startTime) / 1000));
if (secs < 60) return `${secs}s`;
const m = Math.floor(secs / 60);
const s = secs % 60;
@ -198,10 +214,13 @@ export default function TasksPage() {
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<div className="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40">
<Plus className="h-4 w-4" />
</button>
<button type="button" onClick={handlePasteAndAdd} className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40">
<Clipboard className="h-4 w-4" />
</button>
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10">
<Trash2 className="h-4 w-4" />
</button>
@ -228,7 +247,7 @@ export default function TasksPage() {
<span className="text-xs text-neutral-400"> {formatDuration(t.startedAt)}</span>
)}
{t.status === 'success' && (
<span className="text-xs text-neutral-400"> {t.startedAt ? formatDuration(t.startedAt) : '--'}</span>
<span className="text-xs text-neutral-400"> {formatDuration(t.startedAt, t.finishedAt)}</span>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">

View File

@ -9,7 +9,9 @@
"lucide-react": "^0.546.0",
"minio": "^8.0.6",
"next": "15.5.6",
"playwright": "^1.56.1",
"playwright": "1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"react": "19.1.0",
"react-dom": "19.1.0",
},
@ -172,6 +174,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
@ -180,12 +186,18 @@
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
"arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
@ -210,14 +222,22 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@ -256,6 +276,14 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"for-own": ["for-own@0.1.5", "", { "dependencies": { "for-in": "^1.0.1" } }, "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@ -268,6 +296,8 @@
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@ -280,22 +310,38 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"lazy-cache": ["lazy-cache@1.0.4", "", {}, "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@ -326,16 +372,24 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge-deep": ["merge-deep@3.0.3", "", { "dependencies": { "arr-union": "^3.1.0", "clone-deep": "^0.2.4", "kind-of": "^3.0.2" } }, "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mixin-object": ["mixin-object@2.0.1", "", { "dependencies": { "for-in": "^0.1.3", "is-extendable": "^0.1.1" } }, "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="],
@ -346,6 +400,10 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
@ -358,12 +416,22 @@
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"playwright-extra": ["playwright-extra@4.3.6", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "playwright": "*", "playwright-core": "*" }, "optionalPeers": ["playwright", "playwright-core"] }, "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
"puppeteer-extra-plugin": ["puppeteer-extra-plugin@3.2.3", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "merge-deep": "^3.0.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q=="],
"puppeteer-extra-plugin-stealth": ["puppeteer-extra-plugin-stealth@2.11.2", "", { "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ=="],
"puppeteer-extra-plugin-user-data-dir": ["puppeteer-extra-plugin-user-data-dir@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^10.0.0", "puppeteer-extra-plugin": "^3.2.3", "rimraf": "^3.0.2" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g=="],
"puppeteer-extra-plugin-user-preferences": ["puppeteer-extra-plugin-user-preferences@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "deepmerge": "^4.2.2", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-data-dir": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
@ -378,6 +446,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@ -390,6 +460,8 @@
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"shallow-clone": ["shallow-clone@0.1.2", "", { "dependencies": { "is-extendable": "^0.1.1", "kind-of": "^2.0.1", "lazy-cache": "^0.2.3", "mixin-object": "^2.0.1" } }, "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw=="],
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -424,6 +496,8 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@ -432,6 +506,8 @@
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@ -450,6 +526,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"shallow-clone/kind-of": ["kind-of@2.0.1", "", { "dependencies": { "is-buffer": "^1.0.2" } }, "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg=="],
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
}
}

View File

@ -1,6 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
'playwright-extra',
'puppeteer-extra-plugin-stealth',
'puppeteer-extra-plugin',
],
/* config options here */
};

57
package-lock.json generated
View File

@ -9,10 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.16.3",
"chalk": "^5.6.2",
"lucide-react": "^0.546.0",
"minio": "^8.0.6",
"next": "15.5.6",
"playwright": "^1.56.1",
"playwright-extra": "^4.3.6",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@ -578,6 +580,17 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"devOptional": true,
@ -630,6 +643,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"license": "MIT",
@ -1161,6 +1190,11 @@
"node": ">= 18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
"version": "3.3.11",
"funding": [
@ -1331,6 +1365,29 @@
"node": ">=18"
}
},
"node_modules/playwright-extra": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz",
"integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==",
"dependencies": {
"debug": "^4.3.4"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"playwright": "*",
"playwright-core": "*"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
},
"playwright-core": {
"optional": true
}
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"license": "MIT",

View File

@ -15,7 +15,9 @@
"lucide-react": "^0.546.0",
"minio": "^8.0.6",
"next": "15.5.6",
"playwright": "^1.56.1",
"playwright": "1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"react": "19.1.0",
"react-dom": "19.1.0"
},

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER;

View File

@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "CommentImage" (
"id" TEXT NOT NULL,
"commentId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"width" INTEGER,
"height" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommentImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommentImage_commentId_order_idx" ON "CommentImage"("commentId", "order");
-- CreateIndex
CREATE UNIQUE INDEX "CommentImage_commentId_order_key" ON "CommentImage"("commentId", "order");
-- AddForeignKey
ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,41 @@
-- DropForeignKey
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_imagePostId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_userId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Comment" DROP CONSTRAINT "Comment_videoId_fkey";
-- DropForeignKey
ALTER TABLE "public"."CommentImage" DROP CONSTRAINT "CommentImage_commentId_fkey";
-- DropForeignKey
ALTER TABLE "public"."ImageFile" DROP CONSTRAINT "ImageFile_postId_fkey";
-- DropForeignKey
ALTER TABLE "public"."ImagePost" DROP CONSTRAINT "ImagePost_authorId_fkey";
-- DropForeignKey
ALTER TABLE "public"."Video" DROP CONSTRAINT "Video_authorId_fkey";
-- AddForeignKey
ALTER TABLE "Video" ADD CONSTRAINT "Video_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_imagePostId_fkey" FOREIGN KEY ("imagePostId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "CommentUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommentImage" ADD CONSTRAINT "CommentImage_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("cid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImagePost" ADD CONSTRAINT "ImagePost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("sec_uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageFile" ADD CONSTRAINT "ImageFile_postId_fkey" FOREIGN KEY ("postId") REFERENCES "ImagePost"("aweme_id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -49,7 +49,7 @@ model Video {
cover_url String?
authorId String
author Author @relation(fields: [authorId], references: [sec_uid])
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
comments Comment[]
@ -87,13 +87,16 @@ model Comment {
// 可关联视频或图文中的一种
videoId String?
video Video? @relation(fields: [videoId], references: [aweme_id])
video Video? @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
imagePostId String?
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id])
imagePost ImagePost? @relation(fields: [imagePostId], references: [aweme_id], onDelete: Cascade)
userId String
user CommentUser @relation(fields: [userId], references: [id])
user CommentUser @relation(fields: [userId], references: [id], onDelete: Cascade)
// 评论下的贴纸/配图(统一按图片存储)
images CommentImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -102,6 +105,24 @@ model Comment {
@@index([imagePostId, created_at])
}
// 评论图片(贴纸和配图统一保存在这里)
model CommentImage {
id String @id @default(cuid())
commentId String
comment Comment @relation(fields: [commentId], references: [cid], onDelete: Cascade)
url String
order Int // 在该评论中的顺序0 开始)
width Int?
height Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([commentId, order])
@@unique([commentId, order])
}
// 图片作品(图文)
model ImagePost {
aweme_id String @id
@ -115,7 +136,7 @@ model ImagePost {
collect_count BigInt @default(0)
authorId String
author Author @relation(fields: [authorId], references: [sec_uid])
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
tags String[]
music_url String? // 背景音乐(已上传到 MinIO 的外链)
@ -137,13 +158,14 @@ model ImagePost {
model ImageFile {
id String @id @default(cuid())
postId String
post ImagePost @relation(fields: [postId], references: [aweme_id])
post ImagePost @relation(fields: [postId], references: [aweme_id], onDelete: Cascade)
url String
order Int // 在作品中的顺序(从 0 开始)
width Int?
height Int?
animated String? // 如果是动图,存储 video 格式的 URL
duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
stealth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB