|
@@ -7,6 +7,7 @@ import { BrowserManager } from '../automation/browser.js';
|
|
|
import { logger } from '../utils/logger.js';
|
|
import { logger } from '../utils/logger.js';
|
|
|
import { UserDayStatisticsService } from './UserDayStatisticsService.js';
|
|
import { UserDayStatisticsService } from './UserDayStatisticsService.js';
|
|
|
import { AccountService } from './AccountService.js';
|
|
import { AccountService } from './AccountService.js';
|
|
|
|
|
+import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
|
|
|
import type { ProxyConfig } from '@media-manager/shared';
|
|
import type { ProxyConfig } from '@media-manager/shared';
|
|
|
import { WS_EVENTS } from '@media-manager/shared';
|
|
import { WS_EVENTS } from '@media-manager/shared';
|
|
|
import { wsManager } from '../websocket/index.js';
|
|
import { wsManager } from '../websocket/index.js';
|
|
@@ -347,19 +348,104 @@ export class XiaohongshuAccountOverviewImportService {
|
|
|
|
|
|
|
|
logger.info(`[XHS Import] Start. total_accounts=${accounts.length}`);
|
|
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.');
|
|
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> {
|
|
async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
|
|
|
const cookies = parseCookiesFromAccount(account.cookieData);
|
|
const cookies = parseCookiesFromAccount(account.cookieData);
|
|
@@ -367,6 +453,106 @@ export class XiaohongshuAccountOverviewImportService {
|
|
|
throw new Error('cookieData 为空或无法解析');
|
|
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);
|
|
const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
|
|
|
try {
|
|
try {
|
|
|
const statePath = await this.ensureStorageState(account, cookies);
|
|
const statePath = await this.ensureStorageState(account, cookies);
|