Jelajahi Sumber

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

swortect 1 hari lalu
induk
melakukan
2f126d6b72

+ 2 - 0
client/src/components.d.ts

@@ -18,6 +18,7 @@ declare module 'vue' {
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -28,6 +29,7 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']

+ 12 - 34
client/src/views/Works/index.vue

@@ -69,22 +69,13 @@
           @click="openWorkDetail(work)"
         >
           <div class="work-cover">
-            <img :src="getSecureCoverUrl(work.coverUrl)" :alt="work.title" @error="handleImageError" />
-            <span class="work-duration">{{ formatDuration(work.duration) }}</span>
-            <el-tag 
-              class="work-status" 
-              :type="getStatusType(work.status)" 
-              size="small"
-            >
-              {{ getStatusText(work.status) }}
-            </el-tag>
+            <img :src="getWorkCoverSrc(work)" :alt="work.title" @error="handleImageError" />
           </div>
           
           <div class="work-info">
             <div class="work-title" :title="work.title">{{ work.title || '无标题' }}</div>
             <div class="work-meta">
               <el-tag size="small" type="info">{{ getPlatformName(work.platform) }}</el-tag>
-              <span class="work-time">{{ formatDate(work.publishTime) }}</span>
             </div>
             <div class="work-stats">
               <span><el-icon><VideoPlay /></el-icon> {{ formatNumber(work.playCount) }}</span>
@@ -193,7 +184,7 @@
       destroy-on-close
     >
       <div class="comments-drawer-header" v-if="commentsWork">
-        <img :src="getSecureCoverUrl(commentsWork.coverUrl)" class="work-thumb" @error="handleImageError" />
+        <img :src="getWorkCoverSrc(commentsWork)" class="work-thumb" @error="handleImageError" />
         <div class="work-brief">
           <div class="work-brief-title">{{ commentsWork.title || '无标题' }}</div>
           <div class="work-brief-meta">
@@ -495,6 +486,15 @@ function formatDuration(seconds: number | string | undefined): string {
   return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
 }
 
+// 作品列表/评论等处的封面:兼容 coverUrl / cover_url,无封面时用占位图
+const COVER_PLACEHOLDER = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+
+function getWorkCoverSrc(work: Work): string {
+  const url = (work as { coverUrl?: string; cover_url?: string }).coverUrl ?? (work as { cover_url?: string }).cover_url ?? '';
+  if (!url || typeof url !== 'string' || !url.trim()) return COVER_PLACEHOLDER;
+  return getSecureCoverUrl(url);
+}
+
 // 将 HTTP 图片 URL 转换为 HTTPS(小红书等平台的图片 URL 可能是 HTTP)
 function getSecureCoverUrl(url: string): string {
   if (!url) return '';
@@ -507,7 +507,7 @@ function getSecureCoverUrl(url: string): string {
 
 function handleImageError(e: Event) {
   const img = e.target as HTMLImageElement;
-  img.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+  img.src = COVER_PLACEHOLDER;
 }
 
 async function loadWorks() {
@@ -936,23 +936,6 @@ onUnmounted(() => {
       height: 100%;
       object-fit: cover;
     }
-    
-    .work-duration {
-      position: absolute;
-      bottom: 8px;
-      right: 8px;
-      padding: 2px 6px;
-      background: rgba(0, 0, 0, 0.7);
-      color: #fff;
-      font-size: 12px;
-      border-radius: 4px;
-    }
-    
-    .work-status {
-      position: absolute;
-      top: 8px;
-      left: 8px;
-    }
   }
   
   .work-info {
@@ -973,11 +956,6 @@ onUnmounted(() => {
       align-items: center;
       gap: 8px;
       margin-bottom: 8px;
-      
-      .work-time {
-        font-size: 12px;
-        color: $text-secondary;
-      }
     }
     
     .work-stats {

+ 8 - 0
database/migrations/alter_works_video_url_to_text.sql

@@ -0,0 +1,8 @@
+-- 将 works.video_url 扩展为 TEXT,避免抖音 play_addr.url_list[0] 过长导致写库失败
+-- 执行日期: 2026-02-03
+
+USE media_manager;
+
+ALTER TABLE works
+  MODIFY COLUMN video_url TEXT NULL;
+

+ 1 - 1
database/schema.sql

@@ -195,7 +195,7 @@ CREATE TABLE IF NOT EXISTS works (
     title VARCHAR(200) DEFAULT '',
     description TEXT,
     cover_url VARCHAR(500) DEFAULT '',
-    video_url VARCHAR(500),
+    video_url TEXT,
     duration VARCHAR(20) DEFAULT '00:00',
     status VARCHAR(20) DEFAULT 'published',
     publish_time DATETIME,

+ 3 - 0
server/package.json

@@ -10,6 +10,9 @@
     "xhs:work-stats": "tsx src/scripts/run-xhs-work-stats-import.ts",
     "check:trend": "tsx src/scripts/check-trend-data.ts",
     "clean:work-day-orphans": "tsx src/scripts/clean-work-day-statistics-orphans.ts",
+    "check:douyin-account": "tsx src/scripts/check-douyin-account.ts",
+    "list:works-for-account": "tsx src/scripts/list-works-for-account.ts",
+    "sync:works-for-account": "tsx src/scripts/sync-works-for-account.ts",
     "xhs:auth": "set XHS_IMPORT_HEADLESS=0&& set XHS_STORAGE_STATE_BOOTSTRAP=1&& tsx src/scripts/run-xhs-import.ts",
     "build": "tsc",
     "start": "node dist/app.js",

TEMPAT SAMPAH
server/python/platforms/__pycache__/baijiahao.cpython-311.pyc


TEMPAT SAMPAH
server/python/platforms/__pycache__/douyin.cpython-311.pyc


TEMPAT SAMPAH
server/python/platforms/__pycache__/weixin.cpython-311.pyc


TEMPAT SAMPAH
server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc


+ 5 - 0
server/python/platforms/douyin.py

@@ -777,10 +777,14 @@ class DouyinPublisher(BasePublisher):
                 create_time = aweme.get('create_time', 0)
                 publish_time = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') if create_time else ''
                 
+                # 入库 video_url 使用 play_addr.url_list 的第一项,无则用分享页链接
+                url_list = (aweme.get('video') or {}).get('play_addr', {}).get('url_list') or []
+                video_url = url_list[0] if url_list else (f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "")
                 works.append(WorkItem(
                     work_id=aweme_id,
                     title=title,
                     cover_url=cover_url,
+                    video_url=video_url,
                     duration=duration,
                     status='published',
                     publish_time=publish_time,
@@ -788,6 +792,7 @@ class DouyinPublisher(BasePublisher):
                     like_count=int(statistics.get('digg_count', 0)),
                     comment_count=int(statistics.get('comment_count', 0)),
                     share_count=int(statistics.get('share_count', 0)),
+                    collect_count=int(statistics.get('collect_count', 0)),
                 ))
             
             if total == 0:

+ 4 - 0
server/python/platforms/xiaohongshu.py

@@ -913,10 +913,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
                     
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),
@@ -1179,10 +1181,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
 
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),

+ 1 - 0
server/src/automation/platforms/base.ts

@@ -13,6 +13,7 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;

+ 2 - 1
server/src/models/entities/Work.ts

@@ -30,7 +30,8 @@ export class Work {
   @Column({ name: 'cover_url', type: 'varchar', length: 500, default: '' })
   coverUrl!: string;
 
-  @Column({ name: 'video_url', type: 'varchar', length: 500, nullable: true })
+  // 抖音 play_addr.url_list[0] 等直链可能非常长,使用 TEXT 避免长度不足导致同步失败
+  @Column({ name: 'video_url', type: 'text', nullable: true })
   videoUrl!: string | null;
 
   @Column({ type: 'varchar', length: 20, default: '00:00' })

+ 196 - 0
server/src/scripts/check-douyin-account.ts

@@ -0,0 +1,196 @@
+/**
+ * 检查抖音账号同步问题 - 诊断脚本
+ * 用法: tsx src/scripts/check-douyin-account.ts <accountId或account_id>
+ */
+import { initDatabase, AppDataSource, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { headlessBrowserService } from '../services/HeadlessBrowserService.js';
+import { CookieManager } from '../automation/cookie.js';
+
+async function main() {
+  const accountIdOrAccountId = process.argv[2];
+  if (!accountIdOrAccountId) {
+    logger.error('请提供账号ID或account_id');
+    logger.info('用法: tsx src/scripts/check-douyin-account.ts <accountId或account_id>');
+    process.exit(1);
+  }
+
+  try {
+    await initDatabase();
+    const accountRepository = AppDataSource.getRepository(PlatformAccount);
+
+    // 先尝试作为数据库主键ID查询(仅当参数是纯数字时)
+    const maybeId = Number(accountIdOrAccountId);
+    let account: PlatformAccount | null = null;
+    if (!Number.isNaN(maybeId) && Number.isInteger(maybeId) && maybeId > 0) {
+      account = await accountRepository.findOne({
+        where: { id: maybeId },
+      });
+    }
+
+    // 如果没找到,尝试作为account_id(字符串)查询
+    if (!account) {
+      account = await accountRepository.findOne({
+        where: { accountId: accountIdOrAccountId },
+      });
+    }
+
+    // 如果还是没找到,尝试模糊匹配(dy_开头)
+    if (!account && accountIdOrAccountId.startsWith('dy_')) {
+      const accounts = await accountRepository.find({
+        where: { platform: 'douyin' },
+      });
+      account = accounts.find(
+        (a) =>
+          a.accountId?.includes(accountIdOrAccountId.replace('dy_', '')) ||
+          a.accountId === accountIdOrAccountId
+      );
+    }
+
+    if (!account) {
+      logger.error(`未找到账号: ${accountIdOrAccountId}`);
+      logger.info('可用的抖音账号:');
+      const allAccounts = await accountRepository.find({
+        where: { platform: 'douyin' },
+      });
+      allAccounts.forEach((a) => {
+        logger.info(
+          `  ID=${a.id}, accountId=${a.accountId}, name=${a.accountName}, status=${a.status}`
+        );
+      });
+      process.exit(1);
+    }
+
+    logger.info(`找到账号: ID=${account.id}, accountId=${account.accountId}, name=${account.accountName}, status=${account.status}`);
+
+    if (!account.cookieData) {
+      logger.error('账号没有 cookie 数据');
+      process.exit(1);
+    }
+
+    // 解密 Cookie
+    let decryptedCookies: string;
+    try {
+      decryptedCookies = CookieManager.decrypt(account.cookieData);
+      logger.info('Cookie 解密成功');
+    } catch {
+      decryptedCookies = account.cookieData;
+      logger.info('使用原始 cookie 数据');
+    }
+
+    // 解析 Cookie(支持 JSON 和 \"name=value; name2=value2\" 两种格式)
+    let cookieList: { name: string; value: string; domain: string; path: string }[];
+    try {
+      cookieList = JSON.parse(decryptedCookies);
+      logger.info(`从 JSON 解析了 ${cookieList.length} 个 cookie`);
+    } catch {
+      // 回退解析字符串格式的 cookie(与 WorkService.parseCookieString 保持一致)
+      logger.warn('Cookie 不是 JSON,尝试按字符串格式解析...');
+      const domain = '.douyin.com';
+      const cookies: { name: string; value: string; domain: string; path: string }[] = [];
+      const pairs = decryptedCookies.split(';');
+      for (const pair of pairs) {
+        const trimmed = pair.trim();
+        if (!trimmed) continue;
+        const eqIndex = trimmed.indexOf('=');
+        if (eqIndex === -1) continue;
+        const name = trimmed.substring(0, eqIndex).trim();
+        const value = trimmed.substring(eqIndex + 1).trim();
+        if (!name || !value) continue;
+        cookies.push({ name, value, domain, path: '/' });
+      }
+      cookieList = cookies;
+      logger.info(`从字符串解析了 ${cookieList.length} 个 cookie`);
+      if (cookieList.length === 0) {
+        logger.error('Cookie 字符串解析失败,未能获取任何 cookie');
+        logger.info('Cookie 前100字符:', decryptedCookies.substring(0, 100));
+        process.exit(1);
+      }
+    }
+
+    // 调用 fetchAccountInfo
+    logger.info('开始获取账号信息和作品列表...');
+    logger.info('注意:这可能需要一些时间,请耐心等待...');
+    
+    let accountInfo;
+    try {
+      accountInfo = await headlessBrowserService.fetchAccountInfo('douyin', cookieList, {
+        onWorksFetchProgress: (info) => {
+          logger.info(
+            `[进度] 已获取 ${info.totalSoFar} 个作品,总计: ${info.declaredTotal || '?'}, 当前页: ${info.page}`
+          );
+        },
+      });
+    } catch (error) {
+      logger.error('获取账号信息时出错:', error);
+      throw error;
+    }
+
+    logger.info('\n=== 账号信息 ===');
+    logger.info(`accountId: ${accountInfo.accountId}`);
+    logger.info(`accountName: ${accountInfo.accountName}`);
+    logger.info(`avatarUrl: ${accountInfo.avatarUrl ? '有' : '无'}`);
+    logger.info(`fansCount: ${accountInfo.fansCount}`);
+    logger.info(`worksCount: ${accountInfo.worksCount}`);
+    logger.info(`worksList.length: ${accountInfo.worksList?.length || 0}`);
+    logger.info(`source: ${accountInfo.source || 'unknown'}`);
+    logger.info(`pythonAvailable: ${accountInfo.pythonAvailable ? 'yes' : 'no'}`);
+    
+    if (accountInfo.worksCount > 0 && (!accountInfo.worksList || accountInfo.worksList.length === 0)) {
+      logger.warn('\n⚠️  警告:worksCount > 0 但 worksList 为空!');
+      logger.warn('这可能表示:');
+      logger.warn('1. API 返回了总数,但实际列表为空');
+      logger.warn('2. 作品列表在解析过程中丢失');
+      logger.warn('3. 分页逻辑有问题,没有正确获取所有作品');
+    }
+
+    if (accountInfo.worksList && accountInfo.worksList.length > 0) {
+      logger.info('\n=== 作品列表 ===');
+      accountInfo.worksList.forEach((work, idx) => {
+        logger.info(
+          `${idx + 1}. ${work.title} (videoId: ${work.videoId}, playCount: ${work.playCount}, likeCount: ${work.likeCount})`
+        );
+      });
+    } else {
+      logger.warn('\n=== 未获取到作品列表 ===');
+      logger.warn(`worksCount: ${accountInfo.worksCount}`);
+      logger.warn(`worksList.length: ${accountInfo.worksList?.length || 0}`);
+      logger.warn(`source: ${accountInfo.source || 'unknown'}`);
+      logger.warn(`pythonAvailable: ${accountInfo.pythonAvailable ? 'yes' : 'no'}`);
+      
+      if (accountInfo.worksCount > 0 && (!accountInfo.worksList || accountInfo.worksList.length === 0)) {
+        logger.error('\n⚠️  严重问题:worksCount > 0 但 worksList 为空!');
+        logger.error('这表示 API 返回了作品总数,但实际列表为空');
+        logger.error('可能的原因:');
+        logger.error('1. API 分页逻辑有问题');
+        logger.error('2. 作品列表在解析过程中丢失');
+        logger.error('3. API 返回格式变化');
+      } else if (accountInfo.worksCount === 0) {
+        logger.warn('\n可能的原因:');
+        logger.warn('1. API 调用失败或超时(检查上面的错误日志)');
+        logger.warn('2. 账号确实没有作品');
+        logger.warn('3. Cookie 已过期(检查是否跳转到登录页)');
+        logger.warn('4. 账号权限不足(无法访问作品列表)');
+        logger.warn('5. Python API 返回空列表,Playwright 也失败');
+      }
+    }
+
+    // 检查账号ID匹配
+    logger.info('\n=== 账号ID匹配检查 ===');
+    logger.info(`数据库 accountId: ${account.accountId}`);
+    logger.info(`API 返回 accountId: ${accountInfo.accountId}`);
+    if (account.accountId !== accountInfo.accountId) {
+      logger.warn('⚠️  账号ID不匹配!这可能导致同步时无法正确匹配账号');
+      logger.warn('建议: 更新数据库中的 accountId 为 API 返回的值');
+    } else {
+      logger.info('✓ 账号ID匹配');
+    }
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 53 - 0
server/src/scripts/list-works-for-account.ts

@@ -0,0 +1,53 @@
+import { initDatabase, AppDataSource, Work, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    logger.error('请提供账号ID(数据库主键)');
+    logger.info('用法: tsx src/scripts/list-works-for-account.ts <accountId>');
+    process.exit(1);
+  }
+
+  const accountId = Number(arg);
+  if (!Number.isInteger(accountId) || accountId <= 0) {
+    logger.error(`无效的账号ID: ${arg}`);
+    process.exit(1);
+  }
+
+  try {
+    await initDatabase();
+    const accountRepo = AppDataSource.getRepository(PlatformAccount);
+    const workRepo = AppDataSource.getRepository(Work);
+
+    const account = await accountRepo.findOne({ where: { id: accountId } });
+    if (!account) {
+      logger.error(`未找到账号 ID=${accountId}`);
+      process.exit(1);
+    }
+
+    logger.info(
+      `账号: ID=${account.id}, platform=${account.platform}, accountId=${account.accountId}, name=${account.accountName}`
+    );
+
+    const works = await workRepo.find({
+      where: { accountId: account.id },
+      order: { publishTime: 'DESC' },
+    });
+
+    logger.info(`共找到 ${works.length} 条作品记录`);
+    works.forEach((w, idx) => {
+      logger.info(
+        `${idx + 1}. id=${w.id}, platformVideoId=${w.platformVideoId}, title=${w.title}, play=${w.playCount}, like=${w.likeCount}`
+      );
+    });
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 49 - 0
server/src/scripts/sync-works-for-account.ts

@@ -0,0 +1,49 @@
+import { initDatabase, AppDataSource, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkService } from '../services/WorkService.js';
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    logger.error('请提供账号(platform_accounts.id 或 account_id 字符串)');
+    logger.info('用法: tsx src/scripts/sync-works-for-account.ts <6|dy_1742363409>');
+    process.exit(1);
+  }
+
+  await initDatabase();
+  const accountRepo = AppDataSource.getRepository(PlatformAccount);
+
+  const maybeId = Number(arg);
+  let account: PlatformAccount | null = null;
+  if (!Number.isNaN(maybeId) && Number.isInteger(maybeId) && maybeId > 0) {
+    account = await accountRepo.findOne({ where: { id: maybeId } });
+  }
+  if (!account) {
+    account = await accountRepo.findOne({ where: { accountId: arg } });
+  }
+
+  if (!account) {
+    logger.error(`未找到账号: ${arg}`);
+    process.exit(1);
+  }
+
+  logger.info(`准备同步账号: id=${account.id}, userId=${account.userId}, platform=${account.platform}, accountId=${account.accountId}`);
+
+  const workService = new WorkService();
+  const result = await workService.syncWorks(
+    account.userId,
+    account.id,
+    undefined,
+    (p, step) => logger.info(`[进度] ${p}% ${step}`)
+  );
+
+  logger.info(`同步完成: synced=${result.synced}, accounts=${result.accounts}`);
+  logger.info(`账号摘要: ${JSON.stringify(result.accountSummaries, null, 2)}`);
+  process.exit(0);
+}
+
+void main().catch((e) => {
+  logger.error('执行失败:', e);
+  process.exit(1);
+});
+

+ 84 - 13
server/src/services/HeadlessBrowserService.ts

@@ -89,6 +89,8 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  /** 作品播放/详情页 URL,同步到 works.video_url */
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;
@@ -777,6 +779,7 @@ class HeadlessBrowserService {
         work_id: string;
         title: string;
         cover_url: string;
+        video_url?: string;
         duration: number;
         publish_time: string;
         status: string;
@@ -789,6 +792,7 @@ class HeadlessBrowserService {
         videoId: work.work_id,
         title: work.title,
         coverUrl: work.cover_url,
+        videoUrl: work.video_url || '',
         duration: String(work.duration || 0),
         publishTime: work.publish_time,
         status: work.status || 'published',
@@ -1001,9 +1005,15 @@ class HeadlessBrowserService {
           }
 
           // 作品列表为空,尝试用 Playwright 获取账号信息
-          logger.info(`[Python API] Got empty works list for ${platform}, trying Playwright`);
+          if (worksTotal > 0 && worksList.length === 0) {
+            logger.warn(`[Python API] Warning: API reported ${worksTotal} works but returned empty list for ${platform}`);
+            logger.warn(`[Python API] This may indicate a bug in Python API or API format change`);
+          } else {
+            logger.info(`[Python API] Got empty works list for ${platform} (total=${worksTotal}), trying Playwright`);
+          }
         } catch (pythonError) {
           logger.warn(`[Python API] Failed to fetch works for ${platform}:`, pythonError);
+          logger.warn(`[Python API] Error details:`, pythonError instanceof Error ? pythonError.message : String(pythonError));
         }
       } else {
         logger.info(`[Python API] Service not available for ${platform}`);
@@ -1104,7 +1114,7 @@ class HeadlessBrowserService {
         coverUrl: string;
         duration: number;
         createTime: number;
-        statistics: { play_count: number; digg_count: number; comment_count: number; share_count: number };
+        statistics: { play_count: number; digg_count: number; comment_count: number; share_count: number; collect_count: number };
       }>;
       total?: number;
     } = {};
@@ -1149,16 +1159,19 @@ class HeadlessBrowserService {
                   capturedData.total = (capturedData.total || 0) + data.aweme_list.length;
                 }
               }
-              // 解析作品列表
+              // 解析作品列表;video_url 使用 video.play_addr.url_list 的第一项
               capturedData.worksList = data.aweme_list.map((aweme: Record<string, unknown>) => {
                 const statistics = aweme.statistics as Record<string, unknown> || {};
                 const cover = aweme.Cover as { url_list?: string[] } || aweme.video as { cover?: { url_list?: string[] } };
                 const coverUrl = cover?.url_list?.[0] || (cover as { cover?: { url_list?: string[] } })?.cover?.url_list?.[0] || '';
+                const video = aweme.video as { play_addr?: { url_list?: string[] } } | undefined;
+                const videoUrl = video?.play_addr?.url_list?.[0] || '';
 
                 return {
                   awemeId: String(aweme.aweme_id || ''),
                   title: String(aweme.item_title || aweme.desc || '').split('\n')[0].slice(0, 50) || '无标题',
                   coverUrl,
+                  videoUrl,
                   duration: Number(aweme.duration || 0),
                   createTime: Number(aweme.create_time || 0),
                   statistics: {
@@ -1166,6 +1179,7 @@ class HeadlessBrowserService {
                     digg_count: Number(statistics.digg_count || 0),
                     comment_count: Number(statistics.comment_count || 0),
                     share_count: Number(statistics.share_count || 0),
+                    collect_count: Number((statistics as any).collect_count || 0),
                   },
                 };
               });
@@ -1417,6 +1431,8 @@ class HeadlessBrowserService {
       logger.info('[Douyin] Fetching works via API...');
       const apiResult = await this.fetchWorksDirectApi(page);
 
+      logger.info(`[Douyin] fetchWorksDirectApi returned: works.length=${apiResult.works.length}, total=${apiResult.total}`);
+      
       if (apiResult.works.length > 0) {
         // 使用 items 累计数量作为作品数(apiResult.total 现在是累计的 items.length)
         // 如果 total 为 0,则使用 works 列表长度
@@ -1425,22 +1441,28 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: (w as { videoUrl?: string }).videoUrl || (w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : ''),
           duration: '00:00',
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
-          playCount: 0,
-          likeCount: 0,
+          playCount: w.playCount,
+          likeCount: w.likeCount,
           commentCount: w.commentCount,
-          shareCount: 0,
+          shareCount: w.shareCount,
+          collectCount: w.collectCount,
         }));
         logger.info(`[Douyin] Got ${apiResult.works.length} works from API, total count: ${worksCount}`);
-      } else if (capturedData.worksList && capturedData.worksList.length > 0) {
-        // 如果直接 API 调用失败,使用监听到的数据
-        worksCount = capturedData.total || capturedData.worksList.length;
+      } else {
+        logger.warn(`[Douyin] fetchWorksDirectApi returned 0 works`);
+        if (capturedData.worksList && capturedData.worksList.length > 0) {
+          // 如果直接 API 调用失败,使用监听到的数据
+          logger.info(`[Douyin] Falling back to intercepted API data: ${capturedData.worksList.length} works`);
+          worksCount = capturedData.total || capturedData.worksList.length;
         worksList = capturedData.worksList.map(w => ({
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: (w as { videoUrl?: string }).videoUrl || (w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : ''),
           duration: this.formatDuration(w.duration),
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
@@ -1448,12 +1470,18 @@ class HeadlessBrowserService {
           likeCount: w.statistics.digg_count,
           commentCount: w.statistics.comment_count,
           shareCount: w.statistics.share_count,
+          collectCount: w.statistics.collect_count,
         }));
-        logger.info(`[Douyin] Got ${worksCount} works from intercepted API data`);
+          logger.info(`[Douyin] Got ${worksCount} works from intercepted API data`);
+        } else {
+          logger.warn(`[Douyin] No works found: fetchWorksDirectApi returned 0, intercepted data also empty`);
+          logger.warn(`[Douyin] This may indicate: cookie expired, API error, or account has no works`);
+        }
       }
 
     } catch (error) {
-      logger.warn('Failed to fetch Douyin account info:', error);
+      logger.error('Failed to fetch Douyin account info:', error);
+      logger.error('Error details:', error instanceof Error ? error.stack : String(error));
     }
 
     return { accountId, accountName, avatarUrl, fansCount, worksCount, worksList };
@@ -2287,6 +2315,7 @@ class HeadlessBrowserService {
             videoId: note.noteId,
             title: note.title || '无标题',
             coverUrl: note.coverUrl,
+            videoUrl: note.noteId ? `https://www.xiaohongshu.com/explore/${note.noteId}` : '',
             duration: durationStr,
             publishTime: note.publishTime,
             status: statusStr,
@@ -2653,6 +2682,7 @@ class HeadlessBrowserService {
               videoId: item.id || item.article_id || `bjh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
               title: item.title || '',
               coverUrl: coverUrl,
+              videoUrl: item.url || item.article_url || '',
               duration: '00:00',
               publishTime: item.created_at || item.create_time || new Date().toISOString(),
               status: item.status || 'published',
@@ -3753,7 +3783,12 @@ class HeadlessBrowserService {
       awemeId: string;
       title: string;
       coverUrl: string;
+      videoUrl?: string;
+      playCount: number;
+      likeCount: number;
       commentCount: number;
+      shareCount: number;
+      collectCount: number;
       createTime?: number;
     }>;
     total: number;
@@ -3762,7 +3797,12 @@ class HeadlessBrowserService {
       awemeId: string;
       title: string;
       coverUrl: string;
+      videoUrl?: string;
+      playCount: number;
+      likeCount: number;
       commentCount: number;
+      shareCount: number;
+      collectCount: number;
       createTime?: number;
     }> = [];
     let totalCount = 0; // 从 API 获取的总作品数
@@ -3820,12 +3860,24 @@ class HeadlessBrowserService {
         // 检查 API 返回状态
         if (data?.status_code !== 0 && data?.status_code !== undefined) {
           logger.warn(`[DirectAPI] API returned error status_code: ${data.status_code}`);
+          logger.warn(`[DirectAPI] Error message: ${data?.err_msg || data?.errMsg || 'unknown'}`);
           // status_code: 8 表示未授权,可能需要重新登录
           if (data.status_code === 8) {
             logger.warn('[DirectAPI] status_code 8: Not authorized, may need re-login');
           }
+          // 如果是第一页就出错,记录更详细的错误信息
+          if (pageCount === 1) {
+            logger.error(`[DirectAPI] First page failed with status_code ${data.status_code}, cannot fetch works`);
+            logger.error(`[DirectAPI] Response data: ${JSON.stringify(data).substring(0, 500)}`);
+          }
           break;
         }
+        
+        // 如果 status_code 是 0 但 aweme_list 为空,记录警告
+        if (data?.status_code === 0 && awemeList.length === 0 && pageCount === 1) {
+          logger.warn(`[DirectAPI] API returned success but aweme_list is empty on first page`);
+          logger.warn(`[DirectAPI] Response data: ${JSON.stringify(data).substring(0, 500)}`);
+        }
 
         // 优先从第一个作品的 author.aweme_count 获取真实作品数(只在第一页获取)
         if (pageCount === 1 && awemeList.length > 0) {
@@ -3844,9 +3896,13 @@ class HeadlessBrowserService {
           const awemeId = String(aweme.aweme_id || '');
           if (!awemeId) continue;
 
-          // 从 statistics 中获取评论数
+          // 从 statistics 中获取所有统计字段
           const statistics = aweme.statistics || {};
+          const playCount = parseInt(String(statistics.play_count || '0'), 10);
+          const likeCount = parseInt(String(statistics.digg_count || '0'), 10); // 抖音用 digg_count 表示点赞
           const commentCount = parseInt(String(statistics.comment_count || '0'), 10);
+          const shareCount = parseInt(String(statistics.share_count || '0'), 10);
+          const collectCount = parseInt(String(statistics.collect_count || '0'), 10);
 
           // 获取标题:优先使用 item_title,其次使用 desc(描述)
           let title = aweme.item_title || '';
@@ -3864,11 +3920,19 @@ class HeadlessBrowserService {
             coverUrl = aweme.video.cover.url_list[0];
           }
 
+          // 入库 video_url 使用 play_addr.url_list 的第一项
+          const videoUrl = aweme.video?.play_addr?.url_list?.[0] || '';
+
           works.push({
             awemeId,
             title,
             coverUrl,
+            videoUrl,
+            playCount,
+            likeCount,
             commentCount,
+            shareCount,
+            collectCount,
             createTime: aweme.create_time,
           });
         }
@@ -3915,8 +3979,15 @@ class HeadlessBrowserService {
       }
 
       logger.info(`[DirectAPI] Total fetched ${works.length} works from ${pageCount} pages, items count: ${totalCount}`);
+      
+      // 如果总作品数 > 0 但实际获取到的作品数为 0,记录警告
+      if (totalCount > 0 && works.length === 0) {
+        logger.warn(`[DirectAPI] Warning: API reported ${totalCount} works but fetched 0 works`);
+        logger.warn(`[DirectAPI] This may indicate: API error, cookie expired, or permission issue`);
+      }
     } catch (e) {
-      logger.warn('[DirectAPI] Failed to fetch works:', e);
+      logger.error('[DirectAPI] Failed to fetch works:', e);
+      logger.error('[DirectAPI] Error details:', e instanceof Error ? e.stack : String(e));
     }
 
     return { works, total: totalCount };

+ 2 - 0
server/src/services/WorkService.ts

@@ -336,6 +336,7 @@ export class WorkService {
           await this.workRepository.update(work.id, {
             title: workItem.title || work.title,
             coverUrl: workItem.coverUrl || work.coverUrl,
+            videoUrl: workItem.videoUrl !== undefined ? workItem.videoUrl || null : work.videoUrl,
             duration: workItem.duration || work.duration,
             status: workItem.status || work.status,
             playCount: workItem.playCount ?? work.playCount,
@@ -353,6 +354,7 @@ export class WorkService {
             platformVideoId: canonicalVideoId,
             title: workItem.title || '',
             coverUrl: workItem.coverUrl || '',
+            videoUrl: workItem.videoUrl || null,
             duration: workItem.duration || '00:00',
             status: this.normalizeStatus(workItem.status),
             publishTime: this.parsePublishTime(workItem.publishTime),