Ethanfly 1 день тому
батько
коміт
490fbe48b6

+ 63 - 0
server/python/app.py

@@ -1736,6 +1736,69 @@ def baijiahao_fans_basic_info():
         return jsonify({"success": False, "error": str(e), "errno": -1}), 500
 
 
+@app.route("/xiaohongshu/account_base", methods=["POST"])
+def xiaohongshu_account_base():
+    """
+    小红书每日用户数据:代理调用创作者中心 account/base(账号概览近30日)。
+    登录方式与打开后台一致:使用账号已存 Cookie。
+    请求体: { "cookie": "..." }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        if not cookie_str:
+            return jsonify({"data": None, "code": 400, "error": "缺少 cookie 参数"}), 400
+        PublisherClass = get_publisher("xiaohongshu")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(publisher.get_account_base(cookie_str))
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"data": None, "code": 500, "error": str(e)}), 500
+
+
+@app.route("/xiaohongshu/fans_overall_new", methods=["POST"])
+def xiaohongshu_fans_overall_new():
+    """
+    小红书每日用户数据:代理调用创作者中心 fans/overall_new(粉丝趋势近30日)。
+    登录方式与打开后台一致:使用账号已存 Cookie。
+    请求体: { "cookie": "..." }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        if not cookie_str:
+            return jsonify({"data": None, "code": 400, "error": "缺少 cookie 参数"}), 400
+        PublisherClass = get_publisher("xiaohongshu")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(publisher.get_fans_overall_new(cookie_str))
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"data": None, "code": 500, "error": str(e)}), 500
+
+
+@app.route("/xiaohongshu/account_overview", methods=["POST"])
+def xiaohongshu_account_overview():
+    """
+    小红书每日用户数据:一次请求同时拉取 account/base 与 fans/overall_new(内部并行),减少耗时。
+    请求体: { "cookie": "..." }
+    返回: { "account_base": {...}, "fans_overall_new": {...} }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        if not cookie_str:
+            return jsonify({"data": None, "code": 400, "error": "缺少 cookie 参数"}), 400
+        PublisherClass = get_publisher("xiaohongshu")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(publisher.get_account_overview(cookie_str))
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"account_base": None, "fans_overall_new": None, "code": 500, "error": str(e)}), 500
+
+
 @app.route("/xiaohongshu/note_base", methods=["POST"])
 def xiaohongshu_note_base():
     """

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

@@ -2066,3 +2066,150 @@ class XiaohongshuPublisher(BasePublisher):
             return {"data": None, "code": status, "msg": data.get("msg") if isinstance(data, dict) else "request failed"}
 
         return data if isinstance(data, dict) else {"data": None, "code": -1, "msg": "invalid response"}
