|
@@ -3,6 +3,7 @@ import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
|
|
|
import { logger } from '../utils/logger.js';
|
|
import { logger } from '../utils/logger.js';
|
|
|
import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
|
|
import { WorkDayStatisticsService } from './WorkDayStatisticsService.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 { BrowserManager } from '../automation/browser.js';
|
|
import { BrowserManager } from '../automation/browser.js';
|
|
|
import { In } from 'typeorm';
|
|
import { In } from 'typeorm';
|
|
@@ -87,18 +88,39 @@ interface DailyWorkStatPatch {
|
|
|
twoSecondExitRate?: string;
|
|
twoSecondExitRate?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Playwright 只接受 Strict | Lax | None,浏览器导出可能是小写或 no_restriction,需规范化 */
|
|
|
|
|
+function normalizeSameSite(value: unknown): 'Strict' | 'Lax' | 'None' | undefined {
|
|
|
|
|
+ if (value === undefined || value === null) return undefined;
|
|
|
|
|
+ const s = String(value).trim().toLowerCase();
|
|
|
|
|
+ if (s === 'strict') return 'Strict';
|
|
|
|
|
+ if (s === 'lax') return 'Lax';
|
|
|
|
|
+ if (s === 'none' || s === 'no_restriction') return 'None';
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
|
|
function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
|
|
|
if (!cookieData) return [];
|
|
if (!cookieData) return [];
|
|
|
const raw = cookieData.trim();
|
|
const raw = cookieData.trim();
|
|
|
if (!raw) return [];
|
|
if (!raw) return [];
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const parsed = JSON.parse(raw) as PlaywrightCookie[];
|
|
|
|
|
|
|
+ const parsed = JSON.parse(raw) as (PlaywrightCookie & { sameSite?: unknown })[];
|
|
|
if (Array.isArray(parsed)) {
|
|
if (Array.isArray(parsed)) {
|
|
|
- return parsed.map((c) => ({
|
|
|
|
|
- ...c,
|
|
|
|
|
- url: c.url || 'https://creator.xiaohongshu.com',
|
|
|
|
|
- }));
|
|
|
|
|
|
|
+ return parsed.map((c) => {
|
|
|
|
|
+ const sameSite = normalizeSameSite(c.sameSite);
|
|
|
|
|
+ const out: PlaywrightCookie = {
|
|
|
|
|
+ name: String(c.name ?? '').trim(),
|
|
|
|
|
+ value: String(c.value ?? '').trim(),
|
|
|
|
|
+ url: c.url || 'https://creator.xiaohongshu.com',
|
|
|
|
|
+ };
|
|
|
|
|
+ if (c.domain != null) out.domain = String(c.domain);
|
|
|
|
|
+ if (c.path != null) out.path = String(c.path);
|
|
|
|
|
+ if (c.expires != null && Number.isFinite(Number(c.expires))) out.expires = Number(c.expires);
|
|
|
|
|
+ if (typeof c.httpOnly === 'boolean') out.httpOnly = c.httpOnly;
|
|
|
|
|
+ if (typeof c.secure === 'boolean') out.secure = c.secure;
|
|
|
|
|
+ if (sameSite) out.sameSite = sameSite;
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }).filter((c) => c.name.length > 0);
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
// fallthrough
|
|
// fallthrough
|
|
@@ -318,10 +340,17 @@ export class XiaohongshuWorkNoteStatisticsImportService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
|
|
|
|
|
|
|
+ let browser: Browser | null = null;
|
|
|
|
|
+ let shouldClose = false;
|
|
|
let context: BrowserContext | null = null;
|
|
let context: BrowserContext | null = null;
|
|
|
|
|
+ let page: Page | null = null;
|
|
|
let closedDueToLoginExpired = false;
|
|
let closedDueToLoginExpired = false;
|
|
|
- try {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const ensureBrowser = async (): Promise<void> => {
|
|
|
|
|
+ if (page) return;
|
|
|
|
|
+ const x = await createBrowserForAccount(account.proxyConfig);
|
|
|
|
|
+ browser = x.browser;
|
|
|
|
|
+ shouldClose = x.shouldClose;
|
|
|
context = await browser.newContext({
|
|
context = await browser.newContext({
|
|
|
viewport: { width: 1920, height: 1080 },
|
|
viewport: { width: 1920, height: 1080 },
|
|
|
locale: 'zh-CN',
|
|
locale: 'zh-CN',
|
|
@@ -331,13 +360,14 @@ export class XiaohongshuWorkNoteStatisticsImportService {
|
|
|
});
|
|
});
|
|
|
await context.addCookies(cookies as any);
|
|
await context.addCookies(cookies as any);
|
|
|
context.setDefaultTimeout(60_000);
|
|
context.setDefaultTimeout(60_000);
|
|
|
|
|
+ page = await context.newPage();
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
- const page = await context.newPage();
|
|
|
|
|
-
|
|
|
|
|
|
|
+ try {
|
|
|
let totalInserted = 0;
|
|
let totalInserted = 0;
|
|
|
let totalUpdated = 0;
|
|
let totalUpdated = 0;
|
|
|
-
|
|
|
|
|
const total = works.length;
|
|
const total = works.length;
|
|
|
|
|
+
|
|
|
for (let i = 0; i < works.length; i++) {
|
|
for (let i = 0; i < works.length; i++) {
|
|
|
const work = works[i];
|
|
const work = works[i];
|
|
|
if (options?.onProgress) {
|
|
if (options?.onProgress) {
|
|
@@ -347,7 +377,11 @@ export class XiaohongshuWorkNoteStatisticsImportService {
|
|
|
if (!noteId) continue;
|
|
if (!noteId) continue;
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const data = await this.fetchNoteBaseData(page, noteId);
|
|
|
|
|
|
|
+ let data: NoteBaseData | null = await this.fetchNoteBaseViaPython(account, noteId);
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ await ensureBrowser();
|
|
|
|
|
+ data = await this.fetchNoteBaseData(page!, noteId);
|
|
|
|
|
+ }
|
|
|
if (!data) continue;
|
|
if (!data) continue;
|
|
|
|
|
|
|
|
// 同步 base 顶层“汇总指标”到 works 表(用于作品列表/总览等按 work 累计口径展示)
|
|
// 同步 base 顶层“汇总指标”到 works 表(用于作品列表/总览等按 work 累计口径展示)
|
|
@@ -451,13 +485,45 @@ export class XiaohongshuWorkNoteStatisticsImportService {
|
|
|
if (context) {
|
|
if (context) {
|
|
|
await context.close().catch(() => undefined);
|
|
await context.close().catch(() => undefined);
|
|
|
}
|
|
}
|
|
|
- if (shouldClose) {
|
|
|
|
|
|
|
+ if (shouldClose && browser) {
|
|
|
await browser.close().catch(() => undefined);
|
|
await browser.close().catch(() => undefined);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 通过 Python 调用 note/base(登录与打开后台一致:使用账号已存 Cookie) */
|
|
|
|
|
+ private async fetchNoteBaseViaPython(
|
|
|
|
|
+ account: PlatformAccount,
|
|
|
|
|
+ noteId: string
|
|
|
|
|
+ ): Promise<NoteBaseData | null> {
|
|
|
|
|
+ const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
|
|
|
|
|
+ const url = `${base}/xiaohongshu/note_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, note_id: noteId }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const text = await res.text();
|
|
|
|
|
+ const body = text ? (JSON.parse(text) as { data?: unknown; code?: number }) : null;
|
|
|
|
|
+ if (!body || typeof body !== 'object') return null;
|
|
|
|
|
+ const data = body.data;
|
|
|
|
|
+ if (!data || typeof data !== 'object') return null;
|
|
|
|
|
+ return data as NoteBaseData;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ clearTimeout(timeoutId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private async fetchNoteBaseData(page: Page, noteId: string): Promise<NoteBaseData | null> {
|
|
private async fetchNoteBaseData(page: Page, noteId: string): Promise<NoteBaseData | null> {
|
|
|
const noteUrl = `https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(
|
|
const noteUrl = `https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(
|
|
|
noteId
|
|
noteId
|