|
|
@@ -174,12 +174,23 @@ function toInt(val: unknown, defaultValue = 0): number {
|
|
|
function normalizePercentString(val: unknown): string | undefined {
|
|
|
const n = toNumber(val, NaN);
|
|
|
if (!Number.isFinite(n)) return undefined;
|
|
|
- if (n === 0) return '0';
|
|
|
- // 去掉多余的 0:48.730000 -> 48.73
|
|
|
- const s = n.toString();
|
|
|
+ // 小于等于 0 统一记为 "0"
|
|
|
+ if (n <= 0) return '0';
|
|
|
+ // 原始值视为 0-1 之间的小数,这里 *100 后四舍五入保留两位小数并加 "%"
|
|
|
+ const scaled = n * 100;
|
|
|
+ const rounded = Math.round(scaled * 100) / 100;
|
|
|
+ const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
|
|
|
return `${s}%`;
|
|
|
}
|
|
|
|
|
|
+/** 平均时长等:保留两位小数,四舍五入 */
|
|
|
+function toFixed2String(val: unknown): string | undefined {
|
|
|
+ const n = toNonNegativeNumber(val);
|
|
|
+ if (n == null) return undefined;
|
|
|
+ const rounded = Math.round(n * 100) / 100;
|
|
|
+ return rounded.toFixed(2);
|
|
|
+}
|
|
|
+
|
|
|
function isDouyinLoginExpiredByApi(body: any): boolean {
|
|
|
const code = Number(body?.status_code);
|
|
|
const msg = String(body?.status_msg || '');
|
|
|
@@ -213,7 +224,8 @@ class DouyinMetricsTrendClient {
|
|
|
browser_online: 'true',
|
|
|
timezone_name: 'Asia/Shanghai',
|
|
|
item_id: itemId,
|
|
|
- trend_type: '1',
|
|
|
+ // 按照浏览器抓包使用 trend_type=2,表示直接使用原始指标曲线(不是增量/差值)
|
|
|
+ trend_type: '2',
|
|
|
time_unit: '1',
|
|
|
metrics_group: '0,1,3',
|
|
|
metrics: metric,
|
|
|
@@ -338,6 +350,26 @@ class DouyinMetricsTrendClient {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function toNonNegativeNumber(val: unknown): number | undefined {
|
|
|
+ const n = toNumber(val, NaN);
|
|
|
+ if (!Number.isFinite(n)) return undefined;
|
|
|
+ return n < 0 ? 0 : n;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 比率类:0 不加 "%"(返回 "0"),非 0 时 *100 后四舍五入到两位小数并加 "%"
|
|
|
+ * 例如: 0.12345 -> "12.35%", 0.0 -> "0"
|
|
|
+ */
|
|
|
+function toRatePercentStringFromValue(val: unknown): string | undefined {
|
|
|
+ const n = toNumber(val, NaN);
|
|
|
+ if (!Number.isFinite(n)) return undefined;
|
|
|
+ if (n === 0) return '0';
|
|
|
+ const scaled = n * 100;
|
|
|
+ const rounded = Math.round(scaled * 100) / 100; // 保留两位小数,四舍五入
|
|
|
+ const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
|
|
|
+ return `${s}%`;
|
|
|
+}
|
|
|
+
|
|
|
export class DouyinWorkStatisticsImportService {
|
|
|
private accountRepository = AppDataSource.getRepository(PlatformAccount);
|
|
|
private workRepository = AppDataSource.getRepository(Work);
|
|
|
@@ -470,6 +502,19 @@ export class DouyinWorkStatisticsImportService {
|
|
|
const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
|
|
|
if (!body || typeof body !== 'object') continue;
|
|
|
|
|
|
+ // 调试:把 metrics_trend 原始返回打印成 JSON,方便和抖音后台对比
|
|
|
+ if (work.id === 39 && m.metric === 'completion_rate') {
|
|
|
+ try {
|
|
|
+ logger.info(
|
|
|
+ `[DY WorkStats][debug metrics_trend raw] workId=${work.id} itemId=${itemId} metric=${m.metric} body=${JSON.stringify(
|
|
|
+ body
|
|
|
+ )}`
|
|
|
+ );
|
|
|
+ } catch {
|
|
|
+ // ignore JSON stringify error
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if (isDouyinLoginExpiredByApi(body)) {
|
|
|
throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
|
|
|
}
|
|
|
@@ -489,7 +534,27 @@ export class DouyinWorkStatisticsImportService {
|
|
|
? metricMap['0']
|
|
|
: Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));
|
|
|
|
|
|
+ // 调试:打印 workId=39 的 completion_rate 全量 points,确认与抖音后台返回是否一致
|
|
|
+ if (work.id === 39 && m.metric === 'completion_rate') {
|
|
|
+ try {
|
|
|
+ logger.info(
|
|
|
+ `[DY WorkStats][debug completion_rate points] workId=${work.id} itemId=${itemId} points=${JSON.stringify(
|
|
|
+ points
|
|
|
+ )}`
|
|
|
+ );
|
|
|
+ } catch {
|
|
|
+ // ignore JSON stringify error
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
for (const pt of points) {
|
|
|
+ // 调试:打印指定作品的完播率原始值
|
|
|
+ if (work.id === 39 && m.metric === 'completion_rate') {
|
|
|
+ logger.info(
|
|
|
+ `[DY WorkStats][debug completion_rate] workId=${work.id} itemId=${itemId} date=${pt?.date_time} raw_value=${pt?.value}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
const d = parseChinaDateFromDateTimeString(pt?.date_time);
|
|
|
if (!d) continue;
|
|
|
const key = d.getTime();
|
|
|
@@ -507,6 +572,16 @@ export class DouyinWorkStatisticsImportService {
|
|
|
);
|
|
|
if (!patches.length) continue;
|
|
|
|
|
|
+ // 同时补充作品级昨日快照(works.yesterday_*),使用 item/mget metrics
|
|
|
+ try {
|
|
|
+ await this.applyWorkSnapshotFromItemMget(ctx, itemId, detailUrl, work.id);
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn(
|
|
|
+ `[DY WorkStats] Failed to update works snapshot from item/mget. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
|
|
|
+ e
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
|
|
|
patches.map((p) => ({
|
|
|
workId: p.workId,
|
|
|
@@ -583,6 +658,75 @@ export class DouyinWorkStatisticsImportService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 使用 item/mget 接口为 works 表补充昨日快照(yesterday_* 字段)
|
|
|
+ */
|
|
|
+ private async applyWorkSnapshotFromItemMget(
|
|
|
+ ctx: BrowserContext,
|
|
|
+ itemId: string,
|
|
|
+ refererUrl: string,
|
|
|
+ workId: number
|
|
|
+ ): Promise<void> {
|
|
|
+ const url = `https://creator.douyin.com/web/api/creator/item/mget?ids=${encodeURIComponent(
|
|
|
+ itemId
|
|
|
+ )}&fields=metrics%2Creview%2Cplay_info`;
|
|
|
+
|
|
|
+ const headers: Record<string, string> = {
|
|
|
+ accept: '*/*',
|
|
|
+ 'accept-language': 'zh-CN,zh;q=0.9',
|
|
|
+ referer: refererUrl,
|
|
|
+ 'user-agent':
|
|
|
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
|
|
+ };
|
|
|
+
|
|
|
+ const res = await ctx.request.get(url, { headers, timeout: 25_000 });
|
|
|
+ const body = (await res.json().catch(() => null)) as any;
|
|
|
+ if (!body || typeof body !== 'object' || Number(body.status_code) !== 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const items = Array.isArray(body.items) ? body.items : [];
|
|
|
+ const first = items[0];
|
|
|
+ if (!first || typeof first !== 'object') return;
|
|
|
+
|
|
|
+ const metrics = first.metrics || {};
|
|
|
+ const patch: Partial<Work> = {};
|
|
|
+
|
|
|
+ const viewCount = toNonNegativeNumber(metrics.view_count);
|
|
|
+ if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);
|
|
|
+
|
|
|
+ const likeCount = toNonNegativeNumber(metrics.like_count);
|
|
|
+ if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);
|
|
|
+
|
|
|
+ const commentCount = toNonNegativeNumber(metrics.comment_count);
|
|
|
+ if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);
|
|
|
+
|
|
|
+ const shareCount = toNonNegativeNumber(metrics.share_count);
|
|
|
+ if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);
|
|
|
+
|
|
|
+ const collectCount = toNonNegativeNumber(metrics.favorite_count);
|
|
|
+ if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);
|
|
|
+
|
|
|
+ const fansIncrease = toNonNegativeNumber(metrics.subscribe_count);
|
|
|
+ if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);
|
|
|
+
|
|
|
+ // 平均观看时长(秒):保留两位小数
|
|
|
+ const avgWatchStr = toFixed2String(metrics.avg_view_second);
|
|
|
+ if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;
|
|
|
+
|
|
|
+ const completionRateStr = toRatePercentStringFromValue(metrics.completion_rate);
|
|
|
+ if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;
|
|
|
+
|
|
|
+ const twoSecondExitRateStr = toRatePercentStringFromValue(metrics.bounce_rate_2s);
|
|
|
+ if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;
|
|
|
+
|
|
|
+ const completion5sStr = toRatePercentStringFromValue(metrics.completion_rate_5s);
|
|
|
+ if (completion5sStr != null) (patch as any).yesterdayCompletionRate5s = completion5sStr;
|
|
|
+
|
|
|
+ if (Object.keys(patch).length === 0) return;
|
|
|
+ await this.workRepository.update(workId, patch as any);
|
|
|
+ }
|
|
|
+
|
|
|
private async markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
|
|
|
await this.accountRepository.update(account.id, { status: 'expired' as any });
|
|
|
wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
|