+
+    async def get_account_base(self, cookies: str) -> dict:
+        """
+        调用创作者中心「账号概览- account/base」接口,用于每日用户数据同步。
+        使用账号已存 Cookie,不启浏览器,直接带 Referer 请求 API。
+        """
+        import aiohttp
+
+        cookie_list = self.parse_cookies(cookies)
+        cookie_dict = {c.get("name") or "": c.get("value") or "" for c in cookie_list if c.get("name")}
+
+        api_headers = {
+            "Accept": "application/json, text/plain, */*",
+            "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",
+            "Referer": "https://creator.xiaohongshu.com/statistics/account/v2",
+            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+            "Accept-Encoding": "gzip, deflate, br",
+            "Connection": "keep-alive",
+        }
+
+        api_url = "https://creator.xiaohongshu.com/api/galaxy/v2/creator/datacenter/account/base"
+
+        async with aiohttp.ClientSession(cookies=cookie_dict) as session:
+            async with session.get(
+                api_url,
+                headers=api_headers,
+                timeout=aiohttp.ClientTimeout(total=30),
+            ) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] account/base non-JSON: {text[:500]}")
+                    return {"data": None, "code": status, "msg": "invalid response"}
+
+        if status != 200:
+            return {"data": None, "code": status, "msg": data.get("msg") if isinstance(data, dict) else "request failed"}
+
+        return data if isinstance(data, dict) else {"data": None, "code": -1, "msg": "invalid response"}
+
+    async def get_fans_overall_new(self, cookies: str) -> dict:
+        """
+        调用创作者中心「粉丝数据- overall_new」接口,用于每日用户数据中的粉丝趋势。
+        使用账号已存 Cookie,不启浏览器,直接带 Referer 请求 API。
+        """
+        import aiohttp
+
+        cookie_list = self.parse_cookies(cookies)
+        cookie_dict = {c.get("name") or "": c.get("value") or "" for c in cookie_list if c.get("name")}
+
+        api_headers = {
+            "Accept": "application/json, text/plain, */*",
+            "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",
+            "Referer": "https://creator.xiaohongshu.com/statistics/fans-data",
+            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+            "Accept-Encoding": "gzip, deflate, br",
+            "Connection": "keep-alive",
+        }
+
+        api_url = "https://creator.xiaohongshu.com/api/galaxy/creator/data/fans/overall_new"
+
+        async with aiohttp.ClientSession(cookies=cookie_dict) as session:
+            async with session.get(
+                api_url,
+                headers=api_headers,
+                timeout=aiohttp.ClientTimeout(total=30),
+            ) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] fans/overall_new non-JSON: {text[:500]}")
+                    return {"data": None, "code": status, "msg": "invalid response"}
+
+        if status != 200:
+            return {"data": None, "code": status, "msg": data.get("msg") if isinstance(data, dict) else "request failed"}
+
+        return data if isinstance(data, dict) else {"data": None, "code": -1, "msg": "invalid response"}
+
+    async def get_account_overview(self, cookies: str) -> dict:
+        """
+        一次请求同时拉取 account/base 与 fans/overall_new,用于每日用户数据同步。
+        使用已存 Cookie,不先访问页面,直接带 Referer 并行请求两个 API。
+        返回: { "account_base": {...}, "fans_overall_new": {...} }
+        """
+        import aiohttp
+
+        cookie_list = self.parse_cookies(cookies)
+        cookie_dict = {c.get("name") or "": c.get("value") or "" for c in cookie_list if c.get("name")}
+
+        account_api_headers = {
+            "Accept": "application/json, text/plain, */*",
+            "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",
+            "Referer": "https://creator.xiaohongshu.com/statistics/account/v2",
+            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+            "Accept-Encoding": "gzip, deflate, br",
+            "Connection": "keep-alive",
+        }
+        fans_api_headers = {
+            "Accept": "application/json, text/plain, */*",
+            "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",
+            "Referer": "https://creator.xiaohongshu.com/statistics/fans-data",
+            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+            "Accept-Encoding": "gzip, deflate, br",
+            "Connection": "keep-alive",
+        }
+
+        api_account = "https://creator.xiaohongshu.com/api/galaxy/v2/creator/datacenter/account/base"
+        api_fans = "https://creator.xiaohongshu.com/api/galaxy/creator/data/fans/overall_new"
+
+        async def fetch_account_base(session):
+            async with session.get(api_account, headers=account_api_headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] account/base non-JSON: {text[:500]}")
+                    return {"data": None, "code": status, "msg": "invalid response"}
+                if status != 200:
+                    return {"data": None, "code": status, "msg": data.get("msg") if isinstance(data, dict) else "request failed"}
+                return data if isinstance(data, dict) else {"data": None, "code": -1, "msg": "invalid response"}
+
+        async def fetch_fans_overall_new(session):
+            async with session.get(api_fans, headers=fans_api_headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] fans/overall_new non-JSON: {text[:500]}")
+                    return {"data": None, "code": status, "msg": "invalid response"}
+                if status != 200:
+                    return {"data": None, "code": status, "msg": data.get("msg") if isinstance(data, dict) else "request failed"}
+                return data if isinstance(data, dict) else {"data": None, "code": -1, "msg": "invalid response"}
+
+        async with aiohttp.ClientSession(cookies=cookie_dict) as session:
+            account_base_result, fans_overall_new_result = await asyncio.gather(
+                fetch_account_base(session),
+                fetch_fans_overall_new(session),
+            )
+        return {
+            "account_base": account_base_result,
+            "fans_overall_new": fans_overall_new_result,
+        }

