mirror of
https://github.com/sui-feng-cb/AzurLaneAutoScript1.git
synced 2026-07-13 10:58:02 +08:00
346 lines
13 KiB
Python
346 lines
13 KiB
Python
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()
|