"""Мета сервиса с главной страницы: `<title>` и favicon.
Health-URL может быть глубже корня — мета тянем с origin (scheme://netloc).
HTML главной не хранится: из него берутся только <title> (строка ≤100) и href
первой `<link rel=icon…>`; иконка кладётся в `services.icon` data-URL'ом
(b64, ≤100 KB). Неудача — тоже законная попытка: метка meta_fetched_at
пишется всегда, чтобы не долбить мёртвый origin каждую волну проб.
"""
import base64
from datetime import datetime, timedelta, timezone
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit
import httpx
from app.config import get_settings
from app.db import get_db
META_TTL_S = 24 * 3600
MAX_PAGE = 256 * 1024 # хватает на <head> любого разумного сайта
MAX_ICON = 100 * 1024
TITLE_MAX = 100
# rel `<link>`-иконок в порядке приоритета
_ICON_RELS = {"icon": 0, "shortcut icon": 0, "mask-icon": 1, "apple-touch-icon": 1}
class _HeadParser(HTMLParser):
"""Первые <title> и <link rel=icon> — остальное не интересно."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title: str | None = None
self.icon_url: str | None = None
self.icon_rel: int | None = None # приоритет уже найденного
self._in_title = False
self._title_done = False
def handle_starttag(self, tag: str, attrs: list) -> None:
if tag == "title" and not self._title_done:
self._in_title = True
self.title = ""
elif tag == "link":
values = dict(attrs)
rel = (values.get("rel") or "").strip().lower()
if rel in _ICON_RELS and values.get("href"):
rank = _ICON_RELS[rel]
if self.icon_rel is None or rank < self.icon_rel:
self.icon_rel = rank
self.icon_url = values["href"]
def handle_endtag(self, tag: str) -> None:
if tag == "title" and self._in_title:
self._in_title = False
self._title_done = True
cleaned = self.title.strip()
self.title = cleaned[:TITLE_MAX] if cleaned else None
def handle_data(self, data: str) -> None:
if self._in_title and len(self.title) < TITLE_MAX + 20:
self.title += data
def _mime(content: bytes, header: str | None) -> str | None:
"""image/* из заголовка, иначе снифф по магии; не картинка → None."""
if header and header.split(";")[0].strip().lower().startswith("image/"):
return header.split(";")[0].strip().lower()
if content.startswith(b"\x89PNG"):
return "image/png"
if content.startswith(b"\xff\xd8"):
return "image/jpeg"
if content.startswith(b"GIF8"):
return "image/gif"
if content.startswith(b"\x00\x00\x01\x00"):
return "image/x-icon"
if content.lstrip()[:4].lower() == b"<svg":
return "image/svg+xml"
return None
async def _read_limited(client: httpx.AsyncClient, url: str, cap: int) -> tuple[int, str, bytes | None]:
"""GET с ранним разрывом после cap байт: (код, content-type, тело|None)."""
try:
response = await client.send(client.build_request("GET", url), stream=True)
chunks: list[bytes] = []
async for chunk in response.aiter_bytes(4096):
chunks.append(chunk)
if sum(map(len, chunks)) >= cap:
break
await response.aclose()
if response.status_code != 200:
return response.status_code, "", None
return response.status_code, response.headers.get("content-type", ""), b"".join(chunks)[:cap]
except httpx.HTTPError:
return 0, "", None
def _origin(url: str) -> str | None:
parts = urlsplit(url.strip())
if not parts.scheme or not parts.netloc:
return None
return f"{parts.scheme}://{parts.netloc}"
async def refresh_meta(client: httpx.AsyncClient, service_id: int, url: str) -> None:
"""Тяну-меты одного сервиса: title главной + favicon. Всегда пишет метку."""
db = get_db()
site_title: str | None = None
icon: str | None = None
origin = _origin(url)
if origin is not None:
code, ctype, page = await _read_limited(client, origin, MAX_PAGE)
if code == 200 and page and "html" in ctype:
parser = _HeadParser()
parser.feed(page.decode("utf-8", errors="replace"))
site_title = parser.title
if parser.icon_url:
icon_url = urljoin(origin, parser.icon_url)
else: # консервативный фоллбэк — иконка в корне
icon_url = origin + "/favicon.ico"
icode, ictype, icon_bytes = await _read_limited(client, icon_url, MAX_ICON)
mime = _mime(icon_bytes or b"", ictype)
if icon_bytes and mime:
# иконка в <img> не исполняет скрипты — svg безопасен
icon = f"data:{mime};base64," + base64.b64encode(icon_bytes).decode()
await db.execute(
"UPDATE services SET site_title = ?, icon = ?, meta_fetched_at = ? WHERE id = ?",
(
site_title,
icon,
datetime.now(timezone.utc).isoformat(),
service_id,
),
)
await db.commit()
async def refresh_meta_now(service_id: int, url: str) -> None:
"""Разовая мета-тяга со своим клиентом (create/update в API)."""
settings = get_settings()
async with httpx.AsyncClient(
timeout=settings.health_timeout, follow_redirects=True, verify=False
) as client:
await refresh_meta(client, service_id, url)
async def needs_meta_refresh(db, ttl_s: int = META_TTL_S) -> list[dict]:
"""Сервисы, чья мета не тянулась или старше ttl."""
cursor = await db.execute("SELECT id, url, meta_fetched_at FROM services")
rows = await cursor.fetchall()
threshold = datetime.now(timezone.utc) - timedelta(seconds=ttl_s)
due = []
for row in rows:
fetched = row["meta_fetched_at"]
if not fetched:
due.append({"id": row["id"], "url": row["url"]})
continue
try:
if datetime.fromisoformat(fetched) < threshold:
due.append({"id": row["id"], "url": row["url"]})
except ValueError:
due.append({"id": row["id"], "url": row["url"]})
return due