From 9cc7aa804b9962248cf0c178331ded3cc47fda52 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:25:18 +0800 Subject: [PATCH] Feat: add module smart_mgmt --- alas.py | 20 +- config/template.json | 25 ++ module/config/argument/args.json | 72 ++++++ module/config/argument/argument.yaml | 10 + module/config/argument/menu.json | 8 + module/config/argument/override.yaml | 8 + module/config/argument/task.yaml | 12 + module/config/config_generated.py | 7 + module/config/config_manual.py | 1 + module/config/i18n/en-US.json | 36 +++ module/config/i18n/ja-JP.json | 36 +++ module/config/i18n/zh-CN.json | 36 +++ module/config/i18n/zh-TW.json | 36 +++ module/smart_mgmt/downtime_fetch.py | 47 ++++ module/smart_mgmt/downtime_fetcher.py | 345 ++++++++++++++++++++++++++ module/smart_mgmt/utils.py | 34 +++ 16 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 module/smart_mgmt/downtime_fetch.py create mode 100644 module/smart_mgmt/downtime_fetcher.py create mode 100644 module/smart_mgmt/utils.py diff --git a/alas.py b/alas.py index 06b635c53..26ae0cd94 100644 --- a/alas.py +++ b/alas.py @@ -15,6 +15,7 @@ from module.logger import logger from module.notify import handle_notify RESTART_SENSITIVE_TASKS = ['OpsiObscure', 'OpsiAbyssal', 'OpsiCrossMonth'] +DEVICE_FREE_TASKS = 'DowntimeFetch' class AzurLaneAutoScript: @@ -198,6 +199,10 @@ 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() @@ -570,9 +575,8 @@ class AzurLaneAutoScript: self.config.task_call('Restart') # Get task task = self.get_next_task() - # Init device and change server - _ = self.device - self.device.config = self.config + # Network-only tasks skip device init and screenshot + device_free_task = task == DEVICE_FREE_TASKS # Skip first restart if task == 'Restart' and self.is_first_task: logger.info('Skip task `Restart` at scheduler start') @@ -582,10 +586,14 @@ class AzurLaneAutoScript: # Run logger.info(f'Scheduler: Start task `{task}`') - self.device.stuck_record_clear() - self.device.click_record_clear() + # 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() logger.hr(task, level=0) - success = self.run(inflection.underscore(task)) + success = self.run(inflection.underscore(task), skip_first_screenshot=device_free_task) logger.info(f'Scheduler: End task `{task}`') self.is_first_task = False diff --git a/config/template.json b/config/template.json index 10552c881..5fefca2e0 100644 --- a/config/template.json +++ b/config/template.json @@ -149,6 +149,31 @@ "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 + }, + "Storage": { + "Storage": {} + } + }, "Main": { "Scheduler": { "Enable": false, diff --git a/module/config/argument/args.json b/module/config/argument/args.json index f864467a1..78917b0a0 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -669,6 +669,78 @@ } } }, + "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 + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "Main": { "Scheduler": { "Enable": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index cf722385a..c618401eb 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -153,6 +153,16 @@ OldRetire: value: retire_all option: [ retire_all, retire_10 ] +# ==================== SmartMgmt ==================== + +Downtime: + Window: '' + Title: + value: '' + display: disabled +DowntimeStrategy: + ManageExercise: false + # ==================== Farm ==================== Campaign: diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index b6f7a9c4b..f9cb0a1d7 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -8,6 +8,14 @@ "Restart" ] }, + "SmartMgmt": { + "menu": "collapse", + "page": "setting", + "tasks": [ + "DowntimeFetch", + "DowntimeMgmt" + ] + }, "Farm": { "menu": "collapse", "page": "setting", diff --git a/module/config/argument/override.yaml b/module/config/argument/override.yaml index e88d9d3dc..a38f435db 100644 --- a/module/config/argument/override.yaml +++ b/module/config/argument/override.yaml @@ -15,6 +15,14 @@ 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 92c76dd76..ab18f111a 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -22,6 +22,18 @@ Alas: Restart: - Scheduler +# ==================== SmartMgmt ==================== + +SmartMgmt: + menu: 'collapse' + page: 'setting' + tasks: + DowntimeFetch: + - Scheduler + DowntimeMgmt: + - Downtime + - DowntimeStrategy + # ==================== Farm ==================== Farm: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 8537b711f..c982b4b24 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -76,6 +76,13 @@ class GeneratedConfig: OldRetire_SSR = False OldRetire_RetireAmount = 'retire_all' # retire_all, retire_10 + # Group `Downtime` + Downtime_Window = None + Downtime_Title = None + + # Group `DowntimeStrategy` + DowntimeStrategy_ManageExercise = False + # Group `Campaign` Campaign_Name = '12-4' Campaign_Event = 'campaign_main' # campaign_main diff --git a/module/config/config_manual.py b/module/config/config_manual.py index fcc6de026..424790f68 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -11,6 +11,7 @@ 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 6333cf1c3..fac610ea7 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "Smart Management", + "help": "" + }, "Farm": { "name": "Farm", "help": "" @@ -46,6 +50,14 @@ "name": "Restart", "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", + "help": "" + }, "Main": { "name": "Main", "help": "" @@ -706,6 +718,30 @@ "retire_10": "Retire 10" } }, + "Downtime": { + "_info": { + "name": "Downtime Info", + "help": "Auto-filled by 'Downtime Fetch' task" + }, + "Window": { + "name": "Time Window", + "help": "Format YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "Notice Title", + "help": "Display only" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "Strategy Settings", + "help": "Auto-adjust task scheduling and strategy based on downtime info" + }, + "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'" + } + }, "Campaign": { "_info": { "name": "Level Settings", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index dd7ed0ea3..c45896959 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "スマート管理", + "help": "" + }, "Farm": { "name": "出撃", "help": "" @@ -46,6 +50,14 @@ "name": "再起動設定", "help": "" }, + "DowntimeFetch": { + "name": "メンテナンス情報取得", + "help": "国服のみ対応。bilibiliアズールレーンコラムからメンテナンス告知を自動取得し、解析して'メンテナンス情報'に記入" + }, + "DowntimeMgmt": { + "name": "メンテナンス", + "help": "" + }, "Main": { "name": "メイン海域", "help": "" @@ -706,6 +718,30 @@ "retire_10": "retire_10" } }, + "Downtime": { + "_info": { + "name": "メンテナンス情報", + "help": "'メンテナンス情報取得'タスクにより自動入力" + }, + "Window": { + "name": "時間枠", + "help": "形式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "告知タイトル", + "help": "表示のみ" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "機能設定", + "help": "メンテナンス情報に基づきタスクスケジュールと戦略を自動調整" + }, + "ManageExercise": { + "name": "演習", + "help": "メンテナンス終了時刻が15:00以降の場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + } + }, "Campaign": { "_info": { "name": "Campaign._info.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 9e689e391..0d71abbc2 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "智能管理", + "help": "" + }, "Farm": { "name": "出击", "help": "" @@ -46,6 +50,14 @@ "name": "重启设置", "help": "" }, + "DowntimeFetch": { + "name": "公告获取", + "help": "仅支持国服,自动从B站碧蓝航线专栏获取停服维护公告,解析并填写'停服维护信息'" + }, + "DowntimeMgmt": { + "name": "停服维护", + "help": "" + }, "Main": { "name": "主线图", "help": "" @@ -706,6 +718,30 @@ "retire_10": "退役10个" } }, + "Downtime": { + "_info": { + "name": "停服维护信息", + "help": "可由'公告获取'任务自动填写" + }, + "Window": { + "name": "时间窗口", + "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "公告标题", + "help": "仅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能设置", + "help": "基于停服维护信息自动调整任务策略与调度" + }, + "ManageExercise": { + "name": "演习", + "help": "当停服结束时间为15:00及之后时,当天停服维护前演习策略将替换为'清空演习次数'" + } + }, "Campaign": { "_info": { "name": "关卡设置", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 093c3212f..27f94b30c 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -4,6 +4,10 @@ "name": "Alas", "help": "" }, + "SmartMgmt": { + "name": "智能管理", + "help": "" + }, "Farm": { "name": "出擊", "help": "" @@ -46,6 +50,14 @@ "name": "重啟設定", "help": "" }, + "DowntimeFetch": { + "name": "公告取得", + "help": "僅支援國服,自動從B站碧藍航線專欄取得停服維護公告,解析並填寫'停服維護資訊'" + }, + "DowntimeMgmt": { + "name": "停服維護", + "help": "" + }, "Main": { "name": "主線圖", "help": "" @@ -706,6 +718,30 @@ "retire_10": "退役10個" } }, + "Downtime": { + "_info": { + "name": "停服維護資訊", + "help": "可由'公告取得'任務自動填寫" + }, + "Window": { + "name": "時間窗口", + "help": "格式 YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "公告標題", + "help": "僅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能設定", + "help": "基於停服維護資訊自動調整任務排程與策略" + }, + "ManageExercise": { + "name": "演習", + "help": "當停服結束時間為15:00及之後時,當天停服維護前演習策略將替換為'清空演習次數'" + } + }, "Campaign": { "_info": { "name": "地圖設定", diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py new file mode 100644 index 000000000..b13537aa3 --- /dev/null +++ b/module/smart_mgmt/downtime_fetch.py @@ -0,0 +1,47 @@ +import random +from datetime import timedelta + +from module.config.utils import get_server_next_update +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher, FetchError + + +class DowntimeFetch: + def __init__(self, config): + self.config = config + + 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 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') + 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) diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py new file mode 100644 index 000000000..5b6b67e4b --- /dev/null +++ b/module/smart_mgmt/downtime_fetcher.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + +import requests + +from deploy.atomic import atomic_read_text, atomic_write +from module.logger import logger + + +class FetchError(RuntimeError): + pass + + +@dataclass +class CollectionMeta: + collection_id: int + collection_name: str + update_time: int + +@dataclass +class Article: + pub_ts: int + title: str + summary: str + +@dataclass +class DowntimeWindow: + window: str + + +class DowntimeFetcher: + DEFAULT_MID = 233114659 + 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)) + REQUEST_TIMEOUT = 30.0 + + 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})') + + CACHE_FILE = './log/downtime_cache.json' + FETCH_LOCK_FILE = './log/downtime_fetch.lock' + # Multi-instance duplicate fetch avoidance window (seconds) + CACHE_FRESH_SECONDS = 300 + # A bit larger than REQUEST_TIMEOUT + LOCK_WAIT_SECONDS = 35 + + def __init__(self): + self.session = self._session_for() + + def _session_for(self): + 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/{self.DEFAULT_MID}/article', + }) + return s + + def _request_api(self, url, params) -> dict: + """Request Bilibili API and return inner data field (code == 0).""" + http_resp = self.session.get(url, params=params, timeout=self.REQUEST_TIMEOUT) + http_resp.raise_for_status() + body = http_resp.json() + if body['code'] != 0: + raise FetchError(body.get('message') or body.get('msg') or 'unknown error') + return body['data'] + + def find_collection(self) -> CollectionMeta: + """ + Find target collection by name from UP's article lists. + + Returns: + CollectionMeta: Metadata of the matched collection. + """ + lists = self._request_api(self.ARTICLE_LISTS_API, {'mid': str(self.DEFAULT_MID), 'sort': 0})['lists'] + for item in lists: + if item['name'] == self.DEFAULT_COLLECTION_NAME: + collection_meta = CollectionMeta( + collection_id=int(item['id']), + collection_name=item['name'], + update_time=int(item['update_time']), + ) + logger.info(f'Found collection: id={collection_meta.collection_id} ' + f'update_time={collection_meta.update_time}') + return collection_meta + raise FetchError(f'collection not found: mid={self.DEFAULT_MID} name={self.DEFAULT_COLLECTION_NAME!r}') + + @staticmethod + def _normalize_article(article) -> Article: + return Article( + pub_ts=int(article['publish_time']), + title=article['title'], + summary=article['summary'], + ) + + @staticmethod + def _clean_text(text: str): + return text.replace('[图片]', '').replace('\u3000', ' ').strip() + + def _infer_year(self, published, month, day): + year = published.year + delta = (datetime(year, month, day, tzinfo=self.CN_TZ).date() - published.date()).days + if delta < -30: + 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) + return start, end + + def parse_window_from_article(self, article: Article) -> Optional[DowntimeWindow]: + """ + Returns: + Optional[DowntimeWindow]: Parsed downtime window, or None if no pattern matched. + The window string uses '~' as time range separator to prevent config loader + 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) + + 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 = self._build_window_dt(published, sm, sd, 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)) + 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) + end = start + timedelta(hours=hours) + return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + + return None + + @staticmethod + def _latest_article(articles: list[Article]) -> Article: + return max(articles, key=lambda a: a.pub_ts) + + def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: + """ + Full fetch pipeline: find_collection → incremental check → fetch articles. + + Returns: + tuple[Optional[list[Article]], CollectionMeta]: + articles is None when collection not updated (incremental check hit). + """ + collection_meta = self.find_collection() + + if local_update_time is not None and local_update_time == collection_meta.update_time: + logger.info('Collection not updated, reuse local cache') + return None, collection_meta + + logger.info(f'Collection updated, fetching articles (id={collection_meta.collection_id})') + articles_data = self._request_api(self.ARTICLE_COLLECTION_API, {'id': str(collection_meta.collection_id)}) + articles = [self._normalize_article(article) for article in articles_data['articles']] + logger.info(f'Fetched {len(articles)} articles') + return articles, collection_meta + + def collect_info(self, local_update_time=None) -> tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + """ + Full fetch and parse pipeline. + + Returns: + tuple[Optional[DowntimeWindow], CollectionMeta, Optional[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) + if articles is None: + return None, collection_meta, None + latest_article = self._latest_article(articles) + downtime_window = self.parse_window_from_article(latest_article) + logger.info(f'Fetched downtime: window={downtime_window.window if downtime_window else ""} ' + f'title={latest_article.title!r}') + return downtime_window, collection_meta, latest_article + + @classmethod + def load_cache(cls) -> Optional[dict]: + if not Path(cls.CACHE_FILE).exists(): + return None + text = atomic_read_text(cls.CACHE_FILE) + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + logger.warning('Failed to parse downtime_cache.json, will re-fetch') + return None + + @classmethod + def save_cache(cls, payload): + Path(cls.CACHE_FILE).parent.mkdir(parents=True, exist_ok=True) + atomic_write(cls.CACHE_FILE, json.dumps(payload, ensure_ascii=False, indent=2)) + logger.info(f'Saved downtime cache to {cls.CACHE_FILE}') + + @classmethod + def is_cache_fresh(cls): + """Check if cache file was modified within CACHE_FRESH_SECONDS.""" + p = Path(cls.CACHE_FILE) + if not p.exists(): + return False + age = datetime.now().timestamp() - p.stat().st_mtime + return age < cls.CACHE_FRESH_SECONDS + + def get_downtime_info(self) -> dict: + """ + Full pipeline: load cache → collect_info → save 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 + + downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) + + if latest_article is None: + if cache is None: + raise FetchError('No available cache and collection not updated') + return cache + + # Degrade on parse failure: reuse cached window + if downtime_window is None: + if cache and cache.get('window'): + logger.warning('Parse failed, reuse cached window') + window = cache['window'] + else: + window = '' + else: + window = downtime_window.window + + cache_payload = { + 'collection_id': collection_meta.collection_id, + 'update_time': collection_meta.update_time, + 'window': window, + 'title': latest_article.title, + } + self.save_cache(cache_payload) + return cache_payload + + @classmethod + def acquire_lock(cls) -> bool: + """ + Acquire cross-process fetch lock. + + Returns: + bool: True if acquired, False on timeout or OSError. + """ + p = Path(cls.FETCH_LOCK_FILE) + p.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + cls.LOCK_WAIT_SECONDS + while time.monotonic() < deadline: + if p.exists(): + try: + age = datetime.now().timestamp() - p.stat().st_mtime + if age < cls.LOCK_WAIT_SECONDS: + time.sleep(1) + continue + logger.warning(f'Stale lock assumed (age={age:.0f}s), overriding') + p.unlink() + except (OSError, FileNotFoundError): + pass + # Atomic create via O_CREAT|O_EXCL to avoid race between instances + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(datetime.now().timestamp()).encode()) + os.close(fd) + return True + except FileExistsError: + time.sleep(1) + except OSError as e: + logger.warning(f'Failed to acquire fetch lock: {e}') + return False + return False + + @classmethod + def release_lock(cls): + try: + Path(cls.FETCH_LOCK_FILE).unlink() + except FileNotFoundError: + pass + except OSError as e: + logger.warning(f'Failed to release fetch lock: {e}') + + def run(self) -> 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). + 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() + if not self.acquire_lock(): + if self.is_cache_fresh(): + logger.info('Cache updated by another process, reuse') + return self.get_downtime_info() + raise FetchError('Failed to acquire fetch lock') + try: + return self.get_downtime_info() + finally: + self.release_lock() diff --git a/module/smart_mgmt/utils.py b/module/smart_mgmt/utils.py new file mode 100644 index 000000000..e33946163 --- /dev/null +++ b/module/smart_mgmt/utils.py @@ -0,0 +1,34 @@ +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