+ 194 - 8
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -7,6 +7,7 @@ import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
 import { AccountService } from './AccountService.js';
+import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -347,19 +348,104 @@ export class XiaohongshuAccountOverviewImportService {
 
     logger.info(`[XHS Import] Start. total_accounts=${accounts.length}`);
 
-    for (const account of accounts) {
-      try {
-        await this.importAccountLast30Days(account);
-      } catch (e) {
-        logger.error(`[XHS Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
-      }
-    }
+    await Promise.all(
+      accounts.map((account) =>
+        this.importAccountLast30Days(account).catch((e) => {
+          logger.error(`[XHS Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
+        })
+      )
+    );
 
     logger.info('[XHS Import] Done.');
   }
 
+  /** 通过 Python 调用 account/base(登录与打开后台一致:使用账号已存 Cookie) */
+  private async fetchAccountBaseViaPython(account: PlatformAccount): Promise<Record<string, unknown> | null> {
+    const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
+    const url = `${base}/xiaohongshu/account_base`;
+    const cookie = String(account.cookieData || '').trim();
+    if (!cookie) return null;
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 35_000);
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        signal: controller.signal,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cookie }),
+      });
+      const text = await res.text();
+      const body = text ? (JSON.parse(text) as Record<string, unknown>) : null;
+      if (!body || typeof body !== 'object') return null;
+      return body;
+    } catch {
+      return null;
+    } finally {
+      clearTimeout(timeoutId);
+    }
+  }
+
+  /** 通过 Python 调用 fans/overall_new(登录与打开后台一致) */
+  private async fetchFansOverallNewViaPython(account: PlatformAccount): Promise<Record<string, unknown> | null> {
+    const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
+    const url = `${base}/xiaohongshu/fans_overall_new`;
+    const cookie = String(account.cookieData || '').trim();
+    if (!cookie) return null;
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 35_000);
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        signal: controller.signal,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cookie }),
+      });
+      const text = await res.text();
+      const body = text ? (JSON.parse(text) as Record<string, unknown>) : null;
+      if (!body || typeof body !== 'object') return null;
+      return body;
+    } catch {
+      return null;
+    } finally {
+      clearTimeout(timeoutId);
+    }
+  }
+
+  /** 通过 Python 合并接口一次拉取 account_base + fans_overall_new(内部并行,减少耗时) */
+  private async fetchAccountOverviewViaPython(
+    account: PlatformAccount
+  ): Promise<{ account_base: Record<string, unknown> | null; fans_overall_new: Record<string, unknown> | null } | null> {
+    const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
+    const url = `${base}/xiaohongshu/account_overview`;
+    const cookie = String(account.cookieData || '').trim();
+    if (!cookie) return null;
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 50_000);
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        signal: controller.signal,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cookie }),
+      });
+      const text = await res.text();
+      const body = text ? (JSON.parse(text) as Record<string, unknown>) : null;
+      if (!body || typeof body !== 'object') return null;
+      const accountBase = body.account_base as Record<string, unknown> | undefined;
+      const fansOverallNew = body.fans_overall_new as Record<string, unknown> | undefined;
+      return {
+        account_base: accountBase && typeof accountBase === 'object' ? accountBase : null,
+        fans_overall_new: fansOverallNew && typeof fansOverallNew === 'object' ? fansOverallNew : null,
+      };
+    } catch {
+      return null;
+    } finally {
+      clearTimeout(timeoutId);
+    }
+  }
+
   /**
-   * 单账号:导出 Excel → 解析 → 入库 → 删除文件
+   * 单账号:优先 Python(合并接口 account_overview,失败则拆成两次调用),失败则刷新重试一次,再失败则浏览器兜底
    */
   async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
