from __future__ import annotations import json import socket import time from dataclasses import dataclass from urllib.error import HTTPError, URLError from urllib.parse import urlencode import urllib.request from typing import Any UA = "Mozilla/5.0 (compatible; ai-daily-report/1.0)" @dataclass class FetchTextError(Exception): error_type: str message: str http_status: int | None = None attempts: int = 1 def __str__(self) -> str: return self.message def _classify_fetch_exception(exc: Exception) -> tuple[str, int | None, bool]: if isinstance(exc, HTTPError): if exc.code == 404: return "http_404", exc.code, False if exc.code in {429, 500, 502, 503, 504}: return f"http_{exc.code}", exc.code, True return f"http_{exc.code}", exc.code, False if isinstance(exc, TimeoutError | socket.timeout): return "timeout", None, True if isinstance(exc, URLError): reason = exc.reason if isinstance(reason, TimeoutError | socket.timeout): return "timeout", None, True return "network_error", None, True return "fetch_error", None, False def fetch_text( url: str, timeout_seconds: int, *, retries: int = 0, backoff_seconds: float = 0.5, ) -> str: req = urllib.request.Request(url, headers={"User-Agent": UA}) attempts = max(1, retries + 1) last_error: FetchTextError | None = None for attempt in range(1, attempts + 1): try: with urllib.request.urlopen(req, timeout=timeout_seconds) as response: return response.read().decode("utf-8", "ignore") except Exception as exc: error_type, http_status, retryable = _classify_fetch_exception(exc) last_error = FetchTextError( error_type=error_type, message=f"{type(exc).__name__}: {exc}", http_status=http_status, attempts=attempt, ) if not retryable or attempt >= attempts: raise last_error from exc if backoff_seconds > 0: time.sleep(backoff_seconds * (2 ** (attempt - 1))) raise last_error or FetchTextError("fetch_error", "unknown fetch error", attempts=attempts) class OpenAICompatibleClient: def __init__(self, *, api_key: str, base_url: str, model: str, timeout_seconds: int = 600): self.api_key = api_key self.base_url = base_url.rstrip("/") self.model = model self.timeout_seconds = timeout_seconds def chat(self, prompt: str) -> str: payload = json.dumps( { "model": self.model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.2, "max_tokens": 8000, }, ensure_ascii=False, ).encode("utf-8") req = urllib.request.Request( f"{self.base_url}/chat/completions", data=payload, headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=self.timeout_seconds) as response: data = json.loads(response.read().decode("utf-8")) return data["choices"][0]["message"]["content"].strip() class BlogApiClient: def __init__(self, *, base_url: str, token: str, timeout_seconds: int = 25): self.base_url = base_url.rstrip("/") self.token = token self.timeout_seconds = timeout_seconds def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: data = None headers = {"Authorization": f"Bearer {self.token}", "User-Agent": UA} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(f"{self.base_url}{path}", data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=self.timeout_seconds) as response: return json.loads(response.read().decode("utf-8")) def create_post(self, payload: dict[str, Any]) -> dict[str, Any]: return self._request("POST", "/api/service/posts", payload) def _normalize_post_response(self, value: Any, slug: str) -> dict[str, Any] | None: if isinstance(value, dict): if isinstance(value.get("post"), dict): value = value["post"] elif isinstance(value.get("data"), dict): value = value["data"] elif isinstance(value.get("items"), list): for item in value["items"]: if isinstance(item, dict) and item.get("slug") == slug: return item return None if value.get("slug") == slug or value.get("id") or value.get("content") or value.get("markdown"): return value if isinstance(value, list): for item in value: if isinstance(item, dict) and item.get("slug") == slug: return item return None def _request_optional(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any] | list[Any] | None: try: return self._request(method, path, payload) except HTTPError as exc: if exc.code in {403, 404}: return None raise except FetchTextError as exc: if exc.error_type in {"http_403", "http_404"}: return None raise def get_post_by_slug(self, slug: str) -> dict[str, Any] | None: paths = [ f"/api/service/posts/{slug}", f"/api/service/posts?{urlencode({'slug': slug})}", f"/api/service/posts/slug/{slug}", ] for path in paths: value = self._request_optional("GET", path) post = self._normalize_post_response(value, slug) if post is not None: return post return None def publish_post(self, slug: str) -> None: self._request("POST", f"/api/service/posts/{slug}/publish")