onesvm-browser-server/stacks/trafilatura/app.py
chii 9b689b2476 feat: 落地节点指纹与 Cookie 罐,并按现网能力更新消费/接手文档
公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:04:25 +08:00

262 lines
8.8 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 _looks_challenge(text: str) -> bool:
blob = (text or "")[:8000].lower()
keys = (
"just a moment",
"cf-challenge",
"attention required",
"verify you are human",
"captcha",
"access denied",
)
return any(k in blob for k in keys)
def _collect_cookies(sess) -> list[dict]:
out: list[dict] = []
jar = getattr(sess, "cookies", None)
if jar is None:
return out
items = []
inner = getattr(jar, "jar", None)
if inner is not None:
try:
items = list(inner)
except TypeError:
items = []
if not items:
try:
for name, value in jar.items():
out.append({"name": name, "value": value, "domain": "", "path": "/"})
if len(out) >= 20:
break
return out
except Exception:
return out
for c in items:
name = getattr(c, "name", "") or ""
value = getattr(c, "value", "") or ""
if not name or not value:
continue
rec = {
"name": name,
"value": value,
"domain": getattr(c, "domain", "") or "",
"path": getattr(c, "path", "") or "/",
}
exp = getattr(c, "expires", None)
if exp:
rec["expires"] = int(exp)
out.append(rec)
if len(out) >= 20:
break
return out
def _fetch(url: str, headers: dict | None, cookies: list | None, impersonate: str) -> tuple[str | None, list[dict], int, bool]:
"""优先 curl_cffi(TLS=Chrome);缺库时回退 trafilatura.fetch_url(无指纹)。"""
try:
from curl_cffi import requests as cfreq
except ImportError:
downloaded = trafilatura.fetch_url(url, config=_CFG)
return downloaded, [], 0, False
sess = cfreq.Session(impersonate=impersonate or "chrome136")
if headers:
sess.headers.update({k: v for k, v in headers.items() if v})
for c in cookies or []:
name = (c.get("name") or "").strip()
value = c.get("value") or ""
if not name:
continue
kwargs = {}
if c.get("domain"):
kwargs["domain"] = c["domain"]
if c.get("path"):
kwargs["path"] = c["path"]
try:
sess.cookies.set(name, value, **kwargs)
except Exception:
continue
try:
r = sess.get(url, timeout=TIMEOUT_S, allow_redirects=True)
except Exception:
return None, [], 0, False
text = r.text if r is not None else ""
status = int(getattr(r, "status_code", 0) or 0)
poisoned = status in (403, 429, 503) or _looks_challenge(text)
return text or None, _collect_cookies(sess), status, poisoned
def _extract(url: str, max_chars: int, headers=None, cookies=None, impersonate="") -> dict:
err = _guard_url(url)
if err:
return {"ok": False, "error": err, "fail_class": "fetch_fail"}
downloaded, set_cookies, status, poisoned = _fetch(url, headers, cookies, impersonate)
extra = {"set_cookies": set_cookies, "status_code": status, "poisoned": poisoned}
if poisoned:
return {"ok": False, "error": f"challenge_or_http_{status}", "fail_class": "blocked", **extra}
if not downloaded:
return {"ok": False, "error": "fetch_empty", "fail_class": "fetch_fail", **extra}
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": "", **extra}
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,
**extra,
}
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)
headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else None
cookies = payload.get("cookies") if isinstance(payload.get("cookies"), list) else None
impersonate = str(payload.get("impersonate") or "chrome136")
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, headers, cookies, impersonate)
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()