Spaces:
Sleeping
Sleeping
File size: 7,556 Bytes
ff3c435 152ccef ff3c435 a48e17c ff3c435 152ccef ff3c435 a48e17c ff3c435 152ccef ff3c435 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | """Isolated report service. Never calls or modifies the production MiniApp Space."""
import hmac
import logging
import os
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import legacy_parser as engine
from reports import normalize, render
from tgstat_api import archive_link, archived_post
log = logging.getLogger(__name__)
API_KEY = os.getenv("BRIDGE_API_KEY", "")
app = FastAPI(title="ReportBridge", version="0.1.2")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False,
allow_methods=["GET", "POST", "DELETE"], allow_headers=["Content-Type", "X-Bridge-Key"])
jobs, lock = {}, threading.RLock()
queue = ThreadPoolExecutor(max_workers=1, thread_name_prefix="reports")
def authorize(x_bridge_key: str = Header(default="")):
if not API_KEY:
raise HTTPException(503, "В бэкенде расширения не настроен BRIDGE_API_KEY")
if not hmac.compare_digest(x_bridge_key.encode(), API_KEY.encode()):
raise HTTPException(401, "Неверный ключ ReportBridge")
class JobRequest(BaseModel):
request_id: str = Field(min_length=16, max_length=64, pattern=r"^[a-zA-Z0-9-]+$")
links: List[str] = Field(min_length=1, max_length=500)
def fetch_one(record):
result = dict(record)
try:
if record["platform"] == "telegram":
title, views = engine.process_telegram_link(record["a"], record["b"], record["url"])
else:
title, views = engine.process_max_link(record["a"], record["b"], record["url"])
if "ошибка" in title.lower() or views is None:
raise ValueError("Источник не вернул просмотры. Попробуйте повторить позже.")
# The legacy HTML parser returns 0 when it cannot find a counter. Avoid a false zero in new reports.
if record["platform"] == "telegram" and views == 0:
raise ValueError("Не найден счётчик просмотров Telegram; это не подтверждённый ноль")
result.update(title=title, views=views)
except Exception:
result.update(title=record["group"], views=None, error="Не удалось получить просмотры публичного поста")
if record["platform"] == "telegram":
result["display_url"] = archive_link(record["a"], record["b"])
try:
fallback = archived_post(record["a"], record["b"], record["group"])
if fallback:
result.update(fallback, error=None)
except Exception:
# Never log API request URLs: TGStat credentials are query parameters.
pass
return result
def run_job(job_id, records):
result_map = {}
with lock:
jobs[job_id]["status"] = "running"
def store_result(record):
with lock:
result_map[record["url"]] = record
jobs[job_id]["completed"] = len(result_map)
try:
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(fetch_one, r): r for r in records if r["platform"] != "vk"}
vk = [r for r in records if r["platform"] == "vk"]
for start in range(0, len(vk), 100):
batch = vk[start:start + 100]
if not engine.VK_ACCESS_TOKEN:
data = {}
else:
data = engine.process_vk_batch([(r["url"], r["a"], r["b"]) for r in batch])
for record in batch:
title, views = data.get(record["url"], ("Ошибка: VK_ACCESS_TOKEN не настроен", None))
error = ("Не настроен VK_ACCESS_TOKEN в Secrets PR_Tool" if not engine.VK_ACCESS_TOKEN
else "Не удалось получить просмотры VK") if "ошибка" in title.lower() else None
if not error and views is None:
error = "VK API не передал счётчик просмотров этого поста; это не подтверждённый ноль"
store_result(dict(record, title=title if not error else record["group"], views=None if error else views, error=error))
if start + 100 < len(vk):
time.sleep(0.4)
for future in as_completed(futures):
store_result(future.result())
output = render([result_map[r["url"]] for r in records])
with lock:
jobs[job_id].update(status="complete", result=output)
except Exception:
log.exception("ReportBridge job failed")
with lock:
jobs[job_id].update(status="failed", error="Ошибка нового бэкенда. Рабочий MiniApp не затронут.")
@app.get("/")
def root():
return {"service": "ReportBridge", "production_miniapp": "not used", "health": "/health"}
@app.get("/health")
def health():
return {"status": "ok", "service": "ReportBridge", "configured": {
"bridge": bool(API_KEY), "vk": bool(engine.VK_ACCESS_TOKEN), "max": bool(os.getenv("MAX_ACCESS_TOKEN")), "tgstat": bool(os.getenv("TGSTAT_API_TOKEN"))}}
@app.post("/jobs", dependencies=[Depends(authorize)])
def create_job(payload: JobRequest):
records, seen = [], set()
for raw in payload.links:
if len(raw) > 2048:
raise HTTPException(422, "Слишком длинная ссылка")
try:
record = normalize(raw)
except ValueError as exc:
raise HTTPException(422, str(exc)) from None
if record["url"] not in seen:
seen.add(record["url"])
records.append(record)
canonical = [r["url"] for r in records]
with lock:
now = time.time()
expired = [k for k, j in jobs.items() if j["status"] not in ("queued", "running") and now - j["created"] > 3600]
for key in expired:
del jobs[key]
for job in jobs.values():
if job["request_id"] == payload.request_id:
if job["links"] != canonical:
raise HTTPException(409, "Этот идентификатор уже используется для другого списка")
return {"id": job["id"], "status": job["status"]}
if sum(j["status"] in ("queued", "running") for j in jobs.values()) >= 5 or len(jobs) >= 100:
raise HTTPException(429, "Очередь нового Space заполнена. Попробуйте позже.")
job_id = str(uuid.uuid4())
jobs[job_id] = dict(id=job_id, request_id=payload.request_id, links=canonical,
status="queued", completed=0, total=len(records), created=now)
queue.submit(run_job, job_id, records)
return {"id": job_id, "status": "queued"}
@app.get("/jobs/{job_id}", dependencies=[Depends(authorize)])
def get_job(job_id: str):
with lock:
if job_id not in jobs:
raise HTTPException(404, "Задание не найдено. Возможно, новый Space перезапустился; запустите обработку заново.")
return {k: v for k, v in jobs[job_id].items() if k not in ("links", "request_id")}
|