diff --git a/alas.py b/alas.py index 26ae0cd94..bd6da18f4 100644 --- a/alas.py +++ b/alas.py @@ -15,7 +15,6 @@ from module.logger import logger from module.notify import handle_notify RESTART_SENSITIVE_TASKS = ['OpsiObscure', 'OpsiAbyssal', 'OpsiCrossMonth'] -DEVICE_FREE_TASKS = 'DowntimeFetch' class AzurLaneAutoScript: @@ -199,10 +198,6 @@ class AzurLaneAutoScript: LoginHandler(self.config, device=self.device).app_start() UI(self.config, device=self.device).ui_goto_main() - def downtime_fetch(self): - from module.smart_mgmt.downtime_fetch import DowntimeFetch - DowntimeFetch(config=self.config).run() - def research(self): from module.research.research import RewardResearch RewardResearch(config=self.config, device=self.device).run() @@ -465,6 +460,10 @@ class AzurLaneAutoScript: from module.daemon.benchmark import run_benchmark run_benchmark(config=self.config) + def downtime_fetch(self): + from module.smart_mgmt.downtime_fetch import run_downtime_fetch + run_downtime_fetch(config=self.config) + def game_manager(self): from module.daemon.game_manager import GameManager GameManager(config=self.config, device=self.device, task="GameManager").run() @@ -575,8 +574,9 @@ class AzurLaneAutoScript: self.config.task_call('Restart') # Get task task = self.get_next_task() - # Network-only tasks skip device init and screenshot - device_free_task = task == DEVICE_FREE_TASKS + # Init device and change server + _ = self.device + self.device.config = self.config # Skip first restart if task == 'Restart' and self.is_first_task: logger.info('Skip task `Restart` at scheduler start') @@ -586,14 +586,10 @@ class AzurLaneAutoScript: # Run logger.info(f'Scheduler: Start task `{task}`') - # Init device and change server - if not device_free_task: - _ = self.device - self.device.config = self.config - self.device.stuck_record_clear() - self.device.click_record_clear() + self.device.stuck_record_clear() + self.device.click_record_clear() logger.hr(task, level=0) - success = self.run(inflection.underscore(task), skip_first_screenshot=device_free_task) + success = self.run(inflection.underscore(task)) logger.info(f'Scheduler: End task `{task}`') self.is_first_task = False diff --git a/config/template.json b/config/template.json index e9b93414b..9cc3be3be 100644 --- a/config/template.json +++ b/config/template.json @@ -136,6 +136,21 @@ "Storage": {} } }, + "DowntimeMgmt": { + "Downtime": { + "Window": null, + "Title": null + }, + "DowntimeStrategy": { + "AutoFetch": false, + "ManageExercise": false, + "ExerciseClearTime": "15:00:00", + "ManageDaily": false + }, + "Storage": { + "Storage": {} + } + }, "Restart": { "Scheduler": { "Enable": true, @@ -149,32 +164,6 @@ "Storage": {} } }, - "DowntimeFetch": { - "Scheduler": { - "Enable": false, - "NextRun": "2020-01-01 00:00:00", - "Command": "DowntimeFetch", - "SuccessInterval": 30, - "FailureInterval": 30, - "ServerUpdate": "22:30" - }, - "Storage": { - "Storage": {} - } - }, - "DowntimeMgmt": { - "Downtime": { - "Window": null, - "Title": null - }, - "DowntimeStrategy": { - "ManageExercise": false, - "ManageDaily": false - }, - "Storage": { - "Storage": {} - } - }, "Main": { "Scheduler": { "Enable": false, @@ -2379,6 +2368,14 @@ "Storage": {} } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": false + }, + "Storage": { + "Storage": {} + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": "https://gitee.com/LmeSzinc/AzurLaneUncensored" diff --git a/dev_tools/downtime_notice_crawler.py b/dev_tools/downtime_notice_crawler.py deleted file mode 100644 index ca9dfbd54..000000000 --- a/dev_tools/downtime_notice_crawler.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -碧蓝航线停服维护公告爬取工具 - -从B站碧蓝航线官方专栏合集抓取最新维护公告,输出停服时间段。 - -默认项 - --mid 233114659(碧蓝航线B站官方号UID) - --collection-name 《碧蓝航线》维护公告 - --cache 公告缓存文件,默认 maintenance/_cache.jsonl,位于脚本同级目录。 - --state 合集状态文件,默认 maintenance/state.json,位于脚本同级目录。 - -命令 - fetch 爬取:拉取专栏合集数据,写入缓存。 - parse 解析:从缓存读取并解析公告,向 stdout 输出纯文本停服时间段,如:2026-06-20 14:00~18:00 - 解析失败输出空行,并返回退出码 2。 - run 完整流程测试:拉取最新公告并解析停服维护信息,不依赖缓存。 - -示例 - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py fetch --force - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py parse - .\\toolkit\\python.exe dev_tools/bilibili_maintenance.py run --verbose -""" -from __future__ import annotations - -import argparse -import json -import logging -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import requests - -SCRIPT_DIR = Path(__file__).resolve().parent -DEFAULT_MID = 233114659 -DEFAULT_CACHE = SCRIPT_DIR / 'maintenance' / '_cache.jsonl' -DEFAULT_STATE = SCRIPT_DIR / 'maintenance' / 'state.json' -DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' - -ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' -ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles' -CN_TZ = timezone(timedelta(hours=8)) - -WS = r'[\s ]*' -WINDOW_RE = re.compile( - r'司令部将于' + WS + - r'(\d{1,2})月(\d{1,2})日' + WS + - r'(\d{1,2})[::](\d{2})' + WS + - r'[~~至到—\-]+' + WS + - r'(\d{1,2})[::](\d{2})', -) -DURATION_RE = re.compile(r'为期' + WS + r'(\d+)' + WS + r'个?小时') -TITLE_TIME_RE = re.compile(r'(\d{1,2})月(\d{1,2})日' + WS + r'(\d{1,2})[::](\d{2})') - -logger = logging.getLogger('bilibili_maintenance') - - -class FetchError(RuntimeError): - pass - - -@dataclass -class MaintenanceInfo: - window: str - duration_hours: int - - -@dataclass -class CollectionMeta: - collection_id: int - collection_name: str - articles_count: int - update_time: int - - -def configure_stdio() -> None: - if hasattr(sys.stdout, 'reconfigure'): - sys.stdout.reconfigure(encoding='utf-8') - - -def session_for(mid: int) -> requests.Session: - s = requests.Session() - s.headers.update({ - 'User-Agent': ( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' - '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' - ), - 'Referer': f'https://space.bilibili.com/{mid}/article', - }) - return s - - -def get_json(session: requests.Session, url: str, params: dict, timeout: float) -> dict: - response = session.get(url, params=params, timeout=timeout) - response.raise_for_status() - payload = response.json() - if payload['code'] != 0: - raise FetchError(payload.get('message') or payload.get('msg') or 'unknown error') - return payload - - -def find_collection(session: requests.Session, mid: int, name: str, timeout: float) -> tuple[CollectionMeta, dict]: - payload = get_json(session, ARTICLE_LISTS_API, {'mid': str(mid), 'sort': 0}, timeout) - for item in payload['data']['lists']: - if item['name'] == name: - return CollectionMeta(collection_id=int(item['id']), collection_name=item['name'], articles_count=int(item['articles_count']), update_time=int(item['update_time'])), payload - raise FetchError(f'collection not found: mid={mid} name={name!r}') - - -def normalize_article(article: dict, meta: CollectionMeta) -> dict: - cvid = str(article['id']) - pub_ts = int(article['publish_time']) - return { - 'pub_ts': pub_ts, - 'pub_time': datetime.fromtimestamp(pub_ts, tz=CN_TZ).isoformat(), - 'title': article['title'], - 'text': article['summary'], - 'url': f'https://www.bilibili.com/read/cv{cvid}', - 'collection_id': meta.collection_id, - 'collection_name': meta.collection_name, - } - - -def load_state(path: Path) -> dict: - if not path.exists(): - return {} - return json.loads(path.read_text(encoding='utf-8')) - - -def load_jsonl(path: Path) -> list[dict]: - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()] - - -def persist_fetch(cache_path: Path, state_path: Path, records: list[dict], state: dict) -> None: - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_content = ''.join(json.dumps(record, ensure_ascii=False) + '\n' for record in records) - state_content = json.dumps(state, ensure_ascii=False, indent=2) - cache_tmp = cache_path.with_suffix(cache_path.suffix + '.tmp') - state_tmp = state_path.with_suffix(state_path.suffix + '.tmp') - cache_tmp.write_text(cache_content, encoding='utf-8') - state_tmp.write_text(state_content, encoding='utf-8') - cache_tmp.replace(cache_path) - state_tmp.replace(state_path) - - -def clean_text(text: str) -> str: - return text.replace('[图片]', '').replace('\u3000', ' ').strip() - - -def infer_year(published: datetime, month: int, day: int) -> int: - year = published.year - delta = (datetime(year, month, day, tzinfo=CN_TZ).date() - published.date()).days - if delta < -60: - year += 1 - elif delta > 300: - year -= 1 - return year - - -def _build_window_dt(published: datetime, sm: int, sd: int, sh: int, smin: int, eh: int, emin: int) -> tuple[datetime, datetime]: - year = infer_year(published, sm, sd) - start = datetime(year, sm, sd, sh, smin, tzinfo=CN_TZ) - end = datetime(year, sm, sd, eh, emin, tzinfo=CN_TZ) - return start, end - - -def _hours_between(start: datetime, end: datetime) -> int: - return round((end - start).total_seconds() / 3600) - - -def parse_maintenance(text: str, pub_ts: int, title: str) -> Optional[MaintenanceInfo]: - published = datetime.fromtimestamp(pub_ts, tz=CN_TZ) - body = clean_text(text) - window_match = WINDOW_RE.search(body) - duration_match = DURATION_RE.search(body) - - if window_match: - sm = int(window_match.group(1)) - sd = int(window_match.group(2)) - sh = int(window_match.group(3)) - smin = int(window_match.group(4)) - eh = int(window_match.group(5)) - emin = int(window_match.group(6)) - start, end = _build_window_dt(published, sm, sd, sh, smin, eh, emin) - hours = int(duration_match.group(1)) if duration_match else _hours_between(start, end) - return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) - - if duration_match: - title_match = TITLE_TIME_RE.search(title) - if title_match: - sm = int(title_match.group(1)) - sd = int(title_match.group(2)) - sh = int(title_match.group(3)) - smin = int(title_match.group(4)) - hours = int(duration_match.group(1)) - start, _ = _build_window_dt(published, sm, sd, sh, smin, sh, smin) - end = start + timedelta(hours=hours) - logger.warning('WINDOW_RE not matched; synthesized window from TITLE_TIME_RE and DURATION_RE (%02d-%02d %02d:%02d, duration %d h).', sm, sd, sh, smin, hours) - return MaintenanceInfo(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}', duration_hours=hours) - - logger.warning('Failed to parse maintenance window from article text.') - return None - - -def latest_record(records: list[dict]) -> dict: - return max(records, key=lambda r: r['pub_ts']) - - -def parse_records(records: list[dict]) -> Optional[MaintenanceInfo]: - if not records: - return None - record = latest_record(records) - return parse_maintenance(record['text'], record['pub_ts'], record['title']) - - -def fetch_articles(cache_path: Path, state_path: Path, mid: int, collection_name: str, timeout: float, force: bool, verbose: bool) -> tuple[list[dict], bool]: - state = load_state(state_path) - session = session_for(mid) - meta, lists_payload = find_collection(session, mid, collection_name, timeout) - - if verbose: - logger.info('%s', json.dumps(lists_payload, ensure_ascii=False, indent=2)) - - if not force and state.get('articles_count') == meta.articles_count and state.get('update_time') == meta.update_time: - logger.info('skip: count=%s update_time=%s', meta.articles_count, meta.update_time) - return load_jsonl(cache_path), False - - payload = get_json(session, ARTICLE_COLLECTION_API, {'id': str(meta.collection_id)}, timeout) - records = [normalize_article(article, meta) for article in payload['data']['articles']] - persist_fetch(cache_path, state_path, records, {'last_run_at': datetime.now(tz=CN_TZ).isoformat(timespec='seconds'), 'collection_id': meta.collection_id, 'articles_count': meta.articles_count, 'update_time': meta.update_time}) - logger.info('updated: count=%s update_time=%s records=%s', meta.articles_count, meta.update_time, len(records)) - return records, True - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog='bilibili_maintenance.py', description='碧蓝航线停服维护公告爬取工具') - parser.add_argument('command', choices=['fetch', 'parse', 'run'], help='fetch: 拉取最新停服维护公告 | parse: 基于缓存解析停服时间 | run: 拉取并解析最新公告') - parser.add_argument('--cache', type=Path, default=DEFAULT_CACHE, help=f'公告缓存文件路径(默认 {DEFAULT_CACHE})') - parser.add_argument('--state', type=Path, default=DEFAULT_STATE, help=f'合集状态文件路径(默认 {DEFAULT_STATE})') - parser.add_argument('--mid', type=int, default=DEFAULT_MID, help=f'B站UP主UID(默认 {DEFAULT_MID})') - parser.add_argument('--collection-name', default=DEFAULT_COLLECTION_NAME, help=f'专栏合集名称,须与B站合集页标题一致(默认 {DEFAULT_COLLECTION_NAME!r})') - parser.add_argument('--timeout', type=float, default=30.0, help='网络请求超时秒数(默认 30)') - parser.add_argument('--force', action='store_true', help='强制重新拉取并刷新缓存数据') - parser.add_argument('--verbose', action='store_true', help='启用调试日志,输出 API 原始响应') - return parser - - -def cmd_fetch(args: argparse.Namespace) -> int: - try: - fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, args.force, args.verbose) - except (FetchError, requests.RequestException) as exc: - logger.error('fetch failed: %s', exc) - return 1 - return 0 - - -def cmd_parse(args: argparse.Namespace) -> int: - info = parse_records(load_jsonl(args.cache)) - if info: - print(info.window) - return 0 - print() - return 2 - - -def cmd_run(args: argparse.Namespace) -> int: - try: - records, _ = fetch_articles(args.cache, args.state, args.mid, args.collection_name, args.timeout, force=True, verbose=args.verbose) - except (FetchError, requests.RequestException) as exc: - logger.error('fetch failed: %s', exc) - return 1 - info = parse_records(records) - if info: - print(info.window) - return 0 - print() - return 2 - - -def main() -> int: - configure_stdio() - logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s') - args = build_parser().parse_args() - if args.verbose: - logging.getLogger().setLevel(logging.INFO) - if args.command == 'fetch': - return cmd_fetch(args) - if args.command == 'parse': - return cmd_parse(args) - if args.command == 'run': - return cmd_run(args) - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/module/config/argument/args.json b/module/config/argument/args.json index aae11af95..2ad3f1aee 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -622,6 +622,45 @@ } } }, + "DowntimeMgmt": { + "Downtime": { + "Window": { + "type": "input", + "value": "" + }, + "Title": { + "type": "input", + "value": "", + "display": "disabled" + } + }, + "DowntimeStrategy": { + "AutoFetch": { + "type": "checkbox", + "value": false + }, + "ManageExercise": { + "type": "checkbox", + "value": false + }, + "ExerciseClearTime": { + "type": "input", + "value": "15:00:00" + }, + "ManageDaily": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "Restart": { "Scheduler": { "Enable": { @@ -669,82 +708,6 @@ } } }, - "DowntimeFetch": { - "Scheduler": { - "Enable": { - "type": "checkbox", - "value": false, - "option": [ - true, - false - ] - }, - "NextRun": { - "type": "datetime", - "value": "2020-01-01 00:00:00", - "validate": "datetime" - }, - "Command": { - "type": "input", - "value": "DowntimeFetch", - "display": "hide" - }, - "SuccessInterval": { - "type": "input", - "value": 30, - "display": "hide" - }, - "FailureInterval": { - "type": "input", - "value": 30, - "display": "hide" - }, - "ServerUpdate": { - "type": "input", - "value": "22:30", - "display": "hide" - } - }, - "Storage": { - "Storage": { - "type": "storage", - "value": {}, - "valuetype": "ignore", - "display": "disabled" - } - } - }, - "DowntimeMgmt": { - "Downtime": { - "Window": { - "type": "input", - "value": "" - }, - "Title": { - "type": "input", - "value": "", - "display": "disabled" - } - }, - "DowntimeStrategy": { - "ManageExercise": { - "type": "checkbox", - "value": false - }, - "ManageDaily": { - "type": "checkbox", - "value": false - } - }, - "Storage": { - "Storage": { - "type": "storage", - "value": {}, - "valuetype": "ignore", - "display": "disabled" - } - } - }, "Main": { "Scheduler": { "Enable": { @@ -14060,6 +14023,22 @@ } } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 9afe2309d..7cc13060c 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -152,16 +152,15 @@ OldRetire: RetireAmount: value: retire_all option: [ retire_all, retire_10 ] - -# ==================== SmartMgmt ==================== - Downtime: Window: '' Title: value: '' display: disabled DowntimeStrategy: + AutoFetch: false ManageExercise: false + ExerciseClearTime: '15:00:00' ManageDaily: false # ==================== Farm ==================== @@ -893,6 +892,7 @@ PrivateQuarters: TargetShip: value: anchorage option: [ anchorage, noshiro, sirius, new_jersey, taihou, aegir, nakhimov ] + # ==================== Daily ==================== Daily: @@ -1095,6 +1095,8 @@ AzurLaneUncensored: display: disabled GameManager: AutoRestart: true +DowntimeFetch: + FetcherTest: false # ==================== Dashboard ==================== Oil: diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index f9cb0a1d7..30d9f8919 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -5,17 +5,10 @@ "tasks": [ "Alas", "General", + "DowntimeMgmt", "Restart" ] }, - "SmartMgmt": { - "menu": "collapse", - "page": "setting", - "tasks": [ - "DowntimeFetch", - "DowntimeMgmt" - ] - }, "Farm": { "menu": "collapse", "page": "setting", @@ -115,6 +108,7 @@ "BoxDisassemble", "IslandPearl", "Benchmark", + "DowntimeFetch", "AzurLaneUncensored", "GameManager" ] diff --git a/module/config/argument/override.yaml b/module/config/argument/override.yaml index a38f435db..e88d9d3dc 100644 --- a/module/config/argument/override.yaml +++ b/module/config/argument/override.yaml @@ -15,14 +15,6 @@ Restart: FailureInterval: 0 ServerUpdate: 00:00 -# ==================== SmartMgmt ==================== - -DowntimeFetch: - Scheduler: - SuccessInterval: 30 - FailureInterval: 30 - ServerUpdate: "22:30" - # ==================== Farm ==================== Main: diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml index ab18f111a..43324d95c 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -19,20 +19,11 @@ Alas: - OneClickRetire - Enhance - OldRetire - Restart: - - Scheduler - -# ==================== SmartMgmt ==================== - -SmartMgmt: - menu: 'collapse' - page: 'setting' - tasks: - DowntimeFetch: - - Scheduler DowntimeMgmt: - Downtime - DowntimeStrategy + Restart: + - Scheduler # ==================== Farm ==================== @@ -376,6 +367,8 @@ Tool: - IslandPearl Benchmark: - Benchmark + DowntimeFetch: + - DowntimeFetch AzurLaneUncensored: - AzurLaneUncensored GameManager: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index f24d6f3e1..50f8f88c0 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -81,7 +81,9 @@ class GeneratedConfig: Downtime_Title = None # Group `DowntimeStrategy` + DowntimeStrategy_AutoFetch = False DowntimeStrategy_ManageExercise = False + DowntimeStrategy_ExerciseClearTime = '15:00:00' DowntimeStrategy_ManageDaily = False # Group `Campaign` @@ -690,6 +692,9 @@ class GeneratedConfig: # Group `GameManager` GameManager_AutoRestart = True + # Group `DowntimeFetch` + DowntimeFetch_FetcherTest = False + # Group `Oil` Oil_Value = 0 Oil_Limit = 0 diff --git a/module/config/config_manual.py b/module/config/config_manual.py index 424790f68..fcc6de026 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -11,7 +11,6 @@ class ManualConfig: SCHEDULER_PRIORITY = """ Restart > OpsiCrossMonth - > DowntimeFetch > Commission > Tactical > Research > Exercise > Dorm > Meowfficer > Guild > Gacha diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 1cb5de83c..de9d6ce60 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "Smart Management", - "help": "" - }, "Farm": { "name": "Farm", "help": "" @@ -46,16 +42,12 @@ "name": "General", "help": "" }, - "Restart": { - "name": "Restart", + "DowntimeMgmt": { + "name": "Downtime Management", "help": "" }, - "DowntimeFetch": { - "name": "Downtime Fetch", - "help": "CN server only. Automatically fetches downtime maintenance notices from Bilibili Azur Lane columns, parses and fills 'Downtime Info'" - }, - "DowntimeMgmt": { - "name": "Downtime Maintenance", + "Restart": { + "name": "Restart", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "Performance Test", "help": "" }, + "DowntimeFetch": { + "name": "Downtime Fetch", + "help": "" + }, "AzurLaneUncensored": { "name": "[CN] Uncensored Patch", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "Downtime Info", - "help": "Auto-filled by 'Downtime Fetch' task" + "help": "Auto-filled when 'Auto Update Info' is enabled, updated via 'Downtime Fetch' tool, or fill manually" }, "Window": { "name": "Time Window", - "help": "Format YYYY-MM-DD HH:MM~HH:MM" + "help": "Format: YYYY-MM-DD HH:MM~HH:MM; non-CN server users can fill manually" }, "Title": { "name": "Notice Title", @@ -737,9 +733,17 @@ "name": "Strategy Settings", "help": "Auto-adjust task scheduling and strategy based on downtime info" }, + "AutoFetch": { + "name": "Auto Update Info", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nWhen enabled, the above enabled tasks will automatically fetch CN notices and update 'Downtime Info' on execution" + }, "ManageExercise": { "name": "Exercise", - "help": "When the downtime end time is 15:00 or later, exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + "help": "When downtime end time is later than 'Exercise clear threshold', exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + }, + "ExerciseClearTime": { + "name": "Exercise clear threshold", + "help": "Format: HH:MM:SS" }, "ManageDaily": { "name": "Daily", @@ -6368,6 +6372,16 @@ "help": "Log back into the game when the game has been ended." } }, + "DowntimeFetch": { + "_info": { + "name": "Downtime Fetch", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nManually trigger to fetch the latest downtime notice and fill 'Downtime Info'" + }, + "FetcherTest": { + "name": "Fetcher Test", + "help": "Runs diagnostic tests for network, API, and content parsing; does not update config" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index cbbeb9a23..d983c3522 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "スマート管理", - "help": "" - }, "Farm": { "name": "出撃", "help": "" @@ -46,16 +42,12 @@ "name": "通用設定", "help": "" }, - "Restart": { - "name": "再起動設定", + "DowntimeMgmt": { + "name": "メンテナンス管理", "help": "" }, - "DowntimeFetch": { - "name": "メンテナンス情報取得", - "help": "国服のみ対応。bilibiliアズールレーンコラムからメンテナンス告知を自動取得し、解析して'メンテナンス情報'に記入" - }, - "DowntimeMgmt": { - "name": "メンテナンス", + "Restart": { + "name": "再起動設定", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "機能テスト", "help": "" }, + "DowntimeFetch": { + "name": "メンテナンス情報取得", + "help": "" + }, "AzurLaneUncensored": { "name": "中国サーバー規制解除", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "メンテナンス情報", - "help": "'メンテナンス情報取得'タスクにより自動入力" + "help": "'自動更新情報'を有効にするとタスクで自動更新、ツールの'メンテナンス情報取得'で更新、または手動入力" }, "Window": { "name": "時間枠", - "help": "形式 YYYY-MM-DD HH:MM~HH:MM" + "help": "形式:YYYY-MM-DD HH:MM~HH:MM;国服以外のユーザーは手動入力可能" }, "Title": { "name": "告知タイトル", @@ -737,9 +733,17 @@ "name": "機能設定", "help": "メンテナンス情報に基づきタスクスケジュールと戦略を自動調整" }, + "AutoFetch": { + "name": "自動更新情報", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n有効にすると、上記の有効なタスク実行時に国服の告知を自動取得し'メンテナンス情報'を更新します" + }, "ManageExercise": { "name": "演習", - "help": "メンテナンス終了時刻が15:00以降の場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + "help": "メンテナンス終了時刻が'演習クリア閾値'より遅い場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + }, + "ExerciseClearTime": { + "name": "演習クリア閾値", + "help": "形式:HH:MM:SS" }, "ManageDaily": { "name": "デイリー", @@ -6368,6 +6372,16 @@ "help": "GameManager.AutoRestart.help" } }, + "DowntimeFetch": { + "_info": { + "name": "メンテナンス公告取得", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n手動トリガーで最新のメンテナンス告知を取得し'メンテナンス情報'に書き込み" + }, + "FetcherTest": { + "name": "機能テスト", + "help": "ネットワーク、API、内容解析の診断テストを実行、設定は更新しない" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 88b7bad15..6d027e126 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "智能管理", - "help": "" - }, "Farm": { "name": "出击", "help": "" @@ -46,16 +42,12 @@ "name": "通用设置", "help": "" }, - "Restart": { - "name": "重启设置", + "DowntimeMgmt": { + "name": "停服维护设置", "help": "" }, - "DowntimeFetch": { - "name": "公告获取", - "help": "仅支持国服,自动从B站碧蓝航线专栏获取停服维护公告,解析并填写'停服维护信息'" - }, - "DowntimeMgmt": { - "name": "停服维护", + "Restart": { + "name": "重启设置", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "性能测试", "help": "" }, + "DowntimeFetch": { + "name": "公告拉取", + "help": "" + }, "AzurLaneUncensored": { "name": "反和谐", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "停服维护信息", - "help": "可由'公告获取'任务自动填写" + "help": "可在开启'自动拉取信息'后由任务自动更新,也可通过工具的'公告拉取'更新,或手动填写" }, "Window": { "name": "时间窗口", - "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + "help": "格式:YYYY-MM-DD HH:MM~HH:MM" }, "Title": { "name": "公告标题", @@ -737,9 +733,17 @@ "name": "功能设置", "help": "基于停服维护信息自动调整任务策略与调度" }, + "AutoFetch": { + "name": "自动拉取信息", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n开启后,以下启用的任务运行时将自动拉取国服公告并更新'停服维护信息'" + }, "ManageExercise": { "name": "演习", - "help": "当停服结束时间为15:00及之后时,当天停服维护前演习策略将替换为'清空演习次数'" + "help": "当停服结束时间晚于'演习时间阈值'时,当天停服维护前演习策略将替换为'清空演习次数'" + }, + "ExerciseClearTime": { + "name": "演习时间阈值", + "help": "格式:HH:MM:SS" }, "ManageDaily": { "name": "每日任务", @@ -6368,6 +6372,16 @@ "help": "游戏被强制结束后自动登录游戏" } }, + "DowntimeFetch": { + "_info": { + "name": "停服维护公告拉取", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n手动触发拉取最新停服维护公告并写入'停服维护信息'" + }, + "FetcherTest": { + "name": "功能测试", + "help": "运行网络、API、内容解析的诊断测试,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 3b1e0f6ab..07c68c67e 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -4,10 +4,6 @@ "name": "Alas", "help": "" }, - "SmartMgmt": { - "name": "智能管理", - "help": "" - }, "Farm": { "name": "出擊", "help": "" @@ -46,16 +42,12 @@ "name": "通用設定", "help": "" }, - "Restart": { - "name": "重啟設定", + "DowntimeMgmt": { + "name": "停服維護智能管理", "help": "" }, - "DowntimeFetch": { - "name": "公告取得", - "help": "僅支援國服,自動從B站碧藍航線專欄取得停服維護公告,解析並填寫'停服維護資訊'" - }, - "DowntimeMgmt": { - "name": "停服維護", + "Restart": { + "name": "重啟設定", "help": "" }, "Main": { @@ -294,6 +286,10 @@ "name": "性能測試", "help": "" }, + "DowntimeFetch": { + "name": "公告取得", + "help": "" + }, "AzurLaneUncensored": { "name": "反和諧", "help": "" @@ -721,11 +717,11 @@ "Downtime": { "_info": { "name": "停服維護資訊", - "help": "可由'公告取得'任務自動填寫" + "help": "可在開啟'自動更新資訊'後由任務自動更新,也可透過工具的'公告取得'更新,或手動填寫" }, "Window": { "name": "時間窗口", - "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + "help": "格式:YYYY-MM-DD HH:MM~HH:MM;非國服用戶可手動填寫" }, "Title": { "name": "公告標題", @@ -737,9 +733,17 @@ "name": "功能設定", "help": "基於停服維護資訊自動調整任務排程與策略" }, + "AutoFetch": { + "name": "自動更新資訊", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n開啟後,以下啟用的任務執行時將自動拉取國服公告並更新'停服維護資訊'" + }, "ManageExercise": { "name": "演習", - "help": "當停服結束時間為15:00及之後時,當天停服維護前演習策略將替換為'清空演習次數'" + "help": "當停服結束時間晚於'演習清空閾值'時,當天停服維護前演習策略將替換為'清空演習次數'" + }, + "ExerciseClearTime": { + "name": "演習清空閾值", + "help": "格式:HH:MM:SS" }, "ManageDaily": { "name": "每日任務", @@ -6368,6 +6372,16 @@ "help": "遊戲被強制結束後自動登錄遊戲" } }, + "DowntimeFetch": { + "_info": { + "name": "停服維護公告拉取", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n手動觸發拉取最新停服維護公告並寫入'停服維護資訊'" + }, + "FetcherTest": { + "name": "功能測試", + "help": "運行網路、API、內容解析的診斷測試,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/daily/daily.py b/module/daily/daily.py index a43d203b6..7e1ad0290 100644 --- a/module/daily/daily.py +++ b/module/daily/daily.py @@ -9,7 +9,7 @@ from module.combat.combat import Combat from module.daily.assets import * from module.logger import logger from module.ocr.ocr import Digit -from module.smart_mgmt.utils import parse_downtime +from module.smart_mgmt.downtime_fetch import get_downtime_end from module.ui.assets import BACK_ARROW, DAILY_CHECK from module.ui.page import page_campaign_menu, page_daily @@ -347,14 +347,7 @@ class Daily(Combat): maintenance has not ended yet, or None if smart_mgmt unavailable, today is not maintenance day, or maintenance already ended. """ - if self.config.SERVER != 'cn': - return None - if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageDaily', False): - return None - window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) - if not window: - return None - end_dt = parse_downtime(window) + end_dt = get_downtime_end(self.config) if end_dt is None: return None now = datetime.now() diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index 39b89612e..5ff94aa5b 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -5,7 +5,7 @@ from module.exercise.combat import ExerciseCombat from module.logger import logger from module.ocr.ocr import Digit, Ocr, OcrYuv from module.ui.page import page_exercise -from module.smart_mgmt.utils import parse_downtime +from module.smart_mgmt.downtime_fetch import get_downtime_end class DatedDuration(Ocr): def __init__(self, buttons, lang='cnocr', letter=(255, 255, 255), threshold=128, alphabet='0123456789:IDS天日d', @@ -77,7 +77,6 @@ class Exercise(ExerciseCombat): opponent_change_count = 0 remain = 0 preserve = 0 - DOWNTIME_END_HOUR_THRESHOLD = 15 def _new_opponent(self): logger.info('New opponent') @@ -212,24 +211,19 @@ class Exercise(ExerciseCombat): Check smart_mgmt downtime window to decide whether to clear attempts. Returns: - bool: - True if today is maintenance day with end time at or after DOWNTIME_END_HOUR_THRESHOLD. + bool: + True if today is maintenance day with end time at or after ExerciseClearTime. False if available but today is not maintenance day. - None if unavailable (disabled, non-CN server, window missing, or parse failed). + None if unavailable (disabled, window missing, or parse failed). """ - if self.config.SERVER != 'cn': - return None - if not self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ManageExercise', False): - return None - window = self.config.cross_get('DowntimeMgmt.Downtime.Window', None) - if not window: - return None - end_dt = parse_downtime(window) + end_dt = get_downtime_end(self.config) if end_dt is None: return None if end_dt <= now: return False - return end_dt.date() == now.date() and end_dt.hour >= self.DOWNTIME_END_HOUR_THRESHOLD + threshold_str = self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ExerciseClearTime', '15:00:00') + threshold_time = datetime.datetime.strptime(threshold_str, '%H:%M:%S').time() + return end_dt.date() == now.date() and end_dt.time() >= threshold_time def _should_force_clear(self, now, remain_time, admiral_interval): """Decide whether to clear all exercise attempts this run.""" diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py index b13537aa3..516d6525e 100644 --- a/module/smart_mgmt/downtime_fetch.py +++ b/module/smart_mgmt/downtime_fetch.py @@ -1,47 +1,83 @@ -import random -from datetime import timedelta +from __future__ import annotations -from module.config.utils import get_server_next_update +import re +from datetime import datetime +from typing import Optional + +from module.config.config import AzurLaneConfig from module.logger import logger from module.smart_mgmt.downtime_fetcher import DowntimeFetcher, FetchError +from module.smart_mgmt.fetcher_test import DowntimeFetcherTest -class DowntimeFetch: - def __init__(self, config): - self.config = config +DOWNTIME_FORMAT_RE = re.compile( + r'(\d{4})-(\d{1,2})-(\d{1,2}) ' + r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', +) - def _set_downtime(self, window, title): - self.config.modified['DowntimeMgmt.Downtime.Window'] = window - self.config.modified['DowntimeMgmt.Downtime.Title'] = title - self.config.update() - def _update_downtime(self) -> bool: - """Fetch downtime via fetcher and write cross-task config.""" - try: - payload = DowntimeFetcher().run() - except FetchError as e: - # Expected operational failure (API error, collection missing, no cache); - # degrade gracefully and let the caller reschedule - logger.warning(f'Fetch downtime failed: {e}') - return False - except Exception: - # Unexpected programming error; full traceback for diagnosis - logger.exception('Unexpected error during downtime fetch') - return False - self._set_downtime(payload.get('window', ''), payload.get('title', '')) - return True +def parse_downtime(window: str) -> Optional[datetime]: + """ + Args: + window (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. - def run(self): - success = False - if self.config.SERVER != 'cn': - # Downtime fetch targets CN Bilibili notices only; skip on other servers - logger.warning('Downtime fetch is only available for CN server, skip') + Returns: + Optional[datetime]: + End datetime parsed from the window, or None if no pattern matched. + """ + match = DOWNTIME_FORMAT_RE.search(window) + if not match: + logger.warning(f'Failed to parse downtime window: {window}') + return None + year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) + end_hour, end_minute = int(match.group(6)), int(match.group(7)) + end_dt = datetime(year, month, day, end_hour, end_minute) + logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') + return end_dt + + +def update_downtime(config: AzurLaneConfig, force_refresh=False) -> bool: + """Fetch downtime and write config.""" + try: + payload = DowntimeFetcher().run(force_refresh=force_refresh) + except FetchError as e: + # Expected operational failure degrade + logger.warning(f'Failed to fetch downtime: {e}') + return False + except Exception as e: + # Unexpected programming error; full traceback for diagnosis + logger.exception(f'Unexpected error: {e}') + return False + with config.multi_set(): + config.cross_set('DowntimeMgmt.Downtime.Window', payload.get('window', '')) + config.cross_set('DowntimeMgmt.Downtime.Title', payload.get('title', '')) + return True + + +def get_downtime_end(config: AzurLaneConfig) -> Optional[datetime]: + task = config.task.command + if not config.cross_get(f'DowntimeMgmt.DowntimeStrategy.Manage{task}', False): + return None + if config.cross_get('DowntimeMgmt.DowntimeStrategy.AutoFetch', False): + if config.SERVER == 'cn': + update_downtime(config) else: - success = self._update_downtime() - if success: - # Stagger execution across instances within 5 minutes after server update - next_update = get_server_next_update(self.config.Scheduler_ServerUpdate) - target = next_update + timedelta(seconds=random.randint(0, 300)) - self.config.task_delay(target=target) - else: - self.config.task_delay(success=False) + logger.warning('Auto-fetch skipped as fetcher is for CN only, use window from config') + else: + logger.info('Auto-fetch disabled, use window from config') + window = config.cross_get('DowntimeMgmt.Downtime.Window', None) + if not window: + return None + return parse_downtime(window) + + +def run_downtime_fetch(config: AzurLaneConfig): + # Tool task entry is invoked with config bound to Alas by default, + # rebind to DowntimeFetch. + config.init_task('DowntimeFetch') + if config.DowntimeFetch_FetcherTest: + DowntimeFetcherTest().run() + else: + if config.SERVER != 'cn': + logger.warning('Downtime fetch targets CN Bilibili notices, data may be irrelevant on other servers') + update_downtime(config, force_refresh=True) diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py index 5b6b67e4b..6f8c3b99c 100644 --- a/module/smart_mgmt/downtime_fetcher.py +++ b/module/smart_mgmt/downtime_fetcher.py @@ -58,7 +58,7 @@ class DowntimeFetcher: CACHE_FILE = './log/downtime_cache.json' FETCH_LOCK_FILE = './log/downtime_fetch.lock' # Multi-instance duplicate fetch avoidance window (seconds) - CACHE_FRESH_SECONDS = 300 + CACHE_FRESH_SECONDS = 600 # A bit larger than REQUEST_TIMEOUT LOCK_WAIT_SECONDS = 35 @@ -124,10 +124,10 @@ class DowntimeFetcher: year += 1 return year - def _build_window_dt(self, published, sm, sd, sh, smin, eh, emin): - year = self._infer_year(published, sm, sd) - start = datetime(year, sm, sd, sh, smin, tzinfo=self.CN_TZ) - end = datetime(year, sm, sd, eh, emin, tzinfo=self.CN_TZ) + def _build_window_dt(self, published, month, day, sh, smin, eh, emin): + year = self._infer_year(published, month, day) + start = datetime(year, month, day, sh, smin, tzinfo=self.CN_TZ) + end = datetime(year, month, day, eh, emin, tzinfo=self.CN_TZ) return start, end def parse_window_from_article(self, article: Article) -> Optional[DowntimeWindow]: @@ -138,29 +138,29 @@ class DowntimeFetcher: from mis-parsing 'HH:MM-HH:MM' as an ISO 8601 datetime with offset. """ published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ) - body = self._clean_text(article.summary) - window_match = self.WINDOW_RE.search(body) - duration_match = self.DURATION_RE.search(body) + summary = self._clean_text(article.summary) + window_match = self.WINDOW_RE.search(summary) + duration_match = self.DURATION_RE.search(summary) if window_match: - sm = int(window_match.group(1)) - sd = int(window_match.group(2)) + month = int(window_match.group(1)) + day = int(window_match.group(2)) sh = int(window_match.group(3)) smin = int(window_match.group(4)) eh = int(window_match.group(5)) emin = int(window_match.group(6)) - start, end = self._build_window_dt(published, sm, sd, sh, smin, eh, emin) + start, end = self._build_window_dt(published, month, day, sh, smin, eh, emin) return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') if duration_match: title_match = self.TITLE_TIME_RE.search(article.title) if title_match: - sm = int(title_match.group(1)) - sd = int(title_match.group(2)) + month = int(title_match.group(1)) + day = int(title_match.group(2)) sh = int(title_match.group(3)) smin = int(title_match.group(4)) hours = int(duration_match.group(1)) - start, _ = self._build_window_dt(published, sm, sd, sh, smin, sh, smin) + start, _ = self._build_window_dt(published, month, day, sh, smin, sh, smin) end = start + timedelta(hours=hours) return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') @@ -172,7 +172,7 @@ class DowntimeFetcher: def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: """ - Full fetch pipeline: find_collection → incremental check → fetch articles. + find_collection -> incremental check -> fetch articles Returns: tuple[Optional[list[Article]], CollectionMeta]: @@ -196,7 +196,7 @@ class DowntimeFetcher: Returns: tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: - (downtime_window, collection_meta, latest_article). + (downtime_window, collection_meta, latest_article). downtime_window is None if parse failed. latest_article is None if collection not updated. """ articles, collection_meta = self.fetch_articles(local_update_time) @@ -236,19 +236,19 @@ class DowntimeFetcher: age = datetime.now().timestamp() - p.stat().st_mtime return age < cls.CACHE_FRESH_SECONDS - def get_downtime_info(self) -> dict: + def get_downtime_info(self, force_refresh=False) -> dict: """ - Full pipeline: load cache → collect_info → save cache. + Fetch from API, parse, and save cache. + + Args: + force_refresh (bool): + Skip incremental check, always fetch from API and overwrite cache. Returns: dict: payload with window, title, collection_id, update_time. """ cache = self.load_cache() - # Avoid duplicate fetch across instances: reuse fresh cache - if cache and self.is_cache_fresh(): - logger.info('Cache is fresh, reuse without fetch') - return cache - local_update_time = cache.get('update_time') if cache else None + local_update_time = None if force_refresh else (cache.get('update_time') if cache else None) downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) @@ -257,13 +257,13 @@ class DowntimeFetcher: raise FetchError('No available cache and collection not updated') return cache - # Degrade on parse failure: reuse cached window + # Degrade on parse failure: reuse cached window, or raise if no cache if downtime_window is None: if cache and cache.get('window'): - logger.warning('Parse failed, reuse cached window') + logger.warning('Failed to parse, reuse cached window') window = cache['window'] else: - window = '' + raise FetchError('Failed to parse and no cache available') else: window = downtime_window.window @@ -320,26 +320,32 @@ class DowntimeFetcher: except OSError as e: logger.warning(f'Failed to release fetch lock: {e}') - def run(self) -> dict: + def run(self, force_refresh=False) -> dict: """ Lock-guarded entry for the downtime pipeline. - Fast path: cache fresh, invoke get_downtime_info without lock (read-only, no contention). - Common path: acquire cross-process lock, then get_downtime_info (re-checks freshness). + Fast path: fresh cache, return directly without lock (read-only). + Common path: acquire cross-process lock, then fetch. Fallback: lock acquisition failed, reuse cache only if another process has freshly updated it; otherwise raise FetchError. Returns: dict: payload with window, title, collection_id, update_time. """ - if self.is_cache_fresh(): - return self.get_downtime_info() + # Fast path: fresh cache, read-only, no lock needed + if not force_refresh: + cache = self.load_cache() + if cache and self.is_cache_fresh(): + logger.info('Cache is fresh, reuse without fetch') + return cache if not self.acquire_lock(): - if self.is_cache_fresh(): + # Lock failed: reuse fresh cache if another process updated it + cache = self.load_cache() + if cache and self.is_cache_fresh(): logger.info('Cache updated by another process, reuse') - return self.get_downtime_info() + return cache raise FetchError('Failed to acquire fetch lock') try: - return self.get_downtime_info() + return self.get_downtime_info(force_refresh=force_refresh) finally: self.release_lock() diff --git a/module/smart_mgmt/fetcher_test.py b/module/smart_mgmt/fetcher_test.py new file mode 100644 index 000000000..4a0cda5af --- /dev/null +++ b/module/smart_mgmt/fetcher_test.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +import socket +import ssl +import time +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from urllib.parse import urlparse + +import requests +from rich.table import Table +from rich.text import Text + +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher + + +class _TestStatus(Enum): + PASS = 'PASS' + FAIL = 'FAIL' + SKIP = 'SKIP' + + +@dataclass +class _TestResult: + name: str + status: _TestStatus + note: str + + +class DowntimeFetcherTest: + # TCP/TLS probe timeout, shorter than HTTP request timeout for faster fail + CONNECT_TIMEOUT = 10.0 + + # Number of articles (excluding the latest) shown as title-only preview + ARTICLE_PREVIEW_COUNT = 3 + # Max summary length shown for the latest article + SUMMARY_EXCERPT_LEN = 200 + ARTICLES_JSONL_FILE = './log/downtime_articles_cache.jsonl' + + # Test names + TEST_NETWORK = 'Network' + TEST_LISTS_API = 'Lists API' + TEST_COLLECTION = 'Collection lookup' + TEST_ARTICLES_API = 'Articles API' + TEST_PARSE = 'Parse window' + + _STATUS_STYLE = { + _TestStatus.PASS: 'green', + _TestStatus.FAIL: 'bright_red', + _TestStatus.SKIP: 'yellow', + } + + def __init__(self): + self.fetcher = DowntimeFetcher() + # Abstract main-flow params into local test params: single point of + # change when fetcher internals evolve, avoids scattered coupling. + self.session = self.fetcher.session + self.mid = self.fetcher.DEFAULT_MID + self.collection_name = self.fetcher.DEFAULT_COLLECTION_NAME + self.lists_api = self.fetcher.ARTICLE_LISTS_API + self.articles_api = self.fetcher.ARTICLE_COLLECTION_API + self.request_timeout = self.fetcher.REQUEST_TIMEOUT + self.cn_tz = self.fetcher.CN_TZ + self.window_re = self.fetcher.WINDOW_RE + self.duration_re = self.fetcher.DURATION_RE + self.title_time_re = self.fetcher.TITLE_TIME_RE + # State carried across tests along the dependency chain + self._lists = [] + self._up_name = '' + self._collection_id = None + self._latest_article = None + self._results = [] + self._skip_remaining = False + + def run(self): + logger.hr('Downtime fetcher test', level=1) + self._results.clear() + self._skip_remaining = False + + tests = [ + (self.TEST_NETWORK, self.test_network), + (self.TEST_LISTS_API, self.test_lists_api), + (self.TEST_COLLECTION, self.test_find_collection), + (self.TEST_ARTICLES_API, self.test_articles_api), + (self.TEST_PARSE, self.test_parse_window), + ] + for name, func in tests: + if self._skip_remaining: + self._results.append(_TestResult(name, _TestStatus.SKIP, 'Upstream failed')) + continue + try: + func() + except Exception as e: + # Unexpected programming error: record and stop the chain so + # the summary still prints + logger.exception(f'Unexpected error in {name}') + self._results.append(_TestResult(name, _TestStatus.FAIL, f'unexpected: {type(e).__name__}: {e}')) + self._skip_remaining = True + continue + if self._results[-1].status == _TestStatus.FAIL: + self._skip_remaining = True + + self._show_summary() + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def _fetch_json(self, url, params): + """GET url with fetcher session and return (body, error). + + Returns: + tuple: (parsed_body, None) on success; (None, diagnostic_str) on failure. + """ + logger.info(f'Request: {url} params={params}') + try: + resp = self.session.get(url, params=params, timeout=self.request_timeout) + logger.info(f'HTTP status: {resp.status_code}') + return resp.json(), None + except requests.RequestException as e: + logger.warning(f'Request failed: {e}') + return None, f'{type(e).__name__}: {e}' + except ValueError as e: + logger.warning(f'JSON parse failed: {e}') + return None, f'JSON: {e}' + + def _log_body_structure(self, body): + """Log top-level keys and data keys of API response body.""" + if not isinstance(body, dict): + logger.info(f'Body is {type(body).__name__}, not dict') + return + logger.info(f'Body top-level keys: {list(body.keys())}') + data = body.get('data') + if isinstance(data, dict): + logger.info(f'data keys: {list(data.keys())}') + elif data is not None: + logger.info(f'data is {type(data).__name__}, not dict') + + def _format_pub_time(self, pub_ts): + """Format publish timestamp to readable CN time string.""" + return datetime.fromtimestamp(pub_ts, tz=self.cn_tz).strftime('%Y-%m-%d %H:%M') + + def _normalize_article_for_cache(self, article): + """Convert raw API article dict to cache record format. + + Mirrors dev_tools/downtime_notice_crawler.py normalize_article output + so test caches stay consistent with crawler-produced caches. + """ + cvid = str(article['id']) + pub_ts = int(article['publish_time']) + return { + 'pub_ts': pub_ts, + 'pub_time': datetime.fromtimestamp(pub_ts, tz=self.cn_tz).isoformat(), + 'title': article['title'], + 'text': article['summary'], + 'url': f'https://www.bilibili.com/read/cv{cvid}', + 'collection_id': self._collection_id, + 'collection_name': self.collection_name, + } + + def _save_caches(self, articles): + """Persist normalized article records for diagnostic inspection. + + Args: + articles (list[dict]): Raw article list from body['data']['articles']. + """ + Path(self.ARTICLES_JSONL_FILE).parent.mkdir(parents=True, exist_ok=True) + records = [self._normalize_article_for_cache(a) for a in articles] + jsonl_path = Path(self.ARTICLES_JSONL_FILE) + jsonl_path.write_text( + ''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in records), + encoding='utf-8', + ) + logger.info(f'Saved {len(records)} article records to {self.ARTICLES_JSONL_FILE}') + + def _record(self, name, status, note=''): + self._results.append(_TestResult(name, status, note)) + + # ------------------------------------------------------------------ + # Test cases + # ------------------------------------------------------------------ + + def test_network(self): + """Probe DNS/TCP/TLS to the API host to locate where connectivity breaks.""" + logger.hr('Network connectivity', level=2) + host = urlparse(self.lists_api).hostname + port = 443 + logger.info(f'Target: {host}:{port}') + + try: + t0 = time.monotonic() + ip = socket.gethostbyname(host) + dns_cost = time.monotonic() - t0 + logger.info(f'DNS resolved: {host} -> {ip} ({dns_cost:.2f}s)') + except socket.gaierror as e: + logger.warning(f'DNS resolution failed: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'DNS: {e}') + return + + ctx = ssl.create_default_context() + sock = None + try: + t0 = time.monotonic() + sock = socket.create_connection((host, port), timeout=self.CONNECT_TIMEOUT) + tcp_cost = time.monotonic() - t0 + logger.info(f'TCP connected: {host}:{port} ({tcp_cost:.2f}s)') + + t1 = time.monotonic() + ssock = ctx.wrap_socket(sock, server_hostname=host) + tls_cost = time.monotonic() - t1 + ssock.close() + sock = None + logger.info(f'TLS handshake ok ({tls_cost:.2f}s)') + self._record( + self.TEST_NETWORK, _TestStatus.PASS, + f'DNS:{dns_cost:.2f}s TCP:{tcp_cost:.2f}s TLS:{tls_cost:.2f}s', + ) + except OSError as e: + logger.warning(f'Connection failed: {type(e).__name__}: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'{type(e).__name__}: {e}') + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + def test_lists_api(self): + """Verify ARTICLE_LISTS_API returns valid response with data.lists.""" + logger.hr('Lists API', level=2) + body, err = self._fetch_json(self.lists_api, {'mid': str(self.mid), 'sort': 0}) + if body is None: + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + lists = (body.get('data') or {}).get('lists') + if not isinstance(lists, list): + logger.warning(f'Invalid data.lists: type={type(lists).__name__}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'lists invalid: {type(lists).__name__}') + return + + self._lists = lists + logger.info(f'Got {len(lists)} collections') + self._record(self.TEST_LISTS_API, _TestStatus.PASS, f'{len(lists)} collections') + + def test_find_collection(self): + """Check whether target collection exists in UP's lists.""" + logger.hr('Collection lookup', level=2) + logger.info(f'Target collection: {self.collection_name!r}') + names = [item.get('name') for item in self._lists] + logger.info(f'Available collections: {names}') + + target = next( + (item for item in self._lists if item.get('name') == self.collection_name), + None, + ) + if target is None: + logger.warning(f'Target not found: {self.collection_name!r}') + self._record(self.TEST_COLLECTION, _TestStatus.FAIL, f'not found, available={names}') + return + + self._collection_id = int(target['id']) + logger.info(f'Found: id={self._collection_id} name={target["name"]!r}') + self._record(self.TEST_COLLECTION, _TestStatus.PASS, f'id={self._collection_id}') + + def test_articles_api(self): + """Verify ARTICLE_COLLECTION_API returns articles for the collection.""" + logger.hr('Articles API', level=2) + body, err = self._fetch_json(self.articles_api, {'id': str(self._collection_id)}) + if body is None: + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + articles = (body.get('data') or {}).get('articles') + if not isinstance(articles, list) or not articles: + logger.warning(f'Empty or invalid data.articles: type={type(articles).__name__}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'articles empty: {type(articles).__name__}') + return + + # Extract UP name from response body for display + author = (body.get('data') or {}).get('author') + if isinstance(author, dict): + up_name = author.get('name') + if up_name: + self._up_name = up_name + logger.info(f'UP: mid={self.mid} name={up_name!r}') + + logger.info(f'The latest article and recent {self.ARTICLE_PREVIEW_COUNT} articles:') + + # Latest article: title + summary excerpt + sorted_articles = sorted(articles, key=lambda a: a.get('publish_time', 0), reverse=True) + latest = sorted_articles[0] + pub_time = self._format_pub_time(latest.get('publish_time', 0)) + summary = (latest.get('summary') or '')[:self.SUMMARY_EXCERPT_LEN] + logger.info(f'Latest: [{pub_time}] {latest.get("title")} | {summary!r}') + + # Next N articles: title only + for a in sorted_articles[1:1 + self.ARTICLE_PREVIEW_COUNT]: + pub_time = self._format_pub_time(a.get('publish_time', 0)) + logger.info(f'[{pub_time}] {a.get("title")}') + + # Persist article records for diagnostic inspection + self._save_caches(articles) + + articles_normalized = [self.fetcher._normalize_article(a) for a in articles] + self._latest_article = self.fetcher._latest_article(articles_normalized) + self._record(self.TEST_ARTICLES_API, _TestStatus.PASS, f'{len(articles)} articles') + + def test_parse_window(self): + """Run regex parse against the latest article to detect format drift.""" + logger.hr('Parse window', level=2) + article = self._latest_article + body = self.fetcher._clean_text(article.summary) + + # Show individual regex match results for diagnosis + window_match = self.window_re.search(body) + duration_match = self.duration_re.search(body) + title_match = self.title_time_re.search(article.title) + logger.info(f'WINDOW_RE on summary: {"matched" if window_match else "unmatched"} ' + f'groups={window_match.groups() if window_match else None}') + logger.info(f'DURATION_RE on summary: {"matched" if duration_match else "unmatched"} ' + f'groups={duration_match.groups() if duration_match else None}') + logger.info(f'TITLE_TIME_RE on title: {"matched" if title_match else "unmatched"} ' + f'groups={title_match.groups() if title_match else None}') + + window = self.fetcher.parse_window_from_article(article) + if window is None: + logger.warning('parse_window_from_article returned None') + self._record(self.TEST_PARSE, _TestStatus.FAIL, 'no regex matched') + return + + logger.info(f'Parsed window: {window.window!r}') + self._record(self.TEST_PARSE, _TestStatus.PASS, f'window={window.window}') + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + + def _show_summary(self): + logger.hr('Test summary', level=1) + table = Table(show_lines=True) + table.add_column('Test', style='cyan', no_wrap=True) + table.add_column('Result') + table.add_column('Note') + for r in self._results: + style = self._STATUS_STYLE.get(r.status, 'white') + table.add_row(r.name, Text(r.status.value, style=style), r.note) + logger.print(table, justify='center') diff --git a/module/smart_mgmt/utils.py b/module/smart_mgmt/utils.py deleted file mode 100644 index e33946163..000000000 --- a/module/smart_mgmt/utils.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import re -from datetime import datetime -from typing import Optional - -from module.logger import logger - - -DOWNTIME_FORMAT_RE = re.compile( - r'(\d{4})-(\d{1,2})-(\d{1,2}) ' - r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', -) - -def parse_downtime(downtime: str) -> Optional[datetime]: - """ - Parse downtime window string into end datetime. - - Args: - downtime (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. - - Returns: - Optional[datetime]: End datetime parsed from the window, - or None if no pattern matched. - """ - match = DOWNTIME_FORMAT_RE.search(downtime) - if not match: - logger.warning(f'Failed to parse downtime window: {downtime}') - return None - year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) - end_hour, end_minute = int(match.group(6)), int(match.group(7)) - end_dt = datetime(year, month, day, end_hour, end_minute) - logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') - return end_dt diff --git a/module/submodule/utils.py b/module/submodule/utils.py index d7a87d916..86330ba2b 100644 --- a/module/submodule/utils.py +++ b/module/submodule/utils.py @@ -23,6 +23,7 @@ def get_available_func(): 'AzurLaneUncensored', 'Benchmark', 'GameManager', + 'DowntimeFetch', ) def get_available_mod():