XiaohongshuAccountOverviewImportService.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import { chromium, type Browser } from 'playwright';
  4. import * as XLSXNS from 'xlsx';
  5. import { AppDataSource, PlatformAccount } from '../models/index.js';
  6. import { BrowserManager } from '../automation/browser.js';
  7. import { logger } from '../utils/logger.js';
  8. import { UserDayStatisticsService } from './UserDayStatisticsService.js';
  9. import type { ProxyConfig } from '@media-manager/shared';
  10. import { WS_EVENTS } from '@media-manager/shared';
  11. import { wsManager } from '../websocket/index.js';
  12. // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
  13. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  14. const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any);
  15. type PlaywrightCookie = {
  16. name: string;
  17. value: string;
  18. domain?: string;
  19. path?: string;
  20. url?: string;
  21. expires?: number;
  22. httpOnly?: boolean;
  23. secure?: boolean;
  24. sameSite?: 'Lax' | 'None' | 'Strict';
  25. };
  26. type MetricKind =
  27. | 'playCount'
  28. | 'likeCount'
  29. | 'commentCount'
  30. | 'shareCount'
  31. | 'collectCount'
  32. | 'fansIncrease'
  33. | 'coverClickRate'
  34. | 'avgWatchDuration'
  35. | 'totalWatchDuration'
  36. | 'completionRate';
  37. type ExportMode = 'watch' | 'interaction' | 'fans';
  38. function ensureDir(p: string) {
  39. return fs.mkdir(p, { recursive: true });
  40. }
  41. function normalizeDateText(input: unknown): Date | null {
  42. if (!input) return null;
  43. if (input instanceof Date && !Number.isNaN(input.getTime())) {
  44. const d = new Date(input);
  45. d.setHours(0, 0, 0, 0);
  46. return d;
  47. }
  48. const s = String(input).trim();
  49. // 2026年01月27日
  50. const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})\D?/);
  51. if (m1) {
  52. const yyyy = Number(m1[1]);
  53. const mm = Number(m1[2]);
  54. const dd = Number(m1[3]);
  55. if (!yyyy || !mm || !dd) return null;
  56. const d = new Date(yyyy, mm - 1, dd);
  57. d.setHours(0, 0, 0, 0);
  58. return d;
  59. }
  60. // 01-27(兜底:用当前年份)
  61. const m2 = s.match(/^(\d{1,2})[-/](\d{1,2})$/);
  62. if (m2) {
  63. const yyyy = new Date().getFullYear();
  64. const mm = Number(m2[1]);
  65. const dd = Number(m2[2]);
  66. const d = new Date(yyyy, mm - 1, dd);
  67. d.setHours(0, 0, 0, 0);
  68. return d;
  69. }
  70. return null;
  71. }
  72. function parseChineseNumberLike(input: unknown): number | null {
  73. if (input === null || input === undefined) return null;
  74. const s = String(input).trim();
  75. if (!s) return null;
  76. // 8,077
  77. const plain = s.replace(/,/g, '');
  78. // 4.8万
  79. const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
  80. if (wan) return Math.round(Number(wan[1]) * 10000);
  81. const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
  82. if (yi) return Math.round(Number(yi[1]) * 100000000);
  83. const n = Number(plain.replace(/[^\d.-]/g, ''));
  84. if (Number.isFinite(n)) return Math.round(n);
  85. return null;
  86. }
  87. function detectMetricKind(sheetName: string): MetricKind | null {
  88. const n = sheetName.trim();
  89. // 观看数据:子表命名可能是「观看趋势」或「观看数趋势」
  90. if (n.includes('观看趋势') || n.includes('观看数')) return 'playCount';
  91. if (n.includes('封面点击率')) return 'coverClickRate';
  92. if (n.includes('平均观看时长')) return 'avgWatchDuration';
  93. if (n.includes('观看总时长')) return 'totalWatchDuration';
  94. if (n.includes('完播率')) return 'completionRate';
  95. // 互动数据
  96. if (n.includes('点赞') && n.includes('趋势')) return 'likeCount';
  97. if (n.includes('评论') && n.includes('趋势')) return 'commentCount';
  98. if (n.includes('分享') && n.includes('趋势')) return 'shareCount';
  99. if (n.includes('收藏') && n.includes('趋势')) return 'collectCount';
  100. // 涨粉数据(只取净涨粉趋势)
  101. if (n.includes('净涨粉') && n.includes('趋势')) return 'fansIncrease';
  102. return null;
  103. }
  104. function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  105. if (!cookieData) return [];
  106. const raw = cookieData.trim();
  107. if (!raw) return [];
  108. // 1) JSON array(最常见:浏览器插件导出/前端保存)
  109. if (raw.startsWith('[') || raw.startsWith('{')) {
  110. try {
  111. const parsed = JSON.parse(raw);
  112. const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
  113. if (!Array.isArray(arr)) return [];
  114. return arr
  115. .map((c: any) => {
  116. const name = String(c?.name ?? '').trim();
  117. const value = String(c?.value ?? '').trim();
  118. if (!name) return null;
  119. const domain = c?.domain ? String(c.domain) : undefined;
  120. const pathVal = c?.path ? String(c.path) : '/';
  121. const url = !domain ? 'https://creator.xiaohongshu.com' : undefined;
  122. const sameSiteRaw = c?.sameSite;
  123. const sameSite =
  124. sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
  125. ? sameSiteRaw
  126. : undefined;
  127. return {
  128. name,
  129. value,
  130. domain,
  131. path: pathVal,
  132. url,
  133. expires: typeof c?.expires === 'number' ? c.expires : undefined,
  134. httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
  135. secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
  136. sameSite,
  137. } satisfies PlaywrightCookie;
  138. })
  139. .filter(Boolean) as PlaywrightCookie[];
  140. } catch {
  141. // fallthrough
  142. }
  143. }
  144. // 2) "a=b; c=d" 拼接格式
  145. const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  146. const cookies: PlaywrightCookie[] = [];
  147. for (const p of pairs) {
  148. const idx = p.indexOf('=');
  149. if (idx <= 0) continue;
  150. const name = p.slice(0, idx).trim();
  151. const value = p.slice(idx + 1).trim();
  152. if (!name) continue;
  153. cookies.push({ name, value, url: 'https://creator.xiaohongshu.com' });
  154. }
  155. return cookies;
  156. }
  157. async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  158. // 静默同步:默认一律 headless,不弹窗
  159. // 只有在“引导登录/验证”时(XHS_STORAGE_STATE_BOOTSTRAP=1 且 XHS_IMPORT_HEADLESS=0)才允许 headful
  160. const allowHeadfulForBootstrap = process.env.XHS_STORAGE_STATE_BOOTSTRAP === '1' && process.env.XHS_IMPORT_HEADLESS === '0';
  161. const headless = !allowHeadfulForBootstrap;
  162. if (proxy?.enabled) {
  163. const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
  164. const browser = await chromium.launch({
  165. headless,
  166. proxy: {
  167. server,
  168. username: proxy.username,
  169. password: proxy.password,
  170. },
  171. args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
  172. });
  173. return { browser, shouldClose: true };
  174. }
  175. const browser = await BrowserManager.getBrowser({ headless });
  176. return { browser, shouldClose: false };
  177. }
  178. function parseXhsExcel(
  179. filePath: string,
  180. mode: ExportMode
  181. ): Map<string, { recordDate: Date } & Record<string, any>> {
  182. const wb = XLSX.readFile(filePath);
  183. const result = new Map<string, { recordDate: Date } & Record<string, any>>();
  184. logger.info(
  185. `[XHS Import] Excel loaded. mode=${mode} file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`
  186. );
  187. for (const sheetName of wb.SheetNames) {
  188. const kind = detectMetricKind(sheetName);
  189. if (!kind) continue;
  190. // 按导出类型过滤不相关子表,避免误写字段
  191. if (
  192. (mode === 'watch' &&
  193. !['playCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) ||
  194. (mode === 'interaction' && !['likeCount', 'commentCount', 'shareCount', 'collectCount'].includes(kind)) ||
  195. (mode === 'fans' && kind !== 'fansIncrease')
  196. ) {
  197. continue;
  198. }
  199. const sheet = wb.Sheets[sheetName];
  200. const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
  201. if (rows.length) {
  202. const keys = Object.keys(rows[0] || {});
  203. logger.info(`[XHS Import] Sheet parsed. name=${sheetName} kind=${kind} rows=${rows.length} keys=${keys.join(',')}`);
  204. } else {
  205. logger.warn(`[XHS Import] Sheet empty. name=${sheetName} kind=${kind}`);
  206. }
  207. for (const row of rows) {
  208. const dateVal = row['日期'] ?? row['date'] ?? row['Date'] ?? row[Object.keys(row)[0] ?? ''];
  209. const valueVal = row['数值'] ?? row['value'] ?? row['Value'] ?? row[Object.keys(row)[1] ?? ''];
  210. const d = normalizeDateText(dateVal);
  211. if (!d) continue;
  212. const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  213. if (!result.has(key)) result.set(key, { recordDate: d });
  214. const obj = result.get(key)!;
  215. if (kind === 'playCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease') {
  216. const n = parseChineseNumberLike(valueVal);
  217. if (typeof n === 'number') {
  218. if (kind === 'playCount') obj.playCount = n;
  219. if (kind === 'likeCount') obj.likeCount = n;
  220. if (kind === 'commentCount') obj.commentCount = n;
  221. if (kind === 'shareCount') obj.shareCount = n;
  222. if (kind === 'collectCount') obj.collectCount = n;
  223. if (kind === 'fansIncrease') obj.fansIncrease = n; // 允许负数
  224. }
  225. } else {
  226. const s = String(valueVal ?? '').trim();
  227. if (kind === 'coverClickRate') obj.coverClickRate = s || '0';
  228. if (kind === 'avgWatchDuration') obj.avgWatchDuration = s || '0';
  229. if (kind === 'totalWatchDuration') obj.totalWatchDuration = s || '0';
  230. if (kind === 'completionRate') obj.completionRate = s || '0';
  231. }
  232. }
  233. }
  234. return result;
  235. }
  236. export class XiaohongshuAccountOverviewImportService {
  237. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  238. private userDayStatisticsService = new UserDayStatisticsService();
  239. private downloadDir = path.resolve(process.cwd(), 'tmp', 'xhs-account-overview');
  240. private stateDir = path.resolve(process.cwd(), 'tmp', 'xhs-storage-state');
  241. private getStatePath(accountId: number) {
  242. return path.join(this.stateDir, `${accountId}.json`);
  243. }
  244. private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
  245. const statePath = this.getStatePath(account.id);
  246. try {
  247. await fs.access(statePath);
  248. return statePath;
  249. } catch {
  250. // no state
  251. }
  252. // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState
  253. // 启用方式:XHS_IMPORT_HEADLESS=0 且 XHS_STORAGE_STATE_BOOTSTRAP=1
  254. if (!(process.env.XHS_IMPORT_HEADLESS === '0' && process.env.XHS_STORAGE_STATE_BOOTSTRAP === '1')) {
  255. return null;
  256. }
  257. await ensureDir(this.stateDir);
  258. logger.warn(`[XHS Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`);
  259. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  260. try {
  261. const context = await browser.newContext({
  262. viewport: { width: 1920, height: 1080 },
  263. locale: 'zh-CN',
  264. timezoneId: 'Asia/Shanghai',
  265. });
  266. await context.addCookies(cookies as any);
  267. const page = await context.newPage();
  268. await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' });
  269. // 最长等 5 分钟:让你手动完成登录/滑块/短信等
  270. await page
  271. .waitForFunction(() => {
  272. const t = document.body?.innerText || '';
  273. return t.includes('账号概览') || t.includes('数据总览') || t.includes('观看数据');
  274. }, { timeout: 5 * 60_000 })
  275. .catch(() => undefined);
  276. await context.storageState({ path: statePath });
  277. logger.info(`[XHS Import] storageState saved: ${statePath}`);
  278. await context.close();
  279. return statePath;
  280. } finally {
  281. if (shouldClose) await browser.close().catch(() => undefined);
  282. }
  283. }
  284. /**
  285. * 为所有小红书账号导出“观看数据-近30日”并导入 user_day_statistics
  286. */
  287. async runDailyImportForAllXhsAccounts(): Promise<void> {
  288. await ensureDir(this.downloadDir);
  289. const accounts = await this.accountRepository.find({
  290. where: { platform: 'xiaohongshu' as any },
  291. });
  292. logger.info(`[XHS Import] Start. total_accounts=${accounts.length}`);
  293. for (const account of accounts) {
  294. try {
  295. await this.importAccountLast30Days(account);
  296. } catch (e) {
  297. logger.error(`[XHS Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
  298. }
  299. }
  300. logger.info('[XHS Import] Done.');
  301. }
  302. /**
  303. * 单账号:导出 Excel → 解析 → 入库 → 删除文件
  304. */
  305. async importAccountLast30Days(account: PlatformAccount): Promise<void> {
  306. const cookies = parseCookiesFromAccount(account.cookieData);
  307. if (!cookies.length) {
  308. throw new Error('cookieData 为空或无法解析');
  309. }
  310. const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
  311. try {
  312. const statePath = await this.ensureStorageState(account, cookies);
  313. const context = await browser.newContext({
  314. acceptDownloads: true,
  315. viewport: { width: 1920, height: 1080 },
  316. locale: 'zh-CN',
  317. timezoneId: 'Asia/Shanghai',
  318. userAgent:
  319. 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
  320. ...(statePath ? { storageState: statePath } : {}),
  321. });
  322. context.setDefaultTimeout(60_000);
  323. // 如果没 state,就退回 cookie-only(可能导出为 0)
  324. if (!statePath) {
  325. await context.addCookies(cookies as any);
  326. }
  327. const page = await context.newPage();
  328. await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' });
  329. await page.waitForTimeout(1500);
  330. if (page.url().includes('login')) {
  331. throw new Error('未登录/需要重新登录(跳转到 login)');
  332. }
  333. // 检测“暂无访问权限 / 权限申请中”提示:标记账号 expired + 推送提示
  334. const bodyText = (await page.textContent('body').catch(() => '')) || '';
  335. if (bodyText.includes('暂无访问权限') || bodyText.includes('数据权限申请中') || bodyText.includes('次日再来查看')) {
  336. await this.accountRepository.update(account.id, { status: 'expired' as any });
  337. wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
  338. account: { id: account.id, status: 'expired', platform: 'xiaohongshu' },
  339. });
  340. wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
  341. level: 'warning',
  342. message: `小红书账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到小红书创作服务平台申请数据权限(通过后一般次日生效)。`,
  343. platform: 'xiaohongshu',
  344. accountId: account.id,
  345. });
  346. throw new Error('小红书数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
  347. }
  348. // 统一入口:账号概览 -> 笔记数据
  349. await page.getByText('账号概览', { exact: true }).first().click().catch(() => undefined);
  350. await page.getByText('笔记数据', { exact: true }).first().click();
  351. const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据', mode: ExportMode) => {
  352. await page.getByText(tabText, { exact: true }).first().click();
  353. await page.getByText(/近\d+日/).first().click().catch(() => undefined);
  354. await page.getByText('近30日', { exact: true }).click();
  355. await page.waitForTimeout(1200);
  356. const [download] = await Promise.all([
  357. page.waitForEvent('download', { timeout: 60_000 }),
  358. page.getByText('导出数据', { exact: true }).first().click(),
  359. ]);
  360. const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
  361. const filePath = path.join(this.downloadDir, filename);
  362. await download.saveAs(filePath);
  363. let perDay = new Map<string, { recordDate: Date } & Record<string, any>>();
  364. let inserted = 0;
  365. let updated = 0;
  366. try {
  367. perDay = parseXhsExcel(filePath, mode);
  368. for (const v of perDay.values()) {
  369. const { recordDate, ...patch } = v;
  370. const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
  371. inserted += r.inserted;
  372. updated += r.updated;
  373. }
  374. logger.info(
  375. `[XHS Import] ${tabText} imported. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
  376. );
  377. } finally {
  378. // 默认导入后删除 Excel,避免磁盘堆积;仅在显式 KEEP_XHS_XLSX=true 时保留(用于调试)
  379. if (process.env.KEEP_XHS_XLSX === 'true') {
  380. logger.warn(`[XHS Import] KEEP_XHS_XLSX=true, keep file: ${filePath}`);
  381. } else {
  382. await fs.unlink(filePath).catch(() => undefined);
  383. }
  384. }
  385. };
  386. // 1) 观看数据:播放数 + 点击率/时长/完播率
  387. await exportAndImport('观看数据', 'watch');
  388. // 2) 互动数据:点赞/评论/收藏/分享
  389. await exportAndImport('互动数据', 'interaction');
  390. // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
  391. await exportAndImport('涨粉数据', 'fans');
  392. logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`);
  393. await context.close();
  394. } finally {
  395. if (shouldClose) {
  396. await browser.close().catch(() => undefined);
  397. }
  398. }
  399. }
  400. }