Procházet zdrojové kódy

fix: restore xhs account metrics refresh

ethanfly před 9 hodinami
rodič
revize
a2c503bacf

+ 2 - 2
client/src/App.vue

@@ -47,7 +47,7 @@ const servicesReady = ref(false);
 const serviceStatus = ref({ node: false });
 const router = useRouter();
 const serverStore = useServerStore();
-const browserFallbackUrl = 'http://127.0.0.1:3000';
+const browserFallbackUrl = import.meta.env.DEV ? window.location.origin : 'http://127.0.0.1:3000';
 let splashFallbackTimer: ReturnType<typeof setTimeout> | null = null;
 
 function finishSplash(ready: boolean) {
@@ -108,7 +108,7 @@ async function bootstrapElectronMode() {
 async function bootstrapBrowserMode() {
   await serverStore.loadConfig();
 
-  if (!serverStore.isConfigured) {
+  if (import.meta.env.DEV || !serverStore.isConfigured) {
     serverStore.setSingleServer({
       name: '本地 Node 服务',
       url: browserFallbackUrl,

+ 3 - 2
client/src/api/request.ts

@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus';
 import { useAuthStore } from '@/stores/auth';
 import { useServerStore } from '@/stores/server';
 import router from '@/router';
+import { sanitizeApiImageFields } from '@/utils/image';
 import type { ApiResponse } from '@media-manager/shared';
 
 // 创建 axios 实例
@@ -30,7 +31,7 @@ function isLocalBackend(url: string): boolean {
   try {
     const u = new URL(url);
     const port = u.port || (u.protocol === 'https:' ? '443' : '80');
-    return (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') && port === '3000';
+    return (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') && (port === '3000' || port === '3888');
   } catch {
     return false;
   }
@@ -88,7 +89,7 @@ request.interceptors.response.use(
     const data = response.data;
 
     if (data.success) {
-      return data.data as any;
+      return sanitizeApiImageFields(data.data) as any;
     }
 
     // 业务错误

+ 131 - 8
client/src/components/BrowserTab.vue

@@ -2259,6 +2259,118 @@ async function collectDouyinAccountInfo() {
  * 2. 跳转到笔记管理页,监听 API 获取笔记列表,取 notes 数量
  * 3. 账号ID使用 xhs_ 前缀
  */
+function parseXhsMetric(value: unknown): number | undefined {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? Math.round(value) : undefined;
+  }
+  if (typeof value !== 'string') return undefined;
+
+  const cleaned = value.trim().replace(/,/g, '').replace(/\s+/g, '');
+  const match = cleaned.match(/-?\d+(?:\.\d+)?/);
+  if (!match) return undefined;
+
+  let parsed = Number(match[0]);
+  if (!Number.isFinite(parsed)) return undefined;
+  const lower = cleaned.toLowerCase();
+  if (cleaned.includes('万') || lower.includes('w')) parsed *= 10000;
+  else if (cleaned.includes('亿')) parsed *= 100000000;
+  return Math.round(parsed);
+}
+
+function firstXhsString(sources: any[], keys: string[]): string | undefined {
+  for (const source of sources) {
+    if (!source || typeof source !== 'object') continue;
+    for (const key of keys) {
+      const value = source[key];
+      if (typeof value === 'string' && value.trim()) return value.trim();
+      if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+    }
+  }
+  return undefined;
+}
+
+function firstXhsMetric(sources: any[], keys: string[]): number | undefined {
+  for (const source of sources) {
+    if (!source || typeof source !== 'object') continue;
+    for (const key of keys) {
+      if (!(key in source)) continue;
+      const value = parseXhsMetric(source[key]);
+      if (value !== undefined) return value;
+    }
+  }
+  return undefined;
+}
+
+function extractXhsAccountFields(raw: any): {
+  name?: string;
+  avatar?: string;
+  red_num?: string;
+  user_id?: string;
+  fans_count?: number;
+  note_count?: number;
+} {
+  const sources = [
+    raw?.data?.user_info,
+    raw?.data?.userInfo,
+    raw?.data?.user,
+    raw?.data?.account_info,
+    raw?.data?.accountInfo,
+    raw?.data?.author_info,
+    raw?.data?.authorInfo,
+    raw?.data?.basic_info,
+    raw?.data?.basicInfo,
+    raw?.data?.core_user_info,
+    raw?.data?.coreUserInfo,
+    raw?.data?.profile_info,
+    raw?.data?.profileInfo,
+    raw?.data?.profile,
+    raw?.data?.creator,
+    raw?.data,
+    raw?.user_info,
+    raw?.userInfo,
+    raw?.user,
+    raw?.account_info,
+    raw?.accountInfo,
+    raw?.author_info,
+    raw?.authorInfo,
+    raw?.basic_info,
+    raw?.basicInfo,
+    raw?.core_user_info,
+    raw?.coreUserInfo,
+    raw?.profile_info,
+    raw?.profileInfo,
+    raw?.profile,
+    raw,
+  ];
+
+  return {
+    name: firstXhsString(sources, ['name', 'nickname', 'nick_name', 'nickName', 'userName', 'user_name']),
+    avatar: firstXhsString(sources, ['avatar', 'avatarUrl', 'avatar_url', 'image', 'userAvatar', 'user_avatar']),
+    red_num: firstXhsString(sources, ['red_num', 'redNum', 'red_id', 'redId', 'redid', 'redID']),
+    user_id: firstXhsString(sources, ['user_id', 'userId']),
+    fans_count: firstXhsMetric(sources, [
+      'fans_count',
+      'fansCount',
+      'fans',
+      'fans_num',
+      'fansNum',
+      'total_fans',
+      'totalFans',
+      'follower_count',
+      'followers_count',
+      'followersCount',
+      'followers',
+    ]),
+    note_count: firstXhsMetric(sources, ['note_count', 'noteCount', 'notes_count', 'notesCount', 'notes']),
+  };
+}
+
+function normalizeXhsClientAccountId(accountId: string): string {
+  const raw = String(accountId || '').trim();
+  if (!raw) return `xhs_${Date.now()}`;
+  return raw.startsWith('xhs_') ? raw : `xhs_${raw.replace(/^xiaohongshu_/, '')}`;
+}
+
 async function collectXiaohongshuAccountInfo() {
   const webview = webviewRef.value;
   if (!webview) return;
@@ -2284,7 +2396,10 @@ async function collectXiaohongshuAccountInfo() {
   }
   
   // 尝试从页面 HTML 提取信息作为备选
-  let info: any = personalInfo?.data || personalInfo || {};
+  let info: any = {
+    ...(personalInfo?.data || personalInfo || {}),
+    ...extractXhsAccountFields(personalInfo),
+  };
   
   if (!info.red_num && !info.name) {
     console.log('[小红书] API 数据不完整,尝试从页面提取...');
@@ -2306,13 +2421,13 @@ async function collectXiaohongshuAccountInfo() {
         return result;
       })()
     `).catch(() => ({}));
-    info = { ...info, ...pageInfo };
+    info = { ...info, ...pageInfo, ...extractXhsAccountFields({ ...info, ...pageInfo }) };
   }
   
   console.log('[小红书] 个人信息:', info);
   
   // 生成账号ID(如果没有 red_num,使用时间戳)
-  const accountId = info.red_num ? `xhs_${info.red_num}` : `xhs_${Date.now()}`;
+  let accountId = info.red_num ? normalizeXhsClientAccountId(info.red_num) : `xhs_${Date.now()}`;
   
   // 获取作品数 - 小红书笔记管理页有额外权限验证,跳转会触发401
   // 直接从首页尝试获取,获取不到则使用0,后续通过后台刷新获取
@@ -2365,16 +2480,24 @@ async function collectXiaohongshuAccountInfo() {
         cookieData: cookieData.value,
       });
       if (verifyResult?.success && verifyResult.accountInfo) {
-        worksCount = verifyResult.accountInfo.worksCount || 0;
-        info.fans_count = verifyResult.accountInfo.fansCount || 0;
+        if (verifyResult.accountInfo.accountId) {
+          accountId = normalizeXhsClientAccountId(verifyResult.accountInfo.accountId);
+        }
+        if ((verifyResult.accountInfo.worksCount || 0) > 0 || worksCount === 0) {
+          worksCount = verifyResult.accountInfo.worksCount || 0;
+        }
+        const verifiedFans = parseXhsMetric(verifyResult.accountInfo.fansCount);
+        if (verifiedFans !== undefined && (verifiedFans > 0 || !info.fans_count)) {
+          info.fans_count = verifiedFans;
+        }
         if (verifyResult.accountInfo.avatarUrl) info.avatar = verifyResult.accountInfo.avatarUrl;
         if (verifyResult.accountInfo.accountName) info.name = verifyResult.accountInfo.accountName;
       } else {
-        worksCount = 0;
+        worksCount = worksCount || 0;
       }
     } catch (e) {
       console.warn('[小红书] verify-cookie failed:', e);
-      worksCount = 0;
+      worksCount = worksCount || 0;
     }
   }
   
@@ -2385,7 +2508,7 @@ async function collectXiaohongshuAccountInfo() {
     accountId,
     accountName: info.name || '小红书用户',
     avatarUrl: info.avatar || '',
-    fansCount: info.fans_count || 0,
+    fansCount: parseXhsMetric(info.fans_count) || 0,
     worksCount,
   };
   

+ 78 - 0
client/src/utils/image.ts

@@ -0,0 +1,78 @@
+const VOLATILE_IMAGE_HOSTS = new Set([
+  'finder.video.qq.com',
+]);
+
+const VOLATILE_IMAGE_PATH_PARTS = [
+  '/stodownload',
+];
+
+const IMAGE_FIELD_KEYS = new Set([
+  'avatar',
+  'avatarUrl',
+  'avatar_url',
+  'accountAvatar',
+  'authorAvatar',
+  'cover',
+  'coverUrl',
+  'cover_url',
+  'thumbnail',
+  'thumbnailUrl',
+  'thumbnail_url',
+]);
+
+function isVolatileRemoteImageUrl(value: string): boolean {
+  const trimmed = value.trim();
+  if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
+    return false;
+  }
+
+  try {
+    const url = new URL(trimmed);
+    const host = url.hostname.toLowerCase();
+    const path = url.pathname.toLowerCase();
+
+    if (VOLATILE_IMAGE_HOSTS.has(host) && VOLATILE_IMAGE_PATH_PARTS.some((part) => path.includes(part))) {
+      return true;
+    }
+
+    return host.endsWith('.video.qq.com') && url.searchParams.has('encfilekey');
+  } catch {
+    return false;
+  }
+}
+
+export function getSafeImageSrc(value: string | null | undefined): string | undefined {
+  if (!value) return undefined;
+  const trimmed = value.trim();
+  if (!trimmed || isVolatileRemoteImageUrl(trimmed)) return undefined;
+  return trimmed;
+}
+
+export function sanitizeApiImageFields<T>(payload: T): T {
+  const seen = new WeakSet<object>();
+
+  function visit(value: unknown): unknown {
+    if (!value || typeof value !== 'object') return value;
+    if (seen.has(value)) return value;
+    seen.add(value);
+
+    if (Array.isArray(value)) {
+      value.forEach((item) => visit(item));
+      return value;
+    }
+
+    const record = value as Record<string, unknown>;
+    Object.keys(record).forEach((key) => {
+      const fieldValue = record[key];
+      if (typeof fieldValue === 'string' && IMAGE_FIELD_KEYS.has(key) && isVolatileRemoteImageUrl(fieldValue)) {
+        record[key] = '';
+        return;
+      }
+      visit(fieldValue);
+    });
+
+    return value;
+  }
+
+  return visit(payload) as T;
+}

+ 30 - 2
client/vite.config.ts

@@ -6,10 +6,38 @@ import AutoImport from 'unplugin-auto-import/vite';
 import Components from 'unplugin-vue-components/vite';
 import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
 import { resolve } from 'path';
+import { existsSync, readFileSync } from 'fs';
+
+function readServerEnv() {
+  const envPath = resolve(__dirname, '../server/.env');
+  const env: Record<string, string> = {};
+  if (!existsSync(envPath)) return env;
+
+  const lines = readFileSync(envPath, 'utf8').split(/\r?\n/);
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#')) continue;
+    const index = trimmed.indexOf('=');
+    if (index <= 0) continue;
+    const key = trimmed.slice(0, index).trim();
+    const value = trimmed.slice(index + 1).trim().replace(/^['"]|['"]$/g, '');
+    env[key] = value;
+  }
+  return env;
+}
+
+function getDevApiTarget() {
+  const serverEnv = readServerEnv();
+  const rawHost = process.env.VITE_DEV_API_HOST || serverEnv.HOST || '127.0.0.1';
+  const host = rawHost === '0.0.0.0' || rawHost === '::' ? '127.0.0.1' : rawHost;
+  const port = process.env.VITE_DEV_API_PORT || serverEnv.PORT || '3000';
+  return `http://${host}:${port}`;
+}
 
 export default defineConfig(({ command }) => {
   const isServe = command === 'serve';
   const isBuild = command === 'build';
+  const devApiTarget = getDevApiTarget();
 
   return {
     // Electron 打包后从 file:// 加载,需使用相对路径
@@ -117,11 +145,11 @@ export default defineConfig(({ command }) => {
       strictPort: true,
       proxy: {
         '/api': {
-          target: 'http://127.0.0.1:3000',
+          target: devApiTarget,
           changeOrigin: true,
         },
         '/ws': {
-          target: 'ws://127.0.0.1:3000',
+          target: devApiTarget.replace(/^http/, 'ws'),
           ws: true,
         },
       },

+ 7 - 5
server/src/automation/platforms/xiaohongshu.ts

@@ -13,6 +13,7 @@ import type {
 import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
 import { logger } from '../../utils/logger.js';
 import { aiService } from '../../ai/index.js';
+import { extractXiaohongshuProfileInfo } from '../../utils/xiaohongshu.js';
 
 // 服务器根目录(用于构造绝对路径)
 const SERVER_ROOT = path.resolve(process.cwd());
@@ -266,14 +267,15 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
             const data = await response.json();
             logger.info(`[Xiaohongshu API] Personal info:`, JSON.stringify(data).slice(0, 1000));
 
-            if (data?.data) {
-              const info = data.data;
+            const info = extractXiaohongshuProfileInfo(data);
+            if (info.name || info.redNum || info.userId || info.fansCount !== undefined) {
               capturedData.userInfo = {
                 nickname: info.name,
                 avatar: info.avatar,
-                userId: info.red_num,  // 小红书号
-                redId: info.red_num,
-                fans: info.fans_count,
+                userId: info.userId || info.redNum,
+                redId: info.redNum,
+                fans: info.fansCount,
+                notes: info.worksCount,
               };
               logger.info(`[Xiaohongshu API] Captured personal info:`, capturedData.userInfo);
             }

+ 97 - 16
server/src/services/HeadlessBrowserService.ts

@@ -1,7 +1,11 @@
 /// <reference lib="dom" />
 import { chromium, type BrowserContext, type Page } from 'playwright';
 import { logger } from '../utils/logger.js';
-import { extractDeclaredNotesCountFromPostedResponse } from '../utils/xiaohongshu.js';
+import {
+  extractDeclaredNotesCountFromPostedResponse,
+  extractLatestXiaohongshuFansCount,
+  extractXiaohongshuProfileInfo,
+} from '../utils/xiaohongshu.js';
 import type { PlatformType } from '@media-manager/shared';
 
 // 抖音 API 接口配置
@@ -1806,16 +1810,15 @@ class HeadlessBrowserService {
             const data = await response.json();
             logger.info(`[Xiaohongshu API] User info response:`, JSON.stringify(data).slice(0, 500));
 
-            // 解析用户信息
-            const userInfo = data?.data?.user_info || data?.data || data;
-            if (userInfo) {
+            const profile = extractXiaohongshuProfileInfo(data);
+            if (profile.name || profile.redNum || profile.userId || profile.fansCount !== undefined) {
               capturedData.userInfo = {
-                nickname: userInfo.nickname || userInfo.name || userInfo.userName,
-                avatar: userInfo.image || userInfo.avatar || userInfo.images,
-                userId: userInfo.user_id || userInfo.userId,
-                redId: userInfo.red_id || userInfo.redId,
-                fans: userInfo.fans ?? userInfo.fansCount,
-                notes: userInfo.notes ?? userInfo.noteCount,
+                nickname: profile.name,
+                avatar: profile.avatar,
+                userId: profile.userId,
+                redId: profile.redNum,
+                fans: profile.fansCount,
+                notes: profile.worksCount,
               };
               logger.info(`[Xiaohongshu API] Captured user info:`, capturedData.userInfo);
             }
@@ -1828,15 +1831,27 @@ class HeadlessBrowserService {
             logger.info(`[Xiaohongshu API] Creator home response:`, JSON.stringify(data).slice(0, 500));
 
             if (data?.data) {
-              const homeData = data.data;
-              // 获取粉丝数和笔记数
-              if (homeData.fans_count !== undefined) {
+              const homeData = extractXiaohongshuProfileInfo(data);
+              if (homeData.fansCount !== undefined) {
                 capturedData.userInfo = capturedData.userInfo || {};
-                capturedData.userInfo.fans = homeData.fans_count;
+                capturedData.userInfo.fans = homeData.fansCount;
               }
-              if (homeData.note_count !== undefined) {
+              if (homeData.worksCount !== undefined) {
                 capturedData.userInfo = capturedData.userInfo || {};
-                capturedData.userInfo.notes = homeData.note_count;
+                capturedData.userInfo.notes = homeData.worksCount;
+              }
+              if (homeData.name) {
+                capturedData.userInfo = capturedData.userInfo || {};
+                capturedData.userInfo.nickname = capturedData.userInfo.nickname || homeData.name;
+              }
+              if (homeData.avatar) {
+                capturedData.userInfo = capturedData.userInfo || {};
+                capturedData.userInfo.avatar = capturedData.userInfo.avatar || homeData.avatar;
+              }
+              if (homeData.redNum || homeData.userId) {
+                capturedData.userInfo = capturedData.userInfo || {};
+                capturedData.userInfo.redId = capturedData.userInfo.redId || homeData.redNum;
+                capturedData.userInfo.userId = capturedData.userInfo.userId || homeData.userId;
               }
             }
           }
@@ -1949,6 +1964,14 @@ class HeadlessBrowserService {
         }
       }
 
+      if (fansCount === undefined) {
+        const dataPageFansCount = await this.fetchXiaohongshuFansCountFromDataPage(page);
+        if (dataPageFansCount !== undefined) {
+          fansCount = dataPageFansCount;
+          logger.info(`[Xiaohongshu] Found fans count from fans data page: ${fansCount}`);
+        }
+      }
+
       // 如果还没获取到小红书号,尝试从页面文本中提取
       if (!accountId.match(/xiaohongshu_[a-zA-Z0-9_]+/) || accountId.includes('_' + Date.now().toString().slice(0, 8))) {
         const bodyText = await page.textContent('body');
@@ -2206,6 +2229,64 @@ class HeadlessBrowserService {
     return Math.floor(num);
   }
 
+  private async fetchXiaohongshuFansCountFromDataPage(page: Page): Promise<number | undefined> {
+    const fansDataUrl = 'https://creator.xiaohongshu.com/statistics/fans-data';
+    const overallNewPattern = /\/api\/galaxy\/creator\/data\/fans\/overall_new/i;
+
+    try {
+      const responsePromise = page.waitForResponse(
+        (res) => overallNewPattern.test(res.url()) && res.request().method() === 'GET',
+        { timeout: 20000 }
+      ).catch(() => null);
+
+      logger.info('[Xiaohongshu] Navigating to fans data page to fetch fans count...');
+      await page.goto(fansDataUrl, {
+        waitUntil: 'domcontentloaded',
+        timeout: 30000,
+      }).catch((error) => {
+        logger.warn('[Xiaohongshu] Fans data page navigation failed:', error);
+      });
+
+      await page.waitForTimeout(2000);
+
+      const currentUrl = page.url();
+      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
+        logger.warn('[Xiaohongshu] Fans data page redirected to login');
+        return undefined;
+      }
+
+      const near30ButtonSelector =
+        '#content-area > main > div:nth-child(3) > div > div.content > div.css-12s9z8c.fans-data-container > div.title-container > div.extra-box > div > label:nth-child(2)';
+      await page.locator(near30ButtonSelector)
+        .or(page.locator('.fans-data-container').getByText('近30天').first())
+        .click({ timeout: 5000 })
+        .catch(() => undefined);
+
+      let response = await responsePromise;
+      if (!response) {
+        response = await page.waitForResponse(
+          (res) => overallNewPattern.test(res.url()) && res.request().method() === 'GET',
+          { timeout: 10000 }
+        ).catch(() => null);
+      }
+
+      if (!response) {
+        logger.warn('[Xiaohongshu] No fans overall_new response captured');
+        return undefined;
+      }
+
+      const body = await response.json().catch(() => null);
+      const count = extractLatestXiaohongshuFansCount(body);
+      if (count === undefined) {
+        logger.warn('[Xiaohongshu] Fans overall_new response did not contain fans count');
+      }
+      return count;
+    } catch (error) {
+      logger.warn('[Xiaohongshu] Failed to fetch fans count from fans data page:', error);
+      return undefined;
+    }
+  }
+
   /**
    * 获取平台配置
    */

+ 12 - 8
server/src/services/login/XiaohongshuLoginService.ts

@@ -12,7 +12,10 @@
  */
 
 import { logger } from '../../utils/logger.js';
-import { extractNotesCountFromPostedResponse } from '../../utils/xiaohongshu.js';
+import {
+  extractNotesCountFromPostedResponse,
+  extractXiaohongshuProfileInfo,
+} from '../../utils/xiaohongshu.js';
 import { BaseLoginService, type ApiInterceptConfig } from './BaseLoginService.js';
 import type { AccountInfo, LoginSession } from './types.js';
 
@@ -38,14 +41,14 @@ export class XiaohongshuLoginService extends BaseLoginService {
         urlPattern: '/api/galaxy/creator/home/personal_info',
         dataKey: 'personalInfo',
         handler: (data: any) => {
-          const info = data.data || data;
-          // TODO(#6064): fans_count 字段名需与实际 API 响应结构核对,
-          // 若小红书 API 返回结构变化(如嵌套层级调整)会导致粉丝数显示错误
+          const info = extractXiaohongshuProfileInfo(data);
           return {
             avatar: info.avatar,
             name: info.name,
-            redNum: info.red_num,
-            fansCount: info.fans_count,
+            redNum: info.redNum,
+            userId: info.userId,
+            fansCount: info.fansCount,
+            worksCount: info.worksCount,
           };
         },
       },
@@ -80,7 +83,8 @@ export class XiaohongshuLoginService extends BaseLoginService {
         personalInfo = await this.waitForApiData(session, 'personalInfo', 30000);
       }
 
-      if (!personalInfo?.redNum) {
+      const redNum = personalInfo?.redNum || personalInfo?.userId;
+      if (!redNum) {
         logger.error('[小红书] 无法获取小红书号');
         return null;
       }
@@ -98,7 +102,7 @@ export class XiaohongshuLoginService extends BaseLoginService {
 
       // 步骤6: 组装账号信息,使用 xhs_ 前缀
       return {
-        accountId: `xhs_${personalInfo.redNum}`,
+        accountId: `xhs_${redNum}`,
         accountName: personalInfo.name || '小红书用户',
         avatarUrl: personalInfo.avatar || '',
         fansCount: personalInfo.fansCount || 0,

+ 223 - 0
server/src/utils/xiaohongshu.ts

@@ -32,6 +32,229 @@ export function extractNotesCountFromPostedResponse(raw: any): number {
   return countFromTags || countFromPayload || (Array.isArray(notes) ? notes.length : 0);
 }
 
+export interface XiaohongshuProfileInfo {
+  name?: string;
+  avatar?: string;
+  redNum?: string;
+  userId?: string;
+  fansCount?: number;
+  worksCount?: number;
+}
+
+function toCleanString(value: unknown): string | undefined {
+  if (typeof value === 'string') {
+    const trimmed = value.trim();
+    return trimmed || undefined;
+  }
+
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return String(value);
+  }
+
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      const parsed = toCleanString(item);
+      if (parsed) return parsed;
+    }
+  }
+
+  if (value && typeof value === 'object') {
+    const obj = value as Record<string, unknown>;
+    return toCleanString(
+      obj.url ?? obj.link ?? obj.src ?? obj.value ?? obj.image ?? obj.avatar
+    );
+  }
+
+  return undefined;
+}
+
+export function parseXiaohongshuMetric(value: unknown): number | undefined {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? Math.round(value) : undefined;
+  }
+
+  if (typeof value !== 'string') return undefined;
+
+  const cleaned = value.trim().replace(/,/g, '').replace(/\s+/g, '');
+  if (!cleaned) return undefined;
+
+  const match = cleaned.match(/-?\d+(?:\.\d+)?/);
+  if (!match) return undefined;
+
+  let number = Number(match[0]);
+  if (!Number.isFinite(number)) return undefined;
+
+  const lower = cleaned.toLowerCase();
+  if (cleaned.includes('万') || lower.includes('w')) {
+    number *= 10000;
+  } else if (cleaned.includes('亿')) {
+    number *= 100000000;
+  }
+
+  return Math.round(number);
+}
+
+function firstString(candidates: Array<Record<string, unknown>>, keys: string[]): string | undefined {
+  for (const source of candidates) {
+    for (const key of keys) {
+      const value = toCleanString(source?.[key]);
+      if (value) return value;
+    }
+  }
+  return undefined;
+}
+
+function firstMetric(candidates: Array<Record<string, unknown>>, keys: string[]): number | undefined {
+  for (const source of candidates) {
+    for (const key of keys) {
+      if (!(key in source)) continue;
+      const value = parseXiaohongshuMetric(source[key]);
+      if (value !== undefined) return value;
+    }
+  }
+  return undefined;
+}
+
+export function extractXiaohongshuProfileInfo(raw: any): XiaohongshuProfileInfo {
+  const roots = [
+    raw?.data?.user_info,
+    raw?.data?.userInfo,
+    raw?.data?.user,
+    raw?.data?.account_info,
+    raw?.data?.accountInfo,
+    raw?.data?.author_info,
+    raw?.data?.authorInfo,
+    raw?.data?.basic_info,
+    raw?.data?.basicInfo,
+    raw?.data?.core_user_info,
+    raw?.data?.coreUserInfo,
+    raw?.data?.profile_info,
+    raw?.data?.profileInfo,
+    raw?.data?.profile,
+    raw?.data?.creator,
+    raw?.data?.creator_info,
+    raw?.data?.creatorInfo,
+    raw?.data,
+    raw?.user_info,
+    raw?.userInfo,
+    raw?.user,
+    raw?.account_info,
+    raw?.accountInfo,
+    raw?.author_info,
+    raw?.authorInfo,
+    raw?.basic_info,
+    raw?.basicInfo,
+    raw?.core_user_info,
+    raw?.coreUserInfo,
+    raw?.profile_info,
+    raw?.profileInfo,
+    raw?.profile,
+    raw,
+  ].filter((item): item is Record<string, unknown> => !!item && typeof item === 'object');
+
+  const name = firstString(roots, [
+    'name',
+    'nickname',
+    'nick_name',
+    'nickName',
+    'userName',
+    'user_name',
+  ]);
+  const avatar = firstString(roots, [
+    'avatar',
+    'avatarUrl',
+    'avatar_url',
+    'image',
+    'images',
+    'userAvatar',
+    'user_avatar',
+  ]);
+  const redNum = firstString(roots, [
+    'red_num',
+    'redNum',
+    'red_id',
+    'redId',
+    'redid',
+    'redID',
+  ]);
+  const userId = firstString(roots, [
+    'user_id',
+    'userId',
+    'user_id_creator',
+    'creatorUserId',
+  ]);
+  const fansCount = firstMetric(roots, [
+    'fans_count',
+    'fansCount',
+    'fans',
+    'fans_num',
+    'fansNum',
+    'fans_total',
+    'fansTotal',
+    'total_fans',
+    'totalFans',
+    'follower_count',
+    'followers_count',
+    'followersCount',
+    'followers',
+  ]);
+  const worksCount = firstMetric(roots, [
+    'note_count',
+    'noteCount',
+    'notes_count',
+    'notesCount',
+    'notes',
+    'works_count',
+    'worksCount',
+    'total_notes',
+    'totalNotes',
+  ]);
+
+  return { name, avatar, redNum, userId, fansCount, worksCount };
+}
+
+export function extractLatestXiaohongshuFansCount(raw: any): number | undefined {
+  const data = raw?.data || raw || {};
+  const candidates = [
+    data?.thirty?.fans_list,
+    data?.thirty?.fans_list_iterator,
+    data?.fans_list,
+    data?.fans_list_iterator,
+    data?.list,
+    raw?.fans_list,
+    raw?.fans_list_iterator,
+  ];
+
+  let latest: { timestamp: number; count: number } | undefined;
+
+  for (const candidate of candidates) {
+    if (!Array.isArray(candidate)) continue;
+    for (const item of candidate) {
+      if (!item || typeof item !== 'object') continue;
+      const row = item as Record<string, unknown>;
+      const count = parseXiaohongshuMetric(
+        row.count ?? row.fans_count ?? row.fansCount ?? row.value
+      );
+      if (count === undefined || count < 0) continue;
+
+      const rawDate = row.date ?? row.time ?? row.timestamp ?? row.record_date ?? row.recordDate;
+      const timestamp =
+        typeof rawDate === 'number'
+          ? rawDate
+          : rawDate == null
+            ? 0
+            : Number(rawDate);
+
+      const normalizedTimestamp = Number.isFinite(timestamp) ? timestamp : 0;
+      if (!latest || normalizedTimestamp >= latest.timestamp) {
+        latest = { timestamp: normalizedTimestamp, count };
+      }
+    }
+  }
+
+  return latest?.count;
+}
+
 export function extractDeclaredNotesCountFromPostedResponse(raw: any): number {
   const payload = raw?.data || raw || {};
   const tags = payload?.tags || raw?.tags || [];

+ 84 - 0
server/tests/xiaohongshu-profile.test.ts

@@ -0,0 +1,84 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+  extractLatestXiaohongshuFansCount,
+  extractXiaohongshuProfileInfo,
+} from '../src/utils/xiaohongshu.js';
+
+test('extracts profile info from creator personal_info snake_case response', () => {
+  const profile = extractXiaohongshuProfileInfo({
+    data: {
+      name: 'AAA',
+      avatar: 'https://example.test/avatar.jpg',
+      red_num: '63535021536',
+      fans_count: 1288,
+      note_count: 19,
+    },
+  });
+
+  assert.deepEqual(profile, {
+    name: 'AAA',
+    avatar: 'https://example.test/avatar.jpg',
+    redNum: '63535021536',
+    userId: undefined,
+    fansCount: 1288,
+    worksCount: 19,
+  });
+});
+
+test('extracts profile info from creator storage camelCase response', () => {
+  const profile = extractXiaohongshuProfileInfo({
+    userName: 'O_O',
+    userAvatar: 'https://example.test/camel.jpg',
+    redId: '495513171',
+    fansCount: '1.2万',
+    notes: '20',
+  });
+
+  assert.deepEqual(profile, {
+    name: 'O_O',
+    avatar: 'https://example.test/camel.jpg',
+    redNum: '495513171',
+    userId: undefined,
+    fansCount: 12000,
+    worksCount: 20,
+  });
+});
+
+test('extracts profile info from nested creator profile response', () => {
+  const profile = extractXiaohongshuProfileInfo({
+    data: {
+      core_user_info: {
+        nickName: 'Nested Name',
+        avatarUrl: 'https://example.test/nested.jpg',
+        redId: 'nested_123',
+        followers_count: '2,345',
+      },
+    },
+  });
+
+  assert.deepEqual(profile, {
+    name: 'Nested Name',
+    avatar: 'https://example.test/nested.jpg',
+    redNum: 'nested_123',
+    userId: undefined,
+    fansCount: 2345,
+    worksCount: undefined,
+  });
+});
+
+test('extracts latest fans count from overall_new response', () => {
+  const count = extractLatestXiaohongshuFansCount({
+    data: {
+      thirty: {
+        fans_list: [
+          { date: 1777190400000, count: 110 },
+          { date: 1777276800000, count: '1,234' },
+        ],
+      },
+    },
+  });
+
+  assert.equal(count, 1234);
+});