Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Link Preview API - 获取 URL 元数据用于 Embed/书签预览卡片
|
||||
"""
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/link-preview", tags=["链接预览"])
|
||||
|
||||
_cache: dict[str, dict] = {}
|
||||
_CACHE_MAX = 200
|
||||
|
||||
|
||||
class LinkPreviewResponse(BaseModel):
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
favicon: Optional[str] = None
|
||||
site_name: Optional[str] = None
|
||||
|
||||
|
||||
class _MetaParser(HTMLParser):
|
||||
"""轻量 HTML 解析器,提取 title 和 meta 中的 OG 元数据"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title: Optional[str] = None
|
||||
self.og_title: Optional[str] = None
|
||||
self.og_description: Optional[str] = None
|
||||
self.og_image: Optional[str] = None
|
||||
self.og_site_name: Optional[str] = None
|
||||
self.description: Optional[str] = None
|
||||
self.favicon: Optional[str] = None
|
||||
self._in_title = False
|
||||
self._title_parts: list[str] = []
|
||||
self._done = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]):
|
||||
if self._done:
|
||||
return
|
||||
attrs_dict = {k.lower(): v for k, v in attrs if k}
|
||||
|
||||
if tag == "title":
|
||||
self._in_title = True
|
||||
self._title_parts = []
|
||||
return
|
||||
|
||||
if tag == "meta":
|
||||
prop = (attrs_dict.get("property") or "").lower()
|
||||
name = (attrs_dict.get("name") or "").lower()
|
||||
content = attrs_dict.get("content") or ""
|
||||
|
||||
if prop == "og:title":
|
||||
self.og_title = content
|
||||
elif prop == "og:description":
|
||||
self.og_description = content
|
||||
elif prop == "og:image":
|
||||
self.og_image = content
|
||||
elif prop == "og:site_name":
|
||||
self.og_site_name = content
|
||||
elif name == "description":
|
||||
self.description = content
|
||||
|
||||
if tag == "link":
|
||||
rel = (attrs_dict.get("rel") or "").lower()
|
||||
href = attrs_dict.get("href") or ""
|
||||
if "icon" in rel and href:
|
||||
self.favicon = href
|
||||
|
||||
def handle_data(self, data: str):
|
||||
if self._in_title:
|
||||
self._title_parts.append(data)
|
||||
|
||||
def handle_endtag(self, tag: str):
|
||||
if tag == "title" and self._in_title:
|
||||
self._in_title = False
|
||||
self.title = "".join(self._title_parts).strip()
|
||||
if tag == "head":
|
||||
self._done = True
|
||||
|
||||
|
||||
def _resolve_url(base: str, path: Optional[str]) -> Optional[str]:
|
||||
if not path:
|
||||
return None
|
||||
if path.startswith(("http://", "https://", "//")):
|
||||
return path
|
||||
return urljoin(base, path)
|
||||
|
||||
|
||||
def _domain_favicon(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||
|
||||
|
||||
async def _fetch_metadata(url: str) -> dict:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; ZQBot/1.0)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=5.0, verify=False
|
||||
) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "text/html" not in content_type and "application/xhtml" not in content_type:
|
||||
return {"url": url}
|
||||
|
||||
text = resp.text[:50_000]
|
||||
|
||||
parser = _MetaParser()
|
||||
try:
|
||||
parser.feed(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"title": parser.og_title or parser.title,
|
||||
"description": parser.og_description or parser.description,
|
||||
"image": _resolve_url(url, parser.og_image),
|
||||
"favicon": _resolve_url(url, parser.favicon) or _domain_favicon(url),
|
||||
"site_name": parser.og_site_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=LinkPreviewResponse)
|
||||
async def get_link_preview(url: str = Query(..., description="要预览的 URL")):
|
||||
"""获取指定 URL 的元数据"""
|
||||
if not re.match(r"^https?://", url):
|
||||
url = f"https://{url}"
|
||||
|
||||
if url in _cache:
|
||||
return LinkPreviewResponse(**_cache[url])
|
||||
|
||||
try:
|
||||
metadata = await _fetch_metadata(url)
|
||||
except Exception:
|
||||
metadata = {"url": url}
|
||||
|
||||
if len(_cache) >= _CACHE_MAX:
|
||||
oldest = next(iter(_cache))
|
||||
del _cache[oldest]
|
||||
_cache[url] = metadata
|
||||
|
||||
return LinkPreviewResponse(**metadata)
|
||||
Reference in New Issue
Block a user