diff --git a/app/api/comments/[awemeId]/route.ts b/app/api/comments/[awemeId]/route.ts new file mode 100644 index 0000000..e27503c --- /dev/null +++ b/app/api/comments/[awemeId]/route.ts @@ -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(); + 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 } + ); + } +} diff --git a/app/api/fetcher/anti-anti-detector.ts b/app/api/fetcher/anti-anti-detector.ts new file mode 100644 index 0000000..23eaf6f --- /dev/null +++ b/app/api/fetcher/anti-anti-detector.ts @@ -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); +})(); diff --git a/app/api/fetcher/browser.ts b/app/api/fetcher/browser.ts index fb30247..cfe8fbf 100644 --- a/app/api/fetcher/browser.ts +++ b/app/api/fetcher/browser.ts @@ -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 | 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 { 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() + +async function getSharedStorageState(): Promise { + 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 { + 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 { + if (!ctx) return + try { + await ctx.close() + } finally { + const b = isolatedMap.get(ctx) + if (b) { + await b.close().catch(() => {}) + isolatedMap.delete(ctx) + } + } +} diff --git a/app/api/fetcher/index.ts b/app/api/fetcher/index.ts index a555552..b345e8c 100644 --- a/app/api/fetcher/index.ts +++ b/app/api/fetcher/index.ts @@ -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 { + 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 @@ -47,8 +112,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 +146,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 +159,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(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(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(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(firstType.response); if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE'); @@ -161,7 +256,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(firstType.response))!; @@ -216,11 +311,11 @@ export async function scrapeDouyin(url: string) { 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'); @@ -231,14 +326,14 @@ export async function scrapeDouyin(url: string) { if (errMsg.includes('net::')) { throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR'); } - + // 默认服务器错误 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('✓ 资源清理完成')); } diff --git a/app/api/fetcher/media.ts b/app/api/fetcher/media.ts index 46bed87..04aabe2 100644 --- a/app/api/fetcher/media.ts +++ b/app/api/fetcher/media.ts @@ -1,3 +1,5 @@ +export const runtime = 'nodejs' + import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; diff --git a/app/api/fetcher/network.ts b/app/api/fetcher/network.ts index 95a4cc8..959505e 100644 --- a/app/api/fetcher/network.ts +++ b/app/api/fetcher/network.ts @@ -1,3 +1,5 @@ +export const runtime = 'nodejs' + import type { BrowserContext, Response } from 'playwright'; export async function safeJson(res: Response): Promise { @@ -20,12 +22,12 @@ export async function safeJson(res: Response): Promise { */ 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; 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 { + return new Promise((resolve) => { + const collected: Response[] = []; + const seenUrls = new Set(); + 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, diff --git a/app/api/fetcher/persist.ts b/app/api/fetcher/persist.ts index 1c00614..53ff6cd 100644 --- a/app/api/fetcher/persist.ts +++ b/app/api/fetcher/persist.ts @@ -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 }; @@ -138,7 +181,7 @@ export async function saveImagePostToDB( context: BrowserContext, aweme: DouyinImageAweme, commentResp: DouyinCommentResponse, - uploads: { images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }, + uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, rawJson?: any ) { if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); @@ -205,7 +248,7 @@ export async function saveImagePostToDB( // Upsert ImageFiles(按顺序) for (let i = 0; i < uploads.images.length; i++) { - const { url, width, height, video, duration } = uploads.images[i]; + const { url, width, height, video } = uploads.images[i]; await prisma.imageFile.upsert({ where: { postId_order: { postId: imagePost.aweme_id, order: i } }, create: { @@ -215,14 +258,12 @@ export async function saveImagePostToDB( width: typeof width === 'number' ? width : null, height: typeof height === 'number' ? height : null, animated: video || null, - duration: typeof duration === 'number' ? duration : null, }, update: { url, width: typeof width === 'number' ? width : null, height: typeof height === 'number' ? height : null, animated: video || null, - duration: typeof duration === 'number' ? duration : null, }, }); } @@ -251,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, @@ -269,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 }; diff --git a/app/api/fetcher/route.ts b/app/api/fetcher/route.ts index 3a2034c..7c22959 100644 --- a/app/api/fetcher/route.ts +++ b/app/api/fetcher/route.ts @@ -1,3 +1,5 @@ +export const runtime = 'nodejs' + import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { scrapeDouyin, ScrapeError } from '.'; diff --git a/app/api/fetcher/types.d.ts b/app/api/fetcher/types.d.ts index b3a8442..896cc86 100644 --- a/app/api/fetcher/types.d.ts +++ b/app/api/fetcher/types.d.ts @@ -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[] + } + }[] } /** 用户信息(精简版) */ diff --git a/app/api/fetcher/uploader.ts b/app/api/fetcher/uploader.ts index 5b8cc4e..8088917 100644 --- a/app/api/fetcher/uploader.ts +++ b/app/api/fetcher/uploader.ts @@ -1,3 +1,5 @@ +export const runtime = 'nodejs' + import type { BrowserContext } from 'playwright'; import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { downloadBinary } from './network'; @@ -10,7 +12,7 @@ import { getVideoDuration } from './media'; export async function uploadAvatarFromUrl( context: BrowserContext, srcUrl?: string | null, - nameHint?: string + nameHint?: string, ): Promise { if (!srcUrl) return undefined; try { @@ -26,6 +28,28 @@ export async function uploadAvatarFromUrl( } } +/** + * 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。 + */ +export async function uploadImageFromUrl( + context: BrowserContext, + srcUrl?: string | null, + nameHint?: string, +): Promise { + if (!srcUrl) return undefined; + try { + const { buffer, contentType, ext } = await downloadBinary(context, srcUrl); + const safeExt = ext || 'jpg'; + const baseName = nameHint ? `${nameHint}.${safeExt}` : `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, diff --git a/app/api/fetcher/utils.ts b/app/api/fetcher/utils.ts index 172a118..737fc14 100644 --- a/app/api/fetcher/utils.ts +++ b/app/api/fetcher/utils.ts @@ -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()); } diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 4d22e44..24d84ef 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -299,7 +299,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient {/* 导航按钮 */} neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)} onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)} onToggleComments={() => commentState.setOpen((v) => !v)} @@ -312,7 +312,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} /> diff --git a/app/aweme/[awemeId]/components/CommentList.tsx b/app/aweme/[awemeId]/components/CommentList.tsx index 0ce58cd..14630bf 100644 --- a/app/aweme/[awemeId]/components/CommentList.tsx +++ b/app/aweme/[awemeId]/components/CommentList.tsx @@ -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(null); + return ( <>
@@ -42,6 +45,29 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {

+ {c.images && c.images.length > 0 ? ( +
+ {c.images.map((img, idx) => ( + + ))} +
+ ) : null}
{c.digg_count} @@ -51,6 +77,32 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) { ))} {comments.length === 0 ?
  • 暂无评论
  • : null} + + {/* 图片预览灯箱 */} + {previewImage && ( +
    setPreviewImage(null)} + > + {/* 关闭按钮 */} + + + {/* 大图 */} + preview e.stopPropagation()} + /> +
    + )} ); } diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx index f50223e..e312eec 100644 --- a/app/aweme/[awemeId]/components/CommentPanel.tsx +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + // ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定) + const [rankParams, setRankParams] = useState(null); + // 两套滚动容器与哨兵,分别对应横屏与竖屏面板 + const scrollRefLandscape = useRef(null); + const sentinelRefLandscape = useRef(null); + const scrollRefPortrait = useRef(null); + const sentinelRefPortrait = useRef(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(); + 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(); + // 保留首次出现的项,既保证顺序也避免重复 + 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 ( <> + + {/* 横屏评论面板:并排分栏 */}
    -
    +
    + + {/* 底部加载触发区 */} + {hasMore && ( +
    + )} + + {loading && ( +
    加载中...
    + )} + {!hasMore && comments.length > 0 && ( +
    没有更多评论了
    + )}
    @@ -52,7 +209,9 @@ export function CommentPanel({ open, onClose, author, createdAt, comments, mount `} >
    -
    评论 {comments.length > 0 ? `(${comments.length})` : ""}
    +
    + 评论 {total > 0 ? `(${total})` : ""} +
    -
    +
    + + {/* 底部加载触发区 */} + {hasMore && ( +
    + )} + + {loading && ( +
    加载中...
    + )} + {!hasMore && comments.length > 0 && ( +
    没有更多评论了
    + )}
    diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index c75565b..bbb2114 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -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
    找不到该作品
    ; 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,7 @@ 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, } : { type: "image" as const, @@ -86,13 +86,7 @@ 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, }; // Compute prev/next neighbors by created_at across videos and image posts diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 2f0982a..3638e02 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -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,7 @@ export type VideoData = { width?: number | null; height?: number | null; author: User; - comments: Comment[]; + commentsCount: number; }; export type ImageData = { @@ -29,7 +30,7 @@ export type ImageData = { images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[]; music_url?: string | null; author: User; - comments: Comment[]; + commentsCount: number; }; export type AwemeData = VideoData | ImageData; diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 264e1ee..5d93059 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -147,9 +147,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; @@ -228,7 +229,7 @@ export default function TasksPage() { 已耗时 {formatDuration(t.startedAt)} )} {t.status === 'success' && ( - 用时 {t.startedAt ? formatDuration(t.startedAt) : '--'} + 用时 {formatDuration(t.startedAt, t.finishedAt)} )}
    diff --git a/bun.lock b/bun.lock index 1fba115..9c73b68 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/next.config.ts b/next.config.ts index e9ffa30..76c0948 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 */ }; diff --git a/package-lock.json b/package-lock.json index 06914ba..ee2e3b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd04cd5..70fc9ff 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql b/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql new file mode 100644 index 0000000..86b6ec0 --- /dev/null +++ b/prisma/migrations/20251023041400_add_comment_images_for_stickers_and_images/migration.sql @@ -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; diff --git a/prisma/migrations/20251023050111_add_cascade_delete/migration.sql b/prisma/migrations/20251023050111_add_cascade_delete/migration.sql new file mode 100644 index 0000000..bdb11f0 --- /dev/null +++ b/prisma/migrations/20251023050111_add_cascade_delete/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0fe6dc8..4ffa1e8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,7 +158,7 @@ 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? diff --git a/stealth.png b/stealth.png new file mode 100644 index 0000000..1db255d Binary files /dev/null and b/stealth.png differ