|
@@ -89,6 +89,8 @@ export interface WorkItem {
|
|
|
videoId?: string;
|
|
videoId?: string;
|
|
|
title: string;
|
|
title: string;
|
|
|
coverUrl: string;
|
|
coverUrl: string;
|
|
|
|
|
+ /** 作品播放/详情页 URL,同步到 works.video_url */
|
|
|
|
|
+ videoUrl?: string;
|
|
|
duration: string;
|
|
duration: string;
|
|
|
publishTime: string;
|
|
publishTime: string;
|
|
|
status: string;
|
|
status: string;
|
|
@@ -96,6 +98,7 @@ export interface WorkItem {
|
|
|
likeCount: number;
|
|
likeCount: number;
|
|
|
commentCount: number;
|
|
commentCount: number;
|
|
|
shareCount: number;
|
|
shareCount: number;
|
|
|
|
|
+ collectCount?: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export interface CommentItem {
|
|
export interface CommentItem {
|
|
@@ -726,13 +729,13 @@ class HeadlessBrowserService {
|
|
|
|
|
|
|
|
let cursor: string | number = 0;
|
|
let cursor: string | number = 0;
|
|
|
const seenCursors = new Set<string>();
|
|
const seenCursors = new Set<string>();
|
|
|
- // 抖音和小红书使用 cursor 分页(next_page 作为下一页的 max_cursor),其他平台用 pageIndex
|
|
|
|
|
|
|
+ // 抖音、小红书使用 cursor 分页;视频号使用 currentPage 页码(pageIndex 0,1,2...)
|
|
|
const useCursorPagination = platform === 'xiaohongshu' || platform === 'douyin';
|
|
const useCursorPagination = platform === 'xiaohongshu' || platform === 'douyin';
|
|
|
for (let pageIndex = 0; pageIndex < maxPages; pageIndex++) {
|
|
for (let pageIndex = 0; pageIndex < maxPages; pageIndex++) {
|
|
|
- const pageParam = useCursorPagination ? cursor : pageIndex;
|
|
|
|
|
|
|
+ const pageParam: number | string = useCursorPagination ? cursor : pageIndex;
|
|
|
logger.info(`[Python API] Fetching works page=${String(pageParam)}, page_size=${pageSize} for ${platform}`);
|
|
logger.info(`[Python API] Fetching works page=${String(pageParam)}, page_size=${pageSize} for ${platform}`);
|
|
|
|
|
|
|
|
- const response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
|
|
|
|
|
|
|
+ const response: Response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: {
|
|
headers: {
|
|
|
'Content-Type': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
@@ -750,7 +753,7 @@ class HeadlessBrowserService {
|
|
|
throw new Error(`Python API returned ${response.status}`);
|
|
throw new Error(`Python API returned ${response.status}`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const result = await response.json();
|
|
|
|
|
|
|
+ const result: any = await response.json();
|
|
|
|
|
|
|
|
// 记录 Python API 的详细响应(用于调试)
|
|
// 记录 Python API 的详细响应(用于调试)
|
|
|
if (pageIndex === 0) {
|
|
if (pageIndex === 0) {
|
|
@@ -776,6 +779,7 @@ class HeadlessBrowserService {
|
|
|
work_id: string;
|
|
work_id: string;
|
|
|
title: string;
|
|
title: string;
|
|
|
cover_url: string;
|
|
cover_url: string;
|
|
|
|
|
+ video_url?: string;
|
|
|
duration: number;
|
|
duration: number;
|
|
|
publish_time: string;
|
|
publish_time: string;
|
|
|
status: string;
|
|
status: string;
|
|
@@ -788,6 +792,7 @@ class HeadlessBrowserService {
|
|
|
videoId: work.work_id,
|
|
videoId: work.work_id,
|
|
|
title: work.title,
|
|
title: work.title,
|
|
|
coverUrl: work.cover_url,
|
|
coverUrl: work.cover_url,
|
|
|
|
|
+ videoUrl: work.video_url || '',
|
|
|
duration: String(work.duration || 0),
|
|
duration: String(work.duration || 0),
|
|
|
publishTime: work.publish_time,
|
|
publishTime: work.publish_time,
|
|
|
status: work.status || 'published',
|
|
status: work.status || 'published',
|
|
@@ -795,6 +800,7 @@ class HeadlessBrowserService {
|
|
|
likeCount: work.like_count || 0,
|
|
likeCount: work.like_count || 0,
|
|
|
commentCount: work.comment_count || 0,
|
|
commentCount: work.comment_count || 0,
|
|
|
shareCount: work.share_count || 0,
|
|
shareCount: work.share_count || 0,
|
|
|
|
|
+ collectCount: work.collect_count ?? 0,
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
let newCount = 0;
|
|
let newCount = 0;
|
|
@@ -841,7 +847,7 @@ class HeadlessBrowserService {
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
if (useCursorPagination) {
|
|
if (useCursorPagination) {
|
|
|
- const next = result.next_page;
|
|
|
|
|
|
|
+ const next: any = result.next_page;
|
|
|
const hasNextCursor = next !== undefined && next !== null && next !== '' && next !== -1 && next !== '-1';
|
|
const hasNextCursor = next !== undefined && next !== null && next !== '' && next !== -1 && next !== '-1';
|
|
|
|
|
|
|
|
if (hasNextCursor) {
|
|
if (hasNextCursor) {
|
|
@@ -854,7 +860,7 @@ class HeadlessBrowserService {
|
|
|
cursor = (typeof cursor === 'number' ? cursor + 1 : pageIndex + 1);
|
|
cursor = (typeof cursor === 'number' ? cursor + 1 : pageIndex + 1);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 抖音:仅当无下一页游标或本页 0 条时停止(不依赖 has_more/declaredTotal,避免只同步 20 条)
|
|
|
|
|
|
|
+ // 抖音:仅当无下一页游标或本页 0 条时停止
|
|
|
if (platform === 'douyin') {
|
|
if (platform === 'douyin') {
|
|
|
if (!hasNextCursor || pageWorks.length === 0) break;
|
|
if (!hasNextCursor || pageWorks.length === 0) break;
|
|
|
} else {
|
|
} else {
|
|
@@ -896,21 +902,51 @@ class HeadlessBrowserService {
|
|
|
// 百家号:优先走 Python 的 /account_info(包含粉丝数、作品数),避免 Node 直连分散认证问题
|
|
// 百家号:优先走 Python 的 /account_info(包含粉丝数、作品数),避免 Node 直连分散认证问题
|
|
|
if (platform === 'baijiahao') {
|
|
if (platform === 'baijiahao') {
|
|
|
pythonAvailable = await this.checkPythonServiceAvailable();
|
|
pythonAvailable = await this.checkPythonServiceAvailable();
|
|
|
|
|
+
|
|
|
|
|
+ let info: AccountInfo;
|
|
|
if (pythonAvailable) {
|
|
if (pythonAvailable) {
|
|
|
logger.info(`[Python API] Service available, fetching account_info for baijiahao`);
|
|
logger.info(`[Python API] Service available, fetching account_info for baijiahao`);
|
|
|
try {
|
|
try {
|
|
|
- return await this.fetchAccountInfoViaPython(platform, cookies);
|
|
|
|
|
|
|
+ info = await this.fetchAccountInfoViaPython(platform, cookies);
|
|
|
|
|
+ info.source = 'python';
|
|
|
|
|
+ info.pythonAvailable = true;
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- logger.warn(`[Python API] Failed to fetch account_info for baijiahao, falling back to direct API:`, error);
|
|
|
|
|
|
|
+ logger.warn(`[Python API] Failed to fetch account_info for baijiahao, will still try /works:`, error);
|
|
|
|
|
+ info = this.getDefaultAccountInfo(platform);
|
|
|
|
|
+ info.source = 'python';
|
|
|
|
|
+ info.pythonAvailable = true;
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
logger.info(`[Python API] Service not available for baijiahao, falling back to direct API`);
|
|
logger.info(`[Python API] Service not available for baijiahao, falling back to direct API`);
|
|
|
|
|
+ // Python 不可用时,回退到 Node 直连 API(可能仍会遇到分散认证问题)
|
|
|
|
|
+ info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
|
|
|
|
|
+ info.source = 'api';
|
|
|
|
|
+ info.pythonAvailable = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 百家号同步作品需要全量:优先通过 Python /works 自动分页拉取
|
|
|
|
|
+ if (pythonAvailable) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { works: worksList, total: worksTotal } = await this.fetchWorksViaPython(
|
|
|
|
|
+ platform,
|
|
|
|
|
+ cookies,
|
|
|
|
|
+ options?.onWorksFetchProgress
|
|
|
|
|
+ );
|
|
|
|
|
+ info.worksList = worksList;
|
|
|
|
|
+ if (worksTotal && worksTotal > 0) {
|
|
|
|
|
+ info.worksCount = worksTotal;
|
|
|
|
|
+ info.worksListComplete = worksList.length >= worksTotal;
|
|
|
|
|
+ } else if (worksList.length > 0) {
|
|
|
|
|
+ info.worksCount = Math.max(info.worksCount || 0, worksList.length);
|
|
|
|
|
+ info.worksListComplete = undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ info.source = 'python';
|
|
|
|
|
+ info.pythonAvailable = true;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.warn(`[Python API] Failed to fetch works for baijiahao:`, error);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Python 不可用或失败时,回退到 Node 直连 API(可能仍会遇到分散认证问题)
|
|
|
|
|
- const info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
|
|
|
|
|
- info.source = 'api';
|
|
|
|
|
- info.pythonAvailable = pythonAvailable;
|
|
|
|
|
return info;
|
|
return info;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1393,6 +1429,7 @@ class HeadlessBrowserService {
|
|
|
videoId: w.awemeId,
|
|
videoId: w.awemeId,
|
|
|
title: w.title,
|
|
title: w.title,
|
|
|
coverUrl: w.coverUrl,
|
|
coverUrl: w.coverUrl,
|
|
|
|
|
+ videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
|
|
|
duration: '00:00',
|
|
duration: '00:00',
|
|
|
publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
|
|
publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
|
|
|
status: 'published',
|
|
status: 'published',
|
|
@@ -1409,6 +1446,7 @@ class HeadlessBrowserService {
|
|
|
videoId: w.awemeId,
|
|
videoId: w.awemeId,
|
|
|
title: w.title,
|
|
title: w.title,
|
|
|
coverUrl: w.coverUrl,
|
|
coverUrl: w.coverUrl,
|
|
|
|
|
+ videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
|
|
|
duration: this.formatDuration(w.duration),
|
|
duration: this.formatDuration(w.duration),
|
|
|
publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
|
|
publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
|
|
|
status: 'published',
|
|
status: 'published',
|
|
@@ -2255,6 +2293,7 @@ class HeadlessBrowserService {
|
|
|
videoId: note.noteId,
|
|
videoId: note.noteId,
|
|
|
title: note.title || '无标题',
|
|
title: note.title || '无标题',
|
|
|
coverUrl: note.coverUrl,
|
|
coverUrl: note.coverUrl,
|
|
|
|
|
+ videoUrl: note.noteId ? `https://www.xiaohongshu.com/explore/${note.noteId}` : '',
|
|
|
duration: durationStr,
|
|
duration: durationStr,
|
|
|
publishTime: note.publishTime,
|
|
publishTime: note.publishTime,
|
|
|
status: statusStr,
|
|
status: statusStr,
|
|
@@ -2621,6 +2660,7 @@ class HeadlessBrowserService {
|
|
|
videoId: item.id || item.article_id || `bjh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
videoId: item.id || item.article_id || `bjh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
|
title: item.title || '',
|
|
title: item.title || '',
|
|
|
coverUrl: coverUrl,
|
|
coverUrl: coverUrl,
|
|
|
|
|
+ videoUrl: item.url || item.article_url || '',
|
|
|
duration: '00:00',
|
|
duration: '00:00',
|
|
|
publishTime: item.created_at || item.create_time || new Date().toISOString(),
|
|
publishTime: item.created_at || item.create_time || new Date().toISOString(),
|
|
|
status: item.status || 'published',
|
|
status: item.status || 'published',
|
|
@@ -3893,7 +3933,7 @@ class HeadlessBrowserService {
|
|
|
/**
|
|
/**
|
|
|
* 通过 Python API 获取评论 - 分作品逐个获取
|
|
* 通过 Python API 获取评论 - 分作品逐个获取
|
|
|
*/
|
|
*/
|
|
|
- private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu', cookies: CookieData[]): Promise<WorkComments[]> {
|
|
|
|
|
|
|
+ private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu' | 'weixin', cookies: CookieData[]): Promise<WorkComments[]> {
|
|
|
const allWorkComments: WorkComments[] = [];
|
|
const allWorkComments: WorkComments[] = [];
|
|
|
const cookieString = JSON.stringify(cookies);
|
|
const cookieString = JSON.stringify(cookies);
|
|
|
|
|
|
|
@@ -4855,6 +4895,232 @@ class HeadlessBrowserService {
|
|
|
return allWorkComments;
|
|
return allWorkComments;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取微信视频号评论 - 优先使用 Python API
|
|
|
|
|
+ */
|
|
|
|
|
+ async fetchWeixinVideoCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
|
|
|
|
|
+ // 优先使用 Python API(分作品获取)
|
|
|
|
|
+ const pythonAvailable = await this.checkPythonServiceAvailable();
|
|
|
|
|
+ if (pythonAvailable) {
|
|
|
|
|
+ logger.info('[Weixin Video Comments] Using Python API...');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await this.fetchCommentsViaPythonApi('weixin', cookies);
|
|
|
|
|
+ if (result.length > 0) {
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ logger.info('[Weixin Video Comments] Python API returned empty, falling back to Playwright...');
|
|
|
|
|
+ } catch (pythonError) {
|
|
|
|
|
+ logger.warn('[Weixin Video Comments] Python API failed:', pythonError);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 回退到 Playwright 方式
|
|
|
|
|
+ const browser = await chromium.launch({
|
|
|
|
|
+ headless: true,
|
|
|
|
|
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const allWorkComments: WorkComments[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const context = await browser.newContext({
|
|
|
|
|
+ viewport: { width: 1920, height: 1080 },
|
|
|
|
|
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 设置 Cookie
|
|
|
|
|
+ const playwrightCookies = cookies.map(c => ({
|
|
|
|
|
+ name: c.name,
|
|
|
|
|
+ value: c.value,
|
|
|
|
|
+ domain: c.domain || '.weixin.qq.com',
|
|
|
|
|
+ path: c.path || '/',
|
|
|
|
|
+ }));
|
|
|
|
|
+ await context.addCookies(playwrightCookies);
|
|
|
|
|
+ logger.info(`[Weixin Video Comments] Set ${playwrightCookies.length} cookies`);
|
|
|
|
|
+
|
|
|
|
|
+ const page = await context.newPage();
|
|
|
|
|
+
|
|
|
|
|
+ // 用于捕获评论数据
|
|
|
|
|
+ const capturedComments: Map<string, CommentItem[]> = new Map();
|
|
|
|
|
+ const capturedWorks: Array<{
|
|
|
|
|
+ workId: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ coverUrl: string;
|
|
|
|
|
+ }> = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 设置 API 响应监听器
|
|
|
|
|
+ page.on('response', async (response) => {
|
|
|
|
|
+ const url = response.url();
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 监听作品列表 API
|
|
|
|
|
+ if (url.includes('/mmfinderassistant-bin/post/post_list')) {
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ logger.info(`[Weixin Video API] Works list: ${JSON.stringify(data).slice(0, 500)}`);
|
|
|
|
|
+ const posts = data?.data?.list || [];
|
|
|
|
|
+ for (const post of posts) {
|
|
|
|
|
+ capturedWorks.push({
|
|
|
|
|
+ workId: post.objectNonce || post.id || '',
|
|
|
|
|
+ title: post.title || post.desc || '',
|
|
|
|
|
+ coverUrl: post.cover?.url || post.cover || '',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 监听评论列表 API
|
|
|
|
|
+ if (url.includes('/mmfinderassistant-bin/comment/comment_list')) {
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ logger.info(`[Weixin Video API] Comments: ${JSON.stringify(data).slice(0, 500)}`);
|
|
|
|
|
+
|
|
|
|
|
+ const comments: CommentItem[] = [];
|
|
|
|
|
+ const commentList = data?.data?.commentList || data?.comments || [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const comment of commentList) {
|
|
|
|
|
+ comments.push({
|
|
|
|
|
+ commentId: comment.commentId || comment.id || `weixin_${Date.now()}`,
|
|
|
|
|
+ authorId: comment.commenterInfo?.identifier || comment.authorId || '',
|
|
|
|
|
+ authorName: comment.commenterInfo?.nickName || comment.nickname || comment.nick_name || '',
|
|
|
|
|
+ authorAvatar: comment.commenterInfo?.headUrl || comment.avatar || '',
|
|
|
|
|
+ content: comment.content || '',
|
|
|
|
|
+ likeCount: comment.likeCnt || comment.like_count || 0,
|
|
|
|
|
+ commentTime: comment.createTime || comment.create_time || '',
|
|
|
|
|
+ parentCommentId: comment.parentCommentId || undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 处理子评论
|
|
|
|
|
+ const subComments = comment.subCommentList || comment.sub_comments || [];
|
|
|
|
|
+ for (const sub of subComments) {
|
|
|
|
|
+ comments.push({
|
|
|
|
|
+ commentId: sub.commentId || sub.id || `weixin_sub_${Date.now()}`,
|
|
|
|
|
+ authorId: sub.commenterInfo?.identifier || sub.authorId || '',
|
|
|
|
|
+ authorName: sub.commenterInfo?.nickName || sub.nickname || sub.nick_name || '',
|
|
|
|
|
+ authorAvatar: sub.commenterInfo?.headUrl || sub.avatar || '',
|
|
|
|
|
+ content: sub.content || '',
|
|
|
|
|
+ likeCount: sub.likeCnt || sub.like_count || 0,
|
|
|
|
|
+ commentTime: sub.createTime || sub.create_time || '',
|
|
|
|
|
+ parentCommentId: comment.commentId || comment.id || undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试从 URL 获取作品 ID
|
|
|
|
|
+ const workIdMatch = url.match(/objectNonce=([^&]+)/) || url.match(/workId=([^&]+)/);
|
|
|
|
|
+ const workId = workIdMatch?.[1] || `work_${Date.now()}`;
|
|
|
|
|
+
|
|
|
|
|
+ if (comments.length > 0) {
|
|
|
|
|
+ const existing = capturedComments.get(workId) || [];
|
|
|
|
|
+ capturedComments.set(workId, [...existing, ...comments]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch { }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 导航到评论管理页面
|
|
|
|
|
+ logger.info('[Weixin Video Comments] Navigating to comment management...');
|
|
|
|
|
+ await page.goto('https://channels.weixin.qq.com/platform/interaction/comment', {
|
|
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
|
|
+ timeout: 60000,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ await page.waitForTimeout(5000);
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否需要登录
|
|
|
|
|
+ const currentUrl = page.url();
|
|
|
|
|
+ if (currentUrl.includes('login') || currentUrl.includes('passport')) {
|
|
|
|
|
+ logger.warn('[Weixin Video Comments] Cookie expired, need re-login');
|
|
|
|
|
+ await browser.close();
|
|
|
|
|
+ return allWorkComments;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试加载更多评论
|
|
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
|
|
+ await page.evaluate(() => {
|
|
|
|
|
+ window.scrollBy(0, 500);
|
|
|
|
|
+ });
|
|
|
|
|
+ await page.waitForTimeout(1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 API 响应
|
|
|
|
|
+ await page.waitForTimeout(3000);
|
|
|
|
|
+
|
|
|
|
|
+ // 将捕获的评论转换为 WorkComments 格式
|
|
|
|
|
+ for (const [workId, comments] of capturedComments) {
|
|
|
|
|
+ const workInfo = capturedWorks.find(w => w.workId === workId);
|
|
|
|
|
+ allWorkComments.push({
|
|
|
|
|
+ videoId: workId,
|
|
|
|
|
+ videoTitle: workInfo?.title || `作品 ${workId.slice(0, 10)}`,
|
|
|
|
|
+ videoCoverUrl: workInfo?.coverUrl || '',
|
|
|
|
|
+ comments,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有从 API 获取到评论,尝试从页面提取
|
|
|
|
|
+ if (allWorkComments.length === 0) {
|
|
|
|
|
+ logger.info('[Weixin Video Comments] No comments from API, extracting from page...');
|
|
|
|
|
+
|
|
|
|
|
+ const pageComments = await page.evaluate(() => {
|
|
|
|
|
+ const result: Array<{
|
|
|
|
|
+ commentId: string;
|
|
|
|
|
+ authorName: string;
|
|
|
|
|
+ authorAvatar: string;
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ likeCount: number;
|
|
|
|
|
+ commentTime: string;
|
|
|
|
|
+ }> = [];
|
|
|
|
|
+
|
|
|
|
|
+ const commentItems = document.querySelectorAll('[class*="comment-item"], [class*="comment-card"]');
|
|
|
|
|
+ commentItems.forEach((item, index) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const authorEl = item.querySelector('[class*="author"], [class*="name"]');
|
|
|
|
|
+ const avatarEl = item.querySelector('img');
|
|
|
|
|
+ const contentEl = item.querySelector('[class*="content"]');
|
|
|
|
|
+ const timeEl = item.querySelector('[class*="time"]');
|
|
|
|
|
+ const likeEl = item.querySelector('[class*="like"] span');
|
|
|
|
|
+
|
|
|
|
|
+ result.push({
|
|
|
|
|
+ commentId: `weixin_page_${index}`,
|
|
|
|
|
+ authorName: authorEl?.textContent?.trim() || '',
|
|
|
|
|
+ authorAvatar: avatarEl?.src || '',
|
|
|
|
|
+ content: contentEl?.textContent?.trim() || '',
|
|
|
|
|
+ likeCount: parseInt(likeEl?.textContent || '0') || 0,
|
|
|
|
|
+ commentTime: timeEl?.textContent?.trim() || '',
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch { }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (pageComments.length > 0) {
|
|
|
|
|
+ allWorkComments.push({
|
|
|
|
|
+ videoId: 'page_comments',
|
|
|
|
|
+ videoTitle: '页面评论',
|
|
|
|
|
+ videoCoverUrl: '',
|
|
|
|
|
+ comments: pageComments.map(c => ({
|
|
|
|
|
+ ...c,
|
|
|
|
|
+ authorId: '',
|
|
|
|
|
+ })),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await page.close();
|
|
|
|
|
+ await context.close();
|
|
|
|
|
+ await browser.close();
|
|
|
|
|
+
|
|
|
|
|
+ const totalComments = allWorkComments.reduce((sum, w) => sum + w.comments.length, 0);
|
|
|
|
|
+ logger.info(`[Weixin Video Comments] Total: fetched ${totalComments} comments from ${allWorkComments.length} works`);
|
|
|
|
|
+
|
|
|
|
|
+ return allWorkComments;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('[Weixin Video Comments] Error:', error);
|
|
|
|
|
+ try {
|
|
|
|
|
+ await browser.close();
|
|
|
|
|
+ } catch { }
|
|
|
|
|
+ return allWorkComments;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export const headlessBrowserService = new HeadlessBrowserService();
|
|
export const headlessBrowserService = new HeadlessBrowserService();
|