docs: 联网搜索服务架构方案全套(plan-final/design-arch/选型决策/整合导览/MCP文档/部署预设/联调手册) bench: 5 方案 + 代理 + 站点矩阵本机实测工程(无密钥) 部署目标:primary mgr1 先行测试(待批准后执行)
166 lines
5.5 KiB
Python
Executable file
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()
|