@@ -367,6 +453,106 @@ export class XiaohongshuAccountOverviewImportService {
       throw new Error('cookieData 为空或无法解析');
     }
 
+    const processAccountBaseAndFans = async (
+      accountBaseBody: Record<string, unknown> | null,
+      fansBody: Record<string, unknown> | null
+    ): Promise<boolean> => {
+      const data = accountBaseBody?.data as Record<string, unknown> | undefined;
+      const thirty = data?.thirty as Record<string, unknown> | undefined;
+      let pythonAccountBaseOk = false;
+      if (thirty && typeof thirty === 'object') {
+        const perDay = this.parseAccountBaseThirty(thirty);
+        if (perDay.size > 0) {
+          let inserted = 0;
+          let updated = 0;
+          const today = new Date();
+          today.setHours(0, 0, 0, 0);
+          for (const v of perDay.values()) {
+            const { recordDate, ...patch } = v;
+            if (
+              recordDate.getTime() === today.getTime() &&
+              patch.fansCount === undefined &&
+              account.fansCount != null &&
+              account.fansCount > 0
+            ) {
+              (patch as Record<string, unknown>).fansCount = account.fansCount;
+            }
+            const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
+            inserted += r.inserted;
+            updated += r.updated;
+          }
+          logger.info(
+            `[XHS Import] account/base (via Python). accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
+          );
+          pythonAccountBaseOk = true;
+        }
+      }
+      if (fansBody && typeof fansBody === 'object') {
+        const list = this.parseFansOverallNewResponse(fansBody);
+        if (list.length > 0) {
+          let fansUpdated = 0;
+          for (const { recordDate, fansCount } of list) {
+            const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, {
+              fansCount,
+            });
+            fansUpdated += r.inserted + r.updated;
+          }
+          logger.info(`[XHS Import] Fans trend (via Python). accountId=${account.id} days=${list.length} updated=${fansUpdated}`);
+        }
+      }
+      return pythonAccountBaseOk;
+    };
+
+    // 优先 Python:先试合并接口,失败再拆成两次调用
+    try {
+      let accountBaseBody: Record<string, unknown> | null = null;
+      let fansBody: Record<string, unknown> | null = null;
+      const overview = await this.fetchAccountOverviewViaPython(account);
+      if (overview?.account_base != null || overview?.fans_overall_new != null) {
+        accountBaseBody = overview.account_base ?? null;
+        fansBody = overview.fans_overall_new ?? null;
+      }
+      if (accountBaseBody == null && fansBody == null) {
+        accountBaseBody = await this.fetchAccountBaseViaPython(account);
+        if (accountBaseBody != null) {
+          try {
+            fansBody = await this.fetchFansOverallNewViaPython(account);
+          } catch (e) {
+            logger.warn(`[XHS Import] Fans via Python failed (non-fatal). accountId=${account.id}`, e instanceof Error ? e.message : e);
+          }
+        }
+      }
+      if (accountBaseBody != null) {
+        const ok = await processAccountBaseAndFans(accountBaseBody, fansBody);
+        if (ok) {
+          logger.info(`[XHS Import] Account all tabs done (via Python). accountId=${account.id}`);
+          return;
+        }
+      }
+    } catch (pythonError) {
+      logger.warn(
+        '[XHS Import] Python path failed, fallback to browser. accountId=' + account.id,
+        pythonError instanceof Error ? pythonError.message : pythonError
+      );
+    }
+
+    if (!isRetry) {
+      try {
+        const accountService = new AccountService();
+        const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+        if (!refreshResult.needReLogin) {
+          const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+          if (refreshedAccount) {
+            logger.info(`[XHS Import] Account ${account.id} refreshed, retrying import...`);
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          }
+        }
+      } catch (refreshError) {
+        logger.error(`[XHS Import] Account ${account.id} refresh failed:`, refreshError);
+      }
+    }
+
+    // 浏览器兜底
     const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
     try {
       const statePath = await this.ensureStorageState(account, cookies);