2026-07-10 07:25:18 +08:00
|
|
|
|
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
|
2026-08-16 03:13:48 +08:00
|
|
|
|
from module.smart_mgmt.happy_eyeballs import HappyEyeballsHTTPAdapter
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FetchError(RuntimeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class CollectionMeta:
|
|
|
|
|
|
collection_id: int
|
|
|
|
|
|
collection_name: str
|
|
|
|
|
|
update_time: int
|
2026-08-15 22:21:34 +08:00
|
|
|
|
article_count: int
|
|
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Article:
|
|
|
|
|
|
pub_ts: int
|
2026-08-15 22:21:34 +08:00
|
|
|
|
id: str
|
2026-07-10 07:25:18 +08:00
|
|
|
|
title: str
|
|
|
|
|
|
summary: str
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class DowntimeWindow:
|
|
|
|
|
|
window: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DowntimeFetcher:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
# Bilibili UP mid of the AzurLane official account
|
2026-07-10 07:25:18 +08:00
|
|
|
|
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))
|
2026-08-16 03:13:48 +08:00
|
|
|
|
# connect timeout is taken over by Happy Eyeballs; only the read timeout applies
|
|
|
|
|
|
REQUEST_TIMEOUT = (None, 5.0)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
# 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)
|
2026-08-16 03:13:48 +08:00
|
|
|
|
CACHE_FRESH_SECONDS = 2*3600
|
|
|
|
|
|
# Upper bound on how long another instance may hold the fetch lock
|
|
|
|
|
|
LOCK_WAIT_SECONDS = 25
|
2026-08-15 22:21:34 +08:00
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
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})')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
def __init__(self, instance_name=''):
|
2026-07-10 07:25:18 +08:00
|
|
|
|
self.session = self._session_for()
|
2026-08-15 22:21:34 +08:00
|
|
|
|
# Lock ownership token: the ALAS instance name
|
|
|
|
|
|
self._lock_token = instance_name
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
def _session_for(self):
|
2026-08-16 03:13:48 +08:00
|
|
|
|
"""Build the API session with a browser-like User-Agent; HTTPS goes through Happy Eyeballs."""
|
2026-07-10 07:25:18 +08:00
|
|
|
|
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',
|
|
|
|
|
|
})
|
2026-08-16 03:13:48 +08:00
|
|
|
|
s.mount('https://', HappyEyeballsHTTPAdapter())
|
2026-07-10 07:25:18 +08:00
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
def _request_api(self, url, params) -> dict:
|
|
|
|
|
|
"""Request Bilibili API and return inner data field (code == 0)."""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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}')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
if body['code'] != 0:
|
|
|
|
|
|
raise FetchError(body.get('message') or body.get('msg') or 'unknown error')
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
def find_collection(self) -> CollectionMeta:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Find target collection by name from UP's article lists.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
CollectionMeta: Metadata of the matched collection.
|
|
|
|
|
|
"""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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}')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
for item in lists:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
raise FetchError(f'Unexpected collection structure: {type(item).__name__}')
|
|
|
|
|
|
if item.get('name') != self.DEFAULT_COLLECTION_NAME:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
collection_meta = CollectionMeta(
|
|
|
|
|
|
collection_id=int(item['id']),
|
|
|
|
|
|
collection_name=item['name'],
|
|
|
|
|
|
update_time=int(item['update_time']),
|
2026-08-15 22:21:34 +08:00
|
|
|
|
article_count=int(item['articles_count']),
|
2026-07-10 07:25:18 +08:00
|
|
|
|
)
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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}'
|
2026-07-10 07:25:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _clean_text(text: str):
|
|
|
|
|
|
return text.replace('[图片]', '').replace('\u3000', ' ').strip()
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
@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}'
|
|
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
if not isinstance(article.summary, str):
|
|
|
|
|
|
raise FetchError(f'Unexpected article summary: {article.summary}')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ)
|
2026-07-18 10:20:43 +08:00
|
|
|
|
summary = self._clean_text(article.summary)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
window_match = self.WINDOW_RE.search(summary)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
if window_match:
|
2026-07-18 10:20:43 +08:00
|
|
|
|
month = int(window_match.group(1))
|
|
|
|
|
|
day = int(window_match.group(2))
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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))
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
duration_match = self.DURATION_RE.search(summary)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
if duration_match:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
if not isinstance(article.title, str):
|
|
|
|
|
|
raise FetchError(f'Unexpected article title: {article.title}')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
title_match = self.TITLE_TIME_RE.search(article.title)
|
|
|
|
|
|
if title_match:
|
2026-07-18 10:20:43 +08:00
|
|
|
|
month = int(title_match.group(1))
|
|
|
|
|
|
day = int(title_match.group(2))
|
2026-08-15 22:21:34 +08:00
|
|
|
|
start_hour = int(title_match.group(3))
|
|
|
|
|
|
start_minute = int(title_match.group(4))
|
2026-07-10 07:25:18 +08:00
|
|
|
|
hours = int(duration_match.group(1))
|
2026-08-15 22:21:34 +08:00
|
|
|
|
year = self._infer_year(published, month, day)
|
|
|
|
|
|
start = datetime(year, month, day, start_hour, start_minute, tzinfo=self.CN_TZ)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
end = start + timedelta(hours=hours)
|
2026-08-15 22:21:34 +08:00
|
|
|
|
return DowntimeWindow(window=self._format_window(start, end))
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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]]]:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
find_collection -> incremental check -> fetch articles -> consistency check
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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).
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
collection_meta = self.find_collection()
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
# 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
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
@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]]:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Full fetch and parse pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
downtime_window = self.parse_window_from_article(latest_article)
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def load_cache(cls) -> Optional[dict]:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
try:
|
|
|
|
|
|
text = atomic_read_text(cls.CACHE_FILE)
|
|
|
|
|
|
except FileNotFoundError:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
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}')
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _file_age(path: Path) -> float:
|
|
|
|
|
|
return time.time() - path.stat().st_mtime
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
2026-07-18 10:20:43 +08:00
|
|
|
|
def get_downtime_info(self, force_refresh=False) -> dict:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
2026-07-18 10:20:43 +08:00
|
|
|
|
Fetch from API, parse, and save cache.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
force_refresh (bool): Skip incremental check, always fetch from API and overwrite cache.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
dict: payload with window, title, collection metadata, and latest article info.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
cache = self.load_cache()
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
|
|
|
|
|
|
if latest_article is None:
|
|
|
|
|
|
if cache is None:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
raise FetchError(
|
|
|
|
|
|
'No available cache and collection not updated or consistency check failed'
|
|
|
|
|
|
)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
return cache
|
|
|
|
|
|
|
2026-07-18 10:20:43 +08:00
|
|
|
|
# Degrade on parse failure: reuse cached window, or raise if no cache
|
2026-07-10 07:25:18 +08:00
|
|
|
|
if downtime_window is None:
|
|
|
|
|
|
if cache and cache.get('window'):
|
2026-08-15 22:21:34 +08:00
|
|
|
|
logger.warning(f'Failed to parse, reuse cached window: {cache["window"]}')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
window = cache['window']
|
|
|
|
|
|
else:
|
2026-07-18 10:20:43 +08:00
|
|
|
|
raise FetchError('Failed to parse and no cache available')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
else:
|
|
|
|
|
|
window = downtime_window.window
|
|
|
|
|
|
|
|
|
|
|
|
cache_payload = {
|
|
|
|
|
|
'collection_id': collection_meta.collection_id,
|
|
|
|
|
|
'update_time': collection_meta.update_time,
|
2026-08-15 22:21:34 +08:00
|
|
|
|
'article_count': collection_meta.article_count,
|
|
|
|
|
|
'latest_pub_ts': latest_article.pub_ts,
|
|
|
|
|
|
'latest_article_id': latest_article.id,
|
2026-07-10 07:25:18 +08:00
|
|
|
|
'window': window,
|
|
|
|
|
|
'title': latest_article.title,
|
|
|
|
|
|
}
|
|
|
|
|
|
self.save_cache(cache_payload)
|
|
|
|
|
|
return cache_payload
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
def acquire_lock(self) -> bool:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Acquire cross-process fetch lock.
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-10 07:25:18 +08:00
|
|
|
|
Returns:
|
|
|
|
|
|
bool: True if acquired, False on timeout or OSError.
|
|
|
|
|
|
"""
|
2026-08-15 22:21:34 +08:00
|
|
|
|
p = Path(self.FETCH_LOCK_FILE)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
2026-08-15 22:21:34 +08:00
|
|
|
|
deadline = time.monotonic() + self.LOCK_WAIT_SECONDS
|
2026-07-10 07:25:18 +08:00
|
|
|
|
while time.monotonic() < deadline:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
try:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
age = self._file_age(p)
|
|
|
|
|
|
if age < self.LOCK_WAIT_SECONDS:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
continue
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
2026-07-10 07:25:18 +08:00
|
|
|
|
except (OSError, FileNotFoundError):
|
|
|
|
|
|
pass
|
2026-08-15 22:21:34 +08:00
|
|
|
|
continue
|
2026-07-10 07:25:18 +08:00
|
|
|
|
except OSError as e:
|
|
|
|
|
|
logger.warning(f'Failed to acquire fetch lock: {e}')
|
|
|
|
|
|
return False
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
2026-07-10 07:25:18 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
def release_lock(self):
|
|
|
|
|
|
p = Path(self.FETCH_LOCK_FILE)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
try:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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')
|
2026-07-10 07:25:18 +08:00
|
|
|
|
except FileNotFoundError:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
pass # lock already gone
|
2026-07-10 07:25:18 +08:00
|
|
|
|
except OSError as e:
|
|
|
|
|
|
logger.warning(f'Failed to release fetch lock: {e}')
|
|
|
|
|
|
|
2026-08-15 22:21:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-18 10:20:43 +08:00
|
|
|
|
def run(self, force_refresh=False) -> dict:
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Lock-guarded entry for the downtime pipeline.
|
|
|
|
|
|
|
2026-07-18 10:20:43 +08:00
|
|
|
|
Fast path: fresh cache, return directly without lock (read-only).
|
2026-08-15 22:21:34 +08:00
|
|
|
|
Common path: acquire cross-process lock, re-check cache in case
|
|
|
|
|
|
another instance fetched while waiting, then fetch.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
Fallback: lock acquisition failed, reuse cache only if another process
|
|
|
|
|
|
has freshly updated it; otherwise raise FetchError.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
dict: payload with window, title, collection metadata, and latest article info.
|
2026-07-10 07:25:18 +08:00
|
|
|
|
"""
|
2026-07-18 10:20:43 +08:00
|
|
|
|
# Fast path: fresh cache, read-only, no lock needed
|
|
|
|
|
|
if not force_refresh:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
cache = self._try_reuse_fresh_cache('Cache is fresh, reuse without fetch')
|
|
|
|
|
|
if cache is not None:
|
2026-07-18 10:20:43 +08:00
|
|
|
|
return cache
|
2026-07-10 07:25:18 +08:00
|
|
|
|
if not self.acquire_lock():
|
2026-07-18 10:20:43 +08:00
|
|
|
|
# Lock failed: reuse fresh cache if another process updated it
|
2026-08-15 22:21:34 +08:00
|
|
|
|
cache = self._try_reuse_fresh_cache('Cache updated by another process, reuse')
|
|
|
|
|
|
if cache is not None:
|
2026-07-18 10:20:43 +08:00
|
|
|
|
return cache
|
2026-07-10 07:25:18 +08:00
|
|
|
|
raise FetchError('Failed to acquire fetch lock')
|
|
|
|
|
|
try:
|
2026-08-15 22:21:34 +08:00
|
|
|
|
# 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
|
2026-07-18 10:20:43 +08:00
|
|
|
|
return self.get_downtime_info(force_refresh=force_refresh)
|
2026-07-10 07:25:18 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
self.release_lock()
|