From ea771b0aa23846489ad5b432404d1fd010c00642 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:28:23 +0800 Subject: [PATCH 1/7] Revert "Update .gitignore for AI coding local folders" This reverts commit f92ee9907d75e3f257d15103d9556d162889d035. --- .gitignore | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.gitignore b/.gitignore index c4abb6fb7..73ce58dae 100644 --- a/.gitignore +++ b/.gitignore @@ -20,17 +20,6 @@ config/reloadalas test.py test/ - -# Cover some AI IDEs -.claude -.codex -.cursor -.trae - -# Cover some mcp tools -.codegraph - - # Created by .ignore support plugin (hsz.mobi) ### JetBrains template From 983bab459afa07f36c5b573c105e64f848f9a5e9 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:21:34 +0800 Subject: [PATCH 2/7] Fix: harden DowntimeFetcher consistency checks, error boundary and fetch lock --- module/smart_mgmt/downtime_fetch.py | 7 +- module/smart_mgmt/downtime_fetcher.py | 414 +++++++++++++++++++------- module/submodule/utils.py | 2 +- 3 files changed, 304 insertions(+), 119 deletions(-) diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py index 516d6525e..1dc354abb 100644 --- a/module/smart_mgmt/downtime_fetch.py +++ b/module/smart_mgmt/downtime_fetch.py @@ -22,8 +22,7 @@ def parse_downtime(window: str) -> Optional[datetime]: window (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. + Optional[datetime]: End datetime parsed from the window, or None if no pattern matched. """ match = DOWNTIME_FORMAT_RE.search(window) if not match: @@ -38,8 +37,9 @@ def parse_downtime(window: str) -> Optional[datetime]: def update_downtime(config: AzurLaneConfig, force_refresh=False) -> bool: """Fetch downtime and write config.""" + logger.info('Fetch downtime') try: - payload = DowntimeFetcher().run(force_refresh=force_refresh) + payload = DowntimeFetcher(instance_name=config.config_name).run(force_refresh=force_refresh) except FetchError as e: # Expected operational failure degrade logger.warning(f'Failed to fetch downtime: {e}') @@ -75,6 +75,7 @@ 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') + logger.info(f'Downtime fetch start: server={config.SERVER} test_mode={config.DowntimeFetch_FetcherTest}') if config.DowntimeFetch_FetcherTest: DowntimeFetcherTest().run() else: diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py index 6f8c3b99c..47a01d5fb 100644 --- a/module/smart_mgmt/downtime_fetcher.py +++ b/module/smart_mgmt/downtime_fetcher.py @@ -24,19 +24,24 @@ class CollectionMeta: collection_id: int collection_name: str update_time: int + article_count: int + @dataclass class Article: pub_ts: int + id: str title: str summary: str + @dataclass class DowntimeWindow: window: str class DowntimeFetcher: + # Bilibili UP mid of the AzurLane official account DEFAULT_MID = 233114659 DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' @@ -44,6 +49,14 @@ class DowntimeFetcher: CN_TZ = timezone(timedelta(hours=8)) REQUEST_TIMEOUT = 30.0 + # Local cache and cross-process fetch lock + CACHE_FILE = './log/downtime_cache.json' + FETCH_LOCK_FILE = './log/downtime_fetch.lock' + # Multi-instance duplicate fetch avoidance window (seconds) + CACHE_FRESH_SECONDS = 600 + # A bit larger than REQUEST_TIMEOUT + LOCK_WAIT_SECONDS = 35 + WS = r'[\s ]*' WINDOW_RE = re.compile( r'司令部将于' + WS + @@ -55,17 +68,14 @@ class DowntimeFetcher: 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 = 600 - # A bit larger than REQUEST_TIMEOUT - LOCK_WAIT_SECONDS = 35 - def __init__(self): + def __init__(self, instance_name=''): self.session = self._session_for() + # Lock ownership token: the ALAS instance name + self._lock_token = instance_name def _session_for(self): + """Build the API session with a browser-like User-Agent.""" s = requests.Session() s.headers.update({ 'User-Agent': ( @@ -78,12 +88,27 @@ class DowntimeFetcher: 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() + try: + http_resp = self.session.get(url, params=params, timeout=self.REQUEST_TIMEOUT) + http_resp.raise_for_status() + body = http_resp.json() + except requests.RequestException as e: + # Network/HTTP failures are operational, degrade as FetchError + raise FetchError(f'Request failed: {url}: {e}') from e + except ValueError as e: + raise FetchError(f'Invalid JSON response: {url}: {e}') from e + if not isinstance(body, dict): + raise FetchError(f'Unexpected JSON structure: {url}: {type(body).__name__}') + if 'code' not in body: + raise FetchError(f'Missing code field: {url}') if body['code'] != 0: raise FetchError(body.get('message') or body.get('msg') or 'unknown error') - return body['data'] + if 'data' not in body: + raise FetchError(f'Missing data field: {url}') + data = body['data'] + if not isinstance(data, dict): + raise FetchError(f'Unexpected data structure: {url}: {type(data).__name__}') + return data def find_collection(self) -> CollectionMeta: """ @@ -92,31 +117,43 @@ class DowntimeFetcher: Returns: CollectionMeta: Metadata of the matched collection. """ - lists = self._request_api(self.ARTICLE_LISTS_API, {'mid': str(self.DEFAULT_MID), 'sort': 0})['lists'] + data = self._request_api(self.ARTICLE_LISTS_API, {'mid': str(self.DEFAULT_MID), 'sort': 0}) + lists = data.get('lists') + if not isinstance(lists, list): + raise FetchError(f'Unexpected lists structure: {self.ARTICLE_LISTS_API}') for item in lists: - if item['name'] == self.DEFAULT_COLLECTION_NAME: + if not isinstance(item, dict): + raise FetchError(f'Unexpected collection structure: {type(item).__name__}') + if item.get('name') != self.DEFAULT_COLLECTION_NAME: + continue + try: collection_meta = CollectionMeta( collection_id=int(item['id']), collection_name=item['name'], update_time=int(item['update_time']), + article_count=int(item['articles_count']), ) - 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'], + except (KeyError, TypeError, ValueError) as e: + raise FetchError(f'Unexpected collection fields: {e}') from e + logger.info( + f'Found collection: id={collection_meta.collection_id} ' + f'update_time={collection_meta.update_time} ' + f'article_count={collection_meta.article_count}' + ) + return collection_meta + raise FetchError( + f'collection not found: mid={self.DEFAULT_MID} name={self.DEFAULT_COLLECTION_NAME!r}' ) @staticmethod def _clean_text(text: str): return text.replace('[图片]', '').replace('\u3000', ' ').strip() + @staticmethod + def _format_window(start: datetime, end: datetime) -> str: + """Format downtime window as 'YYYY-MM-DD HH:MM~HH:MM'.""" + return f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}' + def _infer_year(self, published, month, day): year = published.year delta = (datetime(year, month, day, tzinfo=self.CN_TZ).date() - published.date()).days @@ -124,12 +161,6 @@ class DowntimeFetcher: year += 1 return year - 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]: """ Returns: @@ -137,82 +168,168 @@ class DowntimeFetcher: 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. """ + if not isinstance(article.summary, str): + raise FetchError(f'Unexpected article summary: {article.summary}') published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ) summary = self._clean_text(article.summary) - window_match = self.WINDOW_RE.search(summary) - duration_match = self.DURATION_RE.search(summary) + window_match = self.WINDOW_RE.search(summary) if window_match: 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, month, day, sh, smin, eh, emin) - return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + start_hour = int(window_match.group(3)) + start_minute = int(window_match.group(4)) + end_hour = int(window_match.group(5)) + end_minute = int(window_match.group(6)) + year = self._infer_year(published, month, day) + start = datetime(year, month, day, start_hour, start_minute, tzinfo=self.CN_TZ) + end = datetime(year, month, day, end_hour, end_minute, tzinfo=self.CN_TZ) + return DowntimeWindow(window=self._format_window(start, end)) + duration_match = self.DURATION_RE.search(summary) if duration_match: + if not isinstance(article.title, str): + raise FetchError(f'Unexpected article title: {article.title}') title_match = self.TITLE_TIME_RE.search(article.title) if title_match: month = int(title_match.group(1)) day = int(title_match.group(2)) - sh = int(title_match.group(3)) - smin = int(title_match.group(4)) + start_hour = int(title_match.group(3)) + start_minute = int(title_match.group(4)) hours = int(duration_match.group(1)) - start, _ = self._build_window_dt(published, month, day, sh, smin, sh, smin) + year = self._infer_year(published, month, day) + start = datetime(year, month, day, start_hour, start_minute, tzinfo=self.CN_TZ) end = start + timedelta(hours=hours) - return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + return DowntimeWindow(window=self._format_window(start, end)) return None @staticmethod - def _latest_article(articles: list[Article]) -> Article: - return max(articles, key=lambda a: a.pub_ts) + def _normalize_article(raw_dict) -> Article: + """Map a raw Bilibili article dict onto the Article dataclass.""" + if not isinstance(raw_dict, dict): + raise FetchError(f'Unexpected article structure: {type(raw_dict).__name__}') + try: + return Article( + pub_ts=int(raw_dict['publish_time']), + id=str(raw_dict['id']), + title=raw_dict['title'], + summary=raw_dict['summary'], + ) + except (KeyError, TypeError, ValueError) as e: + raise FetchError(f'Unexpected article fields: {e}') from e - def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: + def fetch_articles( + self, + cache_update_time=None, + cache_article_count=None, + ) -> tuple[CollectionMeta, Optional[list[Article]]]: """ - find_collection -> incremental check -> fetch articles + find_collection -> incremental check -> fetch articles -> consistency check Returns: - tuple[Optional[list[Article]], CollectionMeta]: - articles is None when collection not updated (incremental check hit). + tuple[CollectionMeta, Optional[list[Article]]]: + Collection metadata and article list fetched from collection API. + Article list is None when collection not updated (incremental check hit) + or consistency check failed (article API lagged, returning stale list). """ 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 + # Incremental check: reuse the cache when both the collection + # update_time and the declared article count are unchanged. + if ( + cache_update_time is not None and cache_article_count is not None + and cache_update_time == collection_meta.update_time + and cache_article_count == collection_meta.article_count + ): + logger.info( + f'Collection not updated, reuse local cache: ' + f'update_time={collection_meta.update_time} ' + f'article_count={collection_meta.article_count}' + ) + return collection_meta, None - 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 + logger.info('Collection updated, fetching articles') + response = self._request_api( + self.ARTICLE_COLLECTION_API, {'id': str(collection_meta.collection_id)}, + ) + articles = response.get('articles') + if not isinstance(articles, list): + raise FetchError(f'Unexpected articles structure: {self.ARTICLE_COLLECTION_API}') + articles_list = [self._normalize_article(raw_dict) for raw_dict in articles] + logger.info(f'Fetched {len(articles_list)} articles') - def collect_info(self, local_update_time=None) -> tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + if not articles_list: + logger.warning('Article API returned an empty list, skip this fetch') + return collection_meta, None + + # Consistency check: article count must match collection's articles_count. + # Article API may lag behind collection API when publish time is close to + # fetch time, returning stale list with fewer articles than declared. + # Skip the fetch so a stale list never lands in the cache. + if len(articles_list) != collection_meta.article_count: + logger.warning( + f'Article API lagged behind collection API: ' + f'fetched={len(articles_list)} expected={collection_meta.article_count}, ' + f'skip this fetch' + ) + return collection_meta, None + + return collection_meta, articles_list + + @staticmethod + def _latest_article(articles_list: list[Article]) -> Article: + return max(articles_list, key=lambda a: a.pub_ts) + + def collect_info( + self, + cache_update_time=None, + cache_article_count=None, + cache_latest_pub_ts=None, + cache_latest_article_id=None, + ) -> tuple[CollectionMeta, Optional[Article], Optional[DowntimeWindow]]: """ 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. + tuple[CollectionMeta, Optional[Article], Optional[DowntimeWindow]]: + Collection metadata, latest article, and parsed downtime window. + Downtime window is None if parse failed. + Latest article is None if collection not updated or consistency check failed. """ - articles, collection_meta = self.fetch_articles(local_update_time) - if articles is None: - return None, collection_meta, None - latest_article = self._latest_article(articles) + collection_meta, articles_list = self.fetch_articles(cache_update_time, cache_article_count) + if articles_list is None: + return collection_meta, None, None + latest_article = self._latest_article(articles_list) + + # Secondary consistency check: + # verify the fetched latest article differs from the cache to confirm a real update. + if ( + cache_latest_pub_ts is not None and cache_latest_article_id is not None + and latest_article.pub_ts == cache_latest_pub_ts + and latest_article.id == cache_latest_article_id + ): + logger.warning( + f'Latest article unchanged despite collection update: ' + f'pub_ts={latest_article.pub_ts} id={latest_article.id}, ' + f'skip this fetch' + ) + return collection_meta, None, None + 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 + logger.info( + f'Fetched downtime: window={downtime_window.window if downtime_window else ""} ' + f'title={latest_article.title!r}' + ) + + return collection_meta, latest_article, downtime_window @classmethod def load_cache(cls) -> Optional[dict]: - if not Path(cls.CACHE_FILE).exists(): + try: + text = atomic_read_text(cls.CACHE_FILE) + except FileNotFoundError: return None - text = atomic_read_text(cls.CACHE_FILE) if not text: return None try: @@ -227,40 +344,47 @@ class DowntimeFetcher: 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 + @staticmethod + def _file_age(path: Path) -> float: + return time.time() - path.stat().st_mtime def get_downtime_info(self, force_refresh=False) -> dict: """ Fetch from API, parse, and save cache. Args: - force_refresh (bool): - Skip incremental check, always fetch from API and overwrite cache. + force_refresh (bool): Skip incremental check, always fetch from API and overwrite cache. Returns: - dict: payload with window, title, collection_id, update_time. + dict: payload with window, title, collection metadata, and latest article info. """ cache = self.load_cache() - local_update_time = None if force_refresh else (cache.get('update_time') if cache else None) + if force_refresh or cache is None: + cache_update_time = cache_article_count = cache_latest_pub_ts = cache_latest_article_id = None + else: + cache_update_time = cache.get('update_time') + cache_article_count = cache.get('article_count') + cache_latest_pub_ts = cache.get('latest_pub_ts') + cache_latest_article_id = cache.get('latest_article_id') - downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) + collection_meta, latest_article, downtime_window = self.collect_info( + cache_update_time, + cache_article_count, + cache_latest_pub_ts, + cache_latest_article_id, + ) if latest_article is None: if cache is None: - raise FetchError('No available cache and collection not updated') + raise FetchError( + 'No available cache and collection not updated or consistency check failed' + ) return cache # 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('Failed to parse, reuse cached window') + logger.warning(f'Failed to parse, reuse cached window: {cache["window"]}') window = cache['window'] else: raise FetchError('Failed to parse and no cache available') @@ -270,82 +394,142 @@ class DowntimeFetcher: cache_payload = { 'collection_id': collection_meta.collection_id, 'update_time': collection_meta.update_time, + 'article_count': collection_meta.article_count, + 'latest_pub_ts': latest_article.pub_ts, + 'latest_article_id': latest_article.id, 'window': window, 'title': latest_article.title, } self.save_cache(cache_payload) return cache_payload - @classmethod - def acquire_lock(cls) -> bool: + def acquire_lock(self) -> bool: """ Acquire cross-process fetch lock. + The lock file carries the owning instance name; release_lock() deletes + it only while the read-back token still matches. Read and unlink are + not atomic; a takeover in between degrades to a duplicate fetch. + Returns: bool: True if acquired, False on timeout or OSError. """ - p = Path(cls.FETCH_LOCK_FILE) + p = Path(self.FETCH_LOCK_FILE) p.parent.mkdir(parents=True, exist_ok=True) - deadline = time.monotonic() + cls.LOCK_WAIT_SECONDS + deadline = time.monotonic() + self.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) + # Lock held: check staleness only on conflict, take over when stale. + # os.replace moves the stale lock aside atomically. + try: + age = self._file_age(p) + if age < self.LOCK_WAIT_SECONDS: + time.sleep(1) + continue + logger.warning(f'Stale lock assumed (age={age:.1f}s), overriding') + stale = p.with_name(f'{p.name}.{self._lock_token}.stale') + try: + os.replace(str(p), str(stale)) + except FileNotFoundError: + pass # already moved by another instance + try: + stale.unlink() + except OSError: + pass + except (OSError, FileNotFoundError): + pass + continue except OSError as e: logger.warning(f'Failed to acquire fetch lock: {e}') return False + write_failed = False + try: + # os.write() may write partially; write the whole token. + data = self._lock_token.encode() + while data: + written = os.write(fd, data) + data = data[written:] + except OSError as e: + logger.warning(f'Failed to write fetch lock: {e}') + write_failed = True + finally: + os.close(fd) + if write_failed: + # Remove the incomplete lock after close; Windows cannot + # unlink an open file. + try: + os.unlink(str(p)) + except OSError: + pass + return False + return True return False - @classmethod - def release_lock(cls): + def release_lock(self): + p = Path(self.FETCH_LOCK_FILE) try: - Path(cls.FETCH_LOCK_FILE).unlink() + if atomic_read_text(p) == self._lock_token: + p.unlink() + else: + # Taken over by another instance (stale-lock handover); leave it. + logger.info('Fetch lock taken over by another instance, keep it') except FileNotFoundError: - pass + pass # lock already gone except OSError as e: logger.warning(f'Failed to release fetch lock: {e}') + def _try_reuse_fresh_cache(self, reason: str) -> Optional[dict]: + """ + Load cache and check if fresh. + + Returns: + Optional[dict]: Fresh cache payload, or None when missing/stale. + """ + cache = self.load_cache() + if cache is None: + return None + age = self._file_age(Path(self.CACHE_FILE)) + if age >= self.CACHE_FRESH_SECONDS: + return None + logger.info( + f'{reason}: ' + f'cache_age={age:.1f}s window={cache.get("window") or ""}' + ) + return cache + def run(self, force_refresh=False) -> dict: """ Lock-guarded entry for the downtime pipeline. Fast path: fresh cache, return directly without lock (read-only). - Common path: acquire cross-process lock, then fetch. + Common path: acquire cross-process lock, re-check cache in case + another instance fetched while waiting, 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. + dict: payload with window, title, collection metadata, and latest article 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') + cache = self._try_reuse_fresh_cache('Cache is fresh, reuse without fetch') + if cache is not None: return cache if not self.acquire_lock(): # 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') + cache = self._try_reuse_fresh_cache('Cache updated by another process, reuse') + if cache is not None: return cache raise FetchError('Failed to acquire fetch lock') try: + # Re-check cache after acquire_lock: another instance may have finished fetching. + # Trades a potentially redundant cache check to prevent duplicate API calls. + if not force_refresh: + cache = self._try_reuse_fresh_cache('Cache updated by another process, reuse') + if cache is not None: + return cache return self.get_downtime_info(force_refresh=force_refresh) finally: self.release_lock() diff --git a/module/submodule/utils.py b/module/submodule/utils.py index 86330ba2b..d1c40e3a7 100644 --- a/module/submodule/utils.py +++ b/module/submodule/utils.py @@ -22,8 +22,8 @@ def get_available_func(): 'IslandPearl', 'AzurLaneUncensored', 'Benchmark', - 'GameManager', 'DowntimeFetch', + 'GameManager', ) def get_available_mod(): From 1041f44262db94841b21a6d26ee96807558bd8e2 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:13:48 +0800 Subject: [PATCH 3/7] Fix: use Happy Eyeballs to enforce total connection timeout in DowntimeFetcher DowntimeFetcher previously relied on urllib3's default connect path which is serial and unbounded: a stalled DNS or unreachable addresses could hold the fetch far beyond any useful timeout. HTTPS now routes through an RFC 8305-inspired Happy Eyeballs adapter. It runs IPv6/IPv4 attempts in parallel with a stagger, fast-fails refused addresses, and enforces a single total deadline spanning the whole connect phase (DNS/TCP/TLS). Connect timeouts are externalized; requests' own connect timeout is disabled, leaving only the read timeout in effect. --- module/smart_mgmt/downtime_fetcher.py | 13 +- module/smart_mgmt/happy_eyeballs.py | 489 ++++++++++++++++++++++++++ 2 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 module/smart_mgmt/happy_eyeballs.py diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py index 47a01d5fb..49db47a43 100644 --- a/module/smart_mgmt/downtime_fetcher.py +++ b/module/smart_mgmt/downtime_fetcher.py @@ -13,6 +13,7 @@ import requests from deploy.atomic import atomic_read_text, atomic_write from module.logger import logger +from module.smart_mgmt.happy_eyeballs import HappyEyeballsHTTPAdapter class FetchError(RuntimeError): @@ -47,15 +48,16 @@ class DowntimeFetcher: 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 + # connect timeout is taken over by Happy Eyeballs; only the read timeout applies + REQUEST_TIMEOUT = (None, 5.0) # Local cache and cross-process fetch lock CACHE_FILE = './log/downtime_cache.json' FETCH_LOCK_FILE = './log/downtime_fetch.lock' # Multi-instance duplicate fetch avoidance window (seconds) - CACHE_FRESH_SECONDS = 600 - # A bit larger than REQUEST_TIMEOUT - LOCK_WAIT_SECONDS = 35 + CACHE_FRESH_SECONDS = 2*3600 + # Upper bound on how long another instance may hold the fetch lock + LOCK_WAIT_SECONDS = 25 WS = r'[\s ]*' WINDOW_RE = re.compile( @@ -75,7 +77,7 @@ class DowntimeFetcher: self._lock_token = instance_name def _session_for(self): - """Build the API session with a browser-like User-Agent.""" + """Build the API session with a browser-like User-Agent; HTTPS goes through Happy Eyeballs.""" s = requests.Session() s.headers.update({ 'User-Agent': ( @@ -84,6 +86,7 @@ class DowntimeFetcher: ), 'Referer': f'https://space.bilibili.com/{self.DEFAULT_MID}/article', }) + s.mount('https://', HappyEyeballsHTTPAdapter()) return s def _request_api(self, url, params) -> dict: diff --git a/module/smart_mgmt/happy_eyeballs.py b/module/smart_mgmt/happy_eyeballs.py new file mode 100644 index 000000000..d46fa941e --- /dev/null +++ b/module/smart_mgmt/happy_eyeballs.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +import math +import select +import socket +import threading +import time +from ipaddress import ip_address + +from requests.adapters import HTTPAdapter +from urllib3 import HTTPSConnectionPool +from urllib3.connection import VerifiedHTTPSConnection +from urllib3.exceptions import ConnectTimeoutError, NewConnectionError +from urllib3.poolmanager import PoolKey, _default_key_normalizer +from urllib3.util.connection import _set_socket_options + +# RFC 8305 Happy Eyeballs tuning. +# Inter-family: IPv6 first attempt goes out immediately, IPv4 follows after +# INTER_FAMILY_DELAY (in-flight capped at 2, one per family). +# The stagger drives invalid connections toward zero when IPv6 works. +INTER_FAMILY_DELAY = 0.25 + +DEFAULT_PER_ADDRESS_TIMEOUT = 2.0 +DEFAULT_CONNECT_TIMEOUT = 5.0 +DEFAULT_TOTAL_DEADLINE = 10.0 +TOTAL_DEADLINE_MARGIN = 5.0 + + +def _validate_timeout(value, name): + if value is None: + return None + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError('%s must be a positive finite number or None' % name) + if not math.isfinite(value) or value <= 0: + raise ValueError('%s must be a positive finite number or None' % name) + return value + + +def _resolve_timeouts(per_address_timeout, connect_timeout, total_deadline): + """ + Apply defaults and validate the three connection timeouts. + + Missing parameters fall back to defaults. + If total_deadline is missing but connect_timeout is given or less than + connect_timeout, it becomes connect_timeout + TOTAL_DEADLINE_MARGIN. + """ + per_address_timeout = _validate_timeout(per_address_timeout, 'per_address_timeout') + if per_address_timeout is None: + per_address_timeout = DEFAULT_PER_ADDRESS_TIMEOUT + + connect_given = connect_timeout is not None + connect_timeout = _validate_timeout(connect_timeout, 'connect_timeout') + if connect_timeout is None: + connect_timeout = DEFAULT_CONNECT_TIMEOUT + + total_deadline = _validate_timeout(total_deadline, 'total_deadline') + if total_deadline is None: + if connect_given: + total_deadline = connect_timeout + TOTAL_DEADLINE_MARGIN + else: + total_deadline = DEFAULT_TOTAL_DEADLINE + elif total_deadline < connect_timeout: + total_deadline = connect_timeout + TOTAL_DEADLINE_MARGIN + + return per_address_timeout, connect_timeout, total_deadline + + +def _is_public_address(ip_str: str) -> bool: + """ + Rejects loopback/private/reserved/link-local addresses. + """ + try: + return ip_address(ip_str).is_global + except ValueError: + return False + + +def _start_connect(af, sa, socket_options, source_address): + """ + Create a non-blocking socket and begin a non-blocking connect. + + Returns (sock, None) when the attempt is in flight, or (None, err) + when the socket could not even be started (e.g. EAFNOSUPPORT). + """ + sock = socket.socket(af, socket.SOCK_STREAM) + try: + sock.setblocking(False) + if socket_options: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + try: + sock.connect(sa) + except (BlockingIOError, InterruptedError): + pass + return sock, None + except OSError as e: + sock.close() + return None, e + + +def _close_all(inflight): + for sock in list(inflight.keys()): + try: + sock.close() + except OSError: + pass + inflight.clear() + + +def _connect_loop( + host, + port, + socket_options, + source_address, + cancel_event, + per_address_timeout, + connect_timeout + ): + """ + RFC 8305-inspired Happy Eyeballs connection loop. + """ + infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + # The caller may have given up (total_deadline) while getaddrinfo() was blocking; + # bail out immediately instead of starting any connect. + if cancel_event.is_set(): + raise socket.timeout('Happy eyeballs connect cancelled for %s:%s' % (host, port)) + v6, v4 = [], [] + + for af, _, _, _, sa in infos: + if not sa or not _is_public_address(sa[0]): + continue + if af == socket.AF_INET6: + v6.append((af, sa)) + elif af == socket.AF_INET: + v4.append((af, sa)) + + if not v6 and not v4: + raise socket.error('No usable public address for %s:%s' % (host, port)) + + start = time.monotonic() + deadline = start + connect_timeout + # No stagger to wait out when there is no IPv6 candidate. + v4_launch_time = start + (INTER_FAMILY_DELAY if v6 else 0.0) + state = {'v6_idx': 0, 'v4_idx': 0, 'v4_launched': False} + inflight = {} + connect_error = None + timed_out = False + + def launch(family): + nonlocal connect_error + candidates = v6 if family == 'v6' else v4 + index_key = family + '_idx' + + while state[index_key] < len(candidates): + if cancel_event.is_set() or time.monotonic() >= deadline: + return + + af, sa = candidates[state[index_key]] + state[index_key] += 1 + + sock, err = _start_connect(af, sa, socket_options, source_address) + if sock is None: + if err is not None: + connect_error = err + continue + + inflight[sock] = { + 'family': family, + 'per_deadline': time.monotonic() + per_address_timeout, + } + return + + def close_and_advance(sock, family, err=None): + nonlocal connect_error, timed_out + if err is None: + timed_out = True + else: + connect_error = err + try: + sock.close() + except OSError: + pass + launch(family) + + if v6: + launch('v6') + + while True: + if cancel_event.is_set(): + _close_all(inflight) + raise socket.timeout('Happy eyeballs connect cancelled for %s:%s' % (host, port)) + + now = time.monotonic() + if now >= deadline: + break + + if v4 and not state['v4_launched'] and now >= v4_launch_time: + state['v4_launched'] = True + launch('v4') + + if not inflight: + # Only waiting for the IPv4 stagger, or fully exhausted. + if v4 and not state['v4_launched']: + # Wait out the stagger window; Event.wait returns early if + # cancelled (Windows select rejects empty fd lists). + wait = max(0.0, v4_launch_time - time.monotonic()) + cancel_event.wait(wait) + continue + break + + next_times = [deadline] + if v4 and not state['v4_launched']: + next_times.append(v4_launch_time) + for info in inflight.values(): + next_times.append(info['per_deadline']) + select_timeout = max(0.0, min(next_times) - time.monotonic()) + + try: + _, writable, exceptional = select.select( + [], list(inflight.keys()), list(inflight.keys()), select_timeout + ) + except (OSError, ValueError): + # select() failing is not "no events": the connect state machine + # can no longer be trusted, close everything and abort. + _close_all(inflight) + raise socket.error( + 'select failed during Happy Eyeballs connect for %s:%s' + % (host, port) + ) + + now = time.monotonic() + if v4 and not state['v4_launched'] and now >= v4_launch_time: + state['v4_launched'] = True + launch('v4') + + winner = None + for sock in writable: + info = inflight.pop(sock, None) + if info is None: + continue + try: + err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + except OSError as e: + close_and_advance(sock, info['family'], e) + continue + if err == 0: + winner = sock + break + close_and_advance(sock, info['family'], socket.error(err)) + + if winner is None: + # Some platforms report refused connects via exceptfds rather + # than writable; use the same SO_ERROR logic as writable. + for sock in exceptional: + info = inflight.pop(sock, None) + if info is None: + continue + try: + err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + except OSError as e: + close_and_advance(sock, info['family'], e) + continue + if err == 0: + winner = sock + break + close_and_advance(sock, info['family'], socket.error(err)) + + if winner is not None: + _close_all(inflight) + winner.setblocking(True) + return winner + + # Per-address short-timeout advancement for sockets not yet writable. + for sock in list(inflight.keys()): + info = inflight.get(sock) + if info is None or now < info['per_deadline']: + continue + inflight.pop(sock, None) + close_and_advance(sock, info['family']) + + exhausted = ( + not inflight + and state['v6_idx'] >= len(v6) + and (not v4 or (state['v4_launched'] and state['v4_idx'] >= len(v4))) + ) + _close_all(inflight) + if exhausted: + if connect_error is not None: + raise socket.error( + 'All connect attempts failed for %s:%s (last error: %s)' + % (host, port, connect_error) + ) + if timed_out: + raise socket.timeout( + 'All connect attempts timed out for %s:%s' + % (host, port) + ) + raise socket.timeout('Happy eyeballs connect timeout exhausted for %s:%s' % (host, port)) + + +def happy_eyeballs_create_connection( + address, + per_address_timeout=None, + connect_timeout=None, + total_deadline=None, + source_address=None, + socket_options=None, + ): + """ + Establish a TCP connection with RFC 8305-inspired Happy Eyeballs. + + The connect loop runs in a daemon worker thread so the blocking getaddrinfo() + cannot stall the caller beyond total_deadline; on expiry the cancel event tells + the worker to self-terminate and socket.timeout is raised. + + Args: + per_address_timeout: window to wait for a single address; + on expiry the address is abandoned and the next candidate of the same family is tried. + connect_timeout: budget for the whole connect loop (getaddrinfo excluded); + the loop gives up when exhausted. + total_deadline: overall deadline including getaddrinfo; + must be at least connect_timeout. + + Raises OSError subclasses (socket.timeout / socket.error) on failure + so urllib3's exception translation in _new_conn applies unchanged. + """ + host, port = address + if host.startswith('[') and host.endswith(']'): + host = host[1:-1] + + per_address_timeout, connect_timeout, total_deadline = _resolve_timeouts( + per_address_timeout, connect_timeout, total_deadline + ) + + holder = {} + cancel_event = threading.Event() + + def worker(): + try: + sock = _connect_loop( + host, + port, + socket_options, + source_address, + cancel_event, + per_address_timeout, + connect_timeout, + ) + remaining = deadline - time.monotonic() + if cancel_event.is_set() or remaining <= 0: + sock.close() + raise socket.timeout( + 'Happy eyeballs total deadline exceeded: %s:%s' + % (host, port) + ) + # Let the TLS handshake done by urllib3 after _new_conn() + # inherit the remaining total_deadline budget. + sock.settimeout(remaining) + holder['sock'] = sock + except Exception as e: + holder['exc'] = e + + t = threading.Thread(target=worker, name='happy-eyeballs', daemon=True) + deadline = time.monotonic() + total_deadline + t.start() + t.join(max(0.0, deadline - time.monotonic())) + + if t.is_alive(): + # Most likely stuck in uninterruptible getaddrinfo(). Signal cancel and + # wait briefly; the thread will self-terminate once getaddrinfo() returns. + cancel_event.set() + t.join(0.5) + # The worker may have completed just before the deadline and left a + # connected socket behind; it must not survive a timed-out call. + sock = holder.get('sock') + if sock is not None: + try: + sock.close() + except OSError: + pass + raise socket.timeout('Happy eyeballs total deadline exceeded: %s:%s' % (host, port)) + + exc = holder.get('exc') + if exc is not None: + raise exc + + sock = holder.get('sock') + if sock is None: + raise socket.error('Happy eyeballs failed without a socket: %s:%s' % (host, port)) + + return sock + + +_HE_TIMEOUT_KEYS = ('he_per_address_timeout', 'he_connect_timeout', 'he_total_deadline') + + +def _he_key_normalizer(context): + """ + urllib3 1.22 pool key normalizer: drop the custom timeout keys before + PoolKey construction. PoolKey only accepts its fixed _key_fields. + """ + context = dict(context) + for key in _HE_TIMEOUT_KEYS: + context.pop(key, None) + return _default_key_normalizer(PoolKey, context) + + +class HappyEyeballsHTTPSConnection(VerifiedHTTPSConnection): + """HTTPS connection using Happy Eyeballs for TCP connection setup.""" + + def __init__(self, *args, **kwargs): + # Pop the custom timeouts before urllib3 sees them: they are not urllib3 parameters + # and would be rejected by HTTPSConnection if left in **kwargs. + # None values are defaulted later in _resolve_timeouts. + self.he_per_address_timeout = kwargs.pop('he_per_address_timeout', None) + self.he_connect_timeout = kwargs.pop('he_connect_timeout', None) + self.he_total_deadline = kwargs.pop('he_total_deadline', None) + super(HappyEyeballsHTTPSConnection, self).__init__(*args, **kwargs) + + def _new_conn(self): + extra_kw = {} + if self.source_address: + extra_kw['source_address'] = self.source_address + if self.socket_options: + extra_kw['socket_options'] = self.socket_options + try: + conn = happy_eyeballs_create_connection( + (self.host, self.port), + self.he_per_address_timeout, + self.he_connect_timeout, + self.he_total_deadline, + **extra_kw + ) + except socket.timeout as e: + raise ConnectTimeoutError(self, 'Connection to %s timed out. (happy eyeballs)' % self.host) + except socket.error as e: + raise NewConnectionError(self, 'Failed to establish a new connection: %s' % e) + + return conn + + +class HappyEyeballsHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = HappyEyeballsHTTPSConnection + + +class HappyEyeballsHTTPAdapter(HTTPAdapter): + """ + Mount onto a requests Session to route HTTPS through Happy Eyeballs. + + Only the HTTPS pool class is replaced; plain HTTP keeps urllib3 defaults. + """ + + def __init__( + self, + per_address_timeout=None, + connect_timeout=None, + total_deadline=None, + *args, + **kwargs + ): + # Timeouts pass through unresolved; defaults and validation happen + # once in _resolve_timeouts at happy_eyeballs_create_connection. + self.he_per_address_timeout = per_address_timeout + self.he_connect_timeout = connect_timeout + self.he_total_deadline = total_deadline + + super(HappyEyeballsHTTPAdapter, self).__init__(*args, **kwargs) + + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + super(HappyEyeballsHTTPAdapter, self).init_poolmanager(connections, maxsize, block=block, **pool_kwargs) + scheme_map = dict(self.poolmanager.pool_classes_by_scheme) + scheme_map['https'] = HappyEyeballsHTTPSConnectionPool + self.poolmanager.pool_classes_by_scheme = scheme_map + + # Push the timeouts into PoolManager's shared pool kwargs so every + # https pool created afterwards forwards them to its connections via + # conn_kw (urllib3 1.22 _new_conn expands conn_kw into ConnectionCls). + self.poolmanager.connection_pool_kw.update({ + 'he_per_address_timeout': self.he_per_address_timeout, + 'he_connect_timeout': self.he_connect_timeout, + 'he_total_deadline': self.he_total_deadline, + }) + # Replace only the https key normalizer + key_scheme_map = dict(self.poolmanager.key_fn_by_scheme) + key_scheme_map['https'] = _he_key_normalizer + self.poolmanager.key_fn_by_scheme = key_scheme_map From 2d2db92c3ccc5373164b93368502919919bed1e3 Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:48:24 +0800 Subject: [PATCH 4/7] Fix: init state_switch before show() in webui On session loss the page reloads, the sidebar becomes clickable while run() is still initializing, and a click in between raises AttributeError on missing state_switch. --- module/webui/app.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/module/webui/app.py b/module/webui/app.py index e983e5d3d..3789acbbb 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -1394,6 +1394,14 @@ class AlasGUI(Frame): """ ) + # Init state switch before rendering, `ui_alas()` and `ui_develop()` may be + # triggered as soon as the sidebar is visible, while `run()` is still initializing + self.state_switch = Switch( + status=self.set_status, + get_state=lambda: getattr(getattr(self, "alas", -1), "state", 0), + name="state", + ) + aside = get_localstorage("aside") self.show() @@ -1423,12 +1431,6 @@ class AlasGUI(Frame): name="visibility_state", ) - self.state_switch = Switch( - status=self.set_status, - get_state=lambda: getattr(getattr(self, "alas", -1), "state", 0), - name="state", - ) - def goto_update(): self.ui_develop() self.dev_update() From 602a35a660cf06d107a18b7a909bb396051594ad Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:45:35 +0800 Subject: [PATCH 5/7] Opt: restructure OS strategic search flow to reduce redundant operations --- module/os/map.py | 11 +++++--- module/os/tasks/cross_month.py | 6 ++++- module/os/tasks/hazard_leveling.py | 7 +++-- module/os/tasks/meowfficer_farming.py | 39 ++++++++++++++++++--------- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/module/os/map.py b/module/os/map.py index c01cf98cb..be0c9a25e 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -1009,8 +1009,11 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): self.hp_get() self._solved_map_event = set() self._solved_fleet_mechanism = False - self.clear_question() - self.map_rescan() + self.get_current_zone() + logger.attr('is_zone_name_hidden', self.is_zone_name_hidden) + if self.is_zone_name_hidden: + self.clear_question() + self.map_rescan() def _swipe_camera_avoid_ui(self, grid, ref=None, _ui_avoid_count=0, name='Grid'): """ @@ -1026,12 +1029,12 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): Returns: bool: True if the camera swiped and the map should be rescanned; False if the grid is safe to click. """ - if _ui_avoid_count < 3 and area_cross_area(grid.button, MAP_OPTIONS_AREA.area, threshold=0): + if _ui_avoid_count < 2 and area_cross_area(grid.button, MAP_OPTIONS_AREA.area, threshold=0): if ref is None: ref = self.view.center_loca vector = np.array(grid.location) - np.array(ref) vector = tuple((vector // 2).astype(int)) - logger.info(f'{name} grid {grid} overlaps with map options area, swipe {vector}') + logger.info(f'{name} grid {grid} overlaps with map options area, swipe: {vector}') self.map_swipe(vector) return True return False diff --git a/module/os/tasks/cross_month.py b/module/os/tasks/cross_month.py index 835fe2c3d..1d80c70bd 100644 --- a/module/os/tasks/cross_month.py +++ b/module/os/tasks/cross_month.py @@ -169,8 +169,12 @@ class OpsiCrossMonth(OSMap): continue else: logger.hr(f'OS meowfficer farming, zone_id={zone.zone_id}', level=1) - self.globe_goto(zone, types='SAFE', refresh=True) self.fleet_set(self.config.OpsiFleet_Fleet) + logger.attr('is_zone_name_hidden', self.is_zone_name_hidden) + if self.zone.zone_id != zone.zone_id or not self.is_zone_name_hidden: + self.globe_goto(zone, types='SAFE', refresh=True) + self.run_auto_search() + self.handle_after_auto_search() self.run_strategic_search() self.handle_after_auto_search() else: diff --git a/module/os/tasks/hazard_leveling.py b/module/os/tasks/hazard_leveling.py index 257c2bb68..558350582 100644 --- a/module/os/tasks/hazard_leveling.py +++ b/module/os/tasks/hazard_leveling.py @@ -52,12 +52,15 @@ class OpsiHazard1Leveling(OSMap): else: zone = 22 logger.hr(f'OS hazard 1 leveling, zone_id={zone}', level=1) + self.fleet_set(self.config.OpsiFleet_Fleet) if self.zone.zone_id != zone or not self.is_zone_name_hidden: self.globe_goto(self.name_to_zone(zone), types='SAFE', refresh=True) - self.fleet_set(self.config.OpsiFleet_Fleet) + self.run_auto_search() + self.handle_after_auto_search() self.run_strategic_search() - self.get_current_zone() + # is_zone_name_hidden is refreshed inside run_strategic_search if self.is_zone_name_hidden and not len(self._solved_map_event): + self.run_auto_search() self.fleets_clear_question() self.handle_after_auto_search() self.config.check_task_switch() diff --git a/module/os/tasks/meowfficer_farming.py b/module/os/tasks/meowfficer_farming.py index 332daea8b..1624db9a7 100644 --- a/module/os/tasks/meowfficer_farming.py +++ b/module/os/tasks/meowfficer_farming.py @@ -6,6 +6,17 @@ from module.os.map import OSMap class OpsiMeowfficerFarming(OSMap): + def check_ap(self, cost=0): + # Check action points first to avoid using remaining AP when it not enough for tomorrow's daily + # When not running CL1 and use oil + keep_current_ap = True + check_rest_ap = True + if self.is_cl1_enabled and self.cl1_enough_yellow_coins: + check_rest_ap = False + if not self.is_cl1_enabled and self.config.OpsiGeneral_BuyActionPointLimit > 0: + keep_current_ap = False + self.action_point_set(cost=cost, keep_current_ap=keep_current_ap, check_rest_ap=check_rest_ap) + def os_meowfficer_farming(self): """ Recommend 3 or 5 for higher meowfficer searching point per action points ratio. @@ -48,17 +59,6 @@ class OpsiMeowfficerFarming(OSMap): logger.info('Ash beacon not fully collected, ignore action point limit temporarily') self.config.OS_ACTION_POINT_PRESERVE = 0 logger.attr('OS_ACTION_POINT_PRESERVE', self.config.OS_ACTION_POINT_PRESERVE) - if not ap_checked: - # Check action points first to avoid using remaining AP when it not enough for tomorrow's daily - # When not running CL1 and use oil - keep_current_ap = True - check_rest_ap = True - if self.is_cl1_enabled and self.cl1_enough_yellow_coins: - check_rest_ap = False - if not self.is_cl1_enabled and self.config.OpsiGeneral_BuyActionPointLimit > 0: - keep_current_ap = False - self.action_point_set(cost=0, keep_current_ap=keep_current_ap, check_rest_ap=check_rest_ap) - ap_checked = True # (1252, 1012) is the coordinate of zone 134 (the center zone) in os_globe_map.png if self.config.OpsiMeowfficerFarming_TargetZone != 0: @@ -68,13 +68,28 @@ class OpsiMeowfficerFarming(OSMap): logger.warning(f'wrong zone_id input:{self.config.OpsiMeowfficerFarming_TargetZone}') raise RequestHumanTakeover('wrong input, task stopped') else: + # Refill current AP every round to keep strategic search running + self.check_ap(cost=100) logger.hr(f'OS meowfficer farming, zone_id={zone.zone_id}', level=1) - self.globe_goto(zone, types='SAFE', refresh=True) self.fleet_set(self.config.OpsiFleet_Fleet) + logger.attr('is_zone_name_hidden', self.is_zone_name_hidden) + if self.zone.zone_id != zone.zone_id or not self.is_zone_name_hidden: + self.globe_goto(zone, types='SAFE', refresh=True) + self.run_auto_search() + self.handle_after_auto_search() + self.check_ap() self.run_strategic_search() + # is_zone_name_hidden is refreshed inside run_strategic_search + if self.is_zone_name_hidden and not len(self._solved_map_event): + self.run_auto_search() + self.fleets_clear_question() self.handle_after_auto_search() self.config.check_task_switch() else: + if not ap_checked: + # Check action points once at the start, without using AP boxes + self.check_ap(cost=0) + ap_checked = True zones = self.zone_select(hazard_level=self.config.OpsiMeowfficerFarming_HazardLevel) \ .delete(SelectedGrids([self.zone])) \ .delete(SelectedGrids(self.zones.select(is_port=True))) \ From 294c8a1f0031d175bdbc4cd52671ec905b0d2b7a Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:05:31 +0800 Subject: [PATCH 6/7] Feat: migrate OpsiShop delay limit from absolute date to days before reset --- config/template.json | 2 +- module/config/argument/args.json | 2 +- module/config/argument/argument.yaml | 2 +- module/config/config_generated.py | 2 +- module/config/config_updater.py | 1 + module/config/i18n/en-US.json | 6 +++--- module/config/i18n/ja-JP.json | 6 +++--- module/config/i18n/zh-CN.json | 6 +++--- module/config/i18n/zh-TW.json | 6 +++--- module/config/redirect_utils/utils.py | 11 +++++++++++ module/os/tasks/shop.py | 8 ++++---- 11 files changed, 32 insertions(+), 20 deletions(-) diff --git a/config/template.json b/config/template.json index e623d10f8..5e810f89a 100644 --- a/config/template.json +++ b/config/template.json @@ -2206,7 +2206,7 @@ "OpsiShop": { "PresetFilter": "max_benefit_meta", "CustomFilter": "LoggerAbyssalT6 > LoggerAbyssalT5 > LoggerAbyssalT4 > LoggerObscureT6 > LoggerObscureT5 > LoggerObscureT4 > LoggerObscureT3 > ActionPoint > PurpleCoins\n> GearDesignPlanT3 > PlateRandomT4 > DevelopmentMaterialT3 > GearDesignPlanT2 > GearPart\n> OrdnanceTestingReportT3 > OrdnanceTestingReportT2 > DevelopmentMaterialT2 > OrdnanceTestingReportT1\n> METARedBook > CrystallizedHeatResistantSteel > NanoceramicAlloy > NeuroplasticProstheticArm > SupercavitationGenerator", - "DisableBeforeDate": 0 + "DisableBeforeResetDays": 0 }, "Storage": { "Storage": {} diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 39843ca36..5b41113f0 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -14364,7 +14364,7 @@ "type": "textarea", "value": "LoggerAbyssalT6 > LoggerAbyssalT5 > LoggerAbyssalT4 > LoggerObscureT6 > LoggerObscureT5 > LoggerObscureT4 > LoggerObscureT3 > ActionPoint > PurpleCoins\n> GearDesignPlanT3 > PlateRandomT4 > DevelopmentMaterialT3 > GearDesignPlanT2 > GearPart\n> OrdnanceTestingReportT3 > OrdnanceTestingReportT2 > DevelopmentMaterialT2 > OrdnanceTestingReportT1\n> METARedBook > CrystallizedHeatResistantSteel > NanoceramicAlloy > NeuroplasticProstheticArm > SupercavitationGenerator" }, - "DisableBeforeDate": { + "DisableBeforeResetDays": { "type": "input", "value": 0 } diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 62d5afb52..3ecffb4b9 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -1019,7 +1019,7 @@ OpsiShop: > GearDesignPlanT3 > PlateRandomT4 > DevelopmentMaterialT3 > GearDesignPlanT2 > GearPart > OrdnanceTestingReportT3 > OrdnanceTestingReportT2 > DevelopmentMaterialT2 > OrdnanceTestingReportT1 > METARedBook > CrystallizedHeatResistantSteel > NanoceramicAlloy > NeuroplasticProstheticArm > SupercavitationGenerator - DisableBeforeDate: 0 + DisableBeforeResetDays: 0 OpsiVoucher: Filter: |- LoggerAbyssal > LoggerObscure > Book > Coin > Fragment diff --git a/module/config/config_generated.py b/module/config/config_generated.py index b86ea14df..6d8fc6628 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -624,7 +624,7 @@ class GeneratedConfig: # Group `OpsiShop` OpsiShop_PresetFilter = 'max_benefit_meta' # max_benefit, max_benefit_meta, no_meta, all, custom OpsiShop_CustomFilter = 'LoggerAbyssalT6 > LoggerAbyssalT5 > LoggerAbyssalT4 > LoggerObscureT6 > LoggerObscureT5 > LoggerObscureT4 > LoggerObscureT3 > ActionPoint > PurpleCoins\n> GearDesignPlanT3 > PlateRandomT4 > DevelopmentMaterialT3 > GearDesignPlanT2 > GearPart\n> OrdnanceTestingReportT3 > OrdnanceTestingReportT2 > DevelopmentMaterialT2 > OrdnanceTestingReportT1\n> METARedBook > CrystallizedHeatResistantSteel > NanoceramicAlloy > NeuroplasticProstheticArm > SupercavitationGenerator' - OpsiShop_DisableBeforeDate = 0 + OpsiShop_DisableBeforeResetDays = 0 # Group `OpsiVoucher` OpsiVoucher_Filter = 'LoggerAbyssal > LoggerObscure > Book > Coin > Fragment' diff --git a/module/config/config_updater.py b/module/config/config_updater.py index 546ca703c..e1f15d6f6 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -608,6 +608,7 @@ class ConfigUpdater: # ('Coalition.Coalition.Mode', 'Coalition.Coalition.Mode', coalition_to_frostfall), # 2025.06.26 # ('Coalition.Coalition.Mode', 'Coalition.Coalition.Mode', coalition_to_little_academy), + ('OpsiShop.OpsiShop.DisableBeforeDate', 'OpsiShop.OpsiShop.DisableBeforeResetDays', date_to_reset_days), ] # redirection += [ diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index eae7036f7..c0f416ab5 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -6631,9 +6631,9 @@ "name": "Custom Research Priority", "help": "To use your own filter, set \"OpSi Shop Filter Select\" to \"custom\". All options have been defined at " }, - "DisableBeforeDate": { - "name": "Don't Buy from OpSi Shop before Date X of Every Month", - "help": "Starting from the day after the specified date, buy OpSi shop once a day; saving as 0 implies unlimited date (purchase every day)" + "DisableBeforeResetDays": { + "name": "Buy from OpSi Shop only on the Last X Days of Every Month", + "help": "e.g. X=3: in a 30-day month, buy only on the 28th, 29th and 30th\n0 means no limit, purchase every day" } }, "OpsiVoucher": { diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 939249223..c212e89bf 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -6631,9 +6631,9 @@ "name": "OpsiShop.CustomFilter.name", "help": "OpsiShop.CustomFilter.help" }, - "DisableBeforeDate": { - "name": "OpsiShop.DisableBeforeDate.name", - "help": "OpsiShop.DisableBeforeDate.help" + "DisableBeforeResetDays": { + "name": "OpsiShop.DisableBeforeResetDays.name", + "help": "OpsiShop.DisableBeforeResetDays.help" } }, "OpsiVoucher": { diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index fb039c464..58111bd87 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -6631,9 +6631,9 @@ "name": "自定义过滤器", "help": "使用自定义过滤器需将 \"港口商店过滤器\" 设置为 \"自定义\",并阅读 https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn" }, - "DisableBeforeDate": { - "name": "每月 X 号前,不购买大世界商店", - "help": "从指定日期的后一天开始,每天买一次大世界商店\n0 表示不限制,每天都购买" + "DisableBeforeResetDays": { + "name": "仅在每月最后 X 天购买", + "help": "例如 X=3:当月为30天时,仅在28、29、30号购买\n0 表示不限制,每天都购买" } }, "OpsiVoucher": { diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 9814ff5fb..fb6ac7e8f 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -6631,9 +6631,9 @@ "name": "自定義過濾器", "help": "使用自定義過濾器需將 \"港口商店過濾器\" 設定為 \"自定義\",並閱讀 https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn" }, - "DisableBeforeDate": { - "name": "每月 X 號前,不購買大世界商店", - "help": "從指定日期的後一天開始,每天買一次大世界商店\n0 表示不限制,每天都購買" + "DisableBeforeResetDays": { + "name": "僅在每月最後 X 天購買", + "help": "例如 X=3:當月為30天時,僅在28、29、30號購買\n0 表示不限制,每天都購買" } }, "OpsiVoucher": { diff --git a/module/config/redirect_utils/utils.py b/module/config/redirect_utils/utils.py index a7eb53074..48f12f5a5 100644 --- a/module/config/redirect_utils/utils.py +++ b/module/config/redirect_utils/utils.py @@ -144,3 +144,14 @@ def submarine_mode_redirect(value): 'every_combat': ('do_not_use', 'every_combat'), } return mapping.get(value, ('do_not_use', 'do_not_use')) + + +def date_to_reset_days(value): + """ + OpsiShop.DisableBeforeDate -> OpsiShop.DisableBeforeResetDays + Old config uses date of month (31 days based), new config uses days before reset. + """ + if value == 0: + return 0 + else: + return 31 - value diff --git a/module/os/tasks/shop.py b/module/os/tasks/shop.py index b2671d3eb..eda30d0f3 100644 --- a/module/os/tasks/shop.py +++ b/module/os/tasks/shop.py @@ -14,10 +14,10 @@ class OpsiShop(OSMap): If not having enough yellow coins or purple coins, skip buying supplies in next port. """ logger.hr('OS port daily', level=1) - today = datetime.now().day - limit = self.config.OpsiShop_DisableBeforeDate - if today <= limit: - logger.info(f'Delay Opsi shop, today\'s date {today} <= limit {limit}') + remain = (get_os_next_reset().date() - datetime.now().date()).days + limit = self.config.OpsiShop_DisableBeforeResetDays + if limit > 0 and remain > limit: + logger.info(f'Delay Opsi shop, reset remain {remain} days > limit {limit} days') self.config.task_delay(server_update=True) self.config.task_stop() From 49444ee1f1150e1b8b09bcebaf712d63243580eb Mon Sep 17 00:00:00 2001 From: positnuec <93694981+positnuec@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:56:18 +0800 Subject: [PATCH 7/7] Fix: restrict restart on sensitive task failure only if logger used --- alas.py | 8 +++++--- config/template.json | 2 +- module/config/argument/args.json | 2 +- module/config/argument/argument.yaml | 2 +- module/config/config_generated.py | 2 +- module/config/config_updater.py | 2 ++ module/config/i18n/en-US.json | 4 ++-- module/config/i18n/ja-JP.json | 6 +++--- module/config/i18n/zh-CN.json | 2 +- module/config/i18n/zh-TW.json | 2 +- module/os/operation_siren.py | 6 +++++- module/os_handler/storage.py | 1 + 12 files changed, 24 insertions(+), 15 deletions(-) diff --git a/alas.py b/alas.py index 8998a341a..fb6c42b09 100644 --- a/alas.py +++ b/alas.py @@ -605,15 +605,17 @@ class AzurLaneAutoScript: failed = deep_get(self.failure_record, keys=task, default=0) failed = 0 if success else failed + 1 deep_set(self.failure_record, keys=task, value=failed) - if failed >= 3 or (self.config.Error_StrictRestart and failed >= 1 and task in RESTART_SENSITIVE_TASKS): + should_restrict = (self.config.Error_RestrictRestart and task in RESTART_SENSITIVE_TASKS + and (task == 'OpsiCrossMonth' or self.config.logger_used)) + if failed >= 3 or (failed >= 1 and should_restrict): logger.critical(f"Task `{task}` failed {failed} or more times.") logger.critical("Possible reason #1: You haven't used it correctly. " "Please read the help text of the options.") logger.critical("Possible reason #2: There is a problem with this task. " "Please contact developers or try to fix it yourself.") - if self.config.Error_StrictRestart and task in RESTART_SENSITIVE_TASKS: + if should_restrict: logger.critical("Possible reason #3: This is a restart sensitive task. " - "Please take over the game manually or turn off 'StrictRestart' option.") + "Please take over the game manually or turn off 'RestrictRestart' option.") logger.critical('Request human takeover') handle_notify( diff --git a/config/template.json b/config/template.json index 5e810f89a..e90aef120 100644 --- a/config/template.json +++ b/config/template.json @@ -85,7 +85,7 @@ "Error": { "HandleError": true, "SaveError": true, - "StrictRestart": false, + "RestrictRestart": false, "OnePushConfig": "provider: null", "ScreenshotLength": 1 }, diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 5b41113f0..9232c25d3 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -402,7 +402,7 @@ "type": "checkbox", "value": true }, - "StrictRestart": { + "RestrictRestart": { "type": "checkbox", "value": false }, diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 3ecffb4b9..701c788a5 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -84,7 +84,7 @@ EmulatorInfo: Error: HandleError: true SaveError: true - StrictRestart: false + RestrictRestart: false OnePushConfig: type: textarea mode: yaml diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 6d8fc6628..913d2463b 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -34,7 +34,7 @@ class GeneratedConfig: # Group `Error` Error_HandleError = True Error_SaveError = True - Error_StrictRestart = False + Error_RestrictRestart = False Error_OnePushConfig = 'provider: null' Error_ScreenshotLength = 1 diff --git a/module/config/config_updater.py b/module/config/config_updater.py index e1f15d6f6..b30723cb5 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -608,7 +608,9 @@ class ConfigUpdater: # ('Coalition.Coalition.Mode', 'Coalition.Coalition.Mode', coalition_to_frostfall), # 2025.06.26 # ('Coalition.Coalition.Mode', 'Coalition.Coalition.Mode', coalition_to_little_academy), + # 2026.08.16 ('OpsiShop.OpsiShop.DisableBeforeDate', 'OpsiShop.OpsiShop.DisableBeforeResetDays', date_to_reset_days), + ('Alas.Error.StrictRestart', 'Alas.Error.RestrictRestart'), ] # redirection += [ diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index c0f416ab5..8c6695bfb 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -516,8 +516,8 @@ "name": "Record Exception", "help": "Records exception and log into directory for review or sharing" }, - "StrictRestart": { - "name": "Strict Restart", + "RestrictRestart": { + "name": "Restrict Restart", "help": "Stop Alas instead of restarting the game when running a sensitive task with an error.\nSensitive tasks include OpsiObscure, OpsiAbyssal and OpsiCrossMonth." }, "OnePushConfig": { diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index c212e89bf..7eae04420 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -516,9 +516,9 @@ "name": "Error.SaveError.name", "help": "Error.SaveError.help" }, - "StrictRestart": { - "name": "Error.StrictRestart.name", - "help": "Error.StrictRestart.help" + "RestrictRestart": { + "name": "Error.RestrictRestart.name", + "help": "Error.RestrictRestart.help" }, "OnePushConfig": { "name": "Error.OnePushConfig.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 58111bd87..2ba409a27 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -516,7 +516,7 @@ "name": "出错时,保存 Log 和截图", "help": "" }, - "StrictRestart": { + "RestrictRestart": { "name": "敏感任务出错时禁止重启", "help": "运行敏感任务出错时停止Alas,而不是重启游戏\n敏感任务包含隐秘海域、深渊海域、跨月每日" }, diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index fb6ac7e8f..819d0fc9c 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -516,7 +516,7 @@ "name": "出錯時,保存 Log 和截圖", "help": "" }, - "StrictRestart": { + "RestrictRestart": { "name": "敏感任務出錯時禁止重啟", "help": "運行敏感任務出錯時停止Alas,而不是重啟遊戲\n敏感任務包含隱秘海域、深淵海域、跨月每日" }, diff --git a/module/os/operation_siren.py b/module/os/operation_siren.py index fbfcc2ed3..9c44bf6f9 100644 --- a/module/os/operation_siren.py +++ b/module/os/operation_siren.py @@ -31,7 +31,11 @@ class OperationSiren( """ Operation Siren main class that combines all task modules. """ - pass + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Whether a coordinate logger was used in the current task run + self.config.logger_used = False if __name__ == '__main__': diff --git a/module/os_handler/storage.py b/module/os_handler/storage.py index 16caa5314..ad5874825 100644 --- a/module/os_handler/storage.py +++ b/module/os_handler/storage.py @@ -126,6 +126,7 @@ class StorageHandler(GlobeOperation, ZoneManager): if len(items): self._storage_item_use(items[0]) + self.config.logger_used = True continue else: logger.info('All loggers in storage have been used')