Newer
Older
hard-panel / panel / backend / app / service_meta.py
"""Мета сервиса с главной страницы: `<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