From 67d6e7d6a76b688d25a1bc49f9e9012ae28071dd Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:20:41 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=90=E8=A7=82=E7=9C=8B=E7=9B=B4=E6=92=AD=E3=80=91=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=BF=83=E8=B7=B3=E5=8F=91=E9=80=81=E6=97=B6=E6=9C=BA?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=94=99=E8=AF=AF=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原先逻辑在长时间运行时心跳发送时间会发生漂移 --- CHANGELOG.md | 4 ++ src/library/bili-api/index.ts | 2 +- .../liveTasks/medalTasks/watchTask.ts | 63 ++++++++++++++----- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebcfb617..861037a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 修复 + +- 修复【观看直播】任务心跳发送时机计算错误的bug + ## [7.3.0] - 2026-6-14 ### 新增 diff --git a/src/library/bili-api/index.ts b/src/library/bili-api/index.ts index bf4a9853..d1967489 100644 --- a/src/library/bili-api/index.ts +++ b/src/library/bili-api/index.ts @@ -193,7 +193,7 @@ const BAPI: BapiMethods = { id: JSON.stringify(id), device: JSON.stringify(device), ruid, // 主播 uid - ets: ets, + ets, benchmark, time, ts, diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts index e53eb217..1c40c66c 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts @@ -80,6 +80,11 @@ class RoomHeart { private secretRule!: number[] /** ets */ private timestamp!: number + /** 下一次 X 心跳的目标发送时间 */ + private nextHeartbeatAt = 0 + + private static readonly HEARTBEAT_WAIT_SLICE_MS = 1000 + private static readonly HEARTBEAT_DRIFT_WARN_MS = 5000 /** * 开始心跳 @@ -95,6 +100,46 @@ class RoomHeart { return this.watchedSeconds > 0 } + private setHeartbeatState(data: { + heartbeat_interval: number + secret_key: string + secret_rule: number[] + timestamp: number + }) { + ;({ + heartbeat_interval: this.heartBeatInterval, + secret_key: this.secretKey, + secret_rule: this.secretRule, + timestamp: this.timestamp, + } = data) + + // 根据观察,心跳响应中的 timestamp = 上次心跳响应中的 timestamp + 上次心跳响应中的 heartbeat_interval + // 如果是第一次心跳,timestamp = 发心跳时的秒级时间戳 -1或-2(可能是B站服务器的时间不准?误差不大实测可忽略) + // 总是使用最新心跳中返回的 timestamp 和 heartBeatInterval 来计算下次发送的时间 + // 如果每次心跳后固定等待 heartBeatInterval 秒,发送时间误差会随时间推移变得越来越大 + this.nextHeartbeatAt = (this.timestamp + this.heartBeatInterval) * 1000 + } + + /** + * 等待下一次心跳的发送时间到来,期间每隔一段时间检查一次剩余时间 + */ + private async waitForNextHeartbeat(): Promise { + while (true) { + const remaining = this.nextHeartbeatAt - tsm() + + if (remaining <= 0) { + const drift = -remaining + if (drift >= RoomHeart.HEARTBEAT_DRIFT_WARN_MS) { + this.logger.warn( + `直播间 ${this.roomID} 的 X 心跳发送已滞后 ${(drift / 1000).toFixed(2)} 秒,下次心跳可能触发服务端 time check failed`, + ) + } + return + } + await sleep(Math.min(remaining, RoomHeart.HEARTBEAT_WAIT_SLICE_MS)) + } + } + /** * E心跳,开始时发送一次 */ @@ -107,13 +152,8 @@ class RoomHeart { ) if (response.code === 0) { this.seq += 1 - ;({ - heartbeat_interval: this.heartBeatInterval, - secret_key: this.secretKey, - secret_rule: this.secretRule, - timestamp: this.timestamp, - } = response.data) - await sleep(this.heartBeatInterval * 1000) + this.setHeartbeatState(response.data) + await this.waitForNextHeartbeat() return this.X() } else { this.logger.error( @@ -169,13 +209,8 @@ class RoomHeart { // 达到目标观看时间,结束 return } - ;({ - heartbeat_interval: this.heartBeatInterval, - secret_key: this.secretKey, - secret_rule: this.secretRule, - timestamp: this.timestamp, - } = response.data) - await sleep(this.heartBeatInterval * 1000) + this.setHeartbeatState(response.data) + await this.waitForNextHeartbeat() return this.X() } else { this.logger.error( From a81c6d39bcab2983ba42e22bf357cf23e4162a65 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:25:09 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E3=80=90=E8=A7=82?= =?UTF-8?q?=E7=9C=8B=E7=9B=B4=E6=92=AD=E3=80=91=E7=9A=84=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=A0=A1=E9=AA=8C=E5=AE=8C=E5=85=A8=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E4=BA=8EAPI=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 舍弃本地观看时长记录逻辑 - 优化日志输出 --- CHANGELOG.md | 5 ++ src/library/storage/defaultValues.ts | 2 - .../liveTasks/medalTasks/watchTask.ts | 51 ++++++++----------- src/stores/useModuleStore.ts | 2 - src/types/storage.d.ts | 2 - 5 files changed, 27 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861037a2..757b95e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - 修复【观看直播】任务心跳发送时机计算错误的bug +### 调整 + +- 【观看直播】的任务进度校验现在完全依赖于API数据(舍弃本地观看时长记录逻辑) +- 优化了【观看直播】任务的日志输出 + ## [7.3.0] - 2026-6-14 ### 新增 diff --git a/src/library/storage/defaultValues.ts b/src/library/storage/defaultValues.ts index 34234bcd..e76e52a3 100644 --- a/src/library/storage/defaultValues.ts +++ b/src/library/storage/defaultValues.ts @@ -107,8 +107,6 @@ const defaultValues: DefaultValues = { targetRounds: 1, isWhiteList: false, roomidList: [], - _watchingProgress: {}, - _lastWatchTime: 0, _lastCompleteTime: 0, }, }, diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts index 1c40c66c..a551abac 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts @@ -24,23 +24,21 @@ class RoomHeart { areaID: number, parentID: number, ruid: number, - watchedSeconds: number, targetSeconds: number, ) { this.roomID = roomID this.areaID = areaID this.parentID = parentID this.ruid = ruid - this.watchedSeconds = watchedSeconds this.targetSeconds = targetSeconds } private logger = new Logger('RoomHeart') - /** 今日当前直播间已观看时间(秒) */ - private watchedSeconds: number + /** 已观看时间(秒) */ + private watchedSeconds: number = 0 - /** 当前直播间观看目标秒数 */ + /** 目标观看时间(秒) */ private readonly targetSeconds: number private readonly areaID: number @@ -55,16 +53,6 @@ class RoomHeart { return [this.parentID, this.areaID, this.seq, this.roomID] } - /** 更新当前直播间的观看任务进度 */ - private updateProgress() { - // 记录观看时间 - this.watchedSeconds += this.heartBeatInterval - - useModuleStore().moduleConfig.DailyTasks.LiveTasks.medalTasks.watch._watchingProgress[ - this.roomID - ] = this.watchedSeconds - } - /** Cookie LIVE_BUVID */ private buvid?: string = useBiliStore().cookies!.LIVE_BUVID @@ -160,9 +148,13 @@ class RoomHeart { `BAPI.liveTrace.E(${this.id}, ${this.device}, ${this.ruid}) 失败`, response.message, ) + this.logger.error( + `直播间 ${this.roomID} 的 E 心跳失败,无法继续执行观看直播任务,跳过该房间`, + ) } } catch (error) { this.logger.error(`BAPI.liveTrace.E(${this.id}, ${this.device}, ${this.ruid}) 出错`, error) + this.logger.error(`直播间 ${this.roomID} 的 E 心跳失败,无法继续执行观看直播任务,跳过该房间`) } } @@ -204,7 +196,10 @@ class RoomHeart { ) if (response.code === 0) { this.seq += 1 - this.updateProgress() + this.watchedSeconds += this.heartBeatInterval + this.logger.log( + `直播间 ${this.roomID} 的第 ${this.seq - 1} 次 X 心跳成功,已观看 ${this.watchedSeconds} 秒`, + ) if (this.watchedSeconds >= this.targetSeconds) { // 达到目标观看时间,结束 return @@ -217,12 +212,18 @@ class RoomHeart { `BAPI.liveTrace.X(${s}, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 失败`, response.message, ) + this.logger.error( + `直播间 ${this.roomID} 的 X 心跳失败,无法继续执行观看直播任务,跳过该房间(目前已观看 ${this.watchedSeconds} 秒)`, + ) } } catch (error) { this.logger.error( `BAPI.liveTrace.X(s, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 出错`, error, ) + this.logger.error( + `直播间 ${this.roomID} 的 X 心跳失败,无法继续执行观看直播任务,跳过该房间(目前已观看 ${this.watchedSeconds} 秒)`, + ) } } @@ -356,13 +357,6 @@ class WatchTask extends MedalModule { } this.status = 'running' - - if (!isTimestampToday(this.config._lastWatchTime, 0, 0)) { - // 如果上次观看(不是完成任务)的时间戳不在今天,将今天已观看的秒数置为0 - this.config._watchingProgress = {} - } - - this.config._lastWatchTime = tsm() const fansMedals = this.getMedals() if (fansMedals.length > 0) { @@ -421,10 +415,10 @@ class WatchTask extends MedalModule { const target = this.config.useTargetRounds ? Math.min(parsed.limit, this.config.targetRounds) : parsed.limit - // 目标秒数 = 目标轮次 × minutes × 60s - const targetSeconds = target * minutes * 60 + // 剩余观看秒数 = (目标轮次 - 已完成轮次) × 分钟数 × 60s + const remainingSeconds = (target - parsed.current) * minutes * 60 - if ((this.config._watchingProgress[roomid] ?? 0) >= targetSeconds) { + if (remainingSeconds <= 0) { // 今日观看时间已达到目标值,跳过 continue } @@ -434,7 +428,7 @@ class WatchTask extends MedalModule { if (area_id > 0 && parent_area_id > 0) { // area_id 和 parent_area_id 都大于 0,说明直播间设置了分区,心跳有效 this.logger.log( - `粉丝勋章【${medal_name}】 开始直播间 ${roomid}(主播【${nick_name}】,UID:${uid})的观看直播任务,目标时长 ${targetSeconds / 60} 分钟)`, + `粉丝勋章【${medal_name}】 开始直播间 ${roomid}(主播【${nick_name}】,UID:${uid})的观看直播任务,目标时长 ${remainingSeconds / 60} 分钟`, ) const hasWatchingProgress = await new RoomHeart( @@ -442,8 +436,7 @@ class WatchTask extends MedalModule { area_id, parent_area_id, uid, - this.config._watchingProgress[roomid] ?? 0, - targetSeconds, + remainingSeconds, ).start() if (hasWatchingProgress) { diff --git a/src/stores/useModuleStore.ts b/src/stores/useModuleStore.ts index 02b3f95b..b768c8c0 100644 --- a/src/stores/useModuleStore.ts +++ b/src/stores/useModuleStore.ts @@ -126,8 +126,6 @@ export const useModuleStore = defineStore('module', () => { watch: () => { moduleStatus.value.DailyTasks.LiveTasks.medalTasks.watch = '' moduleConfig.value.DailyTasks.LiveTasks.medalTasks.watch._lastCompleteTime = 0 - moduleConfig.value.DailyTasks.LiveTasks.medalTasks.watch._lastWatchTime = 0 - moduleConfig.value.DailyTasks.LiveTasks.medalTasks.watch._watchingProgress = {} rerunModule('Default_FansMedals', true) rerunModule('DailyTask_LiveTask_WatchTask') diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index 394ed405..3a6ff8df 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -54,8 +54,6 @@ interface ModuleConfig { targetRounds: number isWhiteList: boolean roomidList: number[] - _watchingProgress: Record - _lastWatchTime: number _lastCompleteTime: number } } From 4831676aa96957c79158f00664d689912c6d3620 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:19:30 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=90=E7=82=B9=E4=BA=AE=E7=86=84=E7=81=AD?= =?UTF-8?q?=E5=8B=8B=E7=AB=A0=E3=80=91=E3=80=81=E3=80=90=E7=82=B9=E8=B5=9E?= =?UTF-8?q?=E3=80=91=E5=92=8C=E3=80=90=E5=8F=91=E5=BC=B9=E5=B9=95=E3=80=91?= =?UTF-8?q?=E4=BC=9A=E5=9C=A8=E6=89=A7=E8=A1=8C=E6=AF=8F=E4=B8=AA=E7=B2=89?= =?UTF-8?q?=E4=B8=9D=E5=8B=8B=E7=AB=A0=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=89=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + .../liveTasks/medalTasks/MedalModule.ts | 307 ++++++++++++------ .../liveTasks/medalTasks/danmuTask.ts | 60 ++-- .../liveTasks/medalTasks/lightTask.ts | 100 +++++- .../liveTasks/medalTasks/likeTask.ts | 58 ++-- .../liveTasks/medalTasks/types.d.ts | 57 +++- 6 files changed, 432 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 757b95e5..1afa7821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 新增 + +- 直播任务【点亮熄灭勋章】、【点赞】和【发弹幕】会在执行每个粉丝勋章的任务前校验直播状态 + ### 修复 - 修复【观看直播】任务心跳发送时机计算错误的bug diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index 22f36918..d8597875 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -2,11 +2,15 @@ import BaseModule from '@/modules/BaseModule' import { useBiliStore, useModuleStore } from '@/stores' import { watch } from 'vue' import type { + LiveStatusSnapshot, + LiveStatusSnapshotSource, MedalTaskSharedConfig, + PreExecuteVerdict, RequestQueueKey, SharedMedalFilters, + TaskExecutionResult, TaskJumpType, - WaitProbeResult, + WaitRoundResult, WaitStrategy, } from './types' import { arrayToMap, sleep } from '@/library/utils' @@ -100,6 +104,21 @@ class MedalModule extends BaseModule { } /** 粉丝勋章数据在多久内视为新鲜(毫秒) */ private static readonly RECENT_FETCH_THRESHOLD = 90_000 + /** + * 直播状态样本在多久内视为新鲜、可直接复用(毫秒) + * + * 与 `RECENT_FETCH_THRESHOLD` 职责分离: + * - `RECENT_FETCH_THRESHOLD` 控制等待轮询是否复用粉丝勋章列表缓存 + * - 本阈值控制「执行前校验」是否需要补一次单房间实时探测 + */ + private static readonly LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD = 30_000 + /** + * 直播状态样本缓存(key:直播间号) + * + * 为 static,使 like / danmu / light 共享同一份样本——三者关心的都是同一个 `living_status`, + * 共享能减少不必要的重复探测。 + */ + private static readonly liveStatusSnapshots = new Map() /** 进行中的全量刷新粉丝勋章 Promise */ private static ongoingFullRefreshPromise: Promise | null = null @@ -286,80 +305,121 @@ class MedalModule extends BaseModule { } /** - * 从粉丝勋章 Map 构建探测结果 + * 用最新的粉丝勋章列表状态播种/更新直播状态样本 * - * @param roomids 待探测的直播间ID列表 - * @param targetPredicate 直播状态判断函数 - * @param medalMap 粉丝勋章 Map - * @param probedLiveStatusMap 探测到的直播状态 Map - * - 为 undefined 时:使用 medal.room_info.living_status - * - 存在键 roomid 但值为 null 时:表示本轮实时探测失败,继续等待 + * 仅当列表数据比现有样本更新时才覆盖,避免把更新鲜的单房间探测结果倒退 */ - private buildResultFromMedalMap( - roomids: number[], - targetPredicate: (liveStatus: number) => boolean, - medalMap: Map, - probedLiveStatusMap?: Map, - ): WaitProbeResult { - const readyMedals: LiveData.FansMedalPanel.List[] = [] - const pendingRoomids: number[] = [] + protected syncSnapshotsFromMedals(): void { + const biliStore = useBiliStore() + const observedAt = biliStore.fansMedalsMeta.lastFetchFinishedAt + if (observedAt === undefined) return + + for (const medal of biliStore.filteredFansMedals) { + const roomid = medal.room_info.room_id + const existing = MedalModule.liveStatusSnapshots.get(roomid) + if (!existing || existing.observedAt < observedAt) { + MedalModule.liveStatusSnapshots.set(roomid, { + liveStatus: medal.room_info.living_status, + observedAt, + source: 'fans-medals', + }) + } + } + } - for (const roomid of roomids) { - const medal = medalMap.get(roomid) + /** + * 判断直播状态样本是否仍足够新鲜、可直接复用 + */ + private isSnapshotFresh(snapshot: LiveStatusSnapshot): boolean { + return tsm() - snapshot.observedAt < MedalModule.LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD + } - if (!medal) { - this.logger.warn(`直播间 ${roomid} 未在粉丝勋章列表中找到,停止等待该房间`) - continue - } - if (!this.SHARED_MEDAL_FILTERS.isLighted(medal)) { - this.logger.log( - `粉丝勋章【${medal.medal.medal_name}】已熄灭,停止等待该房间(${roomid})`, - medal, - ) - continue - } + /** + * 记录一条直播状态样本 + */ + private recordLiveStatusSnapshot( + roomid: number, + liveStatus: number, + source: LiveStatusSnapshotSource, + observedAt: number = tsm(), + ): void { + MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt, source }) + } - // 是否有探测到的直播状态 Map - const hasProbedStatus = probedLiveStatusMap !== undefined - // 直播状态:有直播状态 Map 就从中取,否则使用粉丝勋章 Map 中的值 - const liveStatus = hasProbedStatus - ? probedLiveStatusMap.get(roomid) - : medal.room_info.living_status + /** + * 解析直播间当前的直播状态(执行前校验使用) + * + * - 样本仍新鲜:直接复用样本 + * - 样本过期或不存在:补一次单房间实时探测,并更新样本 + * + * @returns 直播状态;单房间探测失败时返回 null + */ + protected async resolveLiveStatus(roomid: number): Promise { + const snapshot = MedalModule.liveStatusSnapshots.get(roomid) + if (snapshot && this.isSnapshotFresh(snapshot)) { + return snapshot.liveStatus + } - if (hasProbedStatus && _.isNil(liveStatus)) { - this.logger.warn( - `粉丝勋章【${medal.medal.medal_name}】对应的直播间 ${roomid} 本轮实时探测失败,继续等待下次检查`, - medal, - ) - pendingRoomids.push(roomid) - } else if (!_.isNil(liveStatus) && targetPredicate(liveStatus)) { - readyMedals.push(medal) - } else { - pendingRoomids.push(roomid) - } + const liveStatus = await MedalModule.enqueueRoomStatusProbe(() => + this.fetchRoomLiveStatus(roomid), + ) + if (liveStatus !== null) { + this.recordLiveStatusSnapshot(roomid, liveStatus, 'single-probe') + } + return liveStatus + } + + /** + * 执行前直播状态校验 + * + * 在房间第一个依赖直播状态的副作用请求(如 likeReport / sendMsg)发出前调用, + * 以「每房间一次」的粒度确认当前状态仍满足任务要求。 + * + * @param roomid 直播间号 + * @param targetPredicate 目标状态判断函数 + * @param canRequeue 校验未通过时是否回到等待队列(对应 waitUntilLiving / waitUntilNotLiving) + * @returns 校验结论 + */ + protected async preExecuteVerify( + roomid: number, + targetPredicate: (liveStatus: number) => boolean, + canRequeue: boolean, + ): Promise { + const liveStatus = await this.resolveLiveStatus(roomid) + + // 探测失败:不按旧样本执行 + if (liveStatus === null) { + return canRequeue ? 'requeue' : 'skip' } - return { - readyMedals, - pendingRoomids, + if (targetPredicate(liveStatus)) { + return 'pass' } + return canRequeue ? 'requeue' : 'skip' } /** - * 对等待中的直播间做一轮探测 + * 对等待中的直播间做一轮「探测一个,命中即执行一个」的轮询 * - * @param roomids 待探测的直播间ID列表 - * @param targetPredicate 直播状态判断函数,返回"是否符合目标状态" + * 三种状态来源策略: + * - 列表数据新鲜:直接用样本/列表状态筛选,命中即执行 + * - 列表数据不新鲜且待探测数较多:全量刷新一次后筛选 + * - 列表数据不新鲜且待探测数较少:逐房间单独探测 * - * @returns 探测结果,包含符合目标状态的粉丝勋章列表和待处理的直播间ID列表 + * 无论哪种策略,命中目标状态后都立即执行该房间(runOne),不再等整轮探测结束。 + * + * @param roomids 待探测的直播间ID列表 + * @param targetPredicate 目标状态判断函数 + * @param runOne 执行单个直播间任务的回调(其内部应自行做执行前校验) */ - protected async probeWaitingMedals( + protected async runWaitingRound( roomids: number[], targetPredicate: (liveStatus: number) => boolean, - ): Promise { + runOne: (medal: LiveData.FansMedalPanel.List) => Promise, + ): Promise { const biliStore = useBiliStore() - this.logger.log(`开始直播状态探测,待探测直播间数量:${roomids.length}`, { roomids }) + this.logger.log(`开始等待轮询,待探测直播间数量:${roomids.length}`, { roomids }) // 如果有进行中的全量刷新,等待其完成 if (MedalModule.ongoingFullRefreshPromise) { @@ -367,62 +427,117 @@ class MedalModule extends BaseModule { await MedalModule.ongoingFullRefreshPromise } - // 检查粉丝勋章数据是否新鲜 + // 决定本轮筛选时的状态来源:是否对每个房间单独探测 + let useProbe = false + if (this.isMedalDataFresh()) { - this.logger.log('粉丝勋章数据较新,直接使用缓存数据') - return this.buildResultFromMedalMap(roomids, targetPredicate, biliStore.filteredFansMedalsMap) + this.logger.log('粉丝勋章数据较新,直接使用缓存数据筛选') + this.syncSnapshotsFromMedals() + } else { + const strategy = this.decideWaitStrategy(roomids.length) + this.logger.log( + `粉丝勋章数据不新鲜,使用${strategy === 'single-probe' ? '单房间探测' : '刷新粉丝勋章'}策略`, + ) + + if (strategy === 'refresh-fans-medals') { + const promise = this.refreshFansMedals() + MedalModule.ongoingFullRefreshPromise = promise + try { + await promise + } finally { + if (MedalModule.ongoingFullRefreshPromise === promise) { + MedalModule.ongoingFullRefreshPromise = null + } + } + + if (biliStore.fansMedalsMeta.status === 'loaded') { + this.syncSnapshotsFromMedals() + } else { + // 刷新失败,本轮全部继续等待 + return { interrupted: false, verifiedCompleted: true, pendingRoomids: roomids } + } + } else { + useProbe = true + } } - // 数据不新鲜,决定探测策略 - const strategy = this.decideWaitStrategy(roomids.length) - this.logger.log( - `粉丝勋章数据不新鲜,使用${strategy === 'single-probe' ? '单房间探测' : '刷新粉丝勋章'}策略`, - ) + const medalMap = biliStore.filteredFansMedalsMap + const nextPending: number[] = [] + let verifiedCompleted = true - if (strategy === 'refresh-fans-medals') { - const promise = this.refreshFansMedals() - MedalModule.ongoingFullRefreshPromise = promise - try { - await promise - } finally { - if (MedalModule.ongoingFullRefreshPromise === promise) { - MedalModule.ongoingFullRefreshPromise = null + for (let i = 0; i < roomids.length; i++) { + if (this.shouldStopForCrossDay()) { + this.logger.log('即将或刚刚发生跨天,提早结束本轮等待轮询') + return { + interrupted: true, + verifiedCompleted, + pendingRoomids: [...nextPending, ...roomids.slice(i)], } } - if (biliStore.fansMedalsMeta.status === 'loaded') { - return this.buildResultFromMedalMap( - roomids, - targetPredicate, - biliStore.filteredFansMedalsMap, + const roomid = roomids[i] + const medal = medalMap.get(roomid) + + if (!medal) { + this.logger.warn(`直播间 ${roomid} 未在粉丝勋章列表中找到,停止等待该房间`) + continue + } + if (!this.SHARED_MEDAL_FILTERS.isLighted(medal)) { + this.logger.log( + `粉丝勋章【${medal.medal.medal_name}】已熄灭,停止等待该房间(${roomid})`, + medal, ) + continue } - return { readyMedals: [], pendingRoomids: roomids } - } + // 获取本轮筛选用的直播状态 + let liveStatus: number | null + if (useProbe) { + liveStatus = await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid)) + if (liveStatus !== null) { + this.recordLiveStatusSnapshot(roomid, liveStatus, 'single-probe') + } + } else { + const snapshot = MedalModule.liveStatusSnapshots.get(roomid) + liveStatus = snapshot ? snapshot.liveStatus : medal.room_info.living_status + } + + if (liveStatus === null) { + this.logger.warn( + `粉丝勋章【${medal.medal.medal_name}】对应的直播间 ${roomid} 本轮实时探测失败,继续等待下次检查`, + medal, + ) + nextPending.push(roomid) + continue + } - // 单房间探测策略 - const currentMedalMap = biliStore.filteredFansMedalsMap - const probedLiveStatusMap = new Map() + if (!targetPredicate(liveStatus)) { + nextPending.push(roomid) + continue + } - for (let i = 0; i < roomids.length; i++) { - const roomid = roomids[i] - const medal = currentMedalMap.get(roomid) + // 命中目标状态,立即执行该房间 + this.logger.log(`直播间 ${roomid}(${medal.anchor_info.nick_name})已达到目标状态,立即执行`) + const result = await runOne(medal) - if (medal) { - probedLiveStatusMap.set( - roomid, - await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid)), - ) + if (result.interrupted) { + return { + interrupted: true, + verifiedCompleted: false, + pendingRoomids: [...nextPending, ...roomids.slice(i)], + } + } + if (result.requeue) { + // 执行前校验未通过且可等待,回到等待队列 + nextPending.push(roomid) + continue + } + if (!result.verifiedCompleted) { + verifiedCompleted = false } } - return this.buildResultFromMedalMap( - roomids, - targetPredicate, - currentMedalMap, - probedLiveStatusMap, - ) + return { interrupted: false, verifiedCompleted, pendingRoomids: nextPending } } /** diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 5fc6cc75..de7cc5ad 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -5,7 +5,7 @@ import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' import { sleep } from '@/library/utils' -import type { GroupedMedals, TaskExecutionResult } from './types' +import type { BatchExecutionResult, GroupedMedals, TaskExecutionResult } from './types' class DanmuTask extends MedalModule { config = this.medalTasksConfig.danmu @@ -142,6 +142,24 @@ class DanmuTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } + // 执行前校验:仅当开启「仅在未开播的直播间发弹幕」时,发弹幕才依赖直播状态 + // 在发出第一条 sendMsg 前确认直播间当前确实未开播,避免按过期状态发弹幕 + if (this.config.onlyWhenNotLiving) { + const verdict = await this.preExecuteVerify( + room_id, + (liveStatus) => liveStatus !== 1, + this.config.waitUntilNotLiving, + ) + if (verdict !== 'pass') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${verdict === 'requeue' ? '回到等待队列' : '本轮跳过'}`, + ) + return verdict === 'requeue' + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } + } + const target = this.config.useTargetRounds ? Math.min(parsed.limit, this.config.targetRounds) : parsed.limit @@ -189,18 +207,23 @@ class DanmuTask extends MedalModule { /** * 顺序执行多个直播间的发弹幕任务 * - * @returns 执行结果,包含是否中断以及是否都确认完成 + * @returns 执行结果,包含是否中断、是否都确认完成、以及执行前校验未通过需回到等待队列的直播间 */ private async executeDanmuTasks( medals: LiveData.FansMedalPanel.List[], danmuIndexRef: { value: number }, - ): Promise { + ): Promise { let verifiedCompleted = true + const requeueRoomids: number[] = [] for (let i = 0; i < medals.length; i++) { const result = await this.executeDanmuTask(medals[i], danmuIndexRef) - if (result.interrupted) return result - if (!result.verifiedCompleted) { + if (result.interrupted) { + return { interrupted: true, verifiedCompleted: false, requeueRoomids } + } + if (result.requeue) { + requeueRoomids.push(medals[i].room_info.room_id) + } else if (!result.verifiedCompleted) { verifiedCompleted = false } @@ -209,7 +232,7 @@ class DanmuTask extends MedalModule { } } - return { interrupted: false, verifiedCompleted } + return { interrupted: false, verifiedCompleted, requeueRoomids } } public async run(): Promise { @@ -225,15 +248,17 @@ class DanmuTask extends MedalModule { } this.status = 'running' + // 用最新列表状态播种直播状态样本,使紧接着执行的 readyMedals 尽量复用、少探测 + this.syncSnapshotsFromMedals() const { readyMedals, waitingMedals } = this.getMedals() let allCompleted = true const danmuIndexRef = { value: 0 } let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) const initialResult = await this.executeDanmuTasks(readyMedals, danmuIndexRef) - if (initialResult.interrupted) { - allCompleted = false - } else if (!initialResult.verifiedCompleted) { + // 初始 readyMedals 中执行前校验发现仍在直播、且开启了等待下播的房间,并入等待队列 + pendingRoomids.push(...initialResult.requeueRoomids) + if (initialResult.interrupted || !initialResult.verifiedCompleted) { allCompleted = false } @@ -244,17 +269,14 @@ class DanmuTask extends MedalModule { break } - const { readyMedals: newlyReadyMedals, pendingRoomids: nextPendingRoomids } = - await this.probeWaitingMedals(pendingRoomids, (liveStatus) => liveStatus !== 1) - - pendingRoomids = nextPendingRoomids + const roundResult = await this.runWaitingRound( + pendingRoomids, + (liveStatus) => liveStatus !== 1, + (medal) => this.executeDanmuTask(medal, danmuIndexRef), + ) + pendingRoomids = roundResult.pendingRoomids - const waitResult = await this.executeDanmuTasks(newlyReadyMedals, danmuIndexRef) - if (waitResult.interrupted) { - allCompleted = false - break - } - if (!waitResult.verifiedCompleted) { + if (roundResult.interrupted || !roundResult.verifiedCompleted) { allCompleted = false break } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 799b7118..58cdb540 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -5,7 +5,7 @@ import { sleep } from '@/library/utils' import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' -import type { GroupedMedals } from './types' +import type { GroupedMedals, LightPathExecutionResult } from './types' class LightTask extends MedalModule { config = this.medalTasksConfig.light @@ -155,8 +155,10 @@ class LightTask extends MedalModule { */ private async likeTask( medals: LiveData.FansMedalPanel.List[], - ): Promise { + ): Promise { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] + const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] + let probeFailed = false for (let i = 0; i < medals.length; i++) { const medal = medals[i] @@ -180,6 +182,25 @@ class LightTask extends MedalModule { ) continue } + + // 执行前校验:在发出 likeReport 前确认直播间仍在直播 + // 点赞点亮依赖「仍在开播」;若已下播则转交给下一轮发弹幕点亮路径重试 + const liveStatus = await this.resolveLiveStatus(room_id) + if (liveStatus === null) { + probeFailed = true + this.logger.warn( + `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,本轮跳过点赞点亮`, + ) + continue + } + if (liveStatus !== 1) { + skippedByStatusMedals.push(medal) + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,转交下一轮发弹幕点亮`, + ) + continue + } + const times = MedalModule.parseTitleCount(likeItem.title) ?? 30 await this.like(medal, times) attemptedMedals.push(medal) @@ -189,7 +210,7 @@ class LightTask extends MedalModule { } } - return attemptedMedals + return { attemptedMedals, skippedByStatusMedals, probeFailed } } /** @@ -198,9 +219,11 @@ class LightTask extends MedalModule { */ private async sendDanmuTask( medals: LiveData.FansMedalPanel.List[], - ): Promise { + ): Promise { let danmuIndex = 0 const attemptedMedals: LiveData.FansMedalPanel.List[] = [] + const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] + let probeFailed = false for (let i = 0; i < medals.length; i++) { const medal = medals[i] @@ -225,6 +248,24 @@ class LightTask extends MedalModule { continue } + // 执行前校验:在发出 sendMsg 前确认直播间当前未在直播 + // 发弹幕点亮依赖「当前未开播」;若已开播则转交给下一轮点赞点亮路径重试 + const liveStatus = await this.resolveLiveStatus(room_id) + if (liveStatus === null) { + probeFailed = true + this.logger.warn( + `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,本轮跳过发弹幕点亮`, + ) + continue + } + if (liveStatus === 1) { + skippedByStatusMedals.push(medal) + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,转交下一轮点赞点亮`, + ) + continue + } + let remaining = MedalModule.parseTitleCount(danmuItem.title) ?? 10 let failedCount = 0 @@ -248,7 +289,7 @@ class LightTask extends MedalModule { attemptedMedals.push(medal) } - return attemptedMedals + return { attemptedMedals, skippedByStatusMedals, probeFailed } } public async run(): Promise { @@ -262,21 +303,48 @@ class LightTask extends MedalModule { } this.status = 'running' - const { notLivingMedals, livingMedals } = this.getMedals() + // 用最新列表状态播种直播状态样本,使 likeTask 执行前校验尽量复用、少探测 + this.syncSnapshotsFromMedals() + let { notLivingMedals, livingMedals } = this.getMedals() // 是否有需要点亮的粉丝勋章(是不是一次有效运行?) const isEffectiveRun = notLivingMedals.length > 0 || livingMedals.length > 0 let allCompleted = true if (isEffectiveRun) { - const [danmuResult, likeResult] = await Promise.allSettled([ - this.sendDanmuTask(notLivingMedals), - this.likeTask(livingMedals), - ]) - // 尝试点亮过的粉丝勋章 - const attemptedMedals = [ - ...(danmuResult.status === 'fulfilled' ? danmuResult.value : []), - ...(likeResult.status === 'fulfilled' ? likeResult.value : []), - ] + const attemptedMedals: LiveData.FansMedalPanel.List[] = [] + + while (notLivingMedals.length > 0 || livingMedals.length > 0) { + const [danmuResult, likeResult] = await Promise.allSettled([ + this.sendDanmuTask(notLivingMedals), + this.likeTask(livingMedals), + ]) + if (danmuResult.status === 'rejected' || likeResult.status === 'rejected') { + allCompleted = false + break + } + + attemptedMedals.push( + ...danmuResult.value.attemptedMedals, + ...likeResult.value.attemptedMedals, + ) + if (danmuResult.value.probeFailed || likeResult.value.probeFailed) { + // 探测失败,为了防止风控、API变更等原因导致的无限循环,停止点亮 + allCompleted = false + break + } + + livingMedals = danmuResult.value.skippedByStatusMedals + notLivingMedals = likeResult.value.skippedByStatusMedals + if (notLivingMedals.length > 0 || livingMedals.length > 0) { + this.logger.log( + `有 ${notLivingMedals.length + livingMedals.length} 个粉丝勋章因执行前校验发现直播状态不符被跳过,立即按最新状态重试`, + { + retryDanmuMedals: notLivingMedals, + retryLikeMedals: livingMedals, + }, + ) + } + } if (attemptedMedals.length > 0 || this.shouldRefreshFansMedals()) { // 刷新粉丝勋章,确保 点亮任务自身以及 点赞/发弹幕/观看直播 任务都能拿到最新勋章状态 @@ -285,7 +353,7 @@ class LightTask extends MedalModule { if (attemptedMedals.length > 0) { if (refreshed) { - allCompleted = this.verifyLightedMedals(attemptedMedals) + allCompleted = this.verifyLightedMedals(attemptedMedals) && allCompleted } else { this.logger.warn('无法确认点亮任务是否完成,本轮按未完成处理') allCompleted = false diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index 1c015ad3..e59d3d31 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -5,7 +5,7 @@ import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' import { sleep } from '@/library/utils' -import type { GroupedMedals, TaskExecutionResult } from './types' +import type { BatchExecutionResult, GroupedMedals, TaskExecutionResult } from './types' class LikeTask extends MedalModule { config = this.medalTasksConfig.like @@ -127,6 +127,22 @@ class LikeTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } + // 执行前校验:在发出第一个 likeReport 前确认直播间仍在直播 + // 以「每房间一次」的粒度,避免按过期状态点赞 + const verdict = await this.preExecuteVerify( + room_id, + (liveStatus) => liveStatus === 1, + this.config.waitUntilLiving, + ) + if (verdict !== 'pass') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${verdict === 'requeue' ? '回到等待队列' : '本轮跳过'}`, + ) + return verdict === 'requeue' + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } + const times = MedalModule.parseTitleCount(item.title) ?? 30 const target = this.config.useTargetRounds ? Math.min(parsed.limit, this.config.targetRounds) @@ -167,17 +183,22 @@ class LikeTask extends MedalModule { /** * 顺序执行多个直播间的点赞任务 * - * @returns 执行结果,包含是否中断以及是否都确认完成 + * @returns 执行结果,包含是否中断、是否都确认完成、以及执行前校验未通过需回到等待队列的直播间 */ private async executeLikeTasks( medals: LiveData.FansMedalPanel.List[], - ): Promise { + ): Promise { let verifiedCompleted = true + const requeueRoomids: number[] = [] for (let i = 0; i < medals.length; i++) { const result = await this.executeLikeTask(medals[i]) - if (result.interrupted) return result - if (!result.verifiedCompleted) { + if (result.interrupted) { + return { interrupted: true, verifiedCompleted: false, requeueRoomids } + } + if (result.requeue) { + requeueRoomids.push(medals[i].room_info.room_id) + } else if (!result.verifiedCompleted) { verifiedCompleted = false } @@ -186,7 +207,7 @@ class LikeTask extends MedalModule { } } - return { interrupted: false, verifiedCompleted } + return { interrupted: false, verifiedCompleted, requeueRoomids } } public async run(): Promise { @@ -202,14 +223,16 @@ class LikeTask extends MedalModule { } this.status = 'running' + // 用最新列表状态播种直播状态样本,使紧接着执行的 readyMedals 尽量复用、少探测 + this.syncSnapshotsFromMedals() const { readyMedals, waitingMedals } = this.getMedals() let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) let allCompleted = true const initialResult = await this.executeLikeTasks(readyMedals) - if (initialResult.interrupted) { - allCompleted = false - } else if (!initialResult.verifiedCompleted) { + // 初始 readyMedals 中执行前校验发现已下播、且开启了等待开播的房间,并入等待队列 + pendingRoomids.push(...initialResult.requeueRoomids) + if (initialResult.interrupted || !initialResult.verifiedCompleted) { allCompleted = false } @@ -220,17 +243,14 @@ class LikeTask extends MedalModule { break } - const { readyMedals: newlyReadyMedals, pendingRoomids: nextPendingRoomids } = - await this.probeWaitingMedals(pendingRoomids, (liveStatus) => liveStatus === 1) + const roundResult = await this.runWaitingRound( + pendingRoomids, + (liveStatus) => liveStatus === 1, + (medal) => this.executeLikeTask(medal), + ) + pendingRoomids = roundResult.pendingRoomids - pendingRoomids = nextPendingRoomids - - const waitResult = await this.executeLikeTasks(newlyReadyMedals) - if (waitResult.interrupted) { - allCompleted = false - break - } - if (!waitResult.verifiedCompleted) { + if (roundResult.interrupted || !roundResult.verifiedCompleted) { allCompleted = false break } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts index 1d68e08a..0e5b5c78 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts @@ -22,14 +22,58 @@ type RequestQueueKey = 'taskInfo' | 'roomStatus' type TaskJumpType = 'like' | 'sendDanmu' | 'watchLive' | 'feedLight' | 'sendGift' type WaitStrategy = 'single-probe' | 'refresh-fans-medals' -interface WaitProbeResult { - readyMedals: LiveData.FansMedalPanel.List[] - pendingRoomids: number[] +/** 直播状态样本的来源 */ +type LiveStatusSnapshotSource = 'fans-medals' | 'single-probe' + +/** 最近一次观测到的直播间直播状态样本 */ +interface LiveStatusSnapshot { + /** 直播状态:1 直播中,0/2 未开播/轮播 */ + liveStatus: number + /** 观测到该状态的时刻(毫秒时间戳) */ + observedAt: number + /** 样本来源 */ + source: LiveStatusSnapshotSource } +/** + * 执行前直播状态校验结论 + * + * - `pass`:符合目标状态,放行执行 + * - `requeue`:不符或探测失败,且开启等待,回到等待队列 + * - `skip`:不符或探测失败,且未开启等待,本轮跳过 + */ +type PreExecuteVerdict = 'pass' | 'requeue' | 'skip' + +/** 单个直播间任务的执行结果 */ interface TaskExecutionResult { + /** 是否因跨天等原因中断 */ + interrupted: boolean + /** 是否确认任务已完成 */ + verifiedCompleted: boolean + /** 执行前直播状态校验未通过,需要重新回到等待队列(仅 waitUntil* 开启时) */ + requeue?: boolean +} + +/** 顺序执行多个直播间任务的结果 */ +interface BatchExecutionResult { interrupted: boolean verifiedCompleted: boolean + /** 执行前校验未通过、需要回到等待队列的直播间ID */ + requeueRoomids: number[] +} + +/** 一轮等待轮询的执行结果 */ +interface WaitRoundResult { + interrupted: boolean + verifiedCompleted: boolean + /** 本轮结束后仍需继续等待的直播间ID */ + pendingRoomids: number[] +} + +interface LightPathExecutionResult { + attemptedMedals: LiveData.FansMedalPanel.List[] + skippedByStatusMedals: LiveData.FansMedalPanel.List[] + probeFailed: boolean } export { @@ -39,6 +83,11 @@ export { RequestQueueKey, TaskJumpType, WaitStrategy, - WaitProbeResult, + LiveStatusSnapshot, + LiveStatusSnapshotSource, + PreExecuteVerdict, TaskExecutionResult, + BatchExecutionResult, + WaitRoundResult, + LightPathExecutionResult, } From 9c57e9e736a40953dee302eda3f19b387a8a7e23 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:36:27 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E7=B2=89=E4=B8=9D=E5=8B=8B=E7=AB=A0=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 总是使用单房间探测 --- .../liveTasks/medalTasks/MedalModule.ts | 120 ++---------------- .../liveTasks/medalTasks/danmuTask.ts | 3 +- .../liveTasks/medalTasks/lightTask.ts | 3 +- .../liveTasks/medalTasks/likeTask.ts | 3 +- .../liveTasks/medalTasks/types.d.ts | 8 -- 5 files changed, 11 insertions(+), 126 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index d8597875..031282a6 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -3,7 +3,6 @@ import { useBiliStore, useModuleStore } from '@/stores' import { watch } from 'vue' import type { LiveStatusSnapshot, - LiveStatusSnapshotSource, MedalTaskSharedConfig, PreExecuteVerdict, RequestQueueKey, @@ -11,7 +10,6 @@ import type { TaskExecutionResult, TaskJumpType, WaitRoundResult, - WaitStrategy, } from './types' import { arrayToMap, sleep } from '@/library/utils' import type { LiveData } from '@/library/bili-api/data' @@ -102,13 +100,9 @@ class MedalModule extends BaseModule { taskInfo: Promise.resolve(), roomStatus: Promise.resolve(), } - /** 粉丝勋章数据在多久内视为新鲜(毫秒) */ - private static readonly RECENT_FETCH_THRESHOLD = 90_000 /** * 直播状态样本在多久内视为新鲜、可直接复用(毫秒) * - * 与 `RECENT_FETCH_THRESHOLD` 职责分离: - * - `RECENT_FETCH_THRESHOLD` 控制等待轮询是否复用粉丝勋章列表缓存 * - 本阈值控制「执行前校验」是否需要补一次单房间实时探测 */ private static readonly LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD = 30_000 @@ -119,8 +113,6 @@ class MedalModule extends BaseModule { * 共享能减少不必要的重复探测。 */ private static readonly liveStatusSnapshots = new Map() - /** 进行中的全量刷新粉丝勋章 Promise */ - private static ongoingFullRefreshPromise: Promise | null = null /** * 将异步请求加入串行队列,相邻请求之间加入随机延迟 @@ -282,51 +274,6 @@ class MedalModule extends BaseModule { return null } - /** - * 计算等待探测策略 - * - * @param pendingCount 当前待探测的直播间数量 - */ - protected decideWaitStrategy(pendingCount: number): WaitStrategy { - const totalMedalCount = useBiliStore().filteredFansMedals.length - const fullRefreshCost = Math.ceil(totalMedalCount / 10) // 每次请求能获取 10 个粉丝勋章 - return pendingCount <= fullRefreshCost ? 'single-probe' : 'refresh-fans-medals' - } - - /** - * 判断粉丝勋章数据是否足够新鲜 - */ - private isMedalDataFresh(): boolean { - const meta = useBiliStore().fansMedalsMeta - return ( - meta.status === 'loaded' && - tsm() - meta.lastFetchFinishedAt! < MedalModule.RECENT_FETCH_THRESHOLD - ) - } - - /** - * 用最新的粉丝勋章列表状态播种/更新直播状态样本 - * - * 仅当列表数据比现有样本更新时才覆盖,避免把更新鲜的单房间探测结果倒退 - */ - protected syncSnapshotsFromMedals(): void { - const biliStore = useBiliStore() - const observedAt = biliStore.fansMedalsMeta.lastFetchFinishedAt - if (observedAt === undefined) return - - for (const medal of biliStore.filteredFansMedals) { - const roomid = medal.room_info.room_id - const existing = MedalModule.liveStatusSnapshots.get(roomid) - if (!existing || existing.observedAt < observedAt) { - MedalModule.liveStatusSnapshots.set(roomid, { - liveStatus: medal.room_info.living_status, - observedAt, - source: 'fans-medals', - }) - } - } - } - /** * 判断直播状态样本是否仍足够新鲜、可直接复用 */ @@ -340,10 +287,9 @@ class MedalModule extends BaseModule { private recordLiveStatusSnapshot( roomid: number, liveStatus: number, - source: LiveStatusSnapshotSource, observedAt: number = tsm(), ): void { - MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt, source }) + MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt }) } /** @@ -364,7 +310,7 @@ class MedalModule extends BaseModule { this.fetchRoomLiveStatus(roomid), ) if (liveStatus !== null) { - this.recordLiveStatusSnapshot(roomid, liveStatus, 'single-probe') + this.recordLiveStatusSnapshot(roomid, liveStatus) } return liveStatus } @@ -401,12 +347,7 @@ class MedalModule extends BaseModule { /** * 对等待中的直播间做一轮「探测一个,命中即执行一个」的轮询 * - * 三种状态来源策略: - * - 列表数据新鲜:直接用样本/列表状态筛选,命中即执行 - * - 列表数据不新鲜且待探测数较多:全量刷新一次后筛选 - * - 列表数据不新鲜且待探测数较少:逐房间单独探测 - * - * 无论哪种策略,命中目标状态后都立即执行该房间(runOne),不再等整轮探测结束。 + * 命中目标状态后都立即执行该房间任务(runOne),不再等整轮探测结束。 * * @param roomids 待探测的直播间ID列表 * @param targetPredicate 目标状态判断函数 @@ -421,46 +362,6 @@ class MedalModule extends BaseModule { this.logger.log(`开始等待轮询,待探测直播间数量:${roomids.length}`, { roomids }) - // 如果有进行中的全量刷新,等待其完成 - if (MedalModule.ongoingFullRefreshPromise) { - this.logger.log('等待进行中的粉丝勋章刷新完成...') - await MedalModule.ongoingFullRefreshPromise - } - - // 决定本轮筛选时的状态来源:是否对每个房间单独探测 - let useProbe = false - - if (this.isMedalDataFresh()) { - this.logger.log('粉丝勋章数据较新,直接使用缓存数据筛选') - this.syncSnapshotsFromMedals() - } else { - const strategy = this.decideWaitStrategy(roomids.length) - this.logger.log( - `粉丝勋章数据不新鲜,使用${strategy === 'single-probe' ? '单房间探测' : '刷新粉丝勋章'}策略`, - ) - - if (strategy === 'refresh-fans-medals') { - const promise = this.refreshFansMedals() - MedalModule.ongoingFullRefreshPromise = promise - try { - await promise - } finally { - if (MedalModule.ongoingFullRefreshPromise === promise) { - MedalModule.ongoingFullRefreshPromise = null - } - } - - if (biliStore.fansMedalsMeta.status === 'loaded') { - this.syncSnapshotsFromMedals() - } else { - // 刷新失败,本轮全部继续等待 - return { interrupted: false, verifiedCompleted: true, pendingRoomids: roomids } - } - } else { - useProbe = true - } - } - const medalMap = biliStore.filteredFansMedalsMap const nextPending: number[] = [] let verifiedCompleted = true @@ -490,16 +391,11 @@ class MedalModule extends BaseModule { continue } - // 获取本轮筛选用的直播状态 - let liveStatus: number | null - if (useProbe) { - liveStatus = await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid)) - if (liveStatus !== null) { - this.recordLiveStatusSnapshot(roomid, liveStatus, 'single-probe') - } - } else { - const snapshot = MedalModule.liveStatusSnapshots.get(roomid) - liveStatus = snapshot ? snapshot.liveStatus : medal.room_info.living_status + const liveStatus = await MedalModule.enqueueRoomStatusProbe(() => + this.fetchRoomLiveStatus(roomid), + ) + if (liveStatus !== null) { + this.recordLiveStatusSnapshot(roomid, liveStatus) } if (liveStatus === null) { diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index de7cc5ad..44dd42e5 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -248,8 +248,7 @@ class DanmuTask extends MedalModule { } this.status = 'running' - // 用最新列表状态播种直播状态样本,使紧接着执行的 readyMedals 尽量复用、少探测 - this.syncSnapshotsFromMedals() + const { readyMedals, waitingMedals } = this.getMedals() let allCompleted = true const danmuIndexRef = { value: 0 } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 58cdb540..555f535b 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -303,8 +303,7 @@ class LightTask extends MedalModule { } this.status = 'running' - // 用最新列表状态播种直播状态样本,使 likeTask 执行前校验尽量复用、少探测 - this.syncSnapshotsFromMedals() + let { notLivingMedals, livingMedals } = this.getMedals() // 是否有需要点亮的粉丝勋章(是不是一次有效运行?) const isEffectiveRun = notLivingMedals.length > 0 || livingMedals.length > 0 diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index e59d3d31..2f0b604e 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -223,8 +223,7 @@ class LikeTask extends MedalModule { } this.status = 'running' - // 用最新列表状态播种直播状态样本,使紧接着执行的 readyMedals 尽量复用、少探测 - this.syncSnapshotsFromMedals() + const { readyMedals, waitingMedals } = this.getMedals() let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) let allCompleted = true diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts index 0e5b5c78..c2b496ed 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts @@ -20,10 +20,6 @@ type GroupedMedals = Record type RequestQueueKey = 'taskInfo' | 'roomStatus' type TaskJumpType = 'like' | 'sendDanmu' | 'watchLive' | 'feedLight' | 'sendGift' -type WaitStrategy = 'single-probe' | 'refresh-fans-medals' - -/** 直播状态样本的来源 */ -type LiveStatusSnapshotSource = 'fans-medals' | 'single-probe' /** 最近一次观测到的直播间直播状态样本 */ interface LiveStatusSnapshot { @@ -31,8 +27,6 @@ interface LiveStatusSnapshot { liveStatus: number /** 观测到该状态的时刻(毫秒时间戳) */ observedAt: number - /** 样本来源 */ - source: LiveStatusSnapshotSource } /** @@ -82,9 +76,7 @@ export { GroupedMedals, RequestQueueKey, TaskJumpType, - WaitStrategy, LiveStatusSnapshot, - LiveStatusSnapshotSource, PreExecuteVerdict, TaskExecutionResult, BatchExecutionResult, From 781e1f39049fdd9f669e23b79c764ab09436f3af Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:07:33 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E8=8E=B7=E5=8F=96=E7=B2=89=E4=B8=9D=E5=8B=8B?= =?UTF-8?q?=E7=AB=A0=E6=9D=A5=E8=8E=B7=E5=8F=96=E6=8C=87=E5=AE=9A=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E7=9A=84=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81=EF=BC=9B?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liveTasks/medalTasks/MedalModule.ts | 174 ++++++++++++------ .../liveTasks/medalTasks/danmuTask.ts | 25 ++- .../liveTasks/medalTasks/lightTask.ts | 44 ++--- .../liveTasks/medalTasks/likeTask.ts | 25 ++- .../liveTasks/medalTasks/types.d.ts | 11 +- 5 files changed, 172 insertions(+), 107 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index 031282a6..01117b89 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -62,6 +62,55 @@ class MedalModule extends BaseModule { // 直播中返回true,否则返回false isLiving: (medal) => medal.room_info.living_status === 1, } + /** + * 通过获取指定页的粉丝勋章,获取目标直播间的直播状态 + * + * @param page 页码 + * @param roomid 目标直播间ID + * @returns 直播状态和是否可以继续获取下一页的粉丝勋章 + */ + private async fetchMedalPageForLiveStatus( + page: number, + roomid: number, + ): Promise<{ status: number | null; canTryNextPage: boolean }> { + try { + const response = await BAPI.live.fansMedalPanel(page) + this.logger.log(`BAPI.live.fansMedalPanel(${page}) response`, response) + let status = null + + if (response.code === 0) { + const medals = [...response.data.special_list, ...response.data.list] + const observedAt = tsm() + + for (const medal of medals) { + const medalRoomid = medal.room_info.room_id + const medalLiveStatus = medal.room_info.living_status + + MedalModule.liveStatusSnapshots.set(medalRoomid, { + liveStatus: medalLiveStatus, + observedAt, + }) + + if (medalRoomid === roomid) { + status = medalLiveStatus + } + } + + const currentPage = response.data.page_info.current_page + const totalPage = response.data.page_info.total_page + const hasUnlightedMedal = medals.some((m) => !m.medal.is_lighted) + + return { + status, + canTryNextPage: currentPage < totalPage && !hasUnlightedMedal, + } + } + this.logger.warn(`BAPI.live.fansMedalPanel(${page}) 失败`, response.message) + } catch (error) { + this.logger.warn(`BAPI.live.fansMedalPanel(${page}) 出错`, error) + } + return { status: null, canTryNextPage: false } + } /** 直播间直播状态获取函数列表(获取失败时返回null) */ private readonly ROOM_LIVE_STATUS_FETCHERS: Array<(roomid: number) => Promise> = [ async (roomid) => { @@ -69,7 +118,9 @@ class MedalModule extends BaseModule { const response = await BAPI.live.getInfoByRoom(roomid) this.logger.log(`BAPI.live.getInfoByRoom(${roomid}) response`, response) if (response.code === 0) { - return response.data.room_info.live_status + const liveStatus = response.data.room_info.live_status + MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt: tsm() }) + return liveStatus } this.logger.warn(`BAPI.live.getInfoByRoom(${roomid}) 失败`, response.message) } catch (error) { @@ -83,13 +134,47 @@ class MedalModule extends BaseModule { const response = await BAPI.live.getRoomPlayInfo(roomid) this.logger.log(`BAPI.live.getRoomPlayInfo(${roomid}) response`, response) if (response.code === 0) { - return response.data.live_status + const liveStatus = response.data.live_status + MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt: tsm() }) + return liveStatus } this.logger.warn(`BAPI.live.getRoomPlayInfo(${roomid}) 失败`, response.message) } catch (error) { this.logger.warn(`BAPI.live.getRoomPlayInfo(${roomid}) 出错`, error) } + return null + }, + async (roomid) => { + console.log('roomid', roomid) + + const roomids = useBiliStore().filteredFansMedalsMap.keys() + let index = -1 + for (const r of roomids) { + index++ + if (r === roomid) { + break + } + } + + const page = Math.ceil(index / 10) + + console.log('page', page) + + const { status, canTryNextPage } = await this.fetchMedalPageForLiveStatus(page, roomid) + console.log('status, canTryNextPage', status, canTryNextPage) + + if (status !== null) { + return status + } + + if (canTryNextPage) { + await sleep(_.random(300, 500)) + const { status } = await this.fetchMedalPageForLiveStatus(page + 1, roomid) + + return status + } + return null }, ] @@ -101,19 +186,30 @@ class MedalModule extends BaseModule { roomStatus: Promise.resolve(), } /** - * 直播状态样本在多久内视为新鲜、可直接复用(毫秒) + * 直播状态快照在多久内视为新鲜、可直接复用(毫秒) * - * - 本阈值控制「执行前校验」是否需要补一次单房间实时探测 + * - 本阈值控制「执行前校验」是否需要做一次直播状态探测 */ private static readonly LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD = 30_000 /** - * 直播状态样本缓存(key:直播间号) - * - * 为 static,使 like / danmu / light 共享同一份样本——三者关心的都是同一个 `living_status`, - * 共享能减少不必要的重复探测。 + * 直播状态快照(key:直播间号) */ private static readonly liveStatusSnapshots = new Map() - + /** + * 用粉丝勋章列表初始化直播状态快照 + */ + protected static initSnapshotsWithFansMedalsData(): void { + const biliStore = useBiliStore() + // 把开始获取粉丝勋章列表的时间作为直播状态快照的观测时间 + const observedAt = biliStore.fansMedalsMeta.lastFetchStartedAt! + + for (const medal of biliStore.filteredFansMedals) { + MedalModule.liveStatusSnapshots.set(medal.room_info.room_id, { + liveStatus: medal.room_info.living_status, + observedAt, + }) + } + } /** * 将异步请求加入串行队列,相邻请求之间加入随机延迟 */ @@ -274,80 +370,49 @@ class MedalModule extends BaseModule { return null } - /** - * 判断直播状态样本是否仍足够新鲜、可直接复用 - */ - private isSnapshotFresh(snapshot: LiveStatusSnapshot): boolean { - return tsm() - snapshot.observedAt < MedalModule.LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD - } - - /** - * 记录一条直播状态样本 - */ - private recordLiveStatusSnapshot( - roomid: number, - liveStatus: number, - observedAt: number = tsm(), - ): void { - MedalModule.liveStatusSnapshots.set(roomid, { liveStatus, observedAt }) - } - /** * 解析直播间当前的直播状态(执行前校验使用) * - * - 样本仍新鲜:直接复用样本 - * - 样本过期或不存在:补一次单房间实时探测,并更新样本 + * - 快照仍新鲜:直接复用 + * - 快照过期或不存在:探测并更新快照 * - * @returns 直播状态;单房间探测失败时返回 null + * @returns 直播状态;探测失败时返回 null */ - protected async resolveLiveStatus(roomid: number): Promise { + private async resolveLiveStatus(roomid: number): Promise { const snapshot = MedalModule.liveStatusSnapshots.get(roomid) - if (snapshot && this.isSnapshotFresh(snapshot)) { + if ( + snapshot && + tsm() - snapshot.observedAt < MedalModule.LIVE_STATUS_SNAPSHOT_FRESHNESS_THRESHOLD + ) { return snapshot.liveStatus } - const liveStatus = await MedalModule.enqueueRoomStatusProbe(() => - this.fetchRoomLiveStatus(roomid), - ) - if (liveStatus !== null) { - this.recordLiveStatusSnapshot(roomid, liveStatus) - } - return liveStatus + return await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid)) } /** * 执行前直播状态校验 * - * 在房间第一个依赖直播状态的副作用请求(如 likeReport / sendMsg)发出前调用, - * 以「每房间一次」的粒度确认当前状态仍满足任务要求。 - * * @param roomid 直播间号 * @param targetPredicate 目标状态判断函数 - * @param canRequeue 校验未通过时是否回到等待队列(对应 waitUntilLiving / waitUntilNotLiving) * @returns 校验结论 */ protected async preExecuteVerify( roomid: number, targetPredicate: (liveStatus: number) => boolean, - canRequeue: boolean, ): Promise { const liveStatus = await this.resolveLiveStatus(roomid) - // 探测失败:不按旧样本执行 if (liveStatus === null) { - return canRequeue ? 'requeue' : 'skip' + // 探测失败 + return 'error' } - if (targetPredicate(liveStatus)) { - return 'pass' - } - return canRequeue ? 'requeue' : 'skip' + return targetPredicate(liveStatus) ? 'pass' : 'fail' } /** - * 对等待中的直播间做一轮「探测一个,命中即执行一个」的轮询 - * - * 命中目标状态后都立即执行该房间任务(runOne),不再等整轮探测结束。 + * 遍历一轮等待中的直播间,命中目标状态后都立即执行该房间任务 * * @param roomids 待探测的直播间ID列表 * @param targetPredicate 目标状态判断函数 @@ -394,9 +459,6 @@ class MedalModule extends BaseModule { const liveStatus = await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid), ) - if (liveStatus !== null) { - this.recordLiveStatusSnapshot(roomid, liveStatus) - } if (liveStatus === null) { this.logger.warn( diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 44dd42e5..8cbdcbab 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -142,19 +142,23 @@ class DanmuTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } - // 执行前校验:仅当开启「仅在未开播的直播间发弹幕」时,发弹幕才依赖直播状态 - // 在发出第一条 sendMsg 前确认直播间当前确实未开播,避免按过期状态发弹幕 if (this.config.onlyWhenNotLiving) { - const verdict = await this.preExecuteVerify( - room_id, - (liveStatus) => liveStatus !== 1, - this.config.waitUntilNotLiving, - ) - if (verdict !== 'pass') { + // 开启了「仅在未开播的直播间发弹幕」,校验直播状态 + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus !== 1) + if (verdict === 'error') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'};可能遭遇风控,休眠 5 分钟再继续`, + ) + await sleep(300e3) + + return this.config.waitUntilNotLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } else if (verdict === 'fail') { this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${verdict === 'requeue' ? '回到等待队列' : '本轮跳过'}`, + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'}`, ) - return verdict === 'requeue' + return this.config.waitUntilNotLiving ? { interrupted: false, verifiedCompleted: false, requeue: true } : { interrupted: false, verifiedCompleted: true } } @@ -248,6 +252,7 @@ class DanmuTask extends MedalModule { } this.status = 'running' + MedalModule.initSnapshotsWithFansMedalsData() const { readyMedals, waitingMedals } = this.getMedals() let allCompleted = true diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 555f535b..356e2ec5 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -158,7 +158,6 @@ class LightTask extends MedalModule { ): Promise { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] - let probeFailed = false for (let i = 0; i < medals.length; i++) { const medal = medals[i] @@ -183,17 +182,16 @@ class LightTask extends MedalModule { continue } - // 执行前校验:在发出 likeReport 前确认直播间仍在直播 - // 点赞点亮依赖「仍在开播」;若已下播则转交给下一轮发弹幕点亮路径重试 - const liveStatus = await this.resolveLiveStatus(room_id) - if (liveStatus === null) { - probeFailed = true + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) + + if (verdict === 'error') { + skippedByStatusMedals.push(medal) this.logger.warn( - `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,本轮跳过点赞点亮`, + `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,转交下一轮发弹幕点亮;可能遭遇风控,休眠 5 分钟再继续`, ) + await sleep(300e3) continue - } - if (liveStatus !== 1) { + } else if (verdict === 'fail') { skippedByStatusMedals.push(medal) this.logger.log( `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,转交下一轮发弹幕点亮`, @@ -210,7 +208,7 @@ class LightTask extends MedalModule { } } - return { attemptedMedals, skippedByStatusMedals, probeFailed } + return { attemptedMedals, skippedByStatusMedals } } /** @@ -223,7 +221,6 @@ class LightTask extends MedalModule { let danmuIndex = 0 const attemptedMedals: LiveData.FansMedalPanel.List[] = [] const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] - let probeFailed = false for (let i = 0; i < medals.length; i++) { const medal = medals[i] @@ -248,17 +245,16 @@ class LightTask extends MedalModule { continue } - // 执行前校验:在发出 sendMsg 前确认直播间当前未在直播 - // 发弹幕点亮依赖「当前未开播」;若已开播则转交给下一轮点赞点亮路径重试 - const liveStatus = await this.resolveLiveStatus(room_id) - if (liveStatus === null) { - probeFailed = true + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus !== 1) + + if (verdict === 'error') { + skippedByStatusMedals.push(medal) this.logger.warn( - `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,本轮跳过发弹幕点亮`, + `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,转交下一轮点赞点亮;可能遭遇风控,休眠 5 分钟再继续`, ) + await sleep(300e3) continue - } - if (liveStatus === 1) { + } else if (verdict === 'fail') { skippedByStatusMedals.push(medal) this.logger.log( `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,转交下一轮点赞点亮`, @@ -289,7 +285,7 @@ class LightTask extends MedalModule { attemptedMedals.push(medal) } - return { attemptedMedals, skippedByStatusMedals, probeFailed } + return { attemptedMedals, skippedByStatusMedals } } public async run(): Promise { @@ -303,6 +299,7 @@ class LightTask extends MedalModule { } this.status = 'running' + MedalModule.initSnapshotsWithFansMedalsData() let { notLivingMedals, livingMedals } = this.getMedals() // 是否有需要点亮的粉丝勋章(是不是一次有效运行?) @@ -326,11 +323,6 @@ class LightTask extends MedalModule { ...danmuResult.value.attemptedMedals, ...likeResult.value.attemptedMedals, ) - if (danmuResult.value.probeFailed || likeResult.value.probeFailed) { - // 探测失败,为了防止风控、API变更等原因导致的无限循环,停止点亮 - allCompleted = false - break - } livingMedals = danmuResult.value.skippedByStatusMedals notLivingMedals = likeResult.value.skippedByStatusMedals @@ -352,7 +344,7 @@ class LightTask extends MedalModule { if (attemptedMedals.length > 0) { if (refreshed) { - allCompleted = this.verifyLightedMedals(attemptedMedals) && allCompleted + allCompleted = this.verifyLightedMedals(attemptedMedals) } else { this.logger.warn('无法确认点亮任务是否完成,本轮按未完成处理') allCompleted = false diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index 2f0b604e..b824b80b 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -127,18 +127,22 @@ class LikeTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } - // 执行前校验:在发出第一个 likeReport 前确认直播间仍在直播 - // 以「每房间一次」的粒度,避免按过期状态点赞 - const verdict = await this.preExecuteVerify( - room_id, - (liveStatus) => liveStatus === 1, - this.config.waitUntilLiving, - ) - if (verdict !== 'pass') { + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) + + if (verdict === 'error') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilLiving ? '回到等待队列' : '跳过点赞任务'};可能遭遇风控,休眠 5 分钟再继续`, + ) + await sleep(300e3) + + return this.config.waitUntilLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } else if (verdict === 'fail') { this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${verdict === 'requeue' ? '回到等待队列' : '本轮跳过'}`, + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '本轮跳过'}`, ) - return verdict === 'requeue' + return this.config.waitUntilLiving ? { interrupted: false, verifiedCompleted: false, requeue: true } : { interrupted: false, verifiedCompleted: true } } @@ -223,6 +227,7 @@ class LikeTask extends MedalModule { } this.status = 'running' + MedalModule.initSnapshotsWithFansMedalsData() const { readyMedals, waitingMedals } = this.getMedals() let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts index c2b496ed..d0a9b081 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts @@ -32,11 +32,11 @@ interface LiveStatusSnapshot { /** * 执行前直播状态校验结论 * - * - `pass`:符合目标状态,放行执行 - * - `requeue`:不符或探测失败,且开启等待,回到等待队列 - * - `skip`:不符或探测失败,且未开启等待,本轮跳过 + * - `pass`:符合目标状态 + * - `fail`:不符合目标状态 + * - `error`:探测失败 */ -type PreExecuteVerdict = 'pass' | 'requeue' | 'skip' +type PreExecuteVerdict = 'pass' | 'fail' | 'error' /** 单个直播间任务的执行结果 */ interface TaskExecutionResult { @@ -65,9 +65,10 @@ interface WaitRoundResult { } interface LightPathExecutionResult { + /** 尝试点亮过的粉丝勋章列表 */ attemptedMedals: LiveData.FansMedalPanel.List[] + /** 因为直播状态不对被跳过的粉丝勋章列表 */ skippedByStatusMedals: LiveData.FansMedalPanel.List[] - probeFailed: boolean } export { From 5be9edf292bc752ede714a84e890ed36c7b48790 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:34:39 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=97=A0=E6=84=8F=E4=B9=89=E7=9A=84=E9=87=8D=E5=A4=8D=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liveTasks/medalTasks/MedalModule.ts | 46 +++++++----------- .../liveTasks/medalTasks/danmuTask.ts | 47 +++++++++++-------- .../liveTasks/medalTasks/lightTask.ts | 4 +- .../liveTasks/medalTasks/likeTask.ts | 46 ++++++++++-------- 4 files changed, 74 insertions(+), 69 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index 01117b89..962a5af1 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -416,7 +416,7 @@ class MedalModule extends BaseModule { * * @param roomids 待探测的直播间ID列表 * @param targetPredicate 目标状态判断函数 - * @param runOne 执行单个直播间任务的回调(其内部应自行做执行前校验) + * @param runOne 执行单个直播间任务的回调(跳过执行前直播状态校验) */ protected async runWaitingRound( roomids: number[], @@ -445,37 +445,32 @@ class MedalModule extends BaseModule { const medal = medalMap.get(roomid) if (!medal) { - this.logger.warn(`直播间 ${roomid} 未在粉丝勋章列表中找到,停止等待该房间`) - continue - } - if (!this.SHARED_MEDAL_FILTERS.isLighted(medal)) { - this.logger.log( - `粉丝勋章【${medal.medal.medal_name}】已熄灭,停止等待该房间(${roomid})`, - medal, - ) + this.logger.error(`直播间 ${roomid} 未在粉丝勋章列表中找到,停止等待该房间`) continue } - const liveStatus = await MedalModule.enqueueRoomStatusProbe(() => - this.fetchRoomLiveStatus(roomid), - ) + const room_id = medal.room_info.room_id + const target_id = medal.medal.target_id + const nick_name = medal.anchor_info.nick_name + const medal_name = medal.medal.medal_name - if (liveStatus === null) { - this.logger.warn( - `粉丝勋章【${medal.medal.medal_name}】对应的直播间 ${roomid} 本轮实时探测失败,继续等待下次检查`, - medal, - ) - nextPending.push(roomid) - continue - } + const verdict = await this.preExecuteVerify(roomid, targetPredicate) + + if (verdict !== 'pass') { + if (verdict === 'error') { + this.logger.error( + `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,回到等待队列;可能遭遇风控,休眠 5 分钟再继续`, + ) + await sleep(300e3) + } - if (!targetPredicate(liveStatus)) { nextPending.push(roomid) continue } - // 命中目标状态,立即执行该房间 - this.logger.log(`直播间 ${roomid}(${medal.anchor_info.nick_name})已达到目标状态,立即执行`) + this.logger.log( + `粉丝勋章【${medal_name}】 主播【${nick_name}】(UID:${target_id})的直播间 ${roomid} 已达到目标状态,立即执行`, + ) const result = await runOne(medal) if (result.interrupted) { @@ -485,11 +480,6 @@ class MedalModule extends BaseModule { pendingRoomids: [...nextPending, ...roomids.slice(i)], } } - if (result.requeue) { - // 执行前校验未通过且可等待,回到等待队列 - nextPending.push(roomid) - continue - } if (!result.verifiedCompleted) { verifiedCompleted = false } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 8cbdcbab..097caf5a 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -91,11 +91,16 @@ class DanmuTask extends MedalModule { /** * 执行单个直播间的发弹幕任务 * + * @param medal 粉丝勋章 + * @param danmuIndexRef 弹幕索引引用,用于记录当前正在发送的弹幕索引 + * @param skipPreVerify 是否跳过执行前直播状态校验,默认 false + * * @returns 执行结果,包含是否中断以及是否确认完成 */ private async executeDanmuTask( medal: LiveData.FansMedalPanel.List, danmuIndexRef: { value: number }, + skipPreVerify = false, ): Promise { if (this.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') @@ -142,25 +147,27 @@ class DanmuTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } - if (this.config.onlyWhenNotLiving) { - // 开启了「仅在未开播的直播间发弹幕」,校验直播状态 - const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus !== 1) - if (verdict === 'error') { - this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'};可能遭遇风控,休眠 5 分钟再继续`, - ) - await sleep(300e3) - - return this.config.waitUntilNotLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } - } else if (verdict === 'fail') { - this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'}`, - ) - return this.config.waitUntilNotLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + if (!skipPreVerify) { + if (this.config.onlyWhenNotLiving) { + // 开启了「仅在未开播的直播间发弹幕」,校验直播状态 + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus !== 1) + if (verdict === 'error') { + this.logger.error( + `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'};可能遭遇风控,休眠 5 分钟再继续`, + ) + await sleep(300e3) + + return this.config.waitUntilNotLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } else if (verdict === 'fail') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'}`, + ) + return this.config.waitUntilNotLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } } } @@ -276,7 +283,7 @@ class DanmuTask extends MedalModule { const roundResult = await this.runWaitingRound( pendingRoomids, (liveStatus) => liveStatus !== 1, - (medal) => this.executeDanmuTask(medal, danmuIndexRef), + (medal) => this.executeDanmuTask(medal, danmuIndexRef, true), ) pendingRoomids = roundResult.pendingRoomids diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 356e2ec5..7efe8734 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -186,7 +186,7 @@ class LightTask extends MedalModule { if (verdict === 'error') { skippedByStatusMedals.push(medal) - this.logger.warn( + this.logger.error( `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,转交下一轮发弹幕点亮;可能遭遇风控,休眠 5 分钟再继续`, ) await sleep(300e3) @@ -249,7 +249,7 @@ class LightTask extends MedalModule { if (verdict === 'error') { skippedByStatusMedals.push(medal) - this.logger.warn( + this.logger.error( `粉丝勋章【${medal_name}】 执行前校验:无法确认主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前是否在直播,转交下一轮点赞点亮;可能遭遇风控,休眠 5 分钟再继续`, ) await sleep(300e3) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index b824b80b..0c11b574 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -79,9 +79,15 @@ class LikeTask extends MedalModule { /** * 执行单个直播间的点赞任务 * + * @param medal 粉丝勋章 + * @param skipPreVerify 是否跳过执行前直播状态校验,默认 false + * * @returns 执行结果,包含是否中断以及是否确认完成 */ - private async executeLikeTask(medal: LiveData.FansMedalPanel.List): Promise { + private async executeLikeTask( + medal: LiveData.FansMedalPanel.List, + skipPreVerify = false, + ): Promise { if (this.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') return { interrupted: true, verifiedCompleted: false } @@ -127,24 +133,26 @@ class LikeTask extends MedalModule { if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } - const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) + if (!skipPreVerify) { + const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) - if (verdict === 'error') { - this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilLiving ? '回到等待队列' : '跳过点赞任务'};可能遭遇风控,休眠 5 分钟再继续`, - ) - await sleep(300e3) - - return this.config.waitUntilLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } - } else if (verdict === 'fail') { - this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '本轮跳过'}`, - ) - return this.config.waitUntilLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + if (verdict === 'error') { + this.logger.error( + `粉丝勋章【${medal_name}】 执行前校验:无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,${this.config.waitUntilLiving ? '回到等待队列' : '跳过点赞任务'};可能遭遇风控,休眠 5 分钟再继续`, + ) + await sleep(300e3) + + return this.config.waitUntilLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } else if (verdict === 'fail') { + this.logger.log( + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '本轮跳过'}`, + ) + return this.config.waitUntilLiving + ? { interrupted: false, verifiedCompleted: false, requeue: true } + : { interrupted: false, verifiedCompleted: true } + } } const times = MedalModule.parseTitleCount(item.title) ?? 30 @@ -250,7 +258,7 @@ class LikeTask extends MedalModule { const roundResult = await this.runWaitingRound( pendingRoomids, (liveStatus) => liveStatus === 1, - (medal) => this.executeLikeTask(medal), + (medal) => this.executeLikeTask(medal, true), ) pendingRoomids = roundResult.pendingRoomids From 38c60d32d7eb2f4122806a795e48c98255496f45 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:41:28 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=E7=8A=B6=E6=80=81=E6=8E=A2=E6=B5=8B=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9B=E5=B0=86=E7=82=B9=E8=B5=9E=E3=80=81=E5=8F=91?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E6=8F=90=E5=8F=96=E4=B8=BAMedalModule?= =?UTF-8?q?=E5=9F=BA=E7=B1=BB=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- .../liveTasks/medalTasks/MedalModule.ts | 186 +++++++++++++----- .../liveTasks/medalTasks/danmuTask.ts | 37 ---- .../liveTasks/medalTasks/lightTask.ts | 62 ------ .../liveTasks/medalTasks/likeTask.ts | 29 --- 5 files changed, 143 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afa7821..f389fc48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 新增 -- 直播任务【点亮熄灭勋章】、【点赞】和【发弹幕】会在执行每个粉丝勋章的任务前校验直播状态 +- 直播任务【点亮熄灭勋章】、【点赞】和【发弹幕】会在执行每个粉丝勋章的任务前校验直播状态,确保执行任务时直播状态符合预期 ### 修复 @@ -13,7 +13,7 @@ ### 调整 - 【观看直播】的任务进度校验现在完全依赖于API数据(舍弃本地观看时长记录逻辑) -- 优化了【观看直播】任务的日志输出 +- 优化了直播任务【点亮熄灭勋章】、【点赞】和【发弹幕】的房间直播状态探测逻辑 ## [7.3.0] - 2026-6-14 diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index 962a5af1..f1db2704 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -111,8 +111,39 @@ class MedalModule extends BaseModule { } return { status: null, canTryNextPage: false } } - /** 直播间直播状态获取函数列表(获取失败时返回null) */ + /** + * 直播间直播状态获取函数列表(获取失败时返回null) + * + * - 特别说明:第一个 fetcher 通过粉丝勋章API获取直播状态,一次获取一页(10个)房间的直播状态并全部写入状态快照(但是仅返回目标房间的直播状态) + */ private readonly ROOM_LIVE_STATUS_FETCHERS: Array<(roomid: number) => Promise> = [ + async (roomid) => { + const roomids = useBiliStore().filteredFansMedalsMap.keys() + let index = -1 + for (const r of roomids) { + index++ + if (r === roomid) { + break + } + } + + const page = Math.ceil(index / 10) + + const { status, canTryNextPage } = await this.fetchMedalPageForLiveStatus(page, roomid) + + if (status !== null) { + return status + } + + if (canTryNextPage) { + await sleep(_.random(300, 500)) + const { status } = await this.fetchMedalPageForLiveStatus(page + 1, roomid) + + return status + } + + return null + }, async (roomid) => { try { const response = await BAPI.live.getInfoByRoom(roomid) @@ -143,38 +174,6 @@ class MedalModule extends BaseModule { this.logger.warn(`BAPI.live.getRoomPlayInfo(${roomid}) 出错`, error) } - return null - }, - async (roomid) => { - console.log('roomid', roomid) - - const roomids = useBiliStore().filteredFansMedalsMap.keys() - let index = -1 - for (const r of roomids) { - index++ - if (r === roomid) { - break - } - } - - const page = Math.ceil(index / 10) - - console.log('page', page) - - const { status, canTryNextPage } = await this.fetchMedalPageForLiveStatus(page, roomid) - console.log('status, canTryNextPage', status, canTryNextPage) - - if (status !== null) { - return status - } - - if (canTryNextPage) { - await sleep(_.random(300, 500)) - const { status } = await this.fetchMedalPageForLiveStatus(page + 1, roomid) - - return status - } - return null }, ] @@ -204,10 +203,14 @@ class MedalModule extends BaseModule { const observedAt = biliStore.fansMedalsMeta.lastFetchStartedAt! for (const medal of biliStore.filteredFansMedals) { - MedalModule.liveStatusSnapshots.set(medal.room_info.room_id, { - liveStatus: medal.room_info.living_status, - observedAt, - }) + const snapshot = MedalModule.liveStatusSnapshots.get(medal.room_info.room_id) + + if (!snapshot || snapshot.observedAt < observedAt) { + MedalModule.liveStatusSnapshots.set(medal.room_info.room_id, { + liveStatus: medal.room_info.living_status, + observedAt, + }) + } } } /** @@ -294,6 +297,76 @@ class MedalModule extends BaseModule { }) } + /** + * 点赞 + * + * @param medal 粉丝勋章 + * @param click_time 点赞次数 + * + * @returns 是否点赞成功 + */ + protected async like(medal: LiveData.FansMedalPanel.List, click_time: number): Promise { + const room_id = medal.room_info.room_id + const target_id = medal.medal.target_id + const nick_name = medal.anchor_info.nick_name + const medal_name = medal.medal.medal_name + const logMessage = `粉丝勋章【${medal_name}】 给主播【${nick_name}】(UID:${target_id})的直播间(${room_id})点赞 ${click_time} 次` + + try { + const response = await BAPI.live.likeReport(room_id, target_id, click_time) + this.logger.log(`BAPI.live.likeReport(${room_id}, ${target_id}, ${click_time})`, response) + if (response.code === 0) { + this.logger.log(`点赞 ${logMessage} 成功`) + return true + } else { + this.logger.error(`点赞 ${logMessage} 失败`, response.message) + } + } catch (error) { + this.logger.error(`点赞 ${logMessage} 出错`, error) + } + + return false + } + + /** + * 发弹幕 + * + * @param medal 粉丝勋章 + * @param danmu 弹幕内容 + * + * @returns 是否发送成功 + */ + protected async sendDanmu(medal: LiveData.FansMedalPanel.List, danmu: string): Promise { + const room_id = medal.room_info.room_id + const target_id = medal.medal.target_id + const nick_name = medal.anchor_info.nick_name + const medal_name = medal.medal.medal_name + const logMessage = `粉丝勋章【${medal_name}】 在主播【${nick_name}】(UID:${target_id})的直播间(${room_id})发送弹幕 ${danmu}` + + try { + const response = await BAPI.live.sendMsg(danmu, room_id) + this.logger.log(`BAPI.live.sendMsg(${danmu}, ${room_id})`, response) + if (response.code === 0) { + if (response.msg === '') { + this.logger.log(`发弹幕 ${logMessage} 成功`) + return true + } else if (response.msg === 'k') { + this.logger.warn(`发弹幕 ${logMessage} 异常,弹幕可能包含屏蔽词`) + } else if (response.msg === 'f') { + this.logger.warn(`发弹幕 ${logMessage} 异常,弹幕被过滤`) + } else { + this.logger.warn(`发弹幕 ${logMessage} 异常,未知错误:${response.msg}`) + } + } else { + this.logger.error(`发弹幕 ${logMessage} 失败`, response.message) + } + } catch (error) { + this.logger.error(`发弹幕 ${logMessage} 出错`, error) + } + + return false + } + /** * 从粉丝团升级任务 sub_title 解析每日任务进度文案 "每日上限 X/Y" * @@ -347,15 +420,25 @@ class MedalModule extends BaseModule { } /** - * 以随机顺序尝试多个接口,获取单个直播间的直播状态 + * 获取单个直播间的直播状态 + * + * - 默认以随机顺序尝试多个接口 + * + * @param roomid 直播间号 + * @param preferMedalAPI 是否优先使用粉丝勋章API获取直播状态(其余接口仍随机),默认 false * * @returns * - `1`:直播中 * - `0`/`2`:未开播/轮播中 - * - `null`:两个接口都获取失败 + * - `null`:所有接口都获取失败 */ - protected async fetchRoomLiveStatus(roomid: number): Promise { - const fetchers = _.shuffle(this.ROOM_LIVE_STATUS_FETCHERS) + protected async fetchRoomLiveStatus( + roomid: number, + preferMedalAPI = false, + ): Promise { + const fetchers = preferMedalAPI + ? [this.ROOM_LIVE_STATUS_FETCHERS[0], ..._.shuffle(this.ROOM_LIVE_STATUS_FETCHERS.slice(1))] + : _.shuffle(this.ROOM_LIVE_STATUS_FETCHERS) for (let i = 0; i < fetchers.length; i++) { const liveStatus = await fetchers[i](roomid) @@ -376,9 +459,12 @@ class MedalModule extends BaseModule { * - 快照仍新鲜:直接复用 * - 快照过期或不存在:探测并更新快照 * + * @param roomid 直播间号 + * @param preferMedalAPI 是否优先使用粉丝勋章API获取直播状态,默认 false + * * @returns 直播状态;探测失败时返回 null */ - private async resolveLiveStatus(roomid: number): Promise { + private async resolveLiveStatus(roomid: number, preferMedalAPI = false): Promise { const snapshot = MedalModule.liveStatusSnapshots.get(roomid) if ( snapshot && @@ -387,7 +473,9 @@ class MedalModule extends BaseModule { return snapshot.liveStatus } - return await MedalModule.enqueueRoomStatusProbe(() => this.fetchRoomLiveStatus(roomid)) + return await MedalModule.enqueueRoomStatusProbe(() => + this.fetchRoomLiveStatus(roomid, preferMedalAPI), + ) } /** @@ -395,13 +483,16 @@ class MedalModule extends BaseModule { * * @param roomid 直播间号 * @param targetPredicate 目标状态判断函数 + * @param preferMedalAPI 是否优先使用粉丝勋章API获取直播状态,默认 false + * * @returns 校验结论 */ protected async preExecuteVerify( roomid: number, targetPredicate: (liveStatus: number) => boolean, + preferMedalAPI = false, ): Promise { - const liveStatus = await this.resolveLiveStatus(roomid) + const liveStatus = await this.resolveLiveStatus(roomid, preferMedalAPI) if (liveStatus === null) { // 探测失败 @@ -454,7 +545,12 @@ class MedalModule extends BaseModule { const nick_name = medal.anchor_info.nick_name const medal_name = medal.medal.medal_name - const verdict = await this.preExecuteVerify(roomid, targetPredicate) + // 遍历等待中的房间时优先用粉丝勋章API,一次获取一页(10个)房间的直播状态 + // 因为在这种情况下房间直播状态不符合预期的可能性大,经常会连续判断多个房间的直播状态,能复用之前的探测结果 + // 而在给第一批房间执行任务时,直播状态大概率正确, + // 执行完一个房间的任务后,先前获取的直播状态数据多半(具体概率和目标轮次相关)已经不新鲜了,又要重新探测直播状态, + // 所以没必要优先使用粉丝勋章API获取多个房间的直播状态 + const verdict = await this.preExecuteVerify(roomid, targetPredicate, true) if (verdict !== 'pass') { if (verdict === 'error') { diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 097caf5a..6f8e58db 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -1,5 +1,4 @@ import { delayToNextMoment, isNowBefore, isTimestampToday, tsm } from '@/library/luxon' -import BAPI from '@/library/bili-api' import { useBiliStore, useModuleStore } from '@/stores' import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' @@ -52,42 +51,6 @@ class DanmuTask extends MedalModule { return result } - /** - * 发弹幕 - * - * @returns 是否发送成功 - */ - private async sendDanmu(medal: LiveData.FansMedalPanel.List, danmu: string): Promise { - const room_id = medal.room_info.room_id - const target_id = medal.medal.target_id - const nick_name = medal.anchor_info.nick_name - const medal_name = medal.medal.medal_name - const logMessage = `粉丝勋章【${medal_name}】 在主播【${nick_name}】(UID:${target_id})的直播间(${room_id})发送弹幕 ${danmu}` - - try { - const response = await BAPI.live.sendMsg(danmu, room_id) - this.logger.log(`BAPI.live.sendMsg(${danmu}, ${room_id})`, response) - if (response.code === 0) { - if (response.msg === '') { - this.logger.log(`发弹幕 ${logMessage} 成功`) - return true - } else if (response.msg === 'k') { - this.logger.warn(`发弹幕 ${logMessage} 异常,弹幕可能包含屏蔽词`) - } else if (response.msg === 'f') { - this.logger.warn(`发弹幕 ${logMessage} 异常,弹幕被过滤`) - } else { - this.logger.warn(`发弹幕 ${logMessage} 异常,未知错误:${response.msg}`) - } - } else { - this.logger.error(`发弹幕 ${logMessage} 失败`, response.message) - } - } catch (error) { - this.logger.error(`发弹幕 ${logMessage} 出错`, error) - } - - return false - } - /** * 执行单个直播间的发弹幕任务 * diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 7efe8734..39885106 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -1,5 +1,4 @@ import { delayToNextMoment, isNowBefore, isTimestampToday, tsm } from '@/library/luxon' -import BAPI from '@/library/bili-api' import { useBiliStore, useModuleStore } from '@/stores' import { sleep } from '@/library/utils' import type { ModuleStatusTypes } from '@/types' @@ -88,67 +87,6 @@ class LightTask extends MedalModule { return allVerified } - /** - * 点赞 - * @param medal 粉丝勋章 - * @param click_time 点赞次数 - */ - private async like(medal: LiveData.FansMedalPanel.List, click_time: number): Promise { - const room_id = medal.room_info.room_id - const target_id = medal.medal.target_id - const nick_name = medal.anchor_info.nick_name - const medal_name = medal.medal.medal_name - const logMessage = `粉丝勋章【${medal_name}】 给主播【${nick_name}】(UID:${target_id})的直播间(${room_id})点赞 ${click_time} 次` - - try { - const response = await BAPI.live.likeReport(room_id, target_id, click_time) - this.logger.log(`BAPI.live.likeReport(${room_id}, ${target_id}, ${click_time})`, response) - if (response.code === 0) { - this.logger.log(`点亮熄灭勋章-点赞 ${logMessage} 成功`) - } else { - this.logger.error(`点亮熄灭勋章-点赞 ${logMessage} 失败`, response.message) - } - } catch (error) { - this.logger.error(`点亮熄灭勋章-点赞 ${logMessage} 出错`, error) - } - } - - /** - * 发弹幕 - * @param medal 粉丝勋章 - * @param danmu 弹幕内容 - */ - private async sendDanmu(medal: LiveData.FansMedalPanel.List, danmu: string): Promise { - const room_id = medal.room_info.room_id - const target_id = medal.medal.target_id - const nick_name = medal.anchor_info.nick_name - const medal_name = medal.medal.medal_name - const logMessage = `粉丝勋章【${medal_name}】 在主播【${nick_name}】(UID:${target_id})的直播间(${room_id})发送弹幕 ${danmu}` - - try { - const response = await BAPI.live.sendMsg(danmu, room_id) - this.logger.log(`BAPI.live.sendMsg(${danmu}, ${room_id})`, response) - if (response.code === 0) { - if (response.msg === '') { - this.logger.log(`点亮熄灭勋章-发弹幕 ${logMessage} 成功`) - return true - } else if (response.msg === 'k') { - this.logger.warn(`点亮熄灭勋章-发弹幕 ${logMessage} 异常,弹幕可能包含屏蔽词`) - } else if (response.msg === 'f') { - this.logger.warn(`点亮熄灭勋章-发弹幕 ${logMessage} 异常,弹幕被过滤`) - } else { - this.logger.warn(`点亮熄灭勋章-发弹幕 ${logMessage} 异常,未知错误:${response.msg}`) - } - } else { - this.logger.error(`点亮熄灭勋章-发送弹幕 ${logMessage} 失败`, response.message) - } - } catch (error) { - this.logger.error(`点亮熄灭勋章-发送弹幕 ${logMessage} 出错`, error) - } - - return false - } - /** * 给正在直播的直播间点赞 * @param medals diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index 0c11b574..6b82795d 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -1,5 +1,4 @@ import { delayToNextMoment, isNowBefore, isTimestampToday, tsm } from '@/library/luxon' -import BAPI from '@/library/bili-api' import { useBiliStore, useModuleStore } from '@/stores' import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' @@ -48,34 +47,6 @@ class LikeTask extends MedalModule { return result } - /** - * 点赞 - * - * @returns 是否点赞成功 - */ - private async like(medal: LiveData.FansMedalPanel.List, click_time: number): Promise { - const room_id = medal.room_info.room_id - const target_id = medal.medal.target_id - const nick_name = medal.anchor_info.nick_name - const medal_name = medal.medal.medal_name - const logMessage = `粉丝勋章【${medal_name}】 给主播【${nick_name}】(UID:${target_id})的直播间(${room_id})点赞 ${click_time} 次` - - try { - const response = await BAPI.live.likeReport(room_id, target_id, click_time) - this.logger.log(`BAPI.live.likeReport(${room_id}, ${target_id}, ${click_time})`, response) - if (response.code === 0) { - this.logger.log(`点赞 ${logMessage} 成功`) - return true - } else { - this.logger.error(`点赞 ${logMessage} 失败`, response.message) - } - } catch (error) { - this.logger.error(`点赞 ${logMessage} 出错`, error) - } - - return false - } - /** * 执行单个直播间的点赞任务 * From 2df84fabd827e4abab1d89999b322047b24f76f8 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:52:23 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=90=9E=20fix:=20=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复MedalModule中粉丝勋章fetcher分页计划错误的bug - X心跳递归改循环 - LightTask中的弹幕发送改为使用跨房间共享的弹幕索引 --- .../liveTasks/medalTasks/MedalModule.ts | 7 +- .../liveTasks/medalTasks/danmuTask.ts | 1 + .../liveTasks/medalTasks/lightTask.ts | 15 ++- .../liveTasks/medalTasks/watchTask.ts | 106 +++++++++--------- 4 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index f1db2704..eae5cfd3 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -102,6 +102,10 @@ class MedalModule extends BaseModule { return { status, + // 目前B站的粉丝勋章排序规则为:点亮的勋章排在前面,未点亮的排在后面,然后分别再按等级由高到低排序 + // 如果当前页上有未点亮的粉丝勋章,后面的页就都是未点亮的勋章 + // 而点赞、发弹幕任务都是给点亮的勋章做的 + // 因此如果当前页上有未点亮的粉丝勋章,就没必要再看后面的页了 canTryNextPage: currentPage < totalPage && !hasUnlightedMedal, } } @@ -127,7 +131,8 @@ class MedalModule extends BaseModule { } } - const page = Math.ceil(index / 10) + // fansMedalPanel 每页返回 10 个勋章,页码从 1 开始 + const page = Math.floor(index / 10) + 1 const { status, canTryNextPage } = await this.fetchMedalPageForLiveStatus(page, roomid) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 6f8e58db..6213a215 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -226,6 +226,7 @@ class DanmuTask extends MedalModule { const { readyMedals, waitingMedals } = this.getMedals() let allCompleted = true + // 跨房间共享的弹幕索引,避免每个房间都从同一条弹幕开始发 const danmuIndexRef = { value: 0 } let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index 39885106..e738887a 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -151,12 +151,14 @@ class LightTask extends MedalModule { /** * 在未开播的直播间发弹幕 - * @param medals + * + * @param medals 待发弹幕的粉丝勋章 + * @param danmuIndexRef 弹幕索引引用,用于记录当前正在发送的弹幕索引 */ private async sendDanmuTask( medals: LiveData.FansMedalPanel.List[], + danmuIndexRef: { value: number }, ): Promise { - let danmuIndex = 0 const attemptedMedals: LiveData.FansMedalPanel.List[] = [] const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] @@ -204,7 +206,8 @@ class LightTask extends MedalModule { let failedCount = 0 for (let j = 0; j < remaining; j++) { - const danmuText = this.config.danmuList[danmuIndex++ % this.config.danmuList.length] + const danmuText = + this.config.danmuList[danmuIndexRef.value++ % this.config.danmuList.length] if (!(await this.sendDanmu(medal, danmuText))) { if (++failedCount > MedalModule.DANMU_RETRY_LIMIT) { @@ -212,7 +215,7 @@ class LightTask extends MedalModule { if (i < medals.length - 1) await sleep(MedalModule.SEND_DANMU_DYNAMIC_INTERVAL) break } - remaining++ + remaining += 1 } if (i < medals.length - 1 || j < remaining - 1) { @@ -246,10 +249,12 @@ class LightTask extends MedalModule { if (isEffectiveRun) { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] + // 跨房间共享的弹幕索引,避免每个房间都从同一条弹幕开始发 + const danmuIndexRef = { value: 0 } while (notLivingMedals.length > 0 || livingMedals.length > 0) { const [danmuResult, likeResult] = await Promise.allSettled([ - this.sendDanmuTask(notLivingMedals), + this.sendDanmuTask(notLivingMedals, danmuIndexRef), this.likeTask(livingMedals), ]) if (danmuResult.status === 'rejected' || likeResult.status === 'rejected') { diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts index a551abac..1dc7600b 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts @@ -162,68 +162,72 @@ class RoomHeart { * X心跳,E心跳过后都是X心跳 */ private async X(): Promise { - if (isNowAfter(23, 58) || isNowBefore(0, 5)) { - this.logger.log(`即将或刚刚发生跨天,停止直播间 ${this.roomID} 的X心跳`) - return - } - - try { - const spyderData: SpyderData = { - id: JSON.stringify(this.id), - device: JSON.stringify(this.device), - ets: this.timestamp, - benchmark: this.secretKey, - time: this.heartBeatInterval, - ts: tsm(), - ua: this.ua, + while (true) { + if (isNowAfter(23, 58) || isNowBefore(0, 5)) { + this.logger.log(`即将或刚刚发生跨天,停止直播间 ${this.roomID} 的X心跳`) + return } - // 签名 - const s = this.spyder(JSON.stringify(spyderData), this.secretRule) - - const response = await BAPI.liveTrace.X( - s, - this.id, - this.device, - this.ruid, - this.timestamp, - this.secretKey, - this.heartBeatInterval, - spyderData.ts, - ) - this.logger.log( - `BAPI.liveTrace.X(${s}, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}, ${spyderData.ts}) response`, - response, - ) - if (response.code === 0) { - this.seq += 1 - this.watchedSeconds += this.heartBeatInterval + + try { + const spyderData: SpyderData = { + id: JSON.stringify(this.id), + device: JSON.stringify(this.device), + ets: this.timestamp, + benchmark: this.secretKey, + time: this.heartBeatInterval, + ts: tsm(), + ua: this.ua, + } + // 签名 + const s = this.spyder(JSON.stringify(spyderData), this.secretRule) + + const response = await BAPI.liveTrace.X( + s, + this.id, + this.device, + this.ruid, + this.timestamp, + this.secretKey, + this.heartBeatInterval, + spyderData.ts, + ) this.logger.log( - `直播间 ${this.roomID} 的第 ${this.seq - 1} 次 X 心跳成功,已观看 ${this.watchedSeconds} 秒`, + `BAPI.liveTrace.X(${s}, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}, ${spyderData.ts}) response`, + response, ) - if (this.watchedSeconds >= this.targetSeconds) { - // 达到目标观看时间,结束 + if (response.code === 0) { + this.seq += 1 + this.watchedSeconds += this.heartBeatInterval + this.logger.log( + `直播间 ${this.roomID} 的第 ${this.seq - 1} 次 X 心跳成功,已观看 ${this.watchedSeconds} 秒`, + ) + if (this.watchedSeconds >= this.targetSeconds) { + // 达到目标观看时间,结束 + return + } + this.setHeartbeatState(response.data) + await this.waitForNextHeartbeat() + // 继续下一轮 X 心跳 + } else { + this.logger.error( + `BAPI.liveTrace.X(${s}, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 失败`, + response.message, + ) + this.logger.error( + `直播间 ${this.roomID} 的 X 心跳失败,无法继续执行观看直播任务,跳过该房间(目前已观看 ${this.watchedSeconds} 秒)`, + ) return } - this.setHeartbeatState(response.data) - await this.waitForNextHeartbeat() - return this.X() - } else { + } catch (error) { this.logger.error( - `BAPI.liveTrace.X(${s}, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 失败`, - response.message, + `BAPI.liveTrace.X(s, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 出错`, + error, ) this.logger.error( `直播间 ${this.roomID} 的 X 心跳失败,无法继续执行观看直播任务,跳过该房间(目前已观看 ${this.watchedSeconds} 秒)`, ) + return } - } catch (error) { - this.logger.error( - `BAPI.liveTrace.X(s, ${this.id}, ${this.device}, ${this.ruid}, ${this.timestamp}, ${this.secretKey}, ${this.heartBeatInterval}) 出错`, - error, - ) - this.logger.error( - `直播间 ${this.roomID} 的 X 心跳失败,无法继续执行观看直播任务,跳过该房间(目前已观看 ${this.watchedSeconds} 秒)`, - ) } } From 5196d23787803bc303087c48a173b366a07ff08e Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:02:24 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E7=B2=89=E4=B8=9D?= =?UTF-8?q?=E5=8B=8B=E7=AB=A0=E6=A8=A1=E5=9D=97=E4=BB=A3=E7=A0=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 简化任务状态表示 --- .../liveTasks/medalTasks/MedalModule.ts | 67 ++++---- .../liveTasks/medalTasks/danmuTask.ts | 159 +++++++++--------- .../liveTasks/medalTasks/lightTask.ts | 9 +- .../liveTasks/medalTasks/likeTask.ts | 152 ++++++++--------- .../liveTasks/medalTasks/types.d.ts | 50 ++---- .../liveTasks/medalTasks/watchTask.ts | 2 +- 6 files changed, 211 insertions(+), 228 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index eae5cfd3..4e380cd2 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -7,9 +7,9 @@ import type { PreExecuteVerdict, RequestQueueKey, SharedMedalFilters, - TaskExecutionResult, TaskJumpType, - WaitRoundResult, + AfterExecutionAction, + BatchExecutionResult, } from './types' import { arrayToMap, sleep } from '@/library/utils' import type { LiveData } from '@/library/bili-api/data' @@ -63,9 +63,9 @@ class MedalModule extends BaseModule { isLiving: (medal) => medal.room_info.living_status === 1, } /** - * 通过获取指定页的粉丝勋章,获取目标直播间的直播状态 + * 通过获取指定页的粉丝勋章,探测目标直播间的直播状态 * - * @param page 页码 + * @param page 粉丝勋章页码 * @param roomid 目标直播间ID * @returns 直播状态和是否可以继续获取下一页的粉丝勋章 */ @@ -122,6 +122,7 @@ class MedalModule extends BaseModule { */ private readonly ROOM_LIVE_STATUS_FETCHERS: Array<(roomid: number) => Promise> = [ async (roomid) => { + // 计算 roomid 对应的粉丝勋章在第几页 const roomids = useBiliStore().filteredFansMedalsMap.keys() let index = -1 for (const r of roomids) { @@ -131,7 +132,7 @@ class MedalModule extends BaseModule { } } - // fansMedalPanel 每页返回 10 个勋章,页码从 1 开始 + // 获取粉丝勋章 API 每页返回 10 个勋章,页码从 1 开始 const page = Math.floor(index / 10) + 1 const { status, canTryNextPage } = await this.fetchMedalPageForLiveStatus(page, roomid) @@ -271,7 +272,7 @@ class MedalModule extends BaseModule { /** * 是否需要因跨天而提前停止 */ - protected shouldStopForCrossDay(): boolean { + protected static shouldStopForCrossDay(): boolean { return isNowAfter(23, 55) || isNowBefore(0, 5) } @@ -280,7 +281,7 @@ class MedalModule extends BaseModule { * * @returns 是否获取成功 */ - protected waitForFansMedals(): Promise { + protected static waitForFansMedals(): Promise { return new Promise((resolve) => { const biliStore = useBiliStore() if (biliStore.fansMedalsMeta.status === 'loaded') { @@ -376,7 +377,7 @@ class MedalModule extends BaseModule { * 从粉丝团升级任务 sub_title 解析每日任务进度文案 "每日上限 X/Y" * * @param sub_title API 返回的 sub_title - * @returns 解析成功返回 `{ current, limit }`,否则返回 `null` + * @returns 解析成功返回当前任务进度和任务上限,否则返回 `null` */ protected static parseDailyLimit(sub_title?: string): { current: number; limit: number } | null { if (!sub_title) return null @@ -389,7 +390,7 @@ class MedalModule extends BaseModule { * 按 jump_type 在粉丝团升级任务信息 task_info 中查找任务项 */ protected static findTaskInfo( - task_info: LiveData.GetActivatedMedalInfo.TaskInfo[] | null, + task_info: LiveData.GetActivatedMedalInfo.TaskInfo[], jump_type: TaskJumpType, ): LiveData.GetActivatedMedalInfo.TaskInfo | undefined { return task_info?.find((t) => t.jump_type === jump_type) @@ -400,7 +401,7 @@ class MedalModule extends BaseModule { * * @returns 解析成功返回数字,否则返回 null */ - protected static parseTitleCount(title?: string): number | null { + protected static parseTitleCount(title: string): number | null { if (!title) return null const match = title.match(/\d+/) if (!match) return null @@ -417,7 +418,7 @@ class MedalModule extends BaseModule { try { await moduleStore.rerunModule('Default_FansMedals', true) const status = useBiliStore().fansMedalsMeta.status - return status === 'loading' ? await this.waitForFansMedals() : status === 'loaded' + return status === 'loading' ? await MedalModule.waitForFansMedals() : status === 'loaded' } catch (error) { this.logger.error('刷新粉丝勋章列表失败', error) return false @@ -430,14 +431,14 @@ class MedalModule extends BaseModule { * - 默认以随机顺序尝试多个接口 * * @param roomid 直播间号 - * @param preferMedalAPI 是否优先使用粉丝勋章API获取直播状态(其余接口仍随机),默认 false + * @param preferMedalAPI 是否优先使用粉丝勋章API(其余接口仍随机),默认 false * * @returns * - `1`:直播中 * - `0`/`2`:未开播/轮播中 * - `null`:所有接口都获取失败 */ - protected async fetchRoomLiveStatus( + private async fetchRoomLiveStatus( roomid: number, preferMedalAPI = false, ): Promise { @@ -517,24 +518,22 @@ class MedalModule extends BaseModule { protected async runWaitingRound( roomids: number[], targetPredicate: (liveStatus: number) => boolean, - runOne: (medal: LiveData.FansMedalPanel.List) => Promise, - ): Promise { + runOne: ( + medal: LiveData.FansMedalPanel.List, + ) => Promise>, + ): Promise { const biliStore = useBiliStore() this.logger.log(`开始等待轮询,待探测直播间数量:${roomids.length}`, { roomids }) const medalMap = biliStore.filteredFansMedalsMap - const nextPending: number[] = [] - let verifiedCompleted = true + const requeueRoomids: number[] = [] + let markUncompleted = false for (let i = 0; i < roomids.length; i++) { - if (this.shouldStopForCrossDay()) { + if (MedalModule.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮等待轮询') - return { - interrupted: true, - verifiedCompleted, - pendingRoomids: [...nextPending, ...roomids.slice(i)], - } + return { stop: true } } const roomid = roomids[i] @@ -545,7 +544,6 @@ class MedalModule extends BaseModule { continue } - const room_id = medal.room_info.room_id const target_id = medal.medal.target_id const nick_name = medal.anchor_info.nick_name const medal_name = medal.medal.medal_name @@ -560,33 +558,28 @@ class MedalModule extends BaseModule { if (verdict !== 'pass') { if (verdict === 'error') { this.logger.error( - `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的直播状态,回到等待队列;可能遭遇风控,休眠 5 分钟再继续`, + `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${roomid})的直播状态,回到等待队列;可能遭遇风控,休眠 5 分钟再继续`, ) await sleep(300e3) } - nextPending.push(roomid) + requeueRoomids.push(roomid) continue } this.logger.log( `粉丝勋章【${medal_name}】 主播【${nick_name}】(UID:${target_id})的直播间 ${roomid} 已达到目标状态,立即执行`, ) - const result = await runOne(medal) + const action = await runOne(medal) - if (result.interrupted) { - return { - interrupted: true, - verifiedCompleted: false, - pendingRoomids: [...nextPending, ...roomids.slice(i)], - } - } - if (!result.verifiedCompleted) { - verifiedCompleted = false + if (action === 'stop') { + return { stop: true } + } else if (action === 'markUncompleted') { + markUncompleted = true } } - return { interrupted: false, verifiedCompleted, pendingRoomids: nextPending } + return { markUncompleted, requeueRoomids } } /** diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 6213a215..2e3951ce 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -4,7 +4,7 @@ import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' import { sleep } from '@/library/utils' -import type { BatchExecutionResult, GroupedMedals, TaskExecutionResult } from './types' +import type { BatchExecutionResult, GroupedMedals, AfterExecutionAction } from './types' class DanmuTask extends MedalModule { config = this.medalTasksConfig.danmu @@ -58,16 +58,26 @@ class DanmuTask extends MedalModule { * @param danmuIndexRef 弹幕索引引用,用于记录当前正在发送的弹幕索引 * @param skipPreVerify 是否跳过执行前直播状态校验,默认 false * - * @returns 执行结果,包含是否中断以及是否确认完成 + * @returns 执行结果 */ + private async executeDanmuTask( + medal: LiveData.FansMedalPanel.List, + danmuIndexRef: { value: number }, + skipPreVerify?: false, + ): Promise + private async executeDanmuTask( + medal: LiveData.FansMedalPanel.List, + danmuIndexRef: { value: number }, + skipPreVerify?: true, + ): Promise> private async executeDanmuTask( medal: LiveData.FansMedalPanel.List, danmuIndexRef: { value: number }, skipPreVerify = false, - ): Promise { - if (this.shouldStopForCrossDay()) { + ): Promise> { + if (MedalModule.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') - return { interrupted: true, verifiedCompleted: false } + return 'stop' } const room_id = medal.room_info.room_id @@ -80,14 +90,14 @@ class DanmuTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息,跳过发弹幕任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } if (medalData.reach_free_intimacy_limit) { this.logger.warn( `粉丝勋章【${medal_name}】(主播【${nick_name}】,UID:${target_id},直播间:${room_id})已达到储蓄亲密度上限(已储蓄 ${medalData.free_intimacy} 亲密度,投喂一个粉丝灯牌即可领取这些亲密度),无法通过发弹幕获取更多亲密度,跳过发弹幕任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } const item = MedalModule.findTaskInfo(medalData.task_info, 'sendDanmu') @@ -95,20 +105,20 @@ class DanmuTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法在主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息中找到发弹幕任务,跳过发弹幕任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } - if (item.is_done) return { interrupted: false, verifiedCompleted: true } + if (item.is_done) return null const parsed = MedalModule.parseDailyLimit(item.sub_title) if (!parsed) { this.logger.error( `粉丝勋章【${medal_name}】 无法解析主播【${nick_name}】(UID:${target_id},直播间:${room_id})的发弹幕任务的每日上限信息,跳过发弹幕任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } - if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } + if (parsed.current >= parsed.limit) return null if (!skipPreVerify) { if (this.config.onlyWhenNotLiving) { @@ -120,16 +130,12 @@ class DanmuTask extends MedalModule { ) await sleep(300e3) - return this.config.waitUntilNotLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + return this.config.waitUntilNotLiving ? 'requeue' : null } else if (verdict === 'fail') { this.logger.log( `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'}`, ) - return this.config.waitUntilNotLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + return this.config.waitUntilNotLiving ? 'requeue' : null } } } @@ -142,9 +148,9 @@ class DanmuTask extends MedalModule { let hasSuccessfulDanmu = false for (let j = 0; j < remaining; j++) { - if (this.shouldStopForCrossDay()) { + if (MedalModule.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') - return { interrupted: true, verifiedCompleted: false } + return 'stop' } const danmuText = this.config.danmuList[danmuIndexRef.value++ % this.config.danmuList.length] @@ -164,41 +170,39 @@ class DanmuTask extends MedalModule { } if (hasSuccessfulDanmu) { - return { - interrupted: false, - verifiedCompleted: await this.confirmTaskCompletedAfterUpdate( - medal, - 'sendDanmu', - this.config.useTargetRounds ? target : undefined, - ), - } + return (await this.confirmTaskCompletedAfterUpdate( + medal, + 'sendDanmu', + this.config.useTargetRounds ? target : undefined, + )) + ? null + : 'markUncompleted' } // 所有发弹幕尝试均失败,估计是有什么异常状况,跳过 - return { interrupted: false, verifiedCompleted: true } + return null } /** * 顺序执行多个直播间的发弹幕任务 * - * @returns 执行结果,包含是否中断、是否都确认完成、以及执行前校验未通过需回到等待队列的直播间 + * @returns 执行结果 */ private async executeDanmuTasks( medals: LiveData.FansMedalPanel.List[], danmuIndexRef: { value: number }, ): Promise { - let verifiedCompleted = true + let markUncompleted = false const requeueRoomids: number[] = [] for (let i = 0; i < medals.length; i++) { - const result = await this.executeDanmuTask(medals[i], danmuIndexRef) - if (result.interrupted) { - return { interrupted: true, verifiedCompleted: false, requeueRoomids } - } - if (result.requeue) { + const action = await this.executeDanmuTask(medals[i], danmuIndexRef) + if (action === 'stop') { + return { stop: true } + } else if (action === 'requeue') { requeueRoomids.push(medals[i].room_info.room_id) - } else if (!result.verifiedCompleted) { - verifiedCompleted = false + } else if (action === 'markUncompleted') { + markUncompleted = true } if (i < medals.length - 1) { @@ -206,7 +210,7 @@ class DanmuTask extends MedalModule { } } - return { interrupted: false, verifiedCompleted, requeueRoomids } + return { markUncompleted, requeueRoomids } } public async run(): Promise { @@ -215,7 +219,7 @@ class DanmuTask extends MedalModule { if (!isTimestampToday(this.config._lastCompleteTime)) { await this.waitForLightTask() - if (!(await this.waitForFansMedals())) { + if (!(await MedalModule.waitForFansMedals())) { this.logger.error('粉丝勋章数据不存在,不执行发弹幕任务') this.status = 'error' return @@ -225,57 +229,60 @@ class DanmuTask extends MedalModule { MedalModule.initSnapshotsWithFansMedalsData() const { readyMedals, waitingMedals } = this.getMedals() + let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) let allCompleted = true - // 跨房间共享的弹幕索引,避免每个房间都从同一条弹幕开始发 const danmuIndexRef = { value: 0 } - let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) - const initialResult = await this.executeDanmuTasks(readyMedals, danmuIndexRef) - // 初始 readyMedals 中执行前校验发现仍在直播、且开启了等待下播的房间,并入等待队列 - pendingRoomids.push(...initialResult.requeueRoomids) - if (initialResult.interrupted || !initialResult.verifiedCompleted) { + const { stop, markUncompleted, requeueRoomids } = await this.executeDanmuTasks( + readyMedals, + danmuIndexRef, + ) + if (markUncompleted) { allCompleted = false } + if (requeueRoomids) { + pendingRoomids.push(...requeueRoomids) + } - while (allCompleted && pendingRoomids.length > 0) { - if (this.shouldStopForCrossDay()) { - this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') - allCompleted = false - break - } + if (!stop) { + while (pendingRoomids.length > 0) { + if (MedalModule.shouldStopForCrossDay()) { + this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') + allCompleted = false + break + } - const roundResult = await this.runWaitingRound( - pendingRoomids, - (liveStatus) => liveStatus !== 1, - (medal) => this.executeDanmuTask(medal, danmuIndexRef, true), - ) - pendingRoomids = roundResult.pendingRoomids + const { stop, markUncompleted, requeueRoomids } = await this.runWaitingRound( + pendingRoomids, + (liveStatus) => liveStatus !== 1, + (medal) => this.executeDanmuTask(medal, danmuIndexRef, true), + ) + if (stop) { + break + } + if (markUncompleted) { + allCompleted = false + } - if (roundResult.interrupted || !roundResult.verifiedCompleted) { - allCompleted = false - break - } + pendingRoomids = requeueRoomids! - if (pendingRoomids.length > 0) { - const medalMap = useBiliStore().filteredFansMedalsMap - const pendingRoomsInfo: Record = {} + if (pendingRoomids.length > 0) { + const medalMap = useBiliStore().filteredFansMedalsMap + const pendingRoomsInfo: Record = {} - for (const roomid of pendingRoomids) { - pendingRoomsInfo[roomid] = medalMap.get(roomid)?.anchor_info.nick_name - } + for (const roomid of pendingRoomids) { + pendingRoomsInfo[roomid] = medalMap.get(roomid)?.anchor_info.nick_name + } - this.logger.log( - `仍有 ${pendingRoomids.length} 个直播间未下播,${MedalModule.WAIT_POLL_INTERVAL / 1000} 秒后继续检查`, - { pendingRoomsInfo }, - ) - await sleep(MedalModule.WAIT_POLL_INTERVAL) + this.logger.log( + `仍有 ${pendingRoomids.length} 个直播间未下播,${MedalModule.WAIT_POLL_INTERVAL / 1000} 秒后继续检查`, + { pendingRoomsInfo }, + ) + await sleep(MedalModule.WAIT_POLL_INTERVAL) + } } } - if (pendingRoomids.length > 0) { - allCompleted = false - } - if (allCompleted) { this.config._lastCompleteTime = tsm() this.status = 'done' diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts index e738887a..d2265e7a 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/lightTask.ts @@ -4,7 +4,7 @@ import { sleep } from '@/library/utils' import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' -import type { GroupedMedals, LightPathExecutionResult } from './types' +import type { GroupedMedals } from './types' class LightTask extends MedalModule { config = this.medalTasksConfig.light @@ -93,7 +93,7 @@ class LightTask extends MedalModule { */ private async likeTask( medals: LiveData.FansMedalPanel.List[], - ): Promise { + ): Promise> { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] @@ -158,7 +158,7 @@ class LightTask extends MedalModule { private async sendDanmuTask( medals: LiveData.FansMedalPanel.List[], danmuIndexRef: { value: number }, - ): Promise { + ): Promise> { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] const skippedByStatusMedals: LiveData.FansMedalPanel.List[] = [] @@ -233,7 +233,7 @@ class LightTask extends MedalModule { this.logger.log('点亮熄灭勋章模块开始运行') if (!isTimestampToday(this.config._lastCompleteTime)) { - if (!(await this.waitForFansMedals())) { + if (!(await MedalModule.waitForFansMedals())) { this.logger.error('粉丝勋章数据不存在,不执行点亮熄灭勋章任务') this.status = 'error' return @@ -249,7 +249,6 @@ class LightTask extends MedalModule { if (isEffectiveRun) { const attemptedMedals: LiveData.FansMedalPanel.List[] = [] - // 跨房间共享的弹幕索引,避免每个房间都从同一条弹幕开始发 const danmuIndexRef = { value: 0 } while (notLivingMedals.length > 0 || livingMedals.length > 0) { diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index 6b82795d..a697f9dc 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -4,7 +4,7 @@ import type { ModuleStatusTypes } from '@/types' import MedalModule from '@/modules/dailyTasks/liveTasks/medalTasks/MedalModule' import type { LiveData } from '@/library/bili-api/data' import { sleep } from '@/library/utils' -import type { BatchExecutionResult, GroupedMedals, TaskExecutionResult } from './types' +import type { AfterExecutionAction, BatchExecutionResult, GroupedMedals } from './types' class LikeTask extends MedalModule { config = this.medalTasksConfig.like @@ -53,15 +53,23 @@ class LikeTask extends MedalModule { * @param medal 粉丝勋章 * @param skipPreVerify 是否跳过执行前直播状态校验,默认 false * - * @returns 执行结果,包含是否中断以及是否确认完成 + * @returns 执行结果 */ + private async executeLikeTask( + medal: LiveData.FansMedalPanel.List, + skipPreVerify?: false, + ): Promise + private async executeLikeTask( + medal: LiveData.FansMedalPanel.List, + skipPreVerify?: true, + ): Promise> private async executeLikeTask( medal: LiveData.FansMedalPanel.List, skipPreVerify = false, - ): Promise { - if (this.shouldStopForCrossDay()) { + ): Promise> { + if (MedalModule.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') - return { interrupted: true, verifiedCompleted: false } + return 'stop' } const room_id = medal.room_info.room_id @@ -74,14 +82,14 @@ class LikeTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息,跳过点赞任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } if (medalData.reach_free_intimacy_limit) { this.logger.warn( `粉丝勋章【${medal_name}】(主播【${nick_name}】,UID:${target_id},直播间:${room_id})已达到储蓄亲密度上限(已储蓄 ${medalData.free_intimacy} 亲密度,投喂一个粉丝灯牌即可领取这些亲密度),无法通过点赞获取更多亲密度,跳过点赞任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } const item = MedalModule.findTaskInfo(medalData.task_info, 'like') @@ -89,20 +97,20 @@ class LikeTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法在主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息中找到点赞任务,跳过点赞任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } - if (item.is_done) return { interrupted: false, verifiedCompleted: true } + if (item.is_done) return null const parsed = MedalModule.parseDailyLimit(item.sub_title) if (!parsed) { this.logger.error( `粉丝勋章【${medal_name}】 无法解析主播【${nick_name}】(UID:${target_id},直播间:${room_id})的点赞任务的每日上限信息,跳过点赞任务`, ) - return { interrupted: false, verifiedCompleted: true } + return null } - if (parsed.current >= parsed.limit) return { interrupted: false, verifiedCompleted: true } + if (parsed.current >= parsed.limit) return null if (!skipPreVerify) { const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) @@ -113,16 +121,12 @@ class LikeTask extends MedalModule { ) await sleep(300e3) - return this.config.waitUntilLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + return this.config.waitUntilLiving ? 'requeue' : null } else if (verdict === 'fail') { this.logger.log( - `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '本轮跳过'}`, + `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '跳过点赞任务'}`, ) - return this.config.waitUntilLiving - ? { interrupted: false, verifiedCompleted: false, requeue: true } - : { interrupted: false, verifiedCompleted: true } + return this.config.waitUntilLiving ? 'requeue' : null } } @@ -134,9 +138,9 @@ class LikeTask extends MedalModule { let hasSuccessfulLike = false for (let j = 0; j < remaining; j++) { - if (this.shouldStopForCrossDay()) { + if (MedalModule.shouldStopForCrossDay()) { this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') - return { interrupted: true, verifiedCompleted: false } + return 'stop' } if (await this.like(medal, times)) { @@ -149,40 +153,38 @@ class LikeTask extends MedalModule { } if (hasSuccessfulLike) { - return { - interrupted: false, - verifiedCompleted: await this.confirmTaskCompletedAfterUpdate( - medal, - 'like', - this.config.useTargetRounds ? target : undefined, - ), - } + return (await this.confirmTaskCompletedAfterUpdate( + medal, + 'like', + this.config.useTargetRounds ? target : undefined, + )) + ? null + : 'markUncompleted' } // 所有点赞尝试均失败,估计是有什么异常状况,跳过 - return { interrupted: false, verifiedCompleted: true } + return null } /** * 顺序执行多个直播间的点赞任务 * - * @returns 执行结果,包含是否中断、是否都确认完成、以及执行前校验未通过需回到等待队列的直播间 + * @returns 执行结果 */ private async executeLikeTasks( medals: LiveData.FansMedalPanel.List[], ): Promise { - let verifiedCompleted = true + let markUncompleted = false const requeueRoomids: number[] = [] for (let i = 0; i < medals.length; i++) { - const result = await this.executeLikeTask(medals[i]) - if (result.interrupted) { - return { interrupted: true, verifiedCompleted: false, requeueRoomids } - } - if (result.requeue) { + const action = await this.executeLikeTask(medals[i]) + if (action === 'stop') { + return { stop: true } + } else if (action === 'requeue') { requeueRoomids.push(medals[i].room_info.room_id) - } else if (!result.verifiedCompleted) { - verifiedCompleted = false + } else if (action === 'markUncompleted') { + markUncompleted = true } if (i < medals.length - 1) { @@ -190,7 +192,7 @@ class LikeTask extends MedalModule { } } - return { interrupted: false, verifiedCompleted, requeueRoomids } + return { markUncompleted, requeueRoomids } } public async run(): Promise { @@ -199,7 +201,7 @@ class LikeTask extends MedalModule { if (!isTimestampToday(this.config._lastCompleteTime)) { await this.waitForLightTask() - if (!(await this.waitForFansMedals())) { + if (!(await MedalModule.waitForFansMedals())) { this.logger.error('粉丝勋章数据不存在,不执行点赞任务') this.status = 'error' return @@ -212,52 +214,52 @@ class LikeTask extends MedalModule { let pendingRoomids = waitingMedals.map((medal) => medal.room_info.room_id) let allCompleted = true - const initialResult = await this.executeLikeTasks(readyMedals) - // 初始 readyMedals 中执行前校验发现已下播、且开启了等待开播的房间,并入等待队列 - pendingRoomids.push(...initialResult.requeueRoomids) - if (initialResult.interrupted || !initialResult.verifiedCompleted) { + const { stop, markUncompleted, requeueRoomids } = await this.executeLikeTasks(readyMedals) + if (markUncompleted) { allCompleted = false } + if (requeueRoomids) { + pendingRoomids.push(...requeueRoomids) + } + if (!stop) { + while (pendingRoomids.length > 0) { + if (MedalModule.shouldStopForCrossDay()) { + this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') + allCompleted = false + break + } - while (allCompleted && pendingRoomids.length > 0) { - if (this.shouldStopForCrossDay()) { - this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') - allCompleted = false - break - } + const { stop, markUncompleted, requeueRoomids } = await this.runWaitingRound( + pendingRoomids, + (liveStatus) => liveStatus === 1, + (medal) => this.executeLikeTask(medal, true), + ) + if (stop) { + break + } + if (markUncompleted) { + allCompleted = false + } - const roundResult = await this.runWaitingRound( - pendingRoomids, - (liveStatus) => liveStatus === 1, - (medal) => this.executeLikeTask(medal, true), - ) - pendingRoomids = roundResult.pendingRoomids + pendingRoomids = requeueRoomids! - if (roundResult.interrupted || !roundResult.verifiedCompleted) { - allCompleted = false - break - } + if (pendingRoomids.length > 0) { + const medalMap = useBiliStore().filteredFansMedalsMap + const pendingRoomsInfo: Record = {} - if (pendingRoomids.length > 0) { - const medalMap = useBiliStore().filteredFansMedalsMap - const pendingRoomsInfo: Record = {} + for (const roomid of pendingRoomids) { + pendingRoomsInfo[roomid] = medalMap.get(roomid)?.anchor_info.nick_name + } - for (const roomid of pendingRoomids) { - pendingRoomsInfo[roomid] = medalMap.get(roomid)?.anchor_info.nick_name + this.logger.log( + `仍有 ${pendingRoomids.length} 个直播间未开播,${MedalModule.WAIT_POLL_INTERVAL / 1000} 秒后继续检查`, + { pendingRoomsInfo }, + ) + await sleep(MedalModule.WAIT_POLL_INTERVAL) } - - this.logger.log( - `仍有 ${pendingRoomids.length} 个直播间未开播,${MedalModule.WAIT_POLL_INTERVAL / 1000} 秒后继续检查`, - { pendingRoomsInfo }, - ) - await sleep(MedalModule.WAIT_POLL_INTERVAL) } } - if (pendingRoomids.length > 0) { - allCompleted = false - } - if (allCompleted) { this.config._lastCompleteTime = tsm() this.status = 'done' diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts index d0a9b081..d0fe427e 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts @@ -21,7 +21,7 @@ type GroupedMedals = Record type RequestQueueKey = 'taskInfo' | 'roomStatus' type TaskJumpType = 'like' | 'sendDanmu' | 'watchLive' | 'feedLight' | 'sendGift' -/** 最近一次观测到的直播间直播状态样本 */ +/** 直播间直播状态快照 */ interface LiveStatusSnapshot { /** 直播状态:1 直播中,0/2 未开播/轮播 */ liveStatus: number @@ -30,7 +30,7 @@ interface LiveStatusSnapshot { } /** - * 执行前直播状态校验结论 + * 直播间任务执行前直播状态校验结论 * * - `pass`:符合目标状态 * - `fail`:不符合目标状态 @@ -38,37 +38,21 @@ interface LiveStatusSnapshot { */ type PreExecuteVerdict = 'pass' | 'fail' | 'error' -/** 单个直播间任务的执行结果 */ -interface TaskExecutionResult { - /** 是否因跨天等原因中断 */ - interrupted: boolean - /** 是否确认任务已完成 */ - verifiedCompleted: boolean - /** 执行前直播状态校验未通过,需要重新回到等待队列(仅 waitUntil* 开启时) */ - requeue?: boolean -} +/** + * 直播间任务执行后操作 + * + * - `stop`:终止后续任务 + * - `requeue`:把直播间放到等待队列 + * - `markUncompleted`:当前任务标记未完成 + */ +type AfterExecutionAction = 'stop' | 'requeue' | 'markUncompleted' | null -/** 顺序执行多个直播间任务的结果 */ +/** 直播间任务批量执行结果 */ interface BatchExecutionResult { - interrupted: boolean - verifiedCompleted: boolean - /** 执行前校验未通过、需要回到等待队列的直播间ID */ - requeueRoomids: number[] -} - -/** 一轮等待轮询的执行结果 */ -interface WaitRoundResult { - interrupted: boolean - verifiedCompleted: boolean - /** 本轮结束后仍需继续等待的直播间ID */ - pendingRoomids: number[] -} - -interface LightPathExecutionResult { - /** 尝试点亮过的粉丝勋章列表 */ - attemptedMedals: LiveData.FansMedalPanel.List[] - /** 因为直播状态不对被跳过的粉丝勋章列表 */ - skippedByStatusMedals: LiveData.FansMedalPanel.List[] + stop?: boolean + markUncompleted?: boolean + /** 放到等待队列的直播间id列表 */ + requeueRoomids?: number[] } export { @@ -79,8 +63,6 @@ export { TaskJumpType, LiveStatusSnapshot, PreExecuteVerdict, - TaskExecutionResult, + AfterExecutionAction, BatchExecutionResult, - WaitRoundResult, - LightPathExecutionResult, } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts index 1dc7600b..585aa843 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/watchTask.ts @@ -354,7 +354,7 @@ class WatchTask extends MedalModule { if (!isTimestampToday(this.config._lastCompleteTime)) { await this.waitForLightTask() - if (!(await this.waitForFansMedals())) { + if (!(await MedalModule.waitForFansMedals())) { this.logger.error('粉丝勋章数据不存在,不执行观看直播任务') this.status = 'error' return From ec713fb3c45a75e1e408b002c199953a92f6b306 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:25:19 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E9=9B=B6=E6=95=A3?= =?UTF-8?q?=E5=B0=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dailyTasks/mainSiteTasks/coinTask.ts | 13 ++- .../otherTasks/getYearVipPrivilegeTask.ts | 80 +++++++++---------- src/modules/enhanceExperience/noReport.ts | 3 - src/stores/useModuleStore.ts | 6 +- 4 files changed, 48 insertions(+), 54 deletions(-) diff --git a/src/modules/dailyTasks/mainSiteTasks/coinTask.ts b/src/modules/dailyTasks/mainSiteTasks/coinTask.ts index 60170234..18c7151d 100644 --- a/src/modules/dailyTasks/mainSiteTasks/coinTask.ts +++ b/src/modules/dailyTasks/mainSiteTasks/coinTask.ts @@ -66,7 +66,7 @@ class CoinTask extends BaseModule { // 剩余可投硬币数量 const allowed_coin_num = this.MAX_COIN - coined_num if (allowed_coin_num > 0) { - // 剩余可投硬币数和剩余所需投币数取较小者 + // 投币数量:剩余可投硬币数和剩余所需投币数取较小者 const coin_num = Math.min(allowed_coin_num, left_coin_num) const result = await this.coin(aid, coin_num) if (result === 0) { @@ -83,15 +83,12 @@ class CoinTask extends BaseModule { this.logger.warn('硬币余额不足,每日投币任务终止') this.status = 'error' break + } else { + // 接口报错或网络错误 + break } } } - - if (left_coin_num > 0) { - // 硬币余额不足,终止任务,不记录完成时间 - this.logger.warn('硬币余额不足,每日投币任务终止') - this.status = 'error' - } } /** @@ -153,7 +150,7 @@ class CoinTask extends BaseModule { // 剩余要投的硬币数量 const left_coin_num = this.config.num - total_coined_num // 拥有的硬币数量 - const money = biliStore.userInfo!.money ?? 5 + const money = biliStore.userInfo!.money if (left_coin_num > money) { this.logger.log('硬币余额不足,不执行每日投币任务') this.status = 'done' diff --git a/src/modules/dailyTasks/otherTasks/getYearVipPrivilegeTask.ts b/src/modules/dailyTasks/otherTasks/getYearVipPrivilegeTask.ts index 2a1b47ec..336b12ee 100644 --- a/src/modules/dailyTasks/otherTasks/getYearVipPrivilegeTask.ts +++ b/src/modules/dailyTasks/otherTasks/getYearVipPrivilegeTask.ts @@ -163,52 +163,52 @@ class GetYearVipPrivilegeTask extends BaseModule { // 当前时间已经超过了上次记录的下次领取时间,领取权益 this.status = 'running' const list = await this.myPrivilege() - if (list) { - const isSuperVip = this.isSuperVip() + if (!list) return - for (const privilege of list) { - if (!this.canReceivePrivilege(privilege, isSuperVip)) { - continue - } + const isSuperVip = this.isSuperVip() - if (privilege.state === 0) { - if (privilege.type === 9) { - // 专属等级加速包 - await this.addExperience() - } else { - await this.receivePrivilege(privilege.type) - } - } else if (privilege.state === 1) { - this.logger.log( - `该权益(type = ${privilege.type}, ${this.getPrivilegeName(privilege.type)})已经领取过了`, - ) + for (const privilege of list) { + if (!this.canReceivePrivilege(privilege, isSuperVip)) { + continue + } + + if (privilege.state === 0) { + if (privilege.type === 9) { + // 专属等级加速包 + await this.addExperience() } else { - // 需要完成任务才能领取 - if (privilege.type === 9) { - const watchTaskConfig = useModuleStore().moduleConfig.DailyTasks.MainSiteTasks.watch - if (watchTaskConfig.enabled) { - this.logger.log('等待观看视频任务完成后再领取专属等级加速包(10主站经验)...') - watch( - () => watchTaskConfig._lastCompleteTime, - () => sleep(3000).then(() => this.addExperience()), - { once: true }, - ) - } else { - this.logger.warn( - '领取专属等级加速包(10主站经验)前需要观看任意一个视频,请打开【主站任务】中的【每日观看视频】,或是在运行脚本前手动观看', - ) - } - } + await this.receivePrivilege(privilege.type) } - await sleep(500) - } - this.status = 'done' - const nextReceiveTimes = list.map((i) => i.period_end_unix).filter((unix) => unix > ts()) - if (nextReceiveTimes.length > 0) { - this.config._nextReceiveTime = Math.min(...nextReceiveTimes) + } else if (privilege.state === 1) { + this.logger.log( + `该权益(type = ${privilege.type}, ${this.getPrivilegeName(privilege.type)})已经领取过了`, + ) } else { - this.config._nextReceiveTime = ts() + 86400 + // 需要完成任务才能领取 + if (privilege.type === 9) { + const watchTaskConfig = useModuleStore().moduleConfig.DailyTasks.MainSiteTasks.watch + if (watchTaskConfig.enabled) { + this.logger.log('等待观看视频任务完成后再领取专属等级加速包(10主站经验)...') + watch( + () => watchTaskConfig._lastCompleteTime, + () => sleep(3000).then(() => this.addExperience()), + { once: true }, + ) + } else { + this.logger.warn( + '领取专属等级加速包(10主站经验)前需要观看任意一个视频,请打开【主站任务】中的【每日观看视频】,或是在运行脚本前手动观看', + ) + } + } } + await sleep(500) + } + this.status = 'done' + const nextReceiveTimes = list.map((i) => i.period_end_unix).filter((unix) => unix > ts()) + if (nextReceiveTimes.length > 0) { + this.config._nextReceiveTime = Math.min(...nextReceiveTimes) + } else { + this.config._nextReceiveTime = ts() + 86400 } } diff --git a/src/modules/enhanceExperience/noReport.ts b/src/modules/enhanceExperience/noReport.ts index a0485cbb..daba4db9 100644 --- a/src/modules/enhanceExperience/noReport.ts +++ b/src/modules/enhanceExperience/noReport.ts @@ -222,9 +222,6 @@ class NoReport extends BaseModule { handler.next(config) } }, - onResponse(response, handler) { - handler.next(response) - }, } proxy(ajaxHookProxyConfig, unsafeWindow) diff --git a/src/stores/useModuleStore.ts b/src/stores/useModuleStore.ts index b768c8c0..38d3e0ac 100644 --- a/src/stores/useModuleStore.ts +++ b/src/stores/useModuleStore.ts @@ -50,7 +50,7 @@ export const useModuleStore = defineStore('module', () => { // 模块状态 const moduleStatus = ref(defaultModuleStatus) // 模块实例映射(key: 模块名称, value: 模块实例) - const moduleInstances = ref>({}) + const moduleInstances: Record = {} // 模块状态、运行记录重置和再运行 const moduleReset: ModuleReset = { DailyTasks: { @@ -164,7 +164,7 @@ export const useModuleStore = defineStore('module', () => { */ function _runModule(module: typeof BaseModule, name: string): Promise | void { const moduleInstance = new module(name) - moduleInstances.value[name] = moduleInstance + moduleInstances[name] = moduleInstance if (moduleInstance.isEnabled()) { return moduleInstance.run() @@ -276,7 +276,7 @@ export const useModuleStore = defineStore('module', () => { * @param args `run()` 方法参数 */ function rerunModule(moduleName: string, ...args: any[]): Promise | void { - const moduleInstance = moduleInstances.value[moduleName] + const moduleInstance = moduleInstances[moduleName] if (moduleInstance) { clearTimeout(moduleInstance.nextRunTimer) From 828c5e7e7053a6f816bf81898f63587c5315f14b Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:01:43 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=8E=88=20perf:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E9=94=99=E8=AF=AF=E7=B1=BB=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/library/error/index.ts | 4 ++++ src/modules/default/dailyRewardInfo.ts | 2 +- src/modules/default/dynamicVideos.ts | 2 +- src/modules/default/fansMedals.ts | 2 +- src/modules/default/userInfo.ts | 2 +- src/stores/useModuleStore.ts | 3 +-- 6 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 src/library/error/index.ts diff --git a/src/library/error/index.ts b/src/library/error/index.ts new file mode 100644 index 00000000..0ca5fda6 --- /dev/null +++ b/src/library/error/index.ts @@ -0,0 +1,4 @@ +import ModuleError from './ModuleError' +import ModuleCriticalError from './ModuleCriticalError' + +export { ModuleError, ModuleCriticalError } diff --git a/src/modules/default/dailyRewardInfo.ts b/src/modules/default/dailyRewardInfo.ts index fa853aec..9419ede2 100644 --- a/src/modules/default/dailyRewardInfo.ts +++ b/src/modules/default/dailyRewardInfo.ts @@ -3,7 +3,7 @@ import BAPI from '@/library/bili-api' import type { MainData } from '@/library/bili-api/data' import { delayToNextMoment, isTimestampToday } from '@/library/luxon' import BaseModule from '@/modules/BaseModule' -import ModuleError from '@/library/error/ModuleError' +import { ModuleError } from '@/library/error' class DailyRewardInfo extends BaseModule { /** diff --git a/src/modules/default/dynamicVideos.ts b/src/modules/default/dynamicVideos.ts index 0946eb35..b435166f 100644 --- a/src/modules/default/dynamicVideos.ts +++ b/src/modules/default/dynamicVideos.ts @@ -3,7 +3,7 @@ import BAPI from '@/library/bili-api' import type { MainData } from '@/library/bili-api/data' import { delayToNextMoment, isTimestampToday } from '@/library/luxon' import BaseModule from '@/modules/BaseModule' -import ModuleError from '@/library/error/ModuleError' +import { ModuleError } from '@/library/error' class DynamicVideos extends BaseModule { /** diff --git a/src/modules/default/fansMedals.ts b/src/modules/default/fansMedals.ts index 80749594..5cd25127 100644 --- a/src/modules/default/fansMedals.ts +++ b/src/modules/default/fansMedals.ts @@ -4,7 +4,7 @@ import type { LiveData } from '@/library/bili-api/data' import { delayToNextMoment, isTimestampToday, tsm } from '@/library/luxon' import { sleep } from '@/library/utils' import BaseModule from '@/modules/BaseModule' -import ModuleError from '@/library/error/ModuleError' +import { ModuleError } from '@/library/error' import _ from 'lodash' class FansMedals extends BaseModule { diff --git a/src/modules/default/userInfo.ts b/src/modules/default/userInfo.ts index 0b048d19..3e0ea0c1 100644 --- a/src/modules/default/userInfo.ts +++ b/src/modules/default/userInfo.ts @@ -3,7 +3,7 @@ import BAPI from '@/library/bili-api' import type { MainData } from '@/library/bili-api/data' import { delayToNextMoment } from '@/library/luxon' import BaseModule from '@/modules/BaseModule' -import ModuleCriticalError from '@/library/error/ModuleCriticalError' +import { ModuleCriticalError } from '@/library/error' class UserInfo extends BaseModule { /** diff --git a/src/stores/useModuleStore.ts b/src/stores/useModuleStore.ts index 38d3e0ac..3b180e24 100644 --- a/src/stores/useModuleStore.ts +++ b/src/stores/useModuleStore.ts @@ -12,8 +12,7 @@ import { deepestIterate, waitForMoment } from '@/library/utils' import { useCacheStore } from './useCacheStore' import { isSelfTopFrame } from '@/library/dom' import type BaseModule from '@/modules/BaseModule' -import ModuleCriticalError from '@/library/error/ModuleCriticalError' -import ModuleError from '@/library/error/ModuleError' +import { ModuleError, ModuleCriticalError } from '@/library/error' const defaultModuleStatus: ModuleStatus = { DailyTasks: { From 27bd4a1ae05f8b681f9bb02f7f206de09d548cf9 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:22:49 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=90=B3=20chore:=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E7=82=B9=E8=B5=9E/=E5=8F=91=E5=BC=B9=E5=B9=95?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84allCompleted=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liveTasks/medalTasks/danmuTask.ts | 22 ++++++++---------- .../liveTasks/medalTasks/likeTask.ts | 23 +++++++++---------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index 2e3951ce..a102f9f9 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -237,27 +237,25 @@ class DanmuTask extends MedalModule { readyMedals, danmuIndexRef, ) - if (markUncompleted) { + + if (stop) { allCompleted = false - } - if (requeueRoomids) { - pendingRoomids.push(...requeueRoomids) - } + } else { + if (markUncompleted) { + allCompleted = false + } + if (requeueRoomids) { + pendingRoomids.push(...requeueRoomids) + } - if (!stop) { while (pendingRoomids.length > 0) { - if (MedalModule.shouldStopForCrossDay()) { - this.logger.log('即将或刚刚发生跨天,提早结束本轮发弹幕任务') - allCompleted = false - break - } - const { stop, markUncompleted, requeueRoomids } = await this.runWaitingRound( pendingRoomids, (liveStatus) => liveStatus !== 1, (medal) => this.executeDanmuTask(medal, danmuIndexRef, true), ) if (stop) { + allCompleted = false break } if (markUncompleted) { diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index a697f9dc..5c6c1286 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -215,26 +215,25 @@ class LikeTask extends MedalModule { let allCompleted = true const { stop, markUncompleted, requeueRoomids } = await this.executeLikeTasks(readyMedals) - if (markUncompleted) { + + if (stop) { allCompleted = false - } - if (requeueRoomids) { - pendingRoomids.push(...requeueRoomids) - } - if (!stop) { - while (pendingRoomids.length > 0) { - if (MedalModule.shouldStopForCrossDay()) { - this.logger.log('即将或刚刚发生跨天,提早结束本轮点赞任务') - allCompleted = false - break - } + } else { + if (markUncompleted) { + allCompleted = false + } + if (requeueRoomids) { + pendingRoomids.push(...requeueRoomids) + } + while (pendingRoomids.length > 0) { const { stop, markUncompleted, requeueRoomids } = await this.runWaitingRound( pendingRoomids, (liveStatus) => liveStatus === 1, (medal) => this.executeLikeTask(medal, true), ) if (stop) { + allCompleted = false break } if (markUncompleted) { From ab552419e3590b89ef8d643d54f5b96acbcb7b42 Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:11:01 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=A6=82=E6=9E=9C?= =?UTF-8?q?=E6=9C=AA=E5=AE=9E=E9=99=85=E6=89=A7=E8=A1=8C=E7=82=B9=E8=B5=9E?= =?UTF-8?q?/=E5=8F=91=E5=BC=B9=E5=B9=95=E6=93=8D=E4=BD=9C=EF=BC=8C?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=AD=89=E5=BE=85=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../liveTasks/medalTasks/MedalModule.ts | 4 ++-- .../liveTasks/medalTasks/danmuTask.ts | 18 +++++++++--------- .../liveTasks/medalTasks/likeTask.ts | 18 +++++++++--------- .../dailyTasks/liveTasks/medalTasks/types.d.ts | 7 ++++--- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts index 4e380cd2..528315cf 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/MedalModule.ts @@ -524,7 +524,7 @@ class MedalModule extends BaseModule { ): Promise { const biliStore = useBiliStore() - this.logger.log(`开始等待轮询,待探测直播间数量:${roomids.length}`, { roomids }) + this.logger.log(`开始探测等待中的直播间,待探测直播间数量:${roomids.length}`, { roomids }) const medalMap = biliStore.filteredFansMedalsMap const requeueRoomids: number[] = [] @@ -532,7 +532,7 @@ class MedalModule extends BaseModule { for (let i = 0; i < roomids.length; i++) { if (MedalModule.shouldStopForCrossDay()) { - this.logger.log('即将或刚刚发生跨天,提早结束本轮等待轮询') + this.logger.log('即将或刚刚发生跨天,不再等待剩余直播间,提早结束本轮任务') return { stop: true } } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts index a102f9f9..69a149df 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/danmuTask.ts @@ -90,14 +90,14 @@ class DanmuTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息,跳过发弹幕任务`, ) - return null + return 'skipSleep' } if (medalData.reach_free_intimacy_limit) { this.logger.warn( `粉丝勋章【${medal_name}】(主播【${nick_name}】,UID:${target_id},直播间:${room_id})已达到储蓄亲密度上限(已储蓄 ${medalData.free_intimacy} 亲密度,投喂一个粉丝灯牌即可领取这些亲密度),无法通过发弹幕获取更多亲密度,跳过发弹幕任务`, ) - return null + return 'skipSleep' } const item = MedalModule.findTaskInfo(medalData.task_info, 'sendDanmu') @@ -105,20 +105,20 @@ class DanmuTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法在主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息中找到发弹幕任务,跳过发弹幕任务`, ) - return null + return 'skipSleep' } - if (item.is_done) return null + if (item.is_done) return 'skipSleep' const parsed = MedalModule.parseDailyLimit(item.sub_title) if (!parsed) { this.logger.error( `粉丝勋章【${medal_name}】 无法解析主播【${nick_name}】(UID:${target_id},直播间:${room_id})的发弹幕任务的每日上限信息,跳过发弹幕任务`, ) - return null + return 'skipSleep' } - if (parsed.current >= parsed.limit) return null + if (parsed.current >= parsed.limit) return 'skipSleep' if (!skipPreVerify) { if (this.config.onlyWhenNotLiving) { @@ -130,12 +130,12 @@ class DanmuTask extends MedalModule { ) await sleep(300e3) - return this.config.waitUntilNotLiving ? 'requeue' : null + return this.config.waitUntilNotLiving ? 'requeue' : 'skipSleep' } else if (verdict === 'fail') { this.logger.log( `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前正在直播,${this.config.waitUntilNotLiving ? '回到等待队列' : '跳过发弹幕任务'}`, ) - return this.config.waitUntilNotLiving ? 'requeue' : null + return this.config.waitUntilNotLiving ? 'requeue' : 'skipSleep' } } } @@ -205,7 +205,7 @@ class DanmuTask extends MedalModule { markUncompleted = true } - if (i < medals.length - 1) { + if (action !== 'skipSleep' && i < medals.length - 1) { await sleep(MedalModule.SEND_DANMU_DYNAMIC_INTERVAL) } } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts index 5c6c1286..0fc059ec 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/likeTask.ts @@ -82,14 +82,14 @@ class LikeTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法获取主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息,跳过点赞任务`, ) - return null + return 'skipSleep' } if (medalData.reach_free_intimacy_limit) { this.logger.warn( `粉丝勋章【${medal_name}】(主播【${nick_name}】,UID:${target_id},直播间:${room_id})已达到储蓄亲密度上限(已储蓄 ${medalData.free_intimacy} 亲密度,投喂一个粉丝灯牌即可领取这些亲密度),无法通过点赞获取更多亲密度,跳过点赞任务`, ) - return null + return 'skipSleep' } const item = MedalModule.findTaskInfo(medalData.task_info, 'like') @@ -97,20 +97,20 @@ class LikeTask extends MedalModule { this.logger.error( `粉丝勋章【${medal_name}】 无法在主播【${nick_name}】(UID:${target_id},直播间:${room_id})的粉丝团升级任务信息中找到点赞任务,跳过点赞任务`, ) - return null + return 'skipSleep' } - if (item.is_done) return null + if (item.is_done) return 'skipSleep' const parsed = MedalModule.parseDailyLimit(item.sub_title) if (!parsed) { this.logger.error( `粉丝勋章【${medal_name}】 无法解析主播【${nick_name}】(UID:${target_id},直播间:${room_id})的点赞任务的每日上限信息,跳过点赞任务`, ) - return null + return 'skipSleep' } - if (parsed.current >= parsed.limit) return null + if (parsed.current >= parsed.limit) return 'skipSleep' if (!skipPreVerify) { const verdict = await this.preExecuteVerify(room_id, (liveStatus) => liveStatus === 1) @@ -121,12 +121,12 @@ class LikeTask extends MedalModule { ) await sleep(300e3) - return this.config.waitUntilLiving ? 'requeue' : null + return this.config.waitUntilLiving ? 'requeue' : 'skipSleep' } else if (verdict === 'fail') { this.logger.log( `粉丝勋章【${medal_name}】 执行前校验:主播【${nick_name}】(UID:${target_id},直播间:${room_id})当前不在直播,${this.config.waitUntilLiving ? '回到等待队列' : '跳过点赞任务'}`, ) - return this.config.waitUntilLiving ? 'requeue' : null + return this.config.waitUntilLiving ? 'requeue' : 'skipSleep' } } @@ -187,7 +187,7 @@ class LikeTask extends MedalModule { markUncompleted = true } - if (i < medals.length - 1) { + if (action !== 'skipSleep' && i < medals.length - 1) { await sleep(MedalModule.LIKE_DYNAMIC_INTERVAL) } } diff --git a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts index d0fe427e..152638a7 100644 --- a/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts +++ b/src/modules/dailyTasks/liveTasks/medalTasks/types.d.ts @@ -41,11 +41,12 @@ type PreExecuteVerdict = 'pass' | 'fail' | 'error' /** * 直播间任务执行后操作 * - * - `stop`:终止后续任务 + * - `stop`:终止后续直播间任务(也意味着当前任务未完成) * - `requeue`:把直播间放到等待队列 - * - `markUncompleted`:当前任务标记未完成 + * - `markUncompleted`:把当前任务(点赞/发弹幕)标记为未完成 + * - `skipSleep`:跳过执行下一个直播间任务之间的等待时间 */ -type AfterExecutionAction = 'stop' | 'requeue' | 'markUncompleted' | null +type AfterExecutionAction = 'stop' | 'requeue' | 'markUncompleted' | 'skipSleep' | null /** 直播间任务批量执行结果 */ interface BatchExecutionResult { From 4784131f81c89a5034ebdfd0ecde8d18765b6d8b Mon Sep 17 00:00:00 2001 From: andywang425 <56548456+andywang425@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:27:04 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=90=B3=20chore:=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=89=8D=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f389fc48..1a9805c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [7.3.1] - 2026-6-25 + ### 新增 - 直播任务【点亮熄灭勋章】、【点赞】和【发弹幕】会在执行每个粉丝勋章的任务前校验直播状态,确保执行任务时直播状态符合预期 diff --git a/package-lock.json b/package-lock.json index 7545723b..35565f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bilibili-live-tasks-helper", - "version": "7.3.0", + "version": "7.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bilibili-live-tasks-helper", - "version": "7.3.0", + "version": "7.3.1", "dependencies": { "ajax-hook": "^3.0.3", "crypto-js": "^4.2.0", diff --git a/package.json b/package.json index a3d80112..a941017c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bilibili-live-tasks-helper", "private": true, - "version": "7.3.0", + "version": "7.3.1", "type": "module", "scripts": { "dev": "vite",