|
@@ -1,17 +1,32 @@
|
|
|
/**
|
|
/**
|
|
|
* 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
|
|
* 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
|
|
|
*
|
|
*
|
|
|
- * 流程:调用 Python 纯浏览器接口,由 Python 完成:
|
|
|
|
|
|
|
+ * 流程:Node 端 Playwright 完成(不依赖 Python):
|
|
|
* 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
|
|
* 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
|
|
|
- * 2. 监听 post_list 获取 exportId->objectId
|
|
|
|
|
- * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页近30天 → 下载表格
|
|
|
|
|
- * 4. 解析 CSV 存入 work_day_statistics
|
|
|
|
|
|
|
+ * 2. 监听 post_list 获取 exportId->objectId,可选更新 works 表 yesterday_*
|
|
|
|
|
+ * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页监听 feed_aggreagate_data_by_tab_type
|
|
|
|
|
+ * 4. 解析「全部」tab 的 browse/like/comment/forward/fav/follow,写入 work_day_statistics
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
|
|
+import { chromium, type Browser } from 'playwright';
|
|
|
import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
|
|
import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
|
|
|
|
|
+import { BrowserManager } from '../automation/browser.js';
|
|
|
import { logger } from '../utils/logger.js';
|
|
import { logger } from '../utils/logger.js';
|
|
|
import { CookieManager } from '../automation/cookie.js';
|
|
import { CookieManager } from '../automation/cookie.js';
|
|
|
-import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
|
|
|
|
|
|
|
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
|
|
|
|
|
+import type { ProxyConfig } from '@media-manager/shared';
|
|
|
|
|
+
|
|
|
|
|
+type PlaywrightCookie = {
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ value: string;
|
|
|
|
|
+ domain?: string;
|
|
|
|
|
+ path?: string;
|
|
|
|
|
+ url?: string;
|
|
|
|
|
+ expires?: number;
|
|
|
|
|
+ httpOnly?: boolean;
|
|
|
|
|
+ secure?: boolean;
|
|
|
|
|
+ sameSite?: 'Lax' | 'None' | 'Strict';
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
function tryDecryptCookieData(cookieData: string | null): string | null {
|
|
function tryDecryptCookieData(cookieData: string | null): string | null {
|
|
|
if (!cookieData) return null;
|
|
if (!cookieData) return null;
|
|
@@ -24,33 +39,152 @@ function tryDecryptCookieData(cookieData: string | null): string | null {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 将 cookie 转为 Python 接口所需格式(JSON 数组或原始字符串) */
|
|
|
|
|
-function getCookieForPython(cookieData: string | null): string {
|
|
|
|
|
|
|
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
|
|
|
const raw = tryDecryptCookieData(cookieData);
|
|
const raw = tryDecryptCookieData(cookieData);
|
|
|
- if (!raw) return '';
|
|
|
|
|
|
|
+ if (!raw || !raw.trim()) return [];
|
|
|
|
|
+
|
|
|
const s = raw.trim();
|
|
const s = raw.trim();
|
|
|
- if (!s) return '';
|
|
|
|
|
- try {
|
|
|
|
|
- JSON.parse(s);
|
|
|
|
|
- return s; // 已是 JSON
|
|
|
|
|
- } catch {
|
|
|
|
|
- return JSON.stringify(
|
|
|
|
|
- s
|
|
|
|
|
- .split(';')
|
|
|
|
|
- .filter(Boolean)
|
|
|
|
|
- .map((part) => {
|
|
|
|
|
- const idx = part.trim().indexOf('=');
|
|
|
|
|
- const name = idx >= 0 ? part.trim().slice(0, idx) : part.trim();
|
|
|
|
|
- const value = idx >= 0 ? part.trim().slice(idx + 1) : '';
|
|
|
|
|
- return { name, value, domain: '.weixin.qq.com', path: '/' };
|
|
|
|
|
|
|
+ if (s.startsWith('[') || s.startsWith('{')) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(s);
|
|
|
|
|
+ const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
|
|
|
|
|
+ if (!Array.isArray(arr)) return [];
|
|
|
|
|
+ return arr
|
|
|
|
|
+ .map((c: any) => {
|
|
|
|
|
+ const name = String(c?.name ?? '').trim();
|
|
|
|
|
+ const value = String(c?.value ?? '').trim();
|
|
|
|
|
+ if (!name) return null;
|
|
|
|
|
+ const domain = c?.domain ? String(c.domain) : undefined;
|
|
|
|
|
+ const pathVal = c?.path ? String(c.path) : '/';
|
|
|
|
|
+ const url = !domain ? 'https://channels.weixin.qq.com' : undefined;
|
|
|
|
|
+ const sameSiteRaw = c?.sameSite;
|
|
|
|
|
+ const sameSite =
|
|
|
|
|
+ sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
|
|
|
|
|
+ ? sameSiteRaw
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+ return {
|
|
|
|
|
+ name,
|
|
|
|
|
+ value,
|
|
|
|
|
+ domain,
|
|
|
|
|
+ path: pathVal,
|
|
|
|
|
+ url,
|
|
|
|
|
+ expires: typeof c?.expires === 'number' ? c.expires : undefined,
|
|
|
|
|
+ httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
|
|
|
|
|
+ secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
|
|
|
|
|
+ sameSite,
|
|
|
|
|
+ } satisfies PlaywrightCookie;
|
|
|
})
|
|
})
|
|
|
- );
|
|
|
|
|
|
|
+ .filter(Boolean) as PlaywrightCookie[];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // fallthrough
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const pairs = s.split(';').map((p) => p.trim()).filter(Boolean);
|
|
|
|
|
+ const cookies: PlaywrightCookie[] = [];
|
|
|
|
|
+ for (const p of pairs) {
|
|
|
|
|
+ const idx = p.indexOf('=');
|
|
|
|
|
+ if (idx <= 0) continue;
|
|
|
|
|
+ const name = p.slice(0, idx).trim();
|
|
|
|
|
+ const value = p.slice(idx + 1).trim();
|
|
|
|
|
+ if (!name) continue;
|
|
|
|
|
+ cookies.push({ name, value, url: 'https://channels.weixin.qq.com' });
|
|
|
|
|
+ }
|
|
|
|
|
+ return cookies;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function createBrowserForAccount(proxy: ProxyConfig | null, headless: boolean): Promise<{
|
|
|
|
|
+ browser: Browser;
|
|
|
|
|
+ shouldClose: boolean;
|
|
|
|
|
+}> {
|
|
|
|
|
+ if (proxy?.enabled) {
|
|
|
|
|
+ const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
|
|
|
|
|
+ const browser = await chromium.launch({
|
|
|
|
|
+ headless,
|
|
|
|
|
+ proxy: {
|
|
|
|
|
+ server,
|
|
|
|
|
+ username: proxy.username,
|
|
|
|
|
+ password: proxy.password,
|
|
|
|
|
+ },
|
|
|
|
|
+ args: [
|
|
|
|
|
+ '--no-sandbox',
|
|
|
|
|
+ '--disable-setuid-sandbox',
|
|
|
|
|
+ '--disable-dev-shm-usage',
|
|
|
|
|
+ '--disable-gpu',
|
|
|
|
|
+ '--window-size=1920,1080',
|
|
|
|
|
+ ],
|
|
|
|
|
+ });
|
|
|
|
|
+ return { browser, shouldClose: true };
|
|
|
|
|
+ }
|
|
|
|
|
+ const browser = await BrowserManager.getBrowser({ headless });
|
|
|
|
|
+ return { browser, shouldClose: false };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseChineseNumberLike(input: unknown): number | null {
|
|
|
|
|
+ if (input === null || input === undefined) return null;
|
|
|
|
|
+ const s = String(input).trim();
|
|
|
|
|
+ if (!s) return null;
|
|
|
|
|
+ const plain = s.replace(/,/g, '');
|
|
|
|
|
+ const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
|
|
|
|
|
+ if (wan) return Math.round(Number(wan[1]) * 10000);
|
|
|
|
|
+ const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
|
|
|
|
|
+ if (yi) return Math.round(Number(yi[1]) * 100000000);
|
|
|
|
|
+ const n = Number(plain.replace(/[^\d.-]/g, ''));
|
|
|
|
|
+ if (Number.isFinite(n)) return Math.round(n);
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 从昨天往前推 N 天的日期数组(index 0 = 最早),与接口数组顺序一致 */
|
|
|
|
|
+function getRecentChinaDates(days: number): Date[] {
|
|
|
|
|
+ const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
+ timeZone: 'Asia/Shanghai',
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ month: '2-digit',
|
|
|
|
|
+ day: '2-digit',
|
|
|
|
|
+ });
|
|
|
|
|
+ const parts = formatter.formatToParts(new Date());
|
|
|
|
|
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
|
|
|
|
|
+ const y = parseInt(get('year'), 10);
|
|
|
|
|
+ const m = parseInt(get('month'), 10) - 1;
|
|
|
|
|
+ const d = parseInt(get('day'), 10);
|
|
|
|
|
+ const today = new Date(y, m, d, 0, 0, 0, 0);
|
|
|
|
|
+ const end = new Date(today);
|
|
|
|
|
+ end.setDate(end.getDate() - 1);
|
|
|
|
|
+ const start = new Date(end);
|
|
|
|
|
+ start.setDate(end.getDate() - (days - 1));
|
|
|
|
|
+ const dates: Date[] = [];
|
|
|
|
|
+ for (let i = 0; i < days; i++) {
|
|
|
|
|
+ const dt = new Date(start);
|
|
|
|
|
+ dt.setDate(start.getDate() + i);
|
|
|
|
|
+ dt.setHours(0, 0, 0, 0);
|
|
|
|
|
+ dates.push(dt);
|
|
|
}
|
|
}
|
|
|
|
|
+ return dates;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const STATISTIC_POST_URL = 'https://channels.weixin.qq.com/platform/statistic/post';
|
|
|
|
|
+const TAB_SINGLE_VIDEO_SELECTORS = [
|
|
|
|
|
+ 'div.weui-desktop-tab__navs ul li:nth-child(2) a',
|
|
|
|
|
+ 'a:has-text("单篇视频")',
|
|
|
|
|
+];
|
|
|
|
|
+const NEAR_30_DAYS_SELECTORS = [
|
|
|
|
|
+ 'div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text("近30天")',
|
|
|
|
|
+ 'div.post-single-wrap div.filter-wrap div.weui-desktop-radio-group label:nth-child(2)',
|
|
|
|
|
+ 'div.post-single-wrap label:has-text("近30天")',
|
|
|
|
|
+ 'div.weui-desktop-radio-group label:has-text("近30天")',
|
|
|
|
|
+ 'label:has-text("近30天")',
|
|
|
|
|
+];
|
|
|
|
|
+const VIEW_BTN_SELECTORS = (objectId: string) => [
|
|
|
|
|
+ `div.ant-table-fixed-right tr[data-row-key="${objectId}"] a.detail-wrap`,
|
|
|
|
|
+ `tr[data-row-key="${objectId}"] a.detail-wrap`,
|
|
|
|
|
+ `tr[data-row-key="${objectId}"] a:has-text("查看")`,
|
|
|
|
|
+ `tr[data-row-key="${objectId}"] a`,
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
export class WeixinVideoWorkStatisticsImportService {
|
|
export class WeixinVideoWorkStatisticsImportService {
|
|
|
private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
|
private workRepository = AppDataSource.getRepository(Work);
|
|
private workRepository = AppDataSource.getRepository(Work);
|
|
|
|
|
+ private workDayStatisticsService = new WorkDayStatisticsService();
|
|
|
|
|
|
|
|
static async runDailyImport(): Promise<void> {
|
|
static async runDailyImport(): Promise<void> {
|
|
|
const svc = new WeixinVideoWorkStatisticsImportService();
|
|
const svc = new WeixinVideoWorkStatisticsImportService();
|
|
@@ -70,6 +204,23 @@ export class WeixinVideoWorkStatisticsImportService {
|
|
|
await svc.importAccountWorksStatistics(account, showBrowser);
|
|
await svc.importAccountWorksStatistics(account, showBrowser);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 仅同步指定作品(用于接口/测试),showBrowser=true 时显示浏览器窗口;返回 inserted/updated */
|
|
|
|
|
+ static async runDailyImportForWork(
|
|
|
|
|
+ workId: number,
|
|
|
|
|
+ showBrowser = false
|
|
|
|
|
+ ): Promise<{ inserted: number; updated: number }> {
|
|
|
|
|
+ const svc = new WeixinVideoWorkStatisticsImportService();
|
|
|
|
|
+ const work = await svc.workRepository.findOne({
|
|
|
|
|
+ where: { id: workId, platform: 'weixin_video' as any },
|
|
|
|
|
+ relations: ['account'],
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!work) throw new Error(`未找到视频号作品 id=${workId}`);
|
|
|
|
|
+ const account = work.account as PlatformAccount;
|
|
|
|
|
+ if (!account) throw new Error(`作品 ${workId} 无关联账号`);
|
|
|
|
|
+ logger.info(`[WX WorkStats] 单作品同步 workId=${workId} accountId=${account.id} showBrowser=${showBrowser}`);
|
|
|
|
|
+ return svc.importAccountWorksStatistics(account, showBrowser, { onlyWorkIds: [workId] });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
|
|
async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
|
|
|
const accounts = await this.accountRepository.find({
|
|
const accounts = await this.accountRepository.find({
|
|
|
where: { platform: 'weixin_video' as any },
|
|
where: { platform: 'weixin_video' as any },
|
|
@@ -88,76 +239,350 @@ export class WeixinVideoWorkStatisticsImportService {
|
|
|
logger.info('[WX WorkStats] All accounts done');
|
|
logger.info('[WX WorkStats] All accounts done');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private async importAccountWorksStatistics(account: PlatformAccount, showBrowser = false): Promise<void> {
|
|
|
|
|
- const cookieForPython = getCookieForPython(account.cookieData);
|
|
|
|
|
- if (!cookieForPython) {
|
|
|
|
|
|
|
+ private async importAccountWorksStatistics(
|
|
|
|
|
+ account: PlatformAccount,
|
|
|
|
|
+ showBrowser = false,
|
|
|
|
|
+ options?: { onlyWorkIds?: number[] }
|
|
|
|
|
+ ): Promise<{ inserted: number; updated: number }> {
|
|
|
|
|
+ const cookies = parseCookiesFromAccount(account.cookieData);
|
|
|
|
|
+ if (!cookies.length) {
|
|
|
logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
|
|
logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
|
|
|
- return;
|
|
|
|
|
|
|
+ return { inserted: 0, updated: 0 };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const works = await this.workRepository.find({
|
|
|
|
|
|
|
+ let works = await this.workRepository.find({
|
|
|
where: { accountId: account.id, platform: 'weixin_video' as any },
|
|
where: { accountId: account.id, platform: 'weixin_video' as any },
|
|
|
});
|
|
});
|
|
|
|
|
+ if (options?.onlyWorkIds?.length) {
|
|
|
|
|
+ const set = new Set(options.onlyWorkIds);
|
|
|
|
|
+ works = works.filter((w) => set.has(w.id));
|
|
|
|
|
+ }
|
|
|
if (!works.length) {
|
|
if (!works.length) {
|
|
|
logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
|
|
logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
|
|
|
- return;
|
|
|
|
|
|
|
+ return { inserted: 0, updated: 0 };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const worksPayload = works
|
|
|
|
|
- .filter((w) => (w.platformVideoId ?? '').trim())
|
|
|
|
|
- .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() }));
|
|
|
|
|
-
|
|
|
|
|
- if (!worksPayload.length) {
|
|
|
|
|
|
|
+ const exportIdToWorkId = new Map<string, number>();
|
|
|
|
|
+ for (const w of works) {
|
|
|
|
|
+ const pvid = (w.platformVideoId ?? '').trim();
|
|
|
|
|
+ if (!pvid) continue;
|
|
|
|
|
+ exportIdToWorkId.set(pvid, w.id);
|
|
|
|
|
+ if (pvid.includes('/')) exportIdToWorkId.set(pvid.split('/').pop()!, w.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (exportIdToWorkId.size === 0) {
|
|
|
logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
|
|
logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
|
|
|
- return;
|
|
|
|
|
|
|
+ return { inserted: 0, updated: 0 };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- logger.info(
|
|
|
|
|
- `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品`
|
|
|
|
|
|
|
+ const headless =
|
|
|
|
|
+ process.env.WX_IMPORT_HEADLESS === '0' ? false : !showBrowser;
|
|
|
|
|
+ const { browser, shouldClose } = await createBrowserForAccount(
|
|
|
|
|
+ account.proxyConfig as ProxyConfig | null,
|
|
|
|
|
+ headless
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ let totalProcessed = 0;
|
|
|
|
|
+ let totalSkipped = 0;
|
|
|
|
|
+ let inserted = 0;
|
|
|
|
|
+ let updated = 0;
|
|
|
|
|
+ let worksUpdated = 0;
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
|
|
|
|
|
- const pyRes = await fetch(`${pythonUrl}/sync_weixin_account_works_daily_stats`, {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({
|
|
|
|
|
- works: worksPayload,
|
|
|
|
|
- cookie: cookieForPython,
|
|
|
|
|
- show_browser: showBrowser,
|
|
|
|
|
- }),
|
|
|
|
|
- signal: AbortSignal.timeout(600_000), // 10 分钟,批量可能较久
|
|
|
|
|
|
|
+ const context = await browser.newContext({
|
|
|
|
|
+ acceptDownloads: true,
|
|
|
|
|
+ viewport: { width: 1920, height: 1080 },
|
|
|
|
|
+ locale: 'zh-CN',
|
|
|
|
|
+ timezoneId: 'Asia/Shanghai',
|
|
|
|
|
+ userAgent:
|
|
|
|
|
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
|
});
|
|
});
|
|
|
|
|
+ context.setDefaultTimeout(60_000);
|
|
|
|
|
+ await context.addCookies(cookies as any);
|
|
|
|
|
|
|
|
- const data = (await pyRes.json().catch(() => ({}))) as {
|
|
|
|
|
- success?: boolean;
|
|
|
|
|
- error?: string;
|
|
|
|
|
- message?: string;
|
|
|
|
|
- total_processed?: number;
|
|
|
|
|
- total_skipped?: number;
|
|
|
|
|
- inserted?: number;
|
|
|
|
|
- updated?: number;
|
|
|
|
|
- works_updated?: number;
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const page = await context.newPage();
|
|
|
|
|
|
|
|
- if (!pyRes.ok) {
|
|
|
|
|
- logger.warn(`[WX WorkStats] accountId=${account.id} Python 请求失败: ${pyRes.status} ${data.error || ''}`);
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ const postListData = { list: [] as Array<{ exportId?: string; objectId?: string; [k: string]: any }> };
|
|
|
|
|
+
|
|
|
|
|
+ page.on('response', async (response) => {
|
|
|
|
|
+ const url = response.url();
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (url.includes('statistic/post_list') && response.request().method() === 'POST') {
|
|
|
|
|
+ const body = await response.json().catch(() => ({}));
|
|
|
|
|
+ if (body?.errCode === 0 && body?.data?.list) {
|
|
|
|
|
+ postListData.list = body.data.list;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ await page.goto(STATISTIC_POST_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
|
|
|
+ await page.waitForTimeout(showBrowser ? 5000 : 3000);
|
|
|
|
|
+
|
|
|
|
|
+ if (page.url().includes('login') || page.url().includes('passport')) {
|
|
|
|
|
+ throw new Error('Cookie 已过期,请重新登录');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const sel of TAB_SINGLE_VIDEO_SELECTORS) {
|
|
|
|
|
+ const loc = page.locator(sel).first;
|
|
|
|
|
+ if ((await loc.count()) > 0) {
|
|
|
|
|
+ await loc.click().catch(() => undefined);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+
|
|
|
|
|
+ postListData.list = [];
|
|
|
|
|
+ for (const sel of NEAR_30_DAYS_SELECTORS) {
|
|
|
|
|
+ const loc = page.locator(sel).first;
|
|
|
|
|
+ if ((await loc.count()) > 0) {
|
|
|
|
|
+ await loc.click().catch(() => undefined);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(5000);
|
|
|
|
|
+
|
|
|
|
|
+ const items = postListData.list;
|
|
|
|
|
+ if (!items.length) {
|
|
|
|
|
+ logger.warn(`[WX WorkStats] accountId=${account.id} 未监听到 post_list 或列表为空`);
|
|
|
|
|
+ return { inserted: 0, updated: 0 };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (!data.success) {
|
|
|
|
|
- logger.warn(`[WX WorkStats] accountId=${account.id} 同步失败: ${data.error || ''}`);
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ // 首次:用 post_list 更新 works 表 yesterday_*
|
|
|
|
|
+ const workUpdates: Array<{
|
|
|
|
|
+ work_id: number;
|
|
|
|
|
+ yesterday_play_count: number;
|
|
|
|
|
+ yesterday_like_count: number;
|
|
|
|
|
+ yesterday_recommend_count: number;
|
|
|
|
|
+ yesterday_comment_count: number;
|
|
|
|
|
+ yesterday_share_count: number;
|
|
|
|
|
+ yesterday_follow_count: number;
|
|
|
|
|
+ yesterday_completion_rate: string;
|
|
|
|
|
+ yesterday_avg_watch_duration: string;
|
|
|
|
|
+ }> = [];
|
|
|
|
|
+ for (const it of items) {
|
|
|
|
|
+ const eid = (it.exportId ?? '').trim();
|
|
|
|
|
+ if (!eid) continue;
|
|
|
|
|
+ let workId = exportIdToWorkId.get(eid);
|
|
|
|
|
+ if (workId == null) {
|
|
|
|
|
+ for (const [k, v] of exportIdToWorkId) {
|
|
|
|
|
+ if (eid.includes(k) || k.includes(eid)) {
|
|
|
|
|
+ workId = v;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (workId == null) continue;
|
|
|
|
|
+ const readCount = Number(it.readCount) || 0;
|
|
|
|
|
+ const recommendCount = Number(it.likeCount) || 0;
|
|
|
|
|
+ const likeCount = Number(it.favCount) || 0;
|
|
|
|
|
+ const commentCount = Number(it.commentCount) || 0;
|
|
|
|
|
+ const forwardCount = Number(it.forwardCount) || 0;
|
|
|
|
|
+ const followCount = Number(it.followCount) || 0;
|
|
|
|
|
+ const fullPlayRate = it.fullPlayRate;
|
|
|
|
|
+ const compRate = fullPlayRate != null ? `${Number(fullPlayRate) * 100}%` : '0';
|
|
|
|
|
+ const avgSec = it.avgPlayTimeSec;
|
|
|
|
|
+ const avgDur = avgSec != null ? `${Number(avgSec)}秒` : '0';
|
|
|
|
|
+ workUpdates.push({
|
|
|
|
|
+ work_id: workId,
|
|
|
|
|
+ yesterday_play_count: readCount,
|
|
|
|
|
+ yesterday_like_count: likeCount,
|
|
|
|
|
+ yesterday_recommend_count: recommendCount,
|
|
|
|
|
+ yesterday_comment_count: commentCount,
|
|
|
|
|
+ yesterday_share_count: forwardCount,
|
|
|
|
|
+ yesterday_follow_count: followCount,
|
|
|
|
|
+ yesterday_completion_rate: compRate,
|
|
|
|
|
+ yesterday_avg_watch_duration: avgDur,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (workUpdates.length > 0) {
|
|
|
|
|
+ for (const u of workUpdates) {
|
|
|
|
|
+ const result = await this.workRepository.update(u.work_id, {
|
|
|
|
|
+ yesterdayPlayCount: u.yesterday_play_count,
|
|
|
|
|
+ yesterdayLikeCount: u.yesterday_like_count,
|
|
|
|
|
+ yesterdayRecommendCount: u.yesterday_recommend_count,
|
|
|
|
|
+ yesterdayCommentCount: u.yesterday_comment_count,
|
|
|
|
|
+ yesterdayShareCount: u.yesterday_share_count,
|
|
|
|
|
+ yesterdayFollowCount: u.yesterday_follow_count,
|
|
|
|
|
+ yesterdayCompletionRate: u.yesterday_completion_rate,
|
|
|
|
|
+ yesterdayAvgWatchDuration: u.yesterday_avg_watch_duration,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (result.affected && result.affected > 0) worksUpdated += result.affected;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const processedExportIds = new Set<string>();
|
|
|
|
|
+
|
|
|
|
|
+ const ensureSingleVideoNear30 = async () => {
|
|
|
|
|
+ for (const sel of TAB_SINGLE_VIDEO_SELECTORS) {
|
|
|
|
|
+ const loc = page.locator(sel).first;
|
|
|
|
|
+ if ((await loc.count()) > 0) {
|
|
|
|
|
+ await loc.click().catch(() => undefined);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+ for (const sel of NEAR_30_DAYS_SELECTORS) {
|
|
|
|
|
+ const loc = page.locator(sel).first;
|
|
|
|
|
+ if ((await loc.count()) > 0) {
|
|
|
|
|
+ await loc.click().catch(() => undefined);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(3000);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ for (let idx = 0; idx < items.length; idx++) {
|
|
|
|
|
+ const item = items[idx]!;
|
|
|
|
|
+ const eid = (item.exportId ?? '').trim();
|
|
|
|
|
+ const oid = (item.objectId ?? '').trim();
|
|
|
|
|
+ if (!oid) continue;
|
|
|
|
|
+ if (processedExportIds.has(eid)) continue;
|
|
|
|
|
+
|
|
|
|
|
+ if (idx > 0) {
|
|
|
|
|
+ await ensureSingleVideoNear30();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let workId = exportIdToWorkId.get(eid);
|
|
|
|
|
+ if (workId == null) {
|
|
|
|
|
+ for (const [k, v] of exportIdToWorkId) {
|
|
|
|
|
+ if (eid.includes(k) || k.includes(eid)) {
|
|
|
|
|
+ workId = v;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (workId == null) {
|
|
|
|
|
+ totalSkipped += 1;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let viewClicked = false;
|
|
|
|
|
+ for (const sel of VIEW_BTN_SELECTORS(oid)) {
|
|
|
|
|
+ const viewBtn = page.locator(sel);
|
|
|
|
|
+ if ((await viewBtn.count()) > 0) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await viewBtn.first.waitFor({ timeout: 3000 });
|
|
|
|
|
+ await viewBtn.first.click();
|
|
|
|
|
+ viewClicked = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // next selector
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!viewClicked) {
|
|
|
|
|
+ totalSkipped += 1;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+
|
|
|
|
|
+ let body: { data?: { dataByFanstype?: Array<{ dataByTabtype?: Array<{ tabTypeName?: string; tabType?: number; data?: any }> }>; feedData?: Array<{ totalData?: any }> } } | null = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await page.waitForResponse(
|
|
|
|
|
+ (r) => r.url().includes('feed_aggreagate_data_by_tab_type'),
|
|
|
|
|
+ { timeout: 12_000 }
|
|
|
|
|
+ );
|
|
|
|
|
+ const json = await res.json().catch(() => null);
|
|
|
|
|
+ if (json?.errCode === 0 && json?.data) body = json;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // timeout or no response
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!body?.data) {
|
|
|
|
|
+ await page.goBack().catch(() => undefined);
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let tabAll: Record<string, unknown[] | undefined> | undefined;
|
|
|
|
|
+ for (const fanItem of body.data.dataByFanstype ?? []) {
|
|
|
|
|
+ for (const tabItem of fanItem.dataByTabtype ?? []) {
|
|
|
|
|
+ if (tabItem.tabTypeName === '全部' || tabItem.tabType === 999) {
|
|
|
|
|
+ tabAll = tabItem.data as Record<string, unknown[] | undefined>;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (tabAll) break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!tabAll) {
|
|
|
|
|
+ const feedData = body.data.feedData;
|
|
|
|
|
+ tabAll = feedData?.[0]?.totalData as Record<string, unknown[] | undefined> | undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!tabAll) {
|
|
|
|
|
+ await page.goBack().catch(() => undefined);
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const browse = Array.isArray(tabAll.browse) ? tabAll.browse : [];
|
|
|
|
|
+ const n = browse.length;
|
|
|
|
|
+ if (n === 0) {
|
|
|
|
|
+ await page.goBack().catch(() => undefined);
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const likeArr = Array.isArray(tabAll.like) ? tabAll.like : [];
|
|
|
|
|
+ const commentArr = Array.isArray(tabAll.comment) ? tabAll.comment : [];
|
|
|
|
|
+ const forwardArr = Array.isArray(tabAll.forward) ? tabAll.forward : [];
|
|
|
|
|
+ const favArr = Array.isArray(tabAll.fav) ? tabAll.fav : [];
|
|
|
|
|
+ const followArr = Array.isArray(tabAll.follow) ? tabAll.follow : [];
|
|
|
|
|
+ const dates = getRecentChinaDates(n);
|
|
|
|
|
+
|
|
|
|
|
+ const batchItems: Array<{
|
|
|
|
|
+ workId: number;
|
|
|
|
|
+ recordDate: Date;
|
|
|
|
|
+ playCount: number;
|
|
|
|
|
+ likeCount: number;
|
|
|
|
|
+ recommendCount: number;
|
|
|
|
|
+ commentCount: number;
|
|
|
|
|
+ shareCount: number;
|
|
|
|
|
+ collectCount: number;
|
|
|
|
|
+ followCount: number;
|
|
|
|
|
+ completionRate: string;
|
|
|
|
|
+ avgWatchDuration: string;
|
|
|
|
|
+ }> = [];
|
|
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
|
|
+ const recDate = dates[i]!;
|
|
|
|
|
+ const play = parseChineseNumberLike(browse[i]) ?? 0;
|
|
|
|
|
+ const recommend = parseChineseNumberLike(likeArr[i]) ?? 0;
|
|
|
|
|
+ const like = parseChineseNumberLike(favArr[i]) ?? 0;
|
|
|
|
|
+ const comment = parseChineseNumberLike(commentArr[i]) ?? 0;
|
|
|
|
|
+ const share = parseChineseNumberLike(forwardArr[i]) ?? 0;
|
|
|
|
|
+ const follow = parseChineseNumberLike(followArr[i]) ?? 0;
|
|
|
|
|
+ batchItems.push({
|
|
|
|
|
+ workId,
|
|
|
|
|
+ recordDate: recDate,
|
|
|
|
|
+ playCount: play,
|
|
|
|
|
+ likeCount: like,
|
|
|
|
|
+ recommendCount: recommend,
|
|
|
|
|
+ commentCount: comment,
|
|
|
|
|
+ shareCount: share,
|
|
|
|
|
+ collectCount: 0,
|
|
|
|
|
+ followCount: follow,
|
|
|
|
|
+ completionRate: '0',
|
|
|
|
|
+ avgWatchDuration: '0',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(batchItems);
|
|
|
|
|
+ inserted += result.inserted;
|
|
|
|
|
+ updated += result.updated;
|
|
|
|
|
+ totalProcessed += 1;
|
|
|
|
|
+ processedExportIds.add(eid);
|
|
|
|
|
+
|
|
|
|
|
+ await page.goBack().catch(() => undefined);
|
|
|
|
|
+ await page.waitForTimeout(2000);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const worksUpdated = data.works_updated ?? 0;
|
|
|
|
|
logger.info(
|
|
logger.info(
|
|
|
- `[WX WorkStats] accountId=${account.id} 完成: 处理 ${data.total_processed ?? 0} 个, 跳过 ${data.total_skipped ?? 0} 个, 新增 ${data.inserted ?? 0} 条, 更新 ${data.updated ?? 0} 条` +
|
|
|
|
|
|
|
+ `[WX WorkStats] accountId=${account.id} 完成: 处理 ${totalProcessed} 个, 跳过 ${totalSkipped} 个, 新增 ${inserted} 条, 更新 ${updated} 条` +
|
|
|
(worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '')
|
|
(worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '')
|
|
|
);
|
|
);
|
|
|
- } catch (e) {
|
|
|
|
|
- logger.error(`[WX WorkStats] accountId=${account.id} 调用 Python 失败:`, e);
|
|
|
|
|
- throw e;
|
|
|
|
|
|
|
+ return { inserted, updated };
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (shouldClose) await browser.close().catch(() => undefined);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|