Files
dy/backend/link_cards.py
2026-07-23 17:56:25 +08:00

187 lines
5.6 KiB
Python

"""链接卡片落地页:带 SEO meta 与自动跳转。"""
from __future__ import annotations
import html
import json
import os
import re
import secrets
from io import BytesIO
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import Request
_SLUG_RE = re.compile(r"^[a-zA-Z0-9_-]{6,64}$")
FAVICON_SIZE = 32
COVER_SIZE = 256
FAVICON_MIME = "image/png"
def generate_slug() -> str:
return secrets.token_urlsafe(9).replace("-", "_").replace(".", "_")[:12]
def is_valid_slug(slug: str) -> bool:
return bool(_SLUG_RE.match(slug or ""))
def public_base_url(request: Optional["Request"] = None) -> str:
env = os.getenv("KEFU_PUBLIC_BASE_URL", "").strip().rstrip("/")
if env:
return env
if request is not None:
return str(request.base_url).rstrip("/")
return ""
def absolute_media_url(path: str, request: Optional["Request"] = None) -> str:
path = (path or "").strip()
if not path:
return ""
if path.startswith("http://") or path.startswith("https://"):
return path
base = public_base_url(request)
if not base:
return path
if not path.startswith("/"):
path = f"/{path}"
return f"{base}{path}"
def favicon_mime(image_path: str) -> str:
ext = (image_path or "").rsplit(".", 1)[-1].lower()
return {
"svg": "image/svg+xml",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"ico": "image/x-icon",
}.get(ext, FAVICON_MIME)
def favicon_path_for_cover(cover_path: str) -> str:
"""由封面路径推导同名 favicon 文件路径。"""
path = (cover_path or "").strip()
if not path:
return ""
if path.endswith(".favicon.png"):
return path
if path.endswith(".png"):
return f"{path[:-4]}.favicon.png"
base, _ = os.path.splitext(path)
return f"{base}.favicon.png"
def media_path_to_disk(upload_dir: str, media_path: str) -> str:
prefix = "/api/media/link-cards/"
path = (media_path or "").strip()
if not path.startswith(prefix):
return ""
rel = path[len(prefix) :].replace("/", os.sep)
return os.path.join(upload_dir, rel)
def process_card_upload(raw: bytes) -> tuple[bytes, bytes]:
"""将任意位图转为封面 PNG (256x256) 与 favicon PNG (32x32)。"""
if not raw:
raise ValueError("图片为空")
try:
from PIL import Image
except ImportError as exc:
raise RuntimeError("服务器未安装 Pillow,无法转换 favicon") from exc
try:
with Image.open(BytesIO(raw)) as img:
if getattr(img, "is_animated", False):
img.seek(0)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
else:
img = img.copy()
width, height = img.size
if width < 1 or height < 1:
raise ValueError("图片尺寸无效")
side = min(width, height)
left = (width - side) // 2
top = (height - side) // 2
square = img.crop((left, top, left + side, top + side))
resample = Image.Resampling.LANCZOS
cover = square.resize((COVER_SIZE, COVER_SIZE), resample)
favicon = square.resize((FAVICON_SIZE, FAVICON_SIZE), resample)
cover_buf = BytesIO()
cover.save(cover_buf, format="PNG", optimize=True)
favicon_buf = BytesIO()
favicon.save(favicon_buf, format="PNG", optimize=True)
return cover_buf.getvalue(), favicon_buf.getvalue()
except ValueError:
raise
except Exception as exc:
raise ValueError("无法识别图片格式,请上传 JPG、PNG、GIF 或 WebP") from exc
def normalize_target_url(url: str) -> str:
url = (url or "").strip()
if not url:
return ""
if not url.startswith("http://") and not url.startswith("https://"):
url = f"https://{url}"
return url
def build_keywords(title: str, content: str) -> str:
parts = [p.strip() for p in (title, content) if p and p.strip()]
return ", ".join(dict.fromkeys(parts))
def render_link_card_page(
*,
title: str,
content: str,
keywords: str,
cover_url: str,
favicon_url: str,
target_url: str,
) -> str:
safe_title = html.escape(title or "跳转中")
safe_desc = html.escape(content or title or "")
safe_keywords = html.escape(keywords or build_keywords(title, content))
safe_cover = html.escape(cover_url or "")
safe_favicon = html.escape(favicon_url or cover_url or "")
redirect_url = normalize_target_url(target_url)
safe_target = html.escape(redirect_url)
js_target = json.dumps(redirect_url, ensure_ascii=False)
favicon_tag = ""
og_image_tag = ""
if safe_favicon:
favicon_tag = f' <link rel="icon" type="{FAVICON_MIME}" href="{safe_favicon}" />\n'
if safe_cover:
og_image_tag = f' <meta property="og:image" content="{safe_cover}" />\n'
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{safe_title}</title>
<meta name="description" content="{safe_desc}" />
<meta name="keywords" content="{safe_keywords}" />
<meta property="og:title" content="{safe_title}" />
<meta property="og:description" content="{safe_desc}" />
{og_image_tag}{favicon_tag} <meta http-equiv="refresh" content="0;url={safe_target}" />
<script>location.replace({js_target});</script>
</head>
<body>
<p>正在跳转到目标页面…</p>
<p><a href="{safe_target}">若未自动跳转,请点击这里</a></p>
</body>
</html>"""