1
0
mirror of https://github.com/sui-feng-cb/AzurLaneAutoScript1.git synced 2026-08-17 04:18:53 +08:00
Files
AzurLaneAutoScript/module/smart_mgmt/downtime_fetcher.py

536 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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'
ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles'
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 +
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})')
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': (
'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)."""
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')
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:
"""
Find target collection by name from UP's article lists.
Returns:
CollectionMeta: Metadata of the matched collection.
"""
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 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']),
)
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
if delta < -30:
year += 1
return year
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.
"""
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)
if window_match:
month = int(window_match.group(1))
day = int(window_match.group(2))
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))
start_hour = int(title_match.group(3))
start_minute = int(title_match.group(4))
hours = int(duration_match.group(1))
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=self._format_window(start, end))
return None
@staticmethod
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,
cache_update_time=None,
cache_article_count=None,
) -> tuple[CollectionMeta, Optional[list[Article]]]:
"""
find_collection -> incremental check -> fetch articles -> consistency check
Returns:
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()
# 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('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')
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[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.
"""
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 "<none>"} '
f'title={latest_article.title!r}'
)
return collection_meta, latest_article, downtime_window
@classmethod
def load_cache(cls) -> Optional[dict]:
try:
text = atomic_read_text(cls.CACHE_FILE)
except FileNotFoundError:
return None
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}')
@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.
Returns:
dict: payload with window, title, collection metadata, and latest article info.
"""
cache = self.load_cache()
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')
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 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(f'Failed to parse, reuse cached window: {cache["window"]}')
window = cache['window']
else:
raise FetchError('Failed to parse and no cache available')
else:
window = downtime_window.window
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
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(self.FETCH_LOCK_FILE)
p.parent.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + self.LOCK_WAIT_SECONDS
while time.monotonic() < deadline:
try:
fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
# 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
def release_lock(self):
p = Path(self.FETCH_LOCK_FILE)
try:
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 # 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 "<none>"}'
)
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, 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 metadata, and latest article info.
"""
# Fast path: fresh cache, read-only, no lock needed
if not force_refresh:
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._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()