diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8c169d7..e3ce343 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(find:*)", "WebFetch(domain:artplayer.org)", "WebFetch(domain:github.com)", - "WebFetch(domain:www.artplayer.org)" + "WebFetch(domain:www.artplayer.org)", + "WebFetch(domain:m.douban.com)", + "WebFetch(domain:movie.douban.com)" ], "deny": [], "ask": [] diff --git a/CHANGELOG b/CHANGELOG index 2527cad..82d4491 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,14 @@ +## [200.0.0] - 2025-12-02 + +### Added +- 新增外部播放器跳转 +- 新增视频超分 +- 新增弹幕抓取 +- 新增评论抓取 + +### Fixed +- 修复首页卡顿 + ## [100.0.0] - 2025-08-26 ### Added diff --git a/package.json b/package.json index 9390913..238919d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "artplayer": "^5.2.5", "artplayer-plugin-danmuku": "^5.2.0", "bs58": "^6.0.0", + "cheerio": "^1.1.2", "clsx": "^2.0.0", "crypto-js": "^4.2.0", "framer-motion": "^12.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30a6b9f..846dfe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: bs58: specifier: ^6.0.0 version: 6.0.0 + cheerio: + specifier: ^1.1.2 + version: 1.1.2 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -2221,6 +2224,13 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.1.2: + resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + engines: {node: '>=20.18.1'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2607,6 +2617,9 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2615,6 +2628,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -3118,6 +3135,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + http-proxy-agent@4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} @@ -3139,6 +3159,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -4099,9 +4123,18 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5024,6 +5057,10 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -5156,9 +5193,17 @@ packages: whatwg-encoding@1.0.5: resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + whatwg-mimetype@2.3.0: resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -7752,6 +7797,29 @@ snapshots: charenc@0.0.2: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.1.2: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.0.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.16.0 + whatwg-mimetype: 4.0.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8119,6 +8187,11 @@ snapshots: emojis-list@3.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -8126,6 +8199,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -8796,6 +8871,13 @@ snapshots: html-escaper@2.0.2: {} + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 + http-proxy-agent@4.0.1: dependencies: '@tootallnate/once': 1.1.2 @@ -8819,6 +8901,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb@7.1.1: {} ignore@5.3.2: {} @@ -10079,8 +10165,21 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + parse5@6.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -10988,6 +11087,8 @@ snapshots: undici-types@7.8.0: {} + undici@7.16.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -11168,8 +11269,14 @@ snapshots: dependencies: iconv-lite: 0.4.24 + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-mimetype@2.3.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 diff --git a/src/app/api/douban-comments/route.ts b/src/app/api/douban-comments/route.ts new file mode 100644 index 0000000..03e95a5 --- /dev/null +++ b/src/app/api/douban-comments/route.ts @@ -0,0 +1,174 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as cheerio from 'cheerio'; + +export const runtime = 'nodejs'; + +interface DoubanComment { + id: string; + userName: string; + userAvatar: string; + userUrl: string; + rating: number | null; // 1-5 星,null 表示未评分 + content: string; + time: string; + votes: number; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const doubanId = searchParams.get('id'); + const start = searchParams.get('start') || '0'; + const limit = searchParams.get('limit') || '20'; + + if (!doubanId) { + return NextResponse.json({ error: 'Missing douban ID' }, { status: 400 }); + } + + try { + // 请求豆瓣短评页面 + const url = `https://movie.douban.com/subject/${doubanId}/comments?start=${start}&limit=${limit}&status=P&sort=new_score`; + + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + Referer: 'https://movie.douban.com/', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: 'Failed to fetch douban page' }, + { status: response.status } + ); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + const comments: DoubanComment[] = []; + + console.log('开始解析豆瓣评论,start:', start, 'limit:', limit); + + // 解析每条短评 + $('.comment-item').each((index, element) => { + const $comment = $(element); + + // 提取评论 ID + const commentId = $comment.attr('data-cid') || ''; + + // 提取用户信息 + const $avatar = $comment.find('.avatar'); + const userUrl = $avatar.find('a').attr('href') || ''; + const userAvatar = $avatar.find('img').attr('src') || ''; + const userName = $avatar.find('a').attr('title') || ''; + + // 提取评分(星级) + const ratingClass = $comment.find('.rating').attr('class') || ''; + let rating: number | null = null; + const ratingMatch = ratingClass.match(/allstar(\d)0/); + if (ratingMatch) { + rating = parseInt(ratingMatch[1]); + } + + // 提取短评内容 + const $content = $comment.find('.short'); + const content = $content.text().trim(); + + // 提取时间 + const $commentInfo = $comment.find('.comment-info'); + const time = $commentInfo.find('.comment-time').attr('title') || ''; + + // 提取有用数 + const votesText = $comment.find('.votes.vote-count').text().trim(); + const votes = parseInt(votesText) || 0; + + if (commentId && content) { + comments.push({ + id: commentId, + userName, + userAvatar, + userUrl, + rating, + content, + time, + votes, + }); + } + }); + + console.log('解析到评论数:', comments.length); + + // 获取总评论数 - 尝试多种方式 + let total = 0; + + // 方式1: 从标题获取 "全部 XXX 条" + const titleText = $('.mod-hd h2, h2, .section-title').text(); + const titleMatch = titleText.match(/全部\s*(\d+)\s*条/); + if (titleMatch) { + total = parseInt(titleMatch[1]); + } + + // 方式2: 从导航标签获取 "看过(XXX)" + if (total === 0) { + const navText = $('.tabs, .nav-tabs, a').text(); + const navMatch = navText.match(/看过\s*\((\d+)\)/); + if (navMatch) { + total = parseInt(navMatch[1]); + } + } + + // 方式3: 从页面所有文本查找 + if (total === 0) { + const bodyText = $('body').text(); + const bodyMatch = bodyText.match(/全部\s*(\d+)\s*条|看过\s*\((\d+)\)/); + if (bodyMatch) { + total = parseInt(bodyMatch[1] || bodyMatch[2]); + } + } + + // 方式4: 如果有评论但 total 为 0,至少设置为当前评论数,并假设有更多 + if (total === 0 && comments.length > 0) { + total = parseInt(start) + comments.length; + // 如果本次获取了完整的 limit 数量,可能还有更多 + if (comments.length >= parseInt(limit)) { + total += 1; // 暂定有更多 + } + } + + console.log('豆瓣评论统计:', { + total, + commentsCount: comments.length, + start, + limit, + hasMore: parseInt(start) + comments.length < total || (total === 0 && comments.length >= parseInt(limit)), + }); + + return NextResponse.json( + { + comments, + total, + start: parseInt(start), + limit: parseInt(limit), + // 如果知道总数,就用总数判断;否则如果获取了完整页,假设还有更多 + hasMore: total > 0 + ? parseInt(start) + comments.length < total + : comments.length >= parseInt(limit), + }, + { + headers: { + 'Cache-Control': 'public, max-age=600, s-maxage=600', + }, + } + ); + } catch (error) { + console.error('Douban comments fetch error:', error); + return NextResponse.json( + { error: 'Failed to parse douban comments' }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index ff7df1d..9ce544f 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -40,6 +40,7 @@ import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; +import DoubanComments from '@/components/DoubanComments'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { @@ -3280,6 +3281,28 @@ function PlayPageClient() { + + {/* 豆瓣评论区域 */} + {videoDoubanId !== 0 && ( +
{error}
+