onesvm-browser-server/stacks/trafilatura/app.py
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

166 lines
5.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Minimal trafilatura HTTP wrapper. POST /v1/read only (plus GET /health)."""
from __future__ import annotations
import ipaddress
import json
import socket
import threading
import traceback
from copy import deepcopy
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import trafilatura
from trafilatura.metadata import extract_metadata
from trafilatura.settings import DEFAULT_CONFIG
SEM = threading.Semaphore(8)
MAX_DOWNLOAD_BYTES = 5_000_000
TIMEOUT_S = 15
LISTEN = ("0.0.0.0", 8080)
_CFG = deepcopy(DEFAULT_CONFIG)
_CFG["DEFAULT"]["DOWNLOAD_TIMEOUT"] = str(TIMEOUT_S)
_CFG["DEFAULT"]["MAX_FILE_SIZE"] = str(MAX_DOWNLOAD_BYTES)
_BLOCKED_NETS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
_BLOCKED_HOSTS = {
"metadata.google.internal",
"metadata.google.com",
"kubernetes.default.svc",
}
def _forbidden_host(host: str) -> str | None:
h = host.strip("[]").lower()
if h in _BLOCKED_HOSTS or h.endswith(".internal"):
return "blocked_metadata_host"
try:
infos = socket.getaddrinfo(h, None)
except socket.gaierror as exc:
return f"dns_fail:{exc}"
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if any(ip in net for net in _BLOCKED_NETS):
return f"blocked_private_ip:{ip}"
return None
def _guard_url(url: str) -> str | None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return "scheme_not_http"
if not parsed.hostname:
return "no_host"
return _forbidden_host(parsed.hostname)
def _extract(url: str, max_chars: int) -> dict:
err = _guard_url(url)
if err:
return {"ok": False, "error": err, "fail_class": "fetch_fail"}
downloaded = trafilatura.fetch_url(url, config=_CFG)
if not downloaded:
return {"ok": False, "error": "fetch_empty", "fail_class": "fetch_fail"}
if len(downloaded.encode("utf-8", errors="replace")) > MAX_DOWNLOAD_BYTES:
downloaded = downloaded.encode("utf-8", errors="replace")[:MAX_DOWNLOAD_BYTES].decode(
"utf-8", errors="ignore"
)
markdown = trafilatura.extract(
downloaded,
output_format="markdown",
include_comments=False,
include_tables=True,
favor_precision=True,
config=_CFG,
)
if not markdown:
return {"ok": False, "error": "empty_extract", "fail_class": "empty_extract", "title": ""}
meta = extract_metadata(downloaded)
title = (meta.title if meta and getattr(meta, "title", None) else "") or ""
truncated = False
if max_chars and len(markdown) > max_chars:
markdown = markdown[:max_chars]
truncated = True
return {
"ok": True,
"title": title,
"markdown": markdown,
"char_count": len(markdown),
"truncated": truncated,
"url": url,
}
class Handler(BaseHTTPRequestHandler):
server_version = "trafilatura-http/s3b"
def log_message(self, fmt: str, *args) -> None:
print(f"{self.log_date_time_string()} {self.address_string()} {fmt % args}", flush=True)
def _json(self, code: int, obj: dict) -> None:
raw = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self) -> None:
if self.path.split("?", 1)[0] in ("/health", "/"):
self._json(200, {"ok": True, "service": "trafilatura-http"})
return
self._json(404, {"ok": False, "error": "not_found"})
def do_POST(self) -> None:
path = self.path.split("?", 1)[0]
if path != "/v1/read":
self._json(404, {"ok": False, "error": "not_found"})
return
length = int(self.headers.get("Content-Length") or 0)
if length > 1_000_000:
self._json(413, {"ok": False, "error": "body_too_large"})
return
try:
payload = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"ok": False, "error": "bad_json"})
return
url = (payload.get("url") or "").strip()
max_chars = int(payload.get("max_chars") or 20000)
if not url:
self._json(400, {"ok": False, "error": "url_required"})
return
acquired = SEM.acquire(timeout=60)
if not acquired:
self._json(429, {"ok": False, "error": "queue_timeout", "fail_class": "timeout"})
return
try:
result = _extract(url, max_chars)
self._json(200 if result.get("ok") else 200, result)
except Exception as exc: # noqa: BLE001
traceback.print_exc()
self._json(500, {"ok": False, "error": str(exc), "fail_class": "fetch_fail"})
finally:
SEM.release()
def main() -> None:
httpd = ThreadingHTTPServer(LISTEN, Handler)
print(f"trafilatura-http listening on {LISTEN[0]}:{LISTEN[1]} semaphore=8", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()