абузы: free-панели new-api на вкладке (роут /api/panels + грид), CSS вынесен из JS, панельный классификатор даёт freeapi-вес, тесты
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ data/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.bot_token
|
.bot_token
|
||||||
|
*.session*
|
||||||
|
.local/
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ def _qualifies(source, title, text, tags):
|
|||||||
sc -= 26
|
sc -= 26
|
||||||
if not hits:
|
if not hits:
|
||||||
return 0, []
|
return 0, []
|
||||||
|
src0 = str(source or "")
|
||||||
|
# new-api панели: «N бесплатных моделей (цена 0)» — это готовая абуз-поверхность
|
||||||
|
if src0.startswith("pn_"):
|
||||||
|
m = re.search(r"(\d{1,4})\s*бесплатн\w*\s*модел", blob, re.I)
|
||||||
|
if m:
|
||||||
|
cnt = min(int(m.group(1)), 500)
|
||||||
|
return 40 + min(cnt // 10, 30), ["freeapi"]
|
||||||
raw_key = bool(re.search(r"\b(sk-[A-Za-z0-9_\-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})", blob))
|
raw_key = bool(re.search(r"\b(sk-[A-Za-z0-9_\-]{20,}|gh[pousr]_[A-Za-z0-9]{20,})", blob))
|
||||||
src = str(source or "")
|
src = str(source or "")
|
||||||
known_prefix = src.startswith(("ab_", "tg_", "rad_", "reddit_", "discourse_", "xenforo", "pn_", "gh_", "hn_"))
|
known_prefix = src.startswith(("ab_", "tg_", "rad_", "reddit_", "discourse_", "xenforo", "pn_", "gh_", "hn_"))
|
||||||
|
|||||||
33
omni/dates.py
Normal file
33
omni/dates.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Publication dates from RSS/Atom/APIs, normalized to aware UTC datetimes."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def parse_published(value):
|
||||||
|
"""Accept Unix seconds/milliseconds, ISO 8601 or RFC 2822; invalid -> None.
|
||||||
|
|
||||||
|
Dates without a timezone are interpreted as UTC, not the host's local zone.
|
||||||
|
"""
|
||||||
|
if value is None or not str(value).strip():
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
if re.fullmatch(r"\d{4}", text):
|
||||||
|
return None # A bare year is not a Unix timestamp.
|
||||||
|
if re.fullmatch(r"\d{8}", text):
|
||||||
|
return datetime.strptime(text, "%Y%m%d").replace(tzinfo=timezone.utc)
|
||||||
|
if re.fullmatch(r"-?\d+(?:\.\d+)?", text):
|
||||||
|
stamp = float(text)
|
||||||
|
if abs(stamp) >= 10**12:
|
||||||
|
stamp /= 1000
|
||||||
|
return datetime.fromtimestamp(stamp, timezone.utc)
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
dt = parsedate_to_datetime(text)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
except (ValueError, TypeError, OverflowError, OSError):
|
||||||
|
return None
|
||||||
@@ -12,6 +12,7 @@ import xml.etree.ElementTree as ET
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from . import config, net
|
from . import config, net
|
||||||
|
from .dates import parse_published
|
||||||
from .model import Item
|
from .model import Item
|
||||||
|
|
||||||
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36",
|
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36",
|
||||||
@@ -38,8 +39,9 @@ def _item(src, kind, url, title, text="", author="", published="", extra=None):
|
|||||||
m = re.search(r"CVE-\d{4}-\d{4,7}", body or "")
|
m = re.search(r"CVE-\d{4}-\d{4,7}", body or "")
|
||||||
if m:
|
if m:
|
||||||
t = "%s · %s" % (m.group(0), t[:120])
|
t = "%s · %s" % (m.group(0), t[:120])
|
||||||
|
dt = parse_published(published)
|
||||||
return Item(src.name, kind, url=str(url or ""), title=t[:300], text=body[:6000],
|
return Item(src.name, kind, url=str(url or ""), title=t[:300], text=body[:6000],
|
||||||
author=_txt(author)[:80], published=str(published or ""), source=src.name,
|
author=_txt(author)[:80], published=dt.isoformat(timespec="seconds") if dt else str(published or ""), source=src.name,
|
||||||
extra=ex)
|
extra=ex)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,17 +102,26 @@ def _rss(src, limit):
|
|||||||
root = ET.fromstring(r.text.encode("utf-8", "ignore"))
|
root = ET.fromstring(r.text.encode("utf-8", "ignore"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return _rss_regex(src, r.text, limit)
|
return _rss_regex(src, r.text, limit)
|
||||||
ns = {"a": "http://www.w3.org/2005/Atom"}
|
ns = {"a": "http://www.w3.org/2005/Atom", "dc": "http://purl.org/dc/elements/1.1/"}
|
||||||
entries = root.findall(".//item") or root.findall(".//a:entry", ns)
|
entries = root.findall(".//item") or root.findall(".//a:entry", ns)
|
||||||
for e in entries[:limit]:
|
for e in entries[:limit]:
|
||||||
g = lambda tag: (e.findtext(tag) or e.findtext("a:" + tag, namespaces=ns) or "")
|
def g(tag):
|
||||||
|
direct = e.findtext(tag, namespaces=ns) or ""
|
||||||
|
if direct or ":" in tag:
|
||||||
|
return direct
|
||||||
|
return e.findtext("a:" + tag, namespaces=ns) or ""
|
||||||
|
|
||||||
link = g("link")
|
link = g("link")
|
||||||
if not link:
|
if not link:
|
||||||
le = e.find("a:link", ns) or e.find("link")
|
links = list(e.findall("a:link", ns)) + list(e.findall("link"))
|
||||||
link = le.get("href") if le is not None and le.get("href") else ""
|
le = next((el for el in links if el.get("rel", "alternate") == "alternate" and el.get("href")), None)
|
||||||
|
link = le.get("href", "") if le is not None else ""
|
||||||
|
if link:
|
||||||
|
link = urllib.parse.urljoin(src.url, link)
|
||||||
body = g("description") or g("summary") or g("content")
|
body = g("description") or g("summary") or g("content")
|
||||||
|
author = e.findtext("a:author/a:name", namespaces=ns) or g("author") or g("dc:creator")
|
||||||
out.append(_item(src, "entry", link, g("title"), body,
|
out.append(_item(src, "entry", link, g("title"), body,
|
||||||
g("author") or g("dc:creator"), g("pubDate") or g("published") or g("updated")))
|
author, g("pubDate") or g("published") or g("updated")))
|
||||||
if not out and "arxiv.org" in src.url:
|
if not out and "arxiv.org" in src.url:
|
||||||
return _arxiv_api(src, limit)
|
return _arxiv_api(src, limit)
|
||||||
return out
|
return out
|
||||||
@@ -910,18 +921,52 @@ ADAPTERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CHALLENGE_TITLE = re.compile(
|
||||||
|
r"(?:just a moment|access denied|403 forbidden|404(?: not found)?|not found|"
|
||||||
|
r"verify (?:that )?you are human|checking your browser|"
|
||||||
|
r"attention required!?(?:\s*\|\s*cloudflare)?|please enable javascript)[.!…\s]*",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_items(items):
|
||||||
|
"""Drop broken records and obvious challenge pages, not short real posts."""
|
||||||
|
out, seen = [], set()
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, Item) or any(not isinstance(getattr(it, field, None), str)
|
||||||
|
for field in ("url", "title", "text")):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
u = urllib.parse.urlsplit(it.url)
|
||||||
|
valid_url = u.scheme in ("http", "https") and bool(u.hostname)
|
||||||
|
except ValueError:
|
||||||
|
valid_url = False
|
||||||
|
if not valid_url or not (it.title.strip() or it.text.strip()):
|
||||||
|
continue
|
||||||
|
if _CHALLENGE_TITLE.fullmatch(it.title.strip()) and (
|
||||||
|
len(it.text.strip()) < 160 or re.search(
|
||||||
|
r"cloudflare|captcha|verify you are human|checking your browser|just a moment|attention required",
|
||||||
|
it.text, re.I)):
|
||||||
|
continue
|
||||||
|
if it.url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(it.url)
|
||||||
|
out.append(it)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def fetch(src, limit=None):
|
def fetch(src, limit=None):
|
||||||
fn = ADAPTERS.get(src.adapter)
|
fn = ADAPTERS.get(src.adapter)
|
||||||
if not fn:
|
if not fn:
|
||||||
return []
|
return []
|
||||||
n = limit or src.limit
|
n = limit or src.limit
|
||||||
try:
|
try:
|
||||||
return fn(src, n) or []
|
return _valid_items(fn(src, n) or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
SLOW_HOSTS = ("reddit.com", "pullpush.io", "api.github.com", "huggingface.co")
|
SLOW_HOSTS = ("reddit.com", "pullpush.io", "api.github.com", "huggingface.co", "services.nvd.nist.gov")
|
||||||
|
|
||||||
|
|
||||||
def fetch_many(sources, limit=None, workers=8):
|
def fetch_many(sources, limit=None, workers=8):
|
||||||
@@ -944,5 +989,6 @@ def fetch_many(sources, limit=None, workers=8):
|
|||||||
got = fetch(s, limit)
|
got = fetch(s, limit)
|
||||||
report.append((s.name, len(got)))
|
report.append((s.name, len(got)))
|
||||||
items.extend(got)
|
items.extend(got)
|
||||||
time.sleep(3.5 if "reddit.com" in s.url else 1.0)
|
delay = 6.5 if "services.nvd.nist.gov" in s.url else (3.5 if "reddit.com" in s.url else 1.0)
|
||||||
|
time.sleep(delay)
|
||||||
return items, report
|
return items, report
|
||||||
@@ -13,8 +13,8 @@ import urllib.parse
|
|||||||
from . import abuse as abuse_mod
|
from . import abuse as abuse_mod
|
||||||
from . import config, sources, source_state, store, taxonomy
|
from . import config, sources, source_state, store, taxonomy
|
||||||
|
|
||||||
BUCKET = ("CASE WHEN published GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*' "
|
BUCKET = ("CASE WHEN length(published)=10 AND published GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' "
|
||||||
"THEN substr(published,1,10) ELSE date(seen,'unixepoch') END")
|
"THEN published ELSE COALESCE(date(published,'localtime'),date(seen,'unixepoch','localtime')) END")
|
||||||
|
|
||||||
|
|
||||||
def _clean(v, n=0):
|
def _clean(v, n=0):
|
||||||
@@ -32,7 +32,7 @@ def _clean(v, n=0):
|
|||||||
|
|
||||||
# ---------- обзор ----------
|
# ---------- обзор ----------
|
||||||
|
|
||||||
BUCKET_SEEN = "date(seen,'unixepoch')"
|
BUCKET_SEEN = "date(seen,'unixepoch','localtime')"
|
||||||
|
|
||||||
|
|
||||||
def timeline(days=30, cat=None, mode="pub"):
|
def timeline(days=30, cat=None, mode="pub"):
|
||||||
@@ -40,6 +40,8 @@ def timeline(days=30, cat=None, mode="pub"):
|
|||||||
|
|
||||||
mode="pub" — по дате публикации (или дате попадания в базу, если публикации нет);
|
mode="pub" — по дате публикации (или дате попадания в базу, если публикации нет);
|
||||||
mode="seen" — строго по времени импорта: «что комбайн принёс за день».
|
mode="seen" — строго по времени импорта: «что комбайн принёс за день».
|
||||||
|
|
||||||
|
last24 — скользящие 24 часа по выбранному mode; непарсируемая дата → seen.
|
||||||
"""
|
"""
|
||||||
days = 30 if days is None else int(days or 0)
|
days = 30 if days is None else int(days or 0)
|
||||||
days = max(1, min(365, days))
|
days = max(1, min(365, days))
|
||||||
@@ -57,6 +59,11 @@ def timeline(days=30, cat=None, mode="pub"):
|
|||||||
for b, n, cve, sev in c.execute(sql, args):
|
for b, n, cve, sev in c.execute(sql, args):
|
||||||
if b:
|
if b:
|
||||||
got[b] = (int(n or 0), int(cve or 0), int(sev or 0))
|
got[b] = (int(n or 0), int(cve or 0), int(sev or 0))
|
||||||
|
clock = "seen" if mode == "seen" else "COALESCE(CAST(strftime('%s',published) AS REAL),seen)"
|
||||||
|
recent_where = where + (" AND " if where else " WHERE ") + clock + " >= ? AND " + clock + " <= ?"
|
||||||
|
now = time.time()
|
||||||
|
last24 = c.execute("SELECT COUNT(*) FROM items" + recent_where,
|
||||||
|
args + [now - 86400, now]).fetchone()[0]
|
||||||
today = _dt.date.today()
|
today = _dt.date.today()
|
||||||
out = []
|
out = []
|
||||||
for i in range(days - 1, -1, -1):
|
for i in range(days - 1, -1, -1):
|
||||||
@@ -65,7 +72,8 @@ def timeline(days=30, cat=None, mode="pub"):
|
|||||||
out.append({"day": d, "n": n, "cve": cve, "sev": sev})
|
out.append({"day": d, "n": n, "cve": cve, "sev": sev})
|
||||||
return {"ok": True, "days": days, "mode": mode, "points": out,
|
return {"ok": True, "days": days, "mode": mode, "points": out,
|
||||||
"total": sum(p["n"] for p in out),
|
"total": sum(p["n"] for p in out),
|
||||||
"last24": (out[-1] or {}).get("n", 0),
|
"last24": int(last24),
|
||||||
|
"today": out[-1]["n"],
|
||||||
"max": max([p["n"] for p in out] or [0])}
|
"max": max([p["n"] for p in out] or [0])}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
20
omni/rank.py
20
omni/rank.py
@@ -2,6 +2,8 @@
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from .dates import parse_published
|
||||||
|
|
||||||
WEIGHT = {"reddit": 1.15, "x": 1.10, "forums": 1.0, "tavily": 1.0, "exa": 1.0,
|
WEIGHT = {"reddit": 1.15, "x": 1.10, "forums": 1.0, "tavily": 1.0, "exa": 1.0,
|
||||||
"firecrawl": 0.9, "you": 0.9, "ddg": 0.75, "cards": 0.35, "treg": 0.8}
|
"firecrawl": 0.9, "you": 0.9, "ddg": 0.75, "cards": 0.35, "treg": 0.8}
|
||||||
|
|
||||||
@@ -24,20 +26,12 @@ KIND_BONUS = {"tweet": 0.15, "thread": 0.25, "tg_post": 0.2, "card": 0.05, "resu
|
|||||||
|
|
||||||
|
|
||||||
def _fresh(published):
|
def _fresh(published):
|
||||||
"""published: unix-строка или ISO. Возвращает 0..1."""
|
"""Unix seconds/milliseconds, ISO or RFC 2822. Return freshness in 0..1."""
|
||||||
if not published:
|
dt = parse_published(published)
|
||||||
return 0.3
|
if dt is None:
|
||||||
try:
|
|
||||||
if published.isdigit():
|
|
||||||
ts = int(published)
|
|
||||||
if ts > 10**12:
|
|
||||||
ts //= 1000
|
|
||||||
else:
|
|
||||||
ts = time.mktime(time.strptime(published[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
||||||
days = max(0.0, (time.time() - ts) / 86400)
|
|
||||||
return max(0.0, 1.0 - days / 30.0)
|
|
||||||
except Exception:
|
|
||||||
return 0.3
|
return 0.3
|
||||||
|
days = max(0.0, (time.time() - dt.timestamp()) / 86400)
|
||||||
|
return max(0.0, 1.0 - days / 30.0)
|
||||||
|
|
||||||
|
|
||||||
def score(it):
|
def score(it):
|
||||||
|
|||||||
@@ -115,6 +115,33 @@ def _names_in_category(cat):
|
|||||||
return [s.name for s in sources.all_sources() if s.category == cat]
|
return [s.name for s in sources.all_sources() if s.category == cat]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_state(status):
|
||||||
|
"""Класс статуса прогона: ok / empty / none / err.
|
||||||
|
|
||||||
|
"ok", "ok 0/10", "ok-304" — успех (startswith); "пусто"/"empty" — источник ответил пусто,
|
||||||
|
это не ошибка; отсутствующий статус (None/"") — отдельный класс none; остальное — err.
|
||||||
|
"""
|
||||||
|
v = " ".join(str(status or "").split()).lower()
|
||||||
|
if not v:
|
||||||
|
return "none"
|
||||||
|
if v.startswith("ok"):
|
||||||
|
return "ok"
|
||||||
|
if v in ("пусто", "empty"):
|
||||||
|
return "empty"
|
||||||
|
return "err"
|
||||||
|
|
||||||
|
|
||||||
|
def _since_days(days):
|
||||||
|
"""?days=N -> граница since (YYYY-MM-DD) для store.browse2; 0 — без фильтра.
|
||||||
|
|
||||||
|
Единая логика для /api/items и экспортов: «сутки/неделя/месяц» значат одно и то же.
|
||||||
|
"""
|
||||||
|
if not days:
|
||||||
|
return None
|
||||||
|
import datetime as _dt
|
||||||
|
return (_dt.date.today() - _dt.timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _run_feed(job_id, category, limit, tags):
|
def _run_feed(job_id, category, limit, tags):
|
||||||
picked = sources.pick(category, None, tags)
|
picked = sources.pick(category, None, tags)
|
||||||
_job(job_id, state="running", total=len(picked), done=0)
|
_job(job_id, state="running", total=len(picked), done=0)
|
||||||
@@ -211,6 +238,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(404, "no ui", "text/plain")
|
return self._send(404, "no ui", "text/plain")
|
||||||
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
|
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
if path == "/api/panels":
|
||||||
|
import json as _json
|
||||||
|
fp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "panels.json")
|
||||||
|
try:
|
||||||
|
with open(fp, "r", encoding="utf-8") as f:
|
||||||
|
raw = _json.load(f)
|
||||||
|
except Exception:
|
||||||
|
raw = []
|
||||||
|
rows = raw if isinstance(raw, list) else (raw.get("panels") or raw.get("items") or [])
|
||||||
|
rows = sorted(rows, key=lambda x: -(x.get("free_count") or x.get("free") or 0))
|
||||||
|
return self._json({"ok": True, "total": len(rows), "panels": rows[:200]})
|
||||||
|
|
||||||
if path == "/api/stats":
|
if path == "/api/stats":
|
||||||
s = store.stats()
|
s = store.stats()
|
||||||
per_source = store.count_by_source()
|
per_source = store.count_by_source()
|
||||||
@@ -264,6 +303,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
import time as _t
|
import time as _t
|
||||||
con = store._con() if hasattr(store, "_con") else None
|
con = store._con() if hasattr(store, "_con") else None
|
||||||
rows = []
|
rows = []
|
||||||
|
by_status = {"ok": 0, "empty": 0, "err": 0, "none": 0}
|
||||||
try:
|
try:
|
||||||
import sqlite3 as _s3
|
import sqlite3 as _s3
|
||||||
from .config import DB as _DB
|
from .config import DB as _DB
|
||||||
@@ -274,12 +314,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
d = dict(r)
|
d = dict(r)
|
||||||
ts = d.get("ts") or 0
|
ts = d.get("ts") or 0
|
||||||
d["when"] = _t.strftime("%Y-%m-%d %H:%M", _t.localtime(ts)) if ts else ""
|
d["when"] = _t.strftime("%Y-%m-%d %H:%M", _t.localtime(ts)) if ts else ""
|
||||||
|
d["state"] = _run_state(d.get("status"))
|
||||||
|
d["ok"] = d["state"] == "ok"
|
||||||
rows.append(d)
|
rows.append(d)
|
||||||
total = db.execute("SELECT count(*) FROM runs").fetchone()[0]
|
total = db.execute("SELECT count(*) FROM runs").fetchone()[0]
|
||||||
|
# статусы считаем по всей таблице: total-ok из среза limit давал ложные «ошибки»
|
||||||
|
sql = ("SELECT COALESCE(SUM(CASE WHEN lower(trim(status)) LIKE 'ok%' THEN 1 ELSE 0 END),0),"
|
||||||
|
" COALESCE(SUM(CASE WHEN lower(trim(status)) IN ('пусто','empty') THEN 1 ELSE 0 END),0),"
|
||||||
|
" COALESCE(SUM(CASE WHEN status IS NULL OR trim(status)='' THEN 1 ELSE 0 END),0)"
|
||||||
|
" FROM runs")
|
||||||
|
n_ok, n_empty, n_none = db.execute(sql).fetchone()
|
||||||
|
by_status = {"ok": int(n_ok or 0), "empty": int(n_empty or 0), "none": int(n_none or 0)}
|
||||||
|
by_status["err"] = max(0, int(total) - by_status["ok"] - by_status["empty"] - by_status["none"])
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._json({"error": "runs: %s" % e})
|
return self._json({"error": "runs: %s" % e})
|
||||||
return self._json({"items": rows, "total": total, "shown": len(rows)})
|
return self._json({"items": rows, "total": total, "shown": len(rows), "by_status": by_status})
|
||||||
|
|
||||||
if path == "/api/abuse":
|
if path == "/api/abuse":
|
||||||
from . import abuse as _ab
|
from . import abuse as _ab
|
||||||
@@ -355,13 +405,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
typ = (q.get("type") or ["all"])[0]
|
typ = (q.get("type") or ["all"])[0]
|
||||||
src = (q.get("src") or [""])[0].strip() or None
|
src = (q.get("src") or [""])[0].strip() or None
|
||||||
days = _intv(q, "days", 0, 0, 3650)
|
days = _intv(q, "days", 0, 0, 3650)
|
||||||
since = None
|
since = _since_days(days)
|
||||||
if days:
|
|
||||||
import datetime as _dt
|
|
||||||
since = (_dt.date.today() - _dt.timedelta(days=days)).isoformat()
|
|
||||||
rows, total = store.browse2(q=query, names=_names_in_category(cat), limit=limit, offset=offset,
|
rows, total = store.browse2(q=query, names=_names_in_category(cat), limit=limit, offset=offset,
|
||||||
sort=sort, priced=priced, typ=typ, since=since, src=src)
|
sort=sort, priced=priced, typ=typ, since=since, src=src)
|
||||||
|
from . import abuse as _ab
|
||||||
for _r in rows:
|
for _r in rows:
|
||||||
|
# find нужен itemRow/findChips: browse2 сам признаки абуза не считает
|
||||||
|
_r["find"] = _ab.extract(" ".join((_r.get("title") or "", _r.get("text") or "")))
|
||||||
_r["title"] = _clean_txt(_r.get("title"), 180)
|
_r["title"] = _clean_txt(_r.get("title"), 180)
|
||||||
_r["text"] = _clean_txt(_r.get("text"), 260)
|
_r["text"] = _clean_txt(_r.get("text"), 260)
|
||||||
_r["published"] = _fmt_date(_r.get("published"))
|
_r["published"] = _fmt_date(_r.get("published"))
|
||||||
@@ -374,8 +424,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on")
|
priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on")
|
||||||
sort = (q.get("sort") or ["score"])[0]
|
sort = (q.get("sort") or ["score"])[0]
|
||||||
typ = (q.get("type") or ["all"])[0]
|
typ = (q.get("type") or ["all"])[0]
|
||||||
rows, total = store.browse2(q=query, names=_names_in_category(cat), limit=5000, offset=0,
|
since = _since_days(_intv(q, "days", 0, 0, 3650))
|
||||||
sort=sort, priced=priced, typ=typ)
|
limit = _intv(q, "limit", 1000, 1, 5000)
|
||||||
|
src = (q.get("src") or [""])[0].strip() or None
|
||||||
|
rows, total = store.browse2(q=query, names=_names_in_category(cat), limit=limit, offset=0,
|
||||||
|
sort=sort, priced=priced, typ=typ, since=since, src=src)
|
||||||
import io as _io
|
import io as _io
|
||||||
buf = _io.StringIO()
|
buf = _io.StringIO()
|
||||||
w = _csv.writer(buf, delimiter=";")
|
w = _csv.writer(buf, delimiter=";")
|
||||||
@@ -445,12 +498,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path == "/api/export.json":
|
if path == "/api/export.json":
|
||||||
cat = (q.get("category") or [None])[0]
|
cat = (q.get("category") or [None])[0]
|
||||||
|
priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on")
|
||||||
|
since = _since_days(_intv(q, "days", 0, 0, 3650))
|
||||||
|
src = (q.get("src") or [""])[0].strip() or None
|
||||||
rows, total = store.browse2(q=(q.get("q") or [""])[0].strip(),
|
rows, total = store.browse2(q=(q.get("q") or [""])[0].strip(),
|
||||||
names=_names_in_category(cat),
|
names=_names_in_category(cat),
|
||||||
limit=_intv(q, "limit", 500, 1, 5000),
|
limit=_intv(q, "limit", 1000, 1, 5000),
|
||||||
sort=(q.get("sort") or ["score"])[0],
|
sort=(q.get("sort") or ["score"])[0],
|
||||||
typ=(q.get("type") or ["all"])[0])
|
typ=(q.get("type") or ["all"])[0],
|
||||||
data = json.dumps({"total": total, "items": rows}, ensure_ascii=False, indent=1).encode("utf-8")
|
priced=priced, since=since, src=src)
|
||||||
|
data = json.dumps({"total": total, "count": len(rows), "items": rows},
|
||||||
|
ensure_ascii=False, indent=1).encode("utf-8")
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
self.send_header("Content-Disposition", 'attachment; filename="omni.json"')
|
self.send_header("Content-Disposition", 'attachment; filename="omni.json"')
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .sources_tg_abuse import TG_ABUSE
|
|||||||
from .sources_ext4 import EXTRA5
|
from .sources_ext4 import EXTRA5
|
||||||
from .sources_ext5 import EXTRA6
|
from .sources_ext5 import EXTRA6
|
||||||
from .sources_fresh import fresh as _fresh_sources
|
from .sources_fresh import fresh as _fresh_sources
|
||||||
|
from .sources_quality import quality_sources as _quality_sources
|
||||||
from .sources_x import X_SOURCES
|
from .sources_x import X_SOURCES
|
||||||
|
|
||||||
|
|
||||||
@@ -298,7 +299,7 @@ def _gen_sources():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def all_sources():
|
def all_sources(include_fresh=True):
|
||||||
"""Реестр + пользовательские источники из панели, без дублей по url и по имени.
|
"""Реестр + пользовательские источники из панели, без дублей по url и по имени.
|
||||||
|
|
||||||
Дедуп по двум ключам обязателен:
|
Дедуп по двум ключам обязателен:
|
||||||
@@ -306,8 +307,10 @@ def all_sources():
|
|||||||
* name — имя источника является первичным ключом в source_flags/runs, поэтому
|
* name — имя источника является первичным ключом в source_flags/runs, поэтому
|
||||||
зеркала одного и того же ресурса (rss.arxiv vs export.arxiv, old.reddit vs
|
зеркала одного и того же ресурса (rss.arxiv vs export.arxiv, old.reddit vs
|
||||||
www.reddit) обязаны схлопываться, иначе переключение/состояние «плывут».
|
www.reddit) обязаны схлопываться, иначе переключение/состояние «плывут».
|
||||||
Побеждает первое вхождение; порядок: реестр → сгенерированные → свежая волна →
|
Побеждает первое вхождение; порядок: реестр → сгенерированные → свежая и quality-волны →
|
||||||
пользовательские → abuse-radar → panels.json.
|
пользовательские → abuse-radar → panels.json.
|
||||||
|
|
||||||
|
include_fresh=False исключает текущий generated fresh-модуль при регенерации.
|
||||||
"""
|
"""
|
||||||
from . import source_state
|
from . import source_state
|
||||||
extra = []
|
extra = []
|
||||||
@@ -318,7 +321,8 @@ def all_sources():
|
|||||||
("name", "category", "kind", "url", "adapter", "tags", "limit", "detail", "extra")}
|
("name", "category", "kind", "url", "adapter", "tags", "limit", "detail", "extra")}
|
||||||
extra.append(Source(**d))
|
extra.append(Source(**d))
|
||||||
seen_url, seen_name, out = set(), set(), []
|
seen_url, seen_name, out = set(), set(), []
|
||||||
for s in SOURCES + _gen_sources() + _fresh_sources(S) + extra + radar_sources() + panel_sources():
|
fresh = _fresh_sources(S) if include_fresh else []
|
||||||
|
for s in SOURCES + _gen_sources() + fresh + _quality_sources(S) + extra + radar_sources() + panel_sources():
|
||||||
key = (s.url or "").rstrip("/").lower()
|
key = (s.url or "").rstrip("/").lower()
|
||||||
name = (s.name or "").strip().lower()
|
name = (s.name or "").strip().lower()
|
||||||
if (key and key in seen_url) or (name and name in seen_name):
|
if (key and key in seen_url) or (name and name in seen_name):
|
||||||
|
|||||||
@@ -228,8 +228,9 @@ EXTRA = [
|
|||||||
# ---------------- security ----------------
|
# ---------------- security ----------------
|
||||||
S("cisa_kev", "security", "json", "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
S("cisa_kev", "security", "json", "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
||||||
"cisa_kev", ("cve", "kev")),
|
"cisa_kev", ("cve", "kev")),
|
||||||
S("nvd_recent", "security", "json",
|
# nvd_recent намеренно НЕ здесь: живой nvd_recent с пересчитываемым окном
|
||||||
"https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=25&noRejected", "nvd", ("cve",)),
|
# lastMod отдаёт sources_fresh.py (tools/probe_fresh.py --emit). Статическая
|
||||||
|
# запись без окна здесь побеждала по first-wins и глушила свежую.
|
||||||
S("github_advisories", "security", "rss", "https://github.com/advisories.atom", "rss", ("cve", "github")),
|
S("github_advisories", "security", "rss", "https://github.com/advisories.atom", "rss", ("cve", "github")),
|
||||||
S("snyk_blog", "security", "rss", "https://snyk.io/blog/feed/", "rss", ("appsec",)),
|
S("snyk_blog", "security", "rss", "https://snyk.io/blog/feed/", "rss", ("appsec",)),
|
||||||
S("trailofbits_blog", "security", "rss", "https://blog.trailofbits.com/feed/", "rss", ("research",)),
|
S("trailofbits_blog", "security", "rss", "https://blog.trailofbits.com/feed/", "rss", ("research",)),
|
||||||
|
|||||||
@@ -21,58 +21,58 @@ def fresh(S):
|
|||||||
return [
|
return [
|
||||||
S("darkwebinformer", "abuse", "rss", "https://darkwebinformer.com/feed", "rss", ("leak", "news")),
|
S("darkwebinformer", "abuse", "rss", "https://darkwebinformer.com/feed", "rss", ("leak", "news")),
|
||||||
S("databreaches_net", "abuse", "rss", "https://databreaches.net/feed/", "rss", ("breach", "news")),
|
S("databreaches_net", "abuse", "rss", "https://databreaches.net/feed/", "rss", ("breach", "news")),
|
||||||
S("hackread_breaches", "abuse", "rss", "https://hackread.com/category/data-breaches/feed/", "rss", ("breach")),
|
S("hackread_breaches", "abuse", "rss", "https://hackread.com/category/data-breaches/feed/", "rss", ("breach",)),
|
||||||
S("redpacketsecurity", "abuse", "rss", "https://www.redpacketsecurity.com/feed/", "rss", ("cve", "leak")),
|
S("redpacketsecurity", "abuse", "rss", "https://www.redpacketsecurity.com/feed/", "rss", ("cve", "leak")),
|
||||||
S("tg_vulns", "abuse", "html", "https://t.me/s/vulns", "tg_web", ("tg", "leak", "ru")),
|
S("tg_vulns", "abuse", "html", "https://t.me/s/vulns", "tg_web", ("tg", "leak", "ru")),
|
||||||
S("habr_ai", "community", "rss", "https://habr.com/ru/rss/hub/artificial_intelligence/", "rss", ("ru", "ai")),
|
S("habr_ai", "community", "rss", "https://habr.com/ru/rss/hub/artificial_intelligence/", "rss", ("ru", "ai")),
|
||||||
S("infosec_writeups", "community", "rss", "https://infosecwriteups.com/feed", "rss", ("writeup")),
|
S("infosec_writeups", "community", "rss", "https://infosecwriteups.com/feed", "rss", ("writeup",)),
|
||||||
S("tg_ai_machinelearning", "community", "html", "https://t.me/s/ai_machinelearning_big_data", "tg_web", ("tg", "ai", "ru")),
|
S("tg_ai_machinelearning", "community", "html", "https://t.me/s/ai_machinelearning_big_data", "tg_web", ("tg", "ai", "ru")),
|
||||||
S("tg_denissexy", "community", "html", "https://t.me/s/denissexy", "tg_web", ("tg", "ai", "ru")),
|
S("tg_denissexy", "community", "html", "https://t.me/s/denissexy", "tg_web", ("tg", "ai", "ru")),
|
||||||
S("reddit_blueteamsec", "forum", "json", "https://www.reddit.com/r/blueteamsec/.json?limit=25", "reddit_listing", ("reddit", "blue-team")),
|
S("reddit_blueteamsec", "forum", "json", "https://www.reddit.com/r/blueteamsec/.json?limit=25", "reddit_listing", ("reddit", "blue-team")),
|
||||||
S("reddit_exploitdev", "forum", "json", "https://www.reddit.com/r/ExploitDev/.json?limit=25", "reddit_listing", ("reddit", "exploit")),
|
S("reddit_exploitdev", "forum", "json", "https://www.reddit.com/r/ExploitDev/.json?limit=25", "reddit_listing", ("reddit", "exploit")),
|
||||||
S("reddit_purpleteamsec", "forum", "json", "https://www.reddit.com/r/purpleteamsec/.json?limit=25", "reddit_listing", ("reddit", "purple-team")),
|
S("reddit_purpleteamsec", "forum", "json", "https://www.reddit.com/r/purpleteamsec/.json?limit=25", "reddit_listing", ("reddit", "purple-team")),
|
||||||
S("t00ls", "forum", "html", "https://www.t00ls.com/", "html_listing", ("cn", "forum")),
|
S("t00ls", "forum", "html", "https://www.t00ls.com/", "html_listing", ("cn", "forum")),
|
||||||
S("acunetix_blog", "security", "rss", "https://www.acunetix.com/blog/feed/", "rss", ("appsec")),
|
S("acunetix_blog", "security", "rss", "https://www.acunetix.com/blog/feed/", "rss", ("appsec",)),
|
||||||
S("anquanke_news", "security", "html", "https://www.anquanke.com/news", "html_listing", ("cn", "news")),
|
S("anquanke_news", "security", "html", "https://www.anquanke.com/news", "html_listing", ("cn", "news")),
|
||||||
S("arch_linux_asa", "security", "rss", "https://security.archlinux.org/advisory/feed.atom", "rss", ("cve", "linux")),
|
S("arch_linux_asa", "security", "rss", "https://security.archlinux.org/advisory/feed.atom", "rss", ("cve", "linux")),
|
||||||
S("arcticwolf_blog", "security", "rss", "https://arcticwolf.com/feed/", "rss", ("cti")),
|
S("arcticwolf_blog", "security", "rss", "https://arcticwolf.com/feed/", "rss", ("cti",)),
|
||||||
S("arstechnica_security", "security", "rss", "https://arstechnica.com/security/feed/", "rss", ("news")),
|
S("arstechnica_security", "security", "rss", "https://arstechnica.com/security/feed/", "rss", ("news",)),
|
||||||
S("bitdefender_labs", "security", "rss", "https://www.bitdefender.com/blog/api/rss/labs/", "rss", ("research")),
|
S("bitdefender_labs", "security", "rss", "https://www.bitdefender.com/blog/api/rss/labs/", "rss", ("research",)),
|
||||||
S("chrome_releases_security", "security", "rss", "https://chromereleases.googleblog.com/feeds/posts/default", "rss", ("cve", "browser")),
|
S("chrome_releases_security", "security", "rss", "https://chromereleases.googleblog.com/feeds/posts/default", "rss", ("cve", "browser")),
|
||||||
S("cisco_security_blog", "security", "rss", "https://blogs.cisco.com/security/feed", "rss", ("cti", "research")),
|
S("cisco_security_blog", "security", "rss", "https://blogs.cisco.com/security/feed", "rss", ("cti", "research")),
|
||||||
S("citizenlab", "security", "rss", "https://citizenlab.ca/feed/", "rss", ("apt", "research")),
|
S("citizenlab", "security", "rss", "https://citizenlab.ca/feed/", "rss", ("apt", "research")),
|
||||||
S("csoonline", "security", "rss", "https://www.csoonline.com/feed/", "rss", ("news")),
|
S("csoonline", "security", "rss", "https://www.csoonline.com/feed/", "rss", ("news",)),
|
||||||
S("cybersecuritydive", "security", "rss", "https://www.cybersecuritydive.com/feeds/news/", "rss", ("news")),
|
S("cybersecuritydive", "security", "rss", "https://www.cybersecuritydive.com/feeds/news/", "rss", ("news",)),
|
||||||
S("github_advisories_api", "security", "json", "https://api.github.com/advisories?per_page=30&sort=published&direction=desc", "ghsa", ("cve", "ghsa")),
|
S("github_advisories_api", "security", "json", "https://api.github.com/advisories?per_page=30&sort=published&direction=desc", "ghsa", ("cve", "ghsa")),
|
||||||
S("habr_infosecurity", "security", "rss", "https://habr.com/ru/rss/hub/infosecurity/", "rss", ("ru", "research")),
|
S("habr_infosecurity", "security", "rss", "https://habr.com/ru/rss/hub/infosecurity/", "rss", ("ru", "research")),
|
||||||
S("habr_news", "security", "rss", "https://habr.com/ru/rss/news/", "rss", ("ru", "news")),
|
S("habr_news", "security", "rss", "https://habr.com/ru/rss/news/", "rss", ("ru", "news")),
|
||||||
S("hackmag", "security", "rss", "https://hackmag.com/rss", "rss", ("ru", "research")),
|
S("hackmag", "security", "rss", "https://hackmag.com/rss", "rss", ("ru", "research")),
|
||||||
S("helpnetsecurity", "security", "rss", "https://www.helpnetsecurity.com/feed/", "rss", ("news")),
|
S("helpnetsecurity", "security", "rss", "https://www.helpnetsecurity.com/feed/", "rss", ("news",)),
|
||||||
S("industrialcyber", "security", "rss", "https://industrialcyber.co/feed/", "rss", ("ics", "news")),
|
S("industrialcyber", "security", "rss", "https://industrialcyber.co/feed/", "rss", ("ics", "news")),
|
||||||
S("infosecurity_magazine", "security", "rss", "https://www.infosecurity-magazine.com/rss/news/", "rss", ("news")),
|
S("infosecurity_magazine", "security", "rss", "https://www.infosecurity-magazine.com/rss/news/", "rss", ("news",)),
|
||||||
S("malwaretech_blog", "security", "rss", "https://www.malwaretech.com/feed", "rss", ("research")),
|
S("malwaretech_blog", "security", "rss", "https://www.malwaretech.com/feed", "rss", ("research",)),
|
||||||
S("microsoft_security_blog", "security", "rss", "https://www.microsoft.com/en-us/security/blog/feed/", "rss", ("research")),
|
S("microsoft_security_blog", "security", "rss", "https://www.microsoft.com/en-us/security/blog/feed/", "rss", ("research",)),
|
||||||
S("mozilla_advisories", "security", "html", "https://www.mozilla.org/en-US/security/advisories/", "html_listing", ("cve", "browser")),
|
S("mozilla_advisories", "security", "html", "https://www.mozilla.org/en-US/security/advisories/", "html_listing", ("cve", "browser")),
|
||||||
S("ncsc_uk", "security", "rss", "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml", "rss", ("gov", "cve")),
|
S("ncsc_uk", "security", "rss", "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml", "rss", ("gov", "cve")),
|
||||||
S("nvd_exploited_kw", "security", "json", "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=25&keywordSearch=exploited%20in%20the%20wild", "nvd", ("cve", "exploited")),
|
S("nvd_exploited_kw", "security", "json", "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=25&keywordSearch=exploited%20in%20the%20wild", "nvd", ("cve", "exploited")),
|
||||||
S("nvd_recent", "security", "json", _nvd_week(), "nvd", ("cve", "nvd")),
|
S("nvd_recent", "security", "json", _nvd_week(), "nvd", ("cve", "nvd")),
|
||||||
S("paloalto_blog", "security", "rss", "https://www.paloaltonetworks.com/blog/feed/", "rss", ("research")),
|
S("paloalto_blog", "security", "rss", "https://www.paloaltonetworks.com/blog/feed/", "rss", ("research",)),
|
||||||
S("seclists_fulldisclosure", "security", "rss", "https://seclists.org/rss/fulldisclosure.rss", "rss", ("mailing", "exploit")),
|
S("seclists_fulldisclosure", "security", "rss", "https://seclists.org/rss/fulldisclosure.rss", "rss", ("mailing", "exploit")),
|
||||||
S("seclists_nmap", "security", "rss", "https://seclists.org/rss/nmap-dev.rss", "rss", ("mailing")),
|
S("seclists_nmap", "security", "rss", "https://seclists.org/rss/nmap-dev.rss", "rss", ("mailing",)),
|
||||||
S("secrss", "security", "html", "https://www.secrss.com/", "html_listing", ("cn", "news")),
|
S("secrss", "security", "html", "https://www.secrss.com/", "html_listing", ("cn", "news")),
|
||||||
S("sekoia_blog", "security", "rss", "https://blog.sekoia.io/feed/", "rss", ("research")),
|
S("sekoia_blog", "security", "rss", "https://blog.sekoia.io/feed/", "rss", ("research",)),
|
||||||
S("sentinelone_blog", "security", "rss", "https://www.sentinelone.com/blog/feed/", "rss", ("research")),
|
S("sentinelone_blog", "security", "rss", "https://www.sentinelone.com/blog/feed/", "rss", ("research",)),
|
||||||
S("sihaoyou_feed", "security", "rss", "https://www.4hou.com/feed", "rss", ("cn", "research")),
|
S("sihaoyou_feed", "security", "rss", "https://www.4hou.com/feed", "rss", ("cn", "research")),
|
||||||
S("socradar_blog", "security", "rss", "https://socradar.io/feed/", "rss", ("cti")),
|
S("socradar_blog", "security", "rss", "https://socradar.io/feed/", "rss", ("cti",)),
|
||||||
S("sonatype_blog", "security", "rss", "https://www.sonatype.com/blog/rss.xml", "rss", ("supply-chain")),
|
S("sonatype_blog", "security", "rss", "https://www.sonatype.com/blog/rss.xml", "rss", ("supply-chain",)),
|
||||||
S("sploitus_rss", "security", "rss", "https://sploitus.com/rss", "rss", ("exploit")),
|
S("sploitus_rss", "security", "rss", "https://sploitus.com/rss", "rss", ("exploit",)),
|
||||||
S("tg_bugbounty", "security", "html", "https://t.me/s/bugbounty", "tg_web", ("tg", "bugbounty")),
|
S("tg_bugbounty", "security", "html", "https://t.me/s/bugbounty", "tg_web", ("tg", "bugbounty")),
|
||||||
S("tg_hackernews_ru", "security", "html", "https://t.me/s/hackernews_ru", "tg_web", ("tg", "ru")),
|
S("tg_hackernews_ru", "security", "html", "https://t.me/s/hackernews_ru", "tg_web", ("tg", "ru")),
|
||||||
S("tg_kalilinux", "security", "html", "https://t.me/s/kalilinux", "tg_web", ("tg", "tools")),
|
S("tg_kalilinux", "security", "html", "https://t.me/s/kalilinux", "tg_web", ("tg", "tools")),
|
||||||
S("theregister_security", "security", "rss", "https://www.theregister.com/security/headlines.atom", "rss", ("news")),
|
S("theregister_security", "security", "rss", "https://www.theregister.com/security/headlines.atom", "rss", ("news",)),
|
||||||
S("ubuntu_usn", "security", "rss", "https://ubuntu.com/security/notices/rss.xml", "rss", ("cve", "linux")),
|
S("ubuntu_usn", "security", "rss", "https://ubuntu.com/security/notices/rss.xml", "rss", ("cve", "linux")),
|
||||||
S("virustotal_blog", "security", "rss", "https://blog.virustotal.com/feeds/posts/default", "rss", ("malware")),
|
S("virustotal_blog", "security", "rss", "https://blog.virustotal.com/feeds/posts/default", "rss", ("malware",)),
|
||||||
S("vuldb_recent", "security", "rss", "https://vuldb.com/?rss.recent", "rss", ("cve")),
|
S("vuldb_recent", "security", "rss", "https://vuldb.com/?rss.recent", "rss", ("cve",)),
|
||||||
S("xakep_ru", "security", "rss", "https://xakep.ru/feed/", "rss", ("ru", "news")),
|
S("xakep_ru", "security", "rss", "https://xakep.ru/feed/", "rss", ("ru", "news")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
174
omni/sources_quality.py
Normal file
174
omni/sources_quality.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Quality-волна источников: официальные release-фиды проектов и security-адвайзори вендоров.
|
||||||
|
|
||||||
|
Зачем отдельный модуль: основной реестр (sources.py) и волны ext/fresh уже покрывают
|
||||||
|
топовые репозитории, но провал в (1) runtime/инференсе второго эшелона и (2) security-
|
||||||
|
продуктах, где релиз = факт CVE/патча. Здесь только официальные Atom/RSS-фиды
|
||||||
|
(github.com/<repo>/releases.atom, linux-дистрибутивные и вендорные advisory-фиды),
|
||||||
|
каждая запись проверена живым прогоном.
|
||||||
|
|
||||||
|
Модуль статический и НЕ импортирует .sources: sources.py уже импортирует волны,
|
||||||
|
обратный импорт дал бы цикл. Дедуп по url/name делает all_sources().
|
||||||
|
|
||||||
|
Категории — только существующие в реестре, без новой схемы:
|
||||||
|
models — релизы AI-runtime/инференса (diffusers, TensorRT-LLM, onnxruntime, ...);
|
||||||
|
community — SDK/агентные фреймворки (MCP python-sdk, openai-agents, langgraph);
|
||||||
|
security — продукты, где релиз = патч/CVE, контейнеры и advisory-фиды дистрибутивов;
|
||||||
|
company — официальные технические блоги вендоров (NVIDIA, HashiCorp).
|
||||||
|
Признак «это релиз» остаётся тегом `release` у всех 37 записей, kind=atom/rss.
|
||||||
|
|
||||||
|
Проверка (tools/probe_quality_sources.py, отчёт data/quality_sources_probe.json):
|
||||||
|
* каждое имя и URL сверены с полным реестром all_sources() (≈3.5k источников) — ни одного
|
||||||
|
дубля по имени, нормализованному URL и зеркалу репозитория (github.com/<o>/<r>);
|
||||||
|
* первые записи не пересекаются по URL/заголовкам с другими кандидатами (>=50%/60%);
|
||||||
|
* >= 2 записей с валидным http(s)-URL, без challenge/error-заголовков;
|
||||||
|
* штатный адаптер feeds.fetch (rss, Atom поддерживается) отдаёт 10-20 записей.
|
||||||
|
|
||||||
|
Осознанно НЕ включено, хотя фиды живые (шум, не качество): pytorch/pytorch и
|
||||||
|
pydantic/pydantic-ai (в releases.atom CI-теги viable/strict, clai2-bleeding),
|
||||||
|
microsoft/DeepSpeed (nightly-last-green), keycloak отдаёт nightly-теги (оставлен —
|
||||||
|
версии рядом), portainer/immich/netbox/nextcloud/gitea/elastic/opensearch/minio
|
||||||
|
(низкий сигнал на релиз, не security), php.net (фид отдаёт 1 запись), а также
|
||||||
|
зеркала и медиа-перепечатки (Cisco Talos, MSRC blog, PortSwigger, Cloudflare,
|
||||||
|
Ubuntu USN, Rust/Go/Node/Python blogs) — они уже в реестре или дублируют его.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SPECS = [
|
||||||
|
# ---------- AI runtime / inference (официальные релизы) ----------
|
||||||
|
{"name": "gh_rel_diffusers", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/diffusers/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "image"), "limit": 10},
|
||||||
|
{"name": "gh_rel_peft", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/peft/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "finetune"), "limit": 10},
|
||||||
|
{"name": "gh_rel_accelerate", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/accelerate/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "training"), "limit": 10},
|
||||||
|
{"name": "gh_rel_textgenerationinference", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/text-generation-inference/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "inference"), "limit": 10},
|
||||||
|
{"name": "gh_rel_tokenizers", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/tokenizers/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "nlp"), "limit": 10},
|
||||||
|
{"name": "gh_rel_candle", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/huggingface/candle/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "rust"), "limit": 10},
|
||||||
|
{"name": "gh_rel_tensorrtllm", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/NVIDIA/TensorRT-LLM/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "nvidia", "inference"), "limit": 10},
|
||||||
|
{"name": "gh_rel_nvidiacontainertoolkit", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/NVIDIA/nvidia-container-toolkit/releases.atom",
|
||||||
|
"tags": ("release", "github", "gpu", "container"), "limit": 10},
|
||||||
|
{"name": "gh_rel_flashinfer", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/flashinfer-ai/flashinfer/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "inference"), "limit": 10},
|
||||||
|
{"name": "gh_rel_onnxruntime", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/microsoft/onnxruntime/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "inference"), "limit": 10},
|
||||||
|
{"name": "gh_rel_mlx", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/ml-explore/mlx/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "apple"), "limit": 10},
|
||||||
|
{"name": "gh_rel_whispercpp", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/ggml-org/whisper.cpp/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "audio"), "limit": 10},
|
||||||
|
{"name": "gh_rel_exo", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/exo-explore/exo/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "local"), "limit": 10},
|
||||||
|
{"name": "gh_rel_vllmascend", "category": "models", "kind": "atom",
|
||||||
|
"url": "https://github.com/vllm-project/vllm-ascend/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "inference"), "limit": 10},
|
||||||
|
|
||||||
|
# ---------- AI-агенты / SDK ----------
|
||||||
|
{"name": "gh_rel_mcp_pythonsdk", "category": "community", "kind": "atom",
|
||||||
|
"url": "https://github.com/modelcontextprotocol/python-sdk/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "mcp"), "limit": 10},
|
||||||
|
{"name": "gh_rel_openaiagentspython", "category": "community", "kind": "atom",
|
||||||
|
"url": "https://github.com/openai/openai-agents-python/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "agent"), "limit": 10},
|
||||||
|
{"name": "gh_rel_langgraph", "category": "community", "kind": "atom",
|
||||||
|
"url": "https://github.com/langchain-ai/langgraph/releases.atom",
|
||||||
|
"tags": ("release", "github", "ai", "agent"), "limit": 10},
|
||||||
|
|
||||||
|
# ---------- security-продукты: релиз = патч/CVE ----------
|
||||||
|
{"name": "gh_rel_caddy", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/caddyserver/caddy/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "web"), "limit": 10},
|
||||||
|
{"name": "gh_rel_keycloak", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/keycloak/keycloak/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "iam"), "limit": 10},
|
||||||
|
{"name": "gh_rel_grafana", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/grafana/grafana/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "monitoring"), "limit": 10},
|
||||||
|
{"name": "gh_rel_tailscale", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/tailscale/tailscale/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "vpn"), "limit": 10},
|
||||||
|
{"name": "gh_rel_envoy", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/envoyproxy/envoy/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "proxy"), "limit": 10},
|
||||||
|
{"name": "gh_rel_vault", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/hashicorp/vault/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "secrets"), "limit": 10},
|
||||||
|
{"name": "gh_rel_cosign", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/sigstore/cosign/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "supply-chain"), "limit": 10},
|
||||||
|
{"name": "gh_rel_trivy", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/aquasecurity/trivy/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "scanner"), "limit": 10},
|
||||||
|
{"name": "gh_rel_grype", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/anchore/grype/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "scanner"), "limit": 10},
|
||||||
|
{"name": "gh_rel_nuclei", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/projectdiscovery/nuclei/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "scanner"), "limit": 10},
|
||||||
|
{"name": "gh_rel_zaproxy", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/zaproxy/zaproxy/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "web"), "limit": 10},
|
||||||
|
{"name": "gh_rel_openssl", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/openssl/openssl/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "crypto"), "limit": 10},
|
||||||
|
{"name": "gh_rel_curl", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/curl/curl/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "http"), "limit": 10},
|
||||||
|
|
||||||
|
# ---------- контейнеры/оркестрация: CVE-релевантные релизы ----------
|
||||||
|
{"name": "gh_rel_runc", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/opencontainers/runc/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "container"), "limit": 10},
|
||||||
|
{"name": "gh_rel_containerd", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/containerd/containerd/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "container"), "limit": 10},
|
||||||
|
{"name": "gh_rel_kubernetes", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://github.com/kubernetes/kubernetes/releases.atom",
|
||||||
|
"tags": ("security", "release", "github", "kubernetes"), "limit": 10},
|
||||||
|
|
||||||
|
# ---------- официальные advisory/technical фиды вендоров ----------
|
||||||
|
{"name": "amazon_linux_alas", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://alas.aws.amazon.com/alas.rss",
|
||||||
|
"tags": ("security", "release", "advisory", "linux", "aws"), "limit": 20},
|
||||||
|
{"name": "aws_security_bulletins", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://aws.amazon.com/security/security-bulletins/rss/feed/",
|
||||||
|
"tags": ("security", "release", "advisory", "aws"), "limit": 20},
|
||||||
|
{"name": "nvidia_dev_blog", "category": "company", "kind": "rss",
|
||||||
|
"url": "https://developer.nvidia.com/blog/feed/",
|
||||||
|
"tags": ("release", "vendor", "nvidia", "ai"), "limit": 15},
|
||||||
|
{"name": "hashicorp_blog", "category": "company", "kind": "rss",
|
||||||
|
"url": "https://www.hashicorp.com/blog/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "hashicorp"), "limit": 15},
|
||||||
|
]
|
||||||
|
|
||||||
|
NAMES = tuple(d["name"] for d in SPECS)
|
||||||
|
|
||||||
|
|
||||||
|
def quality_sources(S, specs=None):
|
||||||
|
"""Собрать Source-объекты quality-волны через переданный конструктор S.
|
||||||
|
|
||||||
|
S передаётся снаружи (обычно sources.Source), чтобы модуль не импортировал .sources —
|
||||||
|
это дал бы циклический импорт. Вызов позиционный, как в sources_fresh.fresh: так
|
||||||
|
работает и настоящий Source, и лёгкая заглушка `class _Fake: __init__(self, *a, **k)`,
|
||||||
|
которой tools/run_fresh.py --wave quality собирает имена.
|
||||||
|
Теги всегда кортежи; adapter по умолчанию rss — он же разбирает Atom.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for d in (SPECS if specs is None else specs):
|
||||||
|
out.append(S(d["name"], d["category"], d["kind"], d["url"],
|
||||||
|
d.get("adapter", "rss"), tuple(d.get("tags") or ()), d.get("limit", 10)))
|
||||||
|
return out
|
||||||
@@ -257,7 +257,7 @@ def stats():
|
|||||||
by_kind = c.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY 2 DESC").fetchall()
|
by_kind = c.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY 2 DESC").fetchall()
|
||||||
priced = c.execute("SELECT COUNT(*) FROM items WHERE price IS NOT NULL").fetchone()[0]
|
priced = c.execute("SELECT COUNT(*) FROM items WHERE price IS NOT NULL").fetchone()[0]
|
||||||
runs = c.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
runs = c.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||||
ok_runs = c.execute("SELECT COUNT(*) FROM runs WHERE status='ok'").fetchone()[0]
|
ok_runs = c.execute("SELECT COUNT(*) FROM runs WHERE lower(trim(status)) LIKE 'ok%'").fetchone()[0]
|
||||||
return {"items": total, "priced": priced, "runs": runs, "runs_ok": ok_runs,
|
return {"items": total, "priced": priced, "runs": runs, "runs_ok": ok_runs,
|
||||||
"by_provider": by_prov, "by_kind": by_kind}
|
"by_provider": by_prov, "by_kind": by_kind}
|
||||||
|
|
||||||
|
|||||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""All tests use disposable storage, including module-import side effects."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
# This runs before test collection: abuse builds source metadata at import time.
|
||||||
|
_SESSION_DATA = tempfile.TemporaryDirectory(prefix="omni-pytest-")
|
||||||
|
os.environ["OMNI_DATA"] = _SESSION_DATA.name
|
||||||
|
os.environ["OMNI_DB"] = str(Path(_SESSION_DATA.name) / "collection.sqlite3")
|
||||||
|
|
||||||
|
from omni import config
|
||||||
|
|
||||||
|
config.DATA = Path(_SESSION_DATA.name)
|
||||||
|
config.DB = config.DATA / "collection.sqlite3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def sandbox_data(tmp_path, monkeypatch):
|
||||||
|
"""Give every test its own database, not only the new panel tests."""
|
||||||
|
monkeypatch.setattr(config, "DATA", tmp_path)
|
||||||
|
monkeypatch.setattr(config, "DB", tmp_path / "test.sqlite3")
|
||||||
|
monkeypatch.setenv("OMNI_DATA", str(tmp_path))
|
||||||
|
monkeypatch.setenv("OMNI_DB", str(config.DB))
|
||||||
|
from omni import abuse, panel_api
|
||||||
|
monkeypatch.setattr(abuse, "DB", config.DB)
|
||||||
|
monkeypatch.setattr(panel_api, "WATCH", tmp_path / "watch.json")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_unconfigure(config):
|
||||||
|
_SESSION_DATA.cleanup()
|
||||||
103
tests/test_ingest_quality.py
Normal file
103
tests/test_ingest_quality.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Offline regressions for feed metadata, date ranking and challenge filtering."""
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from omni import feeds, rank
|
||||||
|
from omni.dates import parse_published
|
||||||
|
from omni.model import Item
|
||||||
|
from omni.sources import S
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [
|
||||||
|
"2026-10-20T12:00:00Z", "2026-10-20T15:00:00+03:00",
|
||||||
|
"Tue, 20 Oct 2026 12:00:00 GMT", "2026-10-20T12:00:00",
|
||||||
|
"1792497600", "1792497600000", 1792497600,
|
||||||
|
])
|
||||||
|
def test_publication_formats_have_equal_freshness(value, monkeypatch):
|
||||||
|
stamp = datetime(2026, 10, 20, 12, tzinfo=timezone.utc).timestamp()
|
||||||
|
parsed = parse_published(value)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed.timestamp() == stamp
|
||||||
|
monkeypatch.setattr(rank.time, "time", lambda: stamp + 86400)
|
||||||
|
assert rank._fresh(value) == pytest.approx(1 - 1 / 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_only_and_invalid_dates(monkeypatch):
|
||||||
|
stamp = datetime(2026, 10, 20, tzinfo=timezone.utc).timestamp()
|
||||||
|
monkeypatch.setattr(rank.time, "time", lambda: stamp)
|
||||||
|
assert rank._fresh("2026-10-20") == 1
|
||||||
|
for value in (None, "", "not a date", "99999999999999999999999999", "NaN"):
|
||||||
|
assert parse_published(value) is None
|
||||||
|
assert rank._fresh(value) == 0.3
|
||||||
|
assert rank._fresh("2020-01-01") == 0
|
||||||
|
assert parse_published("2026") is None
|
||||||
|
assert parse_published("20261020") == datetime(2026, 10, 20, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_atom_alternate_links_author_and_date(monkeypatch):
|
||||||
|
src = S("atom_test", "models", "rss", "https://example.invalid/feed.atom")
|
||||||
|
xml = '''<feed xmlns="http://www.w3.org/2005/Atom"><entry>
|
||||||
|
<title>Runtime release 2.0</title>
|
||||||
|
<link rel="self" href="https://example.invalid/api/123"/>
|
||||||
|
<link rel="alternate" href="/releases/2.0"/>
|
||||||
|
<author><name>Release team</name></author>
|
||||||
|
<published>2026-10-20T15:00:00+03:00</published>
|
||||||
|
<summary>Faster inference and improved caching.</summary>
|
||||||
|
</entry></feed>'''
|
||||||
|
monkeypatch.setattr(feeds.net, "get", lambda *a, **kw: SimpleNamespace(status=200, text=xml))
|
||||||
|
rows = feeds.fetch(src)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].url == "https://example.invalid/releases/2.0"
|
||||||
|
assert rows[0].author == "Release team"
|
||||||
|
assert rows[0].published == "2026-10-20T12:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rss_namespaced_creator_and_no_author(monkeypatch):
|
||||||
|
src = S("rss_test", "security", "rss", "https://example.invalid/feed")
|
||||||
|
xml = '''<rss xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>
|
||||||
|
<item><title>Security advisory</title><link>https://example.invalid/advisory</link>
|
||||||
|
<dc:creator>Security team</dc:creator><pubDate>Tue, 20 Oct 2026 12:00:00 GMT</pubDate></item>
|
||||||
|
<item><title>New patch available</title><link>https://example.invalid/patch</link></item>
|
||||||
|
</channel></rss>'''
|
||||||
|
monkeypatch.setattr(feeds.net, "get", lambda *a, **kw: SimpleNamespace(status=200, text=xml))
|
||||||
|
rows = feeds.fetch(src)
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0].author == "Security team"
|
||||||
|
assert rows[0].published == "2026-10-20T12:00:00+00:00"
|
||||||
|
assert rows[1].author == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_drops_invalid_pages_but_keeps_short_real_posts(monkeypatch):
|
||||||
|
src = S("test", "security", "rss", "https://example.invalid/feed", "test_adapter")
|
||||||
|
def item(path, title, text=""):
|
||||||
|
return Item("test", "entry", url=path, title=title, text=text)
|
||||||
|
good = item("https://example.invalid/release", "v1.2")
|
||||||
|
real = item("https://example.invalid/article", "Access denied", "Real analysis of authorization failure. " * 20)
|
||||||
|
rows = [
|
||||||
|
item("", "A missing URL"),
|
||||||
|
item("javascript:alert(1)", "A dangerous URL"),
|
||||||
|
item("https://example.invalid/blank", ""),
|
||||||
|
item("https://example.invalid/challenge", "Just a moment...", "Checking your browser"),
|
||||||
|
item("https://example.invalid/denied", "Attention Required! | Cloudflare", "captcha"),
|
||||||
|
item("https://example.invalid/long-challenge", "Just a moment...",
|
||||||
|
"Checking your browser before accessing this site. " * 20),
|
||||||
|
{"title": "Malformed adapter result"}, None, "not an Item",
|
||||||
|
good, item(good.url, "Duplicate record"), real,
|
||||||
|
]
|
||||||
|
monkeypatch.setitem(feeds.ADAPTERS, "test_adapter", lambda source, n: rows)
|
||||||
|
assert feeds.fetch(src) == [good, real]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_bad_record_does_not_discard_valid_siblings(monkeypatch):
|
||||||
|
src = S("test", "security", "rss", "https://example.invalid/feed", "test_adapter")
|
||||||
|
good = Item("test", "entry", url="https://example.invalid/release", title="Real release")
|
||||||
|
bad = Item("test", "entry", url="https://example.invalid/bad", title="Malformed")
|
||||||
|
bad.text = None
|
||||||
|
monkeypatch.setitem(feeds.ADAPTERS, "test_adapter", lambda source, n: [bad, good])
|
||||||
|
assert feeds.fetch(src) == [good]
|
||||||
@@ -5,11 +5,38 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_panel_data(tmp_path, monkeypatch):
|
||||||
|
"""Never touch the operator's database, watchlist, digest or TG session."""
|
||||||
|
from omni import abuse, config, panel_api, store, tgsess
|
||||||
|
from omni.model import Item
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "DATA", tmp_path)
|
||||||
|
monkeypatch.setattr(config, "DB", tmp_path / "test.sqlite3")
|
||||||
|
monkeypatch.setattr(abuse, "DB", config.DB)
|
||||||
|
monkeypatch.setattr(panel_api, "WATCH", tmp_path / "watch.json")
|
||||||
|
monkeypatch.setattr(config, "tg_cfg", lambda: {"session": "", "limit": 100})
|
||||||
|
monkeypatch.setattr(tgsess, "enabled", lambda: False)
|
||||||
|
today = time.strftime("%Y-%m-%d")
|
||||||
|
store.add([
|
||||||
|
Item(provider="pytest", kind="post", source="pytest-panel",
|
||||||
|
title="Firecrawl security dataset leak announcement",
|
||||||
|
text="Public incident report for regression coverage.",
|
||||||
|
url="https://example.invalid/seed", published=today),
|
||||||
|
Item(provider="pytest", kind="post", source="pytest-other",
|
||||||
|
title="Guide to observability and performance metrics",
|
||||||
|
text="A completely different article about tracing.",
|
||||||
|
url="https://example.invalid/other", published=today),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def _tmp_item(tag=None):
|
def _tmp_item(tag=None):
|
||||||
from omni import store
|
from omni import store
|
||||||
from omni.model import Item
|
from omni.model import Item
|
||||||
@@ -124,14 +151,19 @@ def test_health_counts():
|
|||||||
|
|
||||||
|
|
||||||
# ---------- tg ----------
|
# ---------- tg ----------
|
||||||
def test_tg_status_shape_and_flag():
|
@pytest.mark.parametrize("enabled", [False, True])
|
||||||
from omni import panel_api
|
def test_tg_status_shape_and_flag(monkeypatch, enabled):
|
||||||
|
from omni import config, panel_api, tgsess
|
||||||
|
|
||||||
|
monkeypatch.setattr(tgsess, "enabled", lambda: enabled)
|
||||||
|
monkeypatch.setattr(tgsess, "me", lambda: {"id": 123, "first_name": "Test"})
|
||||||
|
monkeypatch.setattr(config, "tg_cfg",
|
||||||
|
lambda: {"session": "test-account", "limit": 100})
|
||||||
d = panel_api.tg_status()
|
d = panel_api.tg_status()
|
||||||
assert {"enabled", "me", "error", "limit"} <= set(d)
|
assert {"enabled", "me", "error", "limit", "session"} <= set(d)
|
||||||
assert isinstance(d["enabled"], bool)
|
assert d["enabled"] is enabled
|
||||||
if d["enabled"]:
|
assert d["me"] == ({"id": 123, "first_name": "Test"} if enabled else None)
|
||||||
assert d["session"]
|
assert d["error"] is None
|
||||||
|
|
||||||
|
|
||||||
# ---------- digest ----------
|
# ---------- digest ----------
|
||||||
@@ -205,3 +237,170 @@ def test_browse2_filters_since_and_src():
|
|||||||
# src + since вместе
|
# src + since вместе
|
||||||
rows5, total5 = store.browse2(q="", limit=5, src=name, since="2099-01-01")
|
rows5, total5 = store.browse2(q="", limit=5, src=name, since="2099-01-01")
|
||||||
assert total5 == 0 and rows5 == []
|
assert total5 == 0 and rows5 == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_dedupes_url_and_name(monkeypatch):
|
||||||
|
from omni import sources, source_state
|
||||||
|
|
||||||
|
first = sources.S("first", "security", "rss", "https://example.invalid/feed")
|
||||||
|
same_url = sources.S("alias", "security", "rss", first.url + "/")
|
||||||
|
same_name = sources.S("FIRST", "security", "rss", "https://example.invalid/mirror")
|
||||||
|
other = sources.S("other", "security", "rss", "https://example.invalid/other")
|
||||||
|
monkeypatch.setattr(sources, "SOURCES", [first, same_url, same_name, other])
|
||||||
|
monkeypatch.setattr(sources, "_gen_sources", lambda: [])
|
||||||
|
monkeypatch.setattr(sources, "_fresh_sources", lambda factory: [])
|
||||||
|
monkeypatch.setattr(sources, "_quality_sources", lambda factory: [])
|
||||||
|
monkeypatch.setattr(sources, "radar_sources", lambda: [])
|
||||||
|
monkeypatch.setattr(sources, "panel_sources", lambda: [])
|
||||||
|
monkeypatch.setattr(source_state, "custom", lambda: [])
|
||||||
|
assert sources.all_sources() == [first, other]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_sources_have_valid_metadata():
|
||||||
|
from omni import feeds, sources
|
||||||
|
from omni.sources_fresh import fresh
|
||||||
|
|
||||||
|
rows = fresh(sources.S)
|
||||||
|
assert rows
|
||||||
|
assert all(isinstance(s.tags, tuple) for s in rows)
|
||||||
|
assert all(s.adapter in feeds.ADAPTERS for s in rows)
|
||||||
|
assert len({s.name for s in rows}) == len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_wave_is_integrated_without_collisions():
|
||||||
|
from omni import feeds, sources
|
||||||
|
from omni.sources_quality import quality_sources
|
||||||
|
|
||||||
|
wave = quality_sources(sources.S)
|
||||||
|
registry = sources.all_sources()
|
||||||
|
assert len(wave) >= 30
|
||||||
|
assert all(isinstance(s.tags, tuple) and s.adapter in feeds.ADAPTERS for s in wave)
|
||||||
|
assert {s.name for s in wave} <= {s.name for s in registry}
|
||||||
|
assert len(registry) == len({s.name.strip().lower() for s in registry})
|
||||||
|
assert len(registry) == len({s.url.rstrip("/").lower() for s in registry})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tags", [(), ("news",), ("news", "cve")])
|
||||||
|
def test_fresh_generator_preserves_tag_tuples(tmp_path, tags):
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
from omni import sources
|
||||||
|
|
||||||
|
emit = runpy.run_path(str(ROOT / "tools" / "probe_fresh.py"))["emit"]
|
||||||
|
row = {"name": "test_feed", "category": "security", "kind": "rss",
|
||||||
|
"adapter": "rss", "url": "https://example.invalid/feed", "tags": tags}
|
||||||
|
path = tmp_path / "generated_sources.py"
|
||||||
|
assert emit([row], [], path, dedupe=False) == 1
|
||||||
|
generated = runpy.run_path(str(path))["fresh"](sources.S)
|
||||||
|
assert generated[0].tags == tags
|
||||||
|
assert isinstance(generated[0].tags, tuple)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_local_day_and_rolling_24_hours(monkeypatch):
|
||||||
|
import datetime
|
||||||
|
from omni import panel_api, store
|
||||||
|
|
||||||
|
now = datetime.datetime(2026, 10, 20, 1, 0).timestamp()
|
||||||
|
class FrozenDate(datetime.date):
|
||||||
|
@classmethod
|
||||||
|
def today(cls):
|
||||||
|
return cls(2026, 10, 20)
|
||||||
|
|
||||||
|
monkeypatch.setattr(panel_api._dt, "date", FrozenDate)
|
||||||
|
monkeypatch.setattr(panel_api.time, "time", lambda: now)
|
||||||
|
with store.db() as c:
|
||||||
|
c.execute("UPDATE items SET seen=? WHERE source='pytest-panel'", (now - 1800,))
|
||||||
|
c.execute("UPDATE items SET seen=? WHERE source='pytest-other'", (now - 23 * 3600,))
|
||||||
|
data = panel_api.timeline(days=2, mode="seen")
|
||||||
|
assert data["last24"] == 2, "rolling 24 hours must include yesterday evening"
|
||||||
|
assert data["today"] == 1, "local 00:30 import belongs to today's bar"
|
||||||
|
assert [p["n"] for p in data["points"]] == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_stats_count_partial_successes():
|
||||||
|
from omni import store
|
||||||
|
|
||||||
|
for status in ("ok", "ok 5/10", "пусто", "Exception: timeout"):
|
||||||
|
store.log_run("pytest", "test", "", 0, 0, status)
|
||||||
|
stats = store.stats()
|
||||||
|
assert stats["runs"] == 4
|
||||||
|
assert stats["runs_ok"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_last24_respects_publication_mode(monkeypatch):
|
||||||
|
import datetime
|
||||||
|
from omni import panel_api, store
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
monkeypatch.setattr(panel_api.time, "time", lambda: now)
|
||||||
|
old = datetime.datetime.fromtimestamp(now - 48 * 3600, datetime.timezone.utc).isoformat()
|
||||||
|
future = datetime.datetime.fromtimestamp(now + 48 * 3600, datetime.timezone.utc).isoformat()
|
||||||
|
with store.db() as c:
|
||||||
|
c.execute("UPDATE items SET seen=?", (now - 60,))
|
||||||
|
c.execute("UPDATE items SET published=? WHERE source='pytest-panel'", (old,))
|
||||||
|
c.execute("UPDATE items SET published=? WHERE source='pytest-other'", (future,))
|
||||||
|
assert panel_api.timeline(mode="seen")["last24"] == 2
|
||||||
|
assert panel_api.timeline(mode="pub")["last24"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_exports_match_feed_filters_and_item_find(monkeypatch):
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
from omni import serve, store
|
||||||
|
from omni.model import Item
|
||||||
|
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
old = (datetime.date.today() - datetime.timedelta(days=10)).isoformat()
|
||||||
|
rows = [
|
||||||
|
Item("pytest", "post", source="quality-http", title="promo code: QUAL2026 $20 30 days",
|
||||||
|
text="Official promotional offer for testing.", url="https://example.invalid/quality-new", published=today),
|
||||||
|
Item("pytest", "post", source="quality-http", title="Legacy license costs $99",
|
||||||
|
text="An old pricing page.", url="https://example.invalid/quality-old", published=old),
|
||||||
|
Item("pytest", "post", source="quality-http", title="Latest debugging guide",
|
||||||
|
text="A free guide to tracing request latency.", url="https://example.invalid/quality-free", published=today),
|
||||||
|
]
|
||||||
|
store.add(rows)
|
||||||
|
for status in (" OK ", "ok 2/5", "пусто", "Exception: timeout", None):
|
||||||
|
store.log_run("pytest", "quality-http", "", 0, 0, status)
|
||||||
|
monkeypatch.setattr(serve, "UI_TOKEN", "")
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
base = "http://127.0.0.1:%s" % server.server_address[1]
|
||||||
|
query = "?src=quality-http&days=1&priced=1&limit=1000"
|
||||||
|
try:
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(base + path, timeout=10) as res:
|
||||||
|
assert res.status == 200
|
||||||
|
return res.read().decode("utf-8-sig")
|
||||||
|
|
||||||
|
feed = json.loads(get("/api/items" + query))
|
||||||
|
export = json.loads(get("/api/export.json" + query))
|
||||||
|
csv_rows = list(csv.DictReader(io.StringIO(get("/api/export.csv" + query)), delimiter=";"))
|
||||||
|
assert feed["total"] == export["total"] == 1
|
||||||
|
assert [r["url"] for r in feed["items"]] == [rows[0].url]
|
||||||
|
assert [r["url"] for r in export["items"]] == [rows[0].url]
|
||||||
|
assert [r["url"] for r in csv_rows] == [rows[0].url]
|
||||||
|
assert "QUAL2026" in feed["items"][0]["find"]["codes"]
|
||||||
|
runs = json.loads(get("/api/runs?limit=2"))
|
||||||
|
assert runs["total"] == 5 and runs["shown"] == 2
|
||||||
|
assert runs["by_status"] == {"ok": 2, "empty": 1, "err": 1, "none": 1}
|
||||||
|
states = {r["state"] for r in json.loads(get("/api/runs?limit=10"))["items"]}
|
||||||
|
assert states == {"ok", "empty", "err", "none"}
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
def test_web_has_free_panel_block():
|
||||||
|
"""Вкладка абузов обязана показывать free-панели new-api."""
|
||||||
|
html = open(os.path.join(ROOT, "web", "index.html"), encoding="utf-8").read()
|
||||||
|
assert "loadPanels" in html
|
||||||
|
assert "/api/panels" in html
|
||||||
|
assert "pn-grid" in html
|
||||||
|
assert ".pn-grid{" in html
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
переезжают в omni/sources_fresh.py (генерируется `--emit`).
|
переезжают в omni/sources_fresh.py (генерируется `--emit`).
|
||||||
Формат dict совпадает с Source(**d); адаптеры — только из feeds.ADAPTERS.
|
Формат dict совпадает с Source(**d); адаптеры — только из feeds.ADAPTERS.
|
||||||
"""
|
"""
|
||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def d(name, category, kind, url, adapter="rss", tags=(), limit=15, detail=False, extra=None, **kw):
|
def d(name, category, kind, url, adapter="rss", tags=(), limit=15, detail=False, extra=None, **kw):
|
||||||
@@ -14,10 +13,6 @@ def d(name, category, kind, url, adapter="rss", tags=(), limit=15, detail=False,
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
_NOW = datetime.datetime.utcnow()
|
|
||||||
_NVD_SINCE = (_NOW - datetime.timedelta(days=7)).strftime("%Y-%m-%dT00:00:00.000")
|
|
||||||
_NVD_UNTIL = _NOW.strftime("%Y-%m-%dT%H:%M:%S.000")
|
|
||||||
|
|
||||||
CANDIDATES = [
|
CANDIDATES = [
|
||||||
# ---------------- 1. security news / research ----------------
|
# ---------------- 1. security news / research ----------------
|
||||||
d("hackread", "security", "rss", "https://hackread.com/feed/", "rss", ("news",)),
|
d("hackread", "security", "rss", "https://hackread.com/feed/", "rss", ("news",)),
|
||||||
|
|||||||
@@ -7,8 +7,19 @@
|
|||||||
Критерий живого: feeds.fetch(src, limit) вернул >= 1 запись.
|
Критерий живого: feeds.fetch(src, limit) вернул >= 1 запись.
|
||||||
Мёртвым добираем диагностику прямым net.get (код/блок/размер), чтобы причина
|
Мёртвым добираем диагностику прямым net.get (код/блок/размер), чтобы причина
|
||||||
в отчёте была фактической, а не «0 items».
|
в отчёте была фактической, а не «0 items».
|
||||||
|
|
||||||
|
Публичные хелперы (для тестов/внешних вызовов):
|
||||||
|
candidate_source(c) -> (Source, marker) — dict кандидата -> Source, url
|
||||||
|
динамического источника пересчитан ДО сети; маркер лежит в extra["_dynamic"].
|
||||||
|
_registry_sources() -> [Source] — all_sources(include_fresh=False)
|
||||||
|
_registry_keys() -> (set url, set name) — нормализованные ключи реестра без
|
||||||
|
текущей sources_fresh (иначе перегенерация считает свою волну дублем).
|
||||||
|
_registry_urls() -> set url — совместимость.
|
||||||
|
emit(rows, dead, path, dedupe=True) — только известный dynamic-маркер;
|
||||||
|
неизвестный маркер = ValueError, а не замороженный протухший url.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import datetime as _dt
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -60,34 +71,130 @@ def probe_one(src, limit):
|
|||||||
|
|
||||||
|
|
||||||
def _norm(u):
|
def _norm(u):
|
||||||
u = u.strip().lower().rstrip("/")
|
u = str(u or "").strip().lower().rstrip("/")
|
||||||
for pre in ("https://", "http://"):
|
for pre in ("https://", "http://"):
|
||||||
if u.startswith(pre):
|
if u.startswith(pre):
|
||||||
u = u[len(pre):]
|
u = u[len(pre):]
|
||||||
return u[4:] if u.startswith("www.") else u
|
return u[4:] if u.startswith("www.") else u
|
||||||
|
|
||||||
|
|
||||||
def _registry_urls():
|
def _norm_name(n):
|
||||||
|
"""Имя источника — первичный ключ source_flags/runs: сравниваем без регистра,
|
||||||
|
пробелов и разделителей, иначе nvd-recent и nvd_recent разъезжаются."""
|
||||||
|
return "".join(ch for ch in str(n or "").strip().lower() if ch.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_sources():
|
||||||
|
"""Реестр без текущего sources_fresh: перегенерация не должна считать
|
||||||
|
собственную прошлую волну «уже существующей» и стирать свои же находки."""
|
||||||
from omni.sources import all_sources
|
from omni.sources import all_sources
|
||||||
return {_norm(s.url) for s in all_sources()}
|
try:
|
||||||
|
return all_sources(include_fresh=False)
|
||||||
|
except TypeError: # реестр без флага — совместимость
|
||||||
|
return all_sources()
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_keys():
|
||||||
|
"""(нормализованные url, нормализованные имена) реестра без sources_fresh."""
|
||||||
|
rows = _registry_sources()
|
||||||
|
return ({_norm(s.url) for s in rows if s.url},
|
||||||
|
{_norm_name(s.name) for s in rows if s.name})
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_urls():
|
||||||
|
"""Только url-ключи реестра (совместимость с внешними вызовами)."""
|
||||||
|
return _registry_keys()[0]
|
||||||
|
|
||||||
|
|
||||||
|
NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||||
|
KNOWN_DYNAMIC = ("NVD_LAST_WEEK",)
|
||||||
|
|
||||||
|
|
||||||
|
def _nvd_week_url():
|
||||||
|
"""Фактический url NVD на момент прогона: окно lastMod <=120 дней, база
|
||||||
|
без окна отдаёт самые старые записи страницы и как «свежая» бесполезна."""
|
||||||
|
now = _dt.datetime.utcnow()
|
||||||
|
since = (now - _dt.timedelta(days=7)).strftime("%Y-%m-%dT00:00:00.000")
|
||||||
|
return ("%s?resultsPerPage=30&lastModStartDate=%s&lastModEndDate=%s"
|
||||||
|
% (NVD_BASE, since, now.strftime("%Y-%m-%dT%H:%M:%S.000")))
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_source(c):
|
||||||
|
"""dict кандидата -> (Source, dynamic-маркер), url пересчитан до сети.
|
||||||
|
|
||||||
|
Маркер сохраняется в extra["_dynamic"], чтобы отчёт/emit знали, что url
|
||||||
|
нельзя замораживать. Неизвестный маркер — ValueError: молча подставить
|
||||||
|
статический url значит опубликовать заведомо протухший источник.
|
||||||
|
"""
|
||||||
|
row = dict(c)
|
||||||
|
marker = str(row.pop("dynamic", "") or "")
|
||||||
|
extra = dict(row.get("extra") or {})
|
||||||
|
if marker:
|
||||||
|
if marker not in KNOWN_DYNAMIC:
|
||||||
|
raise ValueError("unknown dynamic marker %r" % marker)
|
||||||
|
if marker == "NVD_LAST_WEEK":
|
||||||
|
row["url"] = _nvd_week_url()
|
||||||
|
extra["_dynamic"] = marker
|
||||||
|
row["extra"] = extra
|
||||||
|
return Source(**row), marker
|
||||||
|
|
||||||
|
|
||||||
# контент-дубли уже имеющихся в реестре фидов (url отличается, лента та же)
|
# контент-дубли уже имеющихся в реестре фидов (url отличается, лента та же)
|
||||||
CONTENT_DUP = {"elastic_security_labs", "securityweek_alt", "reddit_netsec"}
|
CONTENT_DUP = {"elastic_security_labs", "securityweek_alt", "reddit_netsec"}
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_lit(extra):
|
||||||
|
"""extra-литерал без служебных ключей: _dynamic — метаданные прогона, не источника."""
|
||||||
|
clean = {k: v for k, v in sorted((extra or {}).items()) if k != "_dynamic"}
|
||||||
|
return "" if not clean else ", extra=%r" % (clean,)
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_window(rows):
|
||||||
|
"""Строки, где в url уже стоит вычисленное окно (lastMod), но маркера нет:
|
||||||
|
такой отчёт старее фикса dynamic — при emit окно застынет на дате прогона."""
|
||||||
|
return [str(r.get("name")) for r in rows
|
||||||
|
if not r.get("dynamic") and "lastmodstartdate=" in str(r.get("url") or "").lower()]
|
||||||
|
|
||||||
|
|
||||||
def emit(rows, dead, path, dedupe=True):
|
def emit(rows, dead, path, dedupe=True):
|
||||||
"""omni/sources_fresh.py: только живые, без импортов из omni.sources."""
|
"""omni/sources_fresh.py: только живые, без импортов из omni.sources.
|
||||||
def q(s):
|
|
||||||
return '"%s"' % str(s).replace('"', '\\"')
|
Литералы пишем через repr: json.dumps даёт true/false/null — это не Python.
|
||||||
|
Дедуп сверяет и url, и имя (нормализованные) и добавляет принятые строки в
|
||||||
|
виденное, поэтому партия уникальна и сама по себе. Динамический url
|
||||||
|
поддерживается только для известного маркера: неизвестный — явная ошибка,
|
||||||
|
а не замороженный url, который молча протухнет.
|
||||||
|
"""
|
||||||
|
rows = list(rows)
|
||||||
|
unknown = sorted({str(r.get("dynamic")) for r in rows
|
||||||
|
if r.get("dynamic") and r.get("dynamic") not in KNOWN_DYNAMIC})
|
||||||
|
if unknown:
|
||||||
|
raise ValueError("emit: неизвестный dynamic-маркер %s — замораживать url нельзя"
|
||||||
|
% ", ".join(map(repr, unknown)))
|
||||||
|
frozen = _frozen_window(rows)
|
||||||
|
if frozen:
|
||||||
|
print("emit: ВНИМАНИЕ, окно в url без dynamic-маркера (%s): отчёт устарел, "
|
||||||
|
"перепрогони probe_fresh" % ", ".join(frozen))
|
||||||
|
|
||||||
if dedupe:
|
if dedupe:
|
||||||
have = _registry_urls()
|
have_url, have_name = _registry_keys()
|
||||||
kept, skipped = [], []
|
seen_url, seen_name = set(have_url), set(have_name)
|
||||||
for r in [x for x in rows if x["name"] not in CONTENT_DUP]:
|
kept, skipped, dupes = [], [], []
|
||||||
n = _norm(r["url"])
|
for r in rows:
|
||||||
(skipped if n in have else kept).append(r)
|
n, nm = _norm(r.get("url")), _norm_name(r.get("name"))
|
||||||
|
in_registry = (bool(n) and n in have_url) or (bool(nm) and nm in have_name)
|
||||||
|
if r.get("name") in CONTENT_DUP:
|
||||||
|
in_registry = True
|
||||||
|
if in_registry or (bool(n) and n in seen_url) or (bool(nm) and nm in seen_name):
|
||||||
|
(skipped if in_registry else dupes).append(r)
|
||||||
|
continue
|
||||||
|
if n:
|
||||||
|
seen_url.add(n)
|
||||||
|
if nm:
|
||||||
|
seen_name.add(nm)
|
||||||
|
kept.append(r)
|
||||||
print("emit: уже есть в реестре -> %d: %s" % (len(skipped), ", ".join(r["name"] for r in skipped)))
|
print("emit: уже есть в реестре -> %d: %s" % (len(skipped), ", ".join(r["name"] for r in skipped)))
|
||||||
|
print("emit: дубли внутри партии -> %d: %s" % (len(dupes), ", ".join(r["name"] for r in dupes)))
|
||||||
rows = kept
|
rows = kept
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
@@ -114,23 +221,23 @@ def emit(rows, dead, path, dedupe=True):
|
|||||||
" return [",
|
" return [",
|
||||||
]
|
]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
extra = ""
|
marker = str(r.get("dynamic") or "")
|
||||||
if r.get("extra"):
|
tags = repr(tuple(r.get("tags") or ()))
|
||||||
extra = ", extra=%s" % json.dumps(r["extra"], ensure_ascii=False, sort_keys=True)
|
limit = r.get("limit")
|
||||||
limit = r.get("limit") or 15
|
lim = ", limit=%d" % limit if isinstance(limit, int) and limit != 20 else ""
|
||||||
lim = "" if limit == 15 else ", limit=%d" % limit
|
|
||||||
det = ", detail=True" if r.get("detail") else ""
|
det = ", detail=True" if r.get("detail") else ""
|
||||||
tags = "(%s)" % ", ".join(q(t) for t in r.get("tags") or ())
|
if marker == "NVD_LAST_WEEK":
|
||||||
if r.get("dynamic") == "NVD_LAST_WEEK" or r.get("name") == "nvd_recent":
|
lines.append(" S(%r, %r, %r, _nvd_week(), %r, %s%s%s%s)," % (
|
||||||
lines.append(' S(%s, %s, %s, _nvd_week(), %s, %s%s%s),' % (
|
r["name"], r.get("category", ""), r.get("kind", ""), r.get("adapter", "rss"),
|
||||||
q(r["name"]), q(r["category"]), q(r["kind"]), q(r["adapter"]), tags, lim, det))
|
tags, lim, det, _extra_lit(r.get("extra"))))
|
||||||
continue
|
continue
|
||||||
lines.append(' S(%s, %s, %s, %s, %s, %s%s%s%s),' % (
|
lines.append(" S(%r, %r, %r, %r, %r, %s%s%s%s)," % (
|
||||||
q(r["name"]), q(r["category"]), q(r["kind"]), q(r["url"]), q(r["adapter"]),
|
r["name"], r.get("category", ""), r.get("kind", ""), r.get("url", ""), r.get("adapter", "rss"),
|
||||||
tags, lim, det, extra))
|
tags, lim, det, _extra_lit(r.get("extra"))))
|
||||||
lines += [" ]", "", "", "# --- отклонено прогоном (не отдают записи без ключа/логина) ---"]
|
lines += [" ]", "", "", "# --- отклонено прогоном (не отдают записи без ключа/логина) ---"]
|
||||||
for r in dead:
|
for r in dead:
|
||||||
lines.append("# %-26s %-44s %s" % (r["name"], r["url"][:44], (r["err"] or "0 items")[:90]))
|
lines.append("# %-26s %-44s %s" % (r.get("name", "?"), str(r.get("url") or "")[:44],
|
||||||
|
(r.get("err") or "0 items")[:90]))
|
||||||
text = "\n".join(lines) + "\n"
|
text = "\n".join(lines) + "\n"
|
||||||
path.write_text(text, encoding="utf-8")
|
path.write_text(text, encoding="utf-8")
|
||||||
return len(rows)
|
return len(rows)
|
||||||
@@ -153,6 +260,12 @@ def main():
|
|||||||
rows = rep["results"]
|
rows = rep["results"]
|
||||||
alive = [r for r in rows if r["ok"]]
|
alive = [r for r in rows if r["ok"]]
|
||||||
dead = [r for r in rows if not r["ok"]]
|
dead = [r for r in rows if not r["ok"]]
|
||||||
|
frozen = _frozen_window(alive)
|
||||||
|
if frozen:
|
||||||
|
print("emit-only: отчёт без dynamic-маркера для %s — окно в url застынет на дате прогона; "
|
||||||
|
"перепрогони probe_fresh (сеть) или правь источники вручную"
|
||||||
|
% ", ".join(frozen))
|
||||||
|
return 2
|
||||||
n = emit(alive, dead, ROOT / "omni" / "sources_fresh.py", dedupe=not a.no_dedupe)
|
n = emit(alive, dead, ROOT / "omni" / "sources_fresh.py", dedupe=not a.no_dedupe)
|
||||||
print("omni/sources_fresh.py: %d источников" % n)
|
print("omni/sources_fresh.py: %d источников" % n)
|
||||||
return 0
|
return 0
|
||||||
@@ -161,11 +274,11 @@ def main():
|
|||||||
srcs = []
|
srcs = []
|
||||||
for c in cands:
|
for c in cands:
|
||||||
try:
|
try:
|
||||||
cc = dict(c)
|
src, _marker = candidate_source(c)
|
||||||
cc.pop("dynamic", None)
|
|
||||||
srcs.append(Source(**cc))
|
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print("skip %s: %s" % (c.get("name"), e))
|
print("skip %s: %s" % (c.get("name"), e))
|
||||||
|
continue
|
||||||
|
srcs.append(src)
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
with ThreadPoolExecutor(max_workers=a.workers) as ex:
|
with ThreadPoolExecutor(max_workers=a.workers) as ex:
|
||||||
|
|||||||
439
tools/probe_quality_sources.py
Normal file
439
tools/probe_quality_sources.py
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
"""Живой прогон пула quality-кандидатов (официальные release/security фиды).
|
||||||
|
|
||||||
|
python tools/probe_quality_sources.py # широкий пул -> data/quality_sources_pool.json
|
||||||
|
python tools/probe_quality_sources.py --only gh_rel_ # фильтр по имени
|
||||||
|
python tools/probe_quality_sources.py --confirm # сверить SPECS модуля -> data/quality_sources_probe.json
|
||||||
|
|
||||||
|
Что проверяется (acceptance):
|
||||||
|
1. прямой запрос net.get с жёстким timeout, XML парсится локально (RSS <item> и Atom <entry>);
|
||||||
|
2. >= MIN_ITEMS записей с непустым http(s)-URL;
|
||||||
|
3. ни одна из первых записей не является challenge/error-страницей (заголовок-заглушка);
|
||||||
|
4. нет дублей с основным реестром omni.sources: имя, нормализованный URL, зеркало репозитория
|
||||||
|
(в --confirm свои 37 SPECS из этого множества вычитаются: после интеграции они уже в
|
||||||
|
all_sources(), иначе прогон «отклонил» бы сам себя; остальной реестр сверяется как есть);
|
||||||
|
5. нет дублей между кандидатами: пересечение URL записей и заголовков (>= 60%).
|
||||||
|
Плюс фиксируется, что вернул штатный адаптер feeds.fetch: Atom-link/relative-link/DC-creator
|
||||||
|
в feeds.py починены, поэтому у принятых источников ожидается n >= MIN_ITEMS и blank_url == 0;
|
||||||
|
ненулевой blank_url — сигнал регрессии адаптера, а не норма.
|
||||||
|
|
||||||
|
Отчёт: data/quality_sources_probe.json (data/ в .gitignore). В прод-БД ничего не пишется.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from omni import net # noqa: E402
|
||||||
|
from omni.sources import Source # noqa: E402
|
||||||
|
|
||||||
|
OUT = ROOT / "data" / "quality_sources_probe.json"
|
||||||
|
OUT_POOL = ROOT / "data" / "quality_sources_pool.json"
|
||||||
|
MIN_ITEMS = 2
|
||||||
|
TITLE_OVERLAP_REJECT = 0.6
|
||||||
|
ENTRY_OVERLAP_REJECT = 0.5
|
||||||
|
TIMEOUT = 12
|
||||||
|
|
||||||
|
GH = """huggingface/diffusers
|
||||||
|
huggingface/peft
|
||||||
|
huggingface/accelerate
|
||||||
|
huggingface/text-generation-inference
|
||||||
|
huggingface/tokenizers
|
||||||
|
huggingface/candle
|
||||||
|
pytorch/pytorch
|
||||||
|
pytorch/torchao
|
||||||
|
NVIDIA/TensorRT-LLM
|
||||||
|
NVIDIA/nvidia-container-toolkit
|
||||||
|
flashinfer-ai/flashinfer
|
||||||
|
microsoft/onnxruntime
|
||||||
|
microsoft/DeepSpeed
|
||||||
|
ml-explore/mlx
|
||||||
|
ggml-org/whisper.cpp
|
||||||
|
openai/whisper
|
||||||
|
AUTOMATIC1111/stable-diffusion-webui
|
||||||
|
exo-explore/exo
|
||||||
|
vllm-project/vllm-ascend
|
||||||
|
caddyserver/caddy
|
||||||
|
keycloak/keycloak
|
||||||
|
grafana/grafana
|
||||||
|
tailscale/tailscale
|
||||||
|
envoyproxy/envoy
|
||||||
|
hashicorp/vault
|
||||||
|
hashicorp/terraform
|
||||||
|
hashicorp/consul
|
||||||
|
sigstore/cosign
|
||||||
|
aquasecurity/trivy
|
||||||
|
anchore/grype
|
||||||
|
projectdiscovery/nuclei
|
||||||
|
zaproxy/zaproxy
|
||||||
|
moby/moby
|
||||||
|
containerd/containerd
|
||||||
|
opencontainers/runc
|
||||||
|
kubernetes/kubernetes
|
||||||
|
istio/istio
|
||||||
|
argoproj/argo-cd
|
||||||
|
cert-manager/cert-manager
|
||||||
|
traefik/traefik
|
||||||
|
prometheus/prometheus
|
||||||
|
opensearch-project/OpenSearch
|
||||||
|
elastic/elasticsearch
|
||||||
|
minio/minio
|
||||||
|
go-gitea/gitea
|
||||||
|
nextcloud/server
|
||||||
|
portainer/portainer
|
||||||
|
wazuh/wazuh
|
||||||
|
OISF/suricata
|
||||||
|
zeek/zeek
|
||||||
|
fail2ban/fail2ban
|
||||||
|
openssl/openssl
|
||||||
|
openssh/openssh-portable
|
||||||
|
curl/curl
|
||||||
|
spring-projects/spring-boot
|
||||||
|
quarkusio/quarkus
|
||||||
|
jenkinsci/jenkins
|
||||||
|
immich-app/immich
|
||||||
|
netbox-community/netbox
|
||||||
|
open-telemetry/opentelemetry-collector
|
||||||
|
modelcontextprotocol/python-sdk
|
||||||
|
openai/openai-agents-python
|
||||||
|
pydantic/pydantic-ai
|
||||||
|
langchain-ai/langgraph"""
|
||||||
|
|
||||||
|
# имя = gh_rel_<repo> без дефисов/точек (как в реестре: gh_rel_llamacpp, gh_rel_transformers)
|
||||||
|
def _gh_name(repo):
|
||||||
|
return "gh_rel_" + repo.split("/")[1].lower().replace("-", "").replace(".", "").replace("_", "")
|
||||||
|
|
||||||
|
|
||||||
|
_AI_TAG = ("release", "github", "ai")
|
||||||
|
_SEC_TAG = ("security", "release", "github")
|
||||||
|
|
||||||
|
POOL = [
|
||||||
|
{"name": _gh_name(r), "category": "release", "kind": "atom",
|
||||||
|
"url": "https://github.com/%s/releases.atom" % r, "tags": _AI_TAG, "limit": 10}
|
||||||
|
for r in GH.split()
|
||||||
|
]
|
||||||
|
|
||||||
|
POOL += [
|
||||||
|
# ---------- официальные security-фиды вендоров/проектов ----------
|
||||||
|
{"name": "chrome_releases", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://chromereleases.googleblog.com/feeds/posts/default",
|
||||||
|
"tags": ("security", "vendor", "chrome"), "limit": 15},
|
||||||
|
{"name": "project_zero", "category": "security", "kind": "atom",
|
||||||
|
"url": "https://googleprojectzero.blogspot.com/feeds/posts/default",
|
||||||
|
"tags": ("security", "research", "google"), "limit": 10},
|
||||||
|
{"name": "ubuntu_usn", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://ubuntu.com/security/notices/rss.xml",
|
||||||
|
"tags": ("security", "vendor", "ubuntu"), "limit": 20},
|
||||||
|
{"name": "amazon_linux_alas", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://alas.aws.amazon.com/alas.rss",
|
||||||
|
"tags": ("security", "vendor", "aws"), "limit": 20},
|
||||||
|
{"name": "msrc_blog", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://msrc.microsoft.com/blog/feed",
|
||||||
|
"tags": ("security", "vendor", "microsoft"), "limit": 15},
|
||||||
|
{"name": "ms_security_blog", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://www.microsoft.com/en-us/security/blog/feed/",
|
||||||
|
"tags": ("security", "vendor", "microsoft"), "limit": 15},
|
||||||
|
{"name": "cisco_talos", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://blog.talosintelligence.com/rss/",
|
||||||
|
"tags": ("security", "research", "cisco"), "limit": 15},
|
||||||
|
{"name": "portswigger_research", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://portswigger.net/research/rss",
|
||||||
|
"tags": ("security", "research", "web"), "limit": 15},
|
||||||
|
{"name": "trail_of_bits", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://blog.trailofbits.com/feed/",
|
||||||
|
"tags": ("security", "research"), "limit": 15},
|
||||||
|
{"name": "cisa_advisories", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://www.cisa.gov/cybersecurity-advisories/all.xml",
|
||||||
|
"tags": ("security", "vendor", "cisa"), "limit": 20},
|
||||||
|
{"name": "sans_isc", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://isc.sans.edu/rssfeed.xml",
|
||||||
|
"tags": ("security", "research", "sans"), "limit": 15},
|
||||||
|
{"name": "nodejs_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://nodejs.org/en/feed/blog.xml",
|
||||||
|
"tags": ("release", "vendor", "nodejs"), "limit": 15},
|
||||||
|
{"name": "rust_blog", "category": "release", "kind": "atom",
|
||||||
|
"url": "https://blog.rust-lang.org/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "rust"), "limit": 15},
|
||||||
|
{"name": "go_blog", "category": "release", "kind": "atom",
|
||||||
|
"url": "https://go.dev/blog/feed.atom",
|
||||||
|
"tags": ("release", "vendor", "go"), "limit": 15},
|
||||||
|
{"name": "php_news", "category": "release", "kind": "atom",
|
||||||
|
"url": "https://www.php.net/feed.atom",
|
||||||
|
"tags": ("release", "vendor", "php"), "limit": 15},
|
||||||
|
{"name": "python_insider", "category": "release", "kind": "atom",
|
||||||
|
"url": "https://pythoninsider.blogspot.com/feeds/posts/default",
|
||||||
|
"tags": ("release", "vendor", "python"), "limit": 15},
|
||||||
|
{"name": "gitlab_releases", "category": "release", "kind": "atom",
|
||||||
|
"url": "https://about.gitlab.com/atom.xml",
|
||||||
|
"tags": ("release", "vendor", "gitlab"), "limit": 15},
|
||||||
|
{"name": "cloudflare_security", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://blog.cloudflare.com/tag/security/rss/",
|
||||||
|
"tags": ("security", "vendor", "cloudflare"), "limit": 15},
|
||||||
|
{"name": "aws_security_bulletins", "category": "security", "kind": "rss",
|
||||||
|
"url": "https://aws.amazon.com/security/security-bulletins/rss/feed/",
|
||||||
|
"tags": ("security", "vendor", "aws"), "limit": 20},
|
||||||
|
{"name": "hashicorp_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://www.hashicorp.com/blog/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "hashicorp"), "limit": 15},
|
||||||
|
{"name": "kubernetes_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://kubernetes.io/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "kubernetes"), "limit": 15},
|
||||||
|
{"name": "docker_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://www.docker.com/feed/",
|
||||||
|
"tags": ("release", "vendor", "docker"), "limit": 15},
|
||||||
|
# ---------- технические/AI release-блоги вендоров ----------
|
||||||
|
{"name": "nvidia_dev_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://developer.nvidia.com/blog/feed/",
|
||||||
|
"tags": ("release", "vendor", "nvidia", "ai"), "limit": 15},
|
||||||
|
{"name": "pytorch_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://pytorch.org/feed/",
|
||||||
|
"tags": ("release", "vendor", "pytorch", "ai"), "limit": 15},
|
||||||
|
{"name": "vllm_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://blog.vllm.ai/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "vllm", "ai"), "limit": 15},
|
||||||
|
{"name": "ray_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://www.anyscale.com/blog/rss.xml",
|
||||||
|
"tags": ("release", "vendor", "ray", "ai"), "limit": 15},
|
||||||
|
{"name": "langchain_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://blog.langchain.dev/rss/",
|
||||||
|
"tags": ("release", "vendor", "langchain", "ai"), "limit": 15},
|
||||||
|
{"name": "modal_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://modal.com/blog/feed.xml",
|
||||||
|
"tags": ("release", "vendor", "modal", "ai"), "limit": 15},
|
||||||
|
{"name": "ollama_blog", "category": "release", "kind": "rss",
|
||||||
|
"url": "https://ollama.com/blog/rss.xml",
|
||||||
|
"tags": ("release", "vendor", "ollama", "ai"), "limit": 15},
|
||||||
|
]
|
||||||
|
|
||||||
|
BAD_TITLE_MARKS = ("just a moment", "attention required", "access denied", "403 forbidden",
|
||||||
|
"404 not found", "not found", "rate limit", "too many requests",
|
||||||
|
"checking your browser", "ddos-guard", "domain not found",
|
||||||
|
"privacy error", "service unavailable", "bad gateway", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_url(u):
|
||||||
|
u = (u or "").strip().lower().rstrip("/")
|
||||||
|
for pre in ("https://", "http://"):
|
||||||
|
if u.startswith(pre):
|
||||||
|
u = u[len(pre):]
|
||||||
|
return u[4:] if u.startswith("www.") else u
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_url(u):
|
||||||
|
try:
|
||||||
|
p = urllib.parse.urlsplit(u or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return p.scheme in ("http", "https") and bool(p.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_title(t):
|
||||||
|
low = (t or "").strip().lower()
|
||||||
|
if len(low) < 3:
|
||||||
|
return True
|
||||||
|
return any(m in low for m in BAD_TITLE_MARKS) and len(low) < 60
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(text):
|
||||||
|
"""Локальный парсер RSS/Atom -> [(title, link, published)]."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text.encode("utf-8", "ignore"))
|
||||||
|
except Exception:
|
||||||
|
return out
|
||||||
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
items = root.findall(".//item")
|
||||||
|
if items:
|
||||||
|
for e in items:
|
||||||
|
link = (e.findtext("link") or "").strip()
|
||||||
|
if not link:
|
||||||
|
ge = e.find("{http://www.w3.org/2005/Atom}link")
|
||||||
|
if ge is not None:
|
||||||
|
link = (ge.get("href") or "").strip()
|
||||||
|
out.append(((e.findtext("title") or "").strip(), link,
|
||||||
|
(e.findtext("pubDate") or e.findtext("published") or "").strip()))
|
||||||
|
return out
|
||||||
|
for e in root.findall(".//a:entry", ns):
|
||||||
|
link = ""
|
||||||
|
for le in e.findall("a:link", ns):
|
||||||
|
if le.get("rel") in (None, "alternate") and le.get("href"):
|
||||||
|
link = le.get("href").strip()
|
||||||
|
if le.get("rel") == "alternate":
|
||||||
|
break
|
||||||
|
out.append(((e.findtext("a:title", namespaces=ns) or "").strip(), link,
|
||||||
|
(e.findtext("a:published", namespaces=ns) or e.findtext("a:updated", namespaces=ns) or "").strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _registry():
|
||||||
|
from omni import sources as reg
|
||||||
|
rows = reg.all_sources()
|
||||||
|
names, urls, repos = set(), set(), set()
|
||||||
|
for s in rows:
|
||||||
|
names.add((s.name or "").strip().lower())
|
||||||
|
urls.add(_norm_url(s.url))
|
||||||
|
m = re.search(r"github\.com/([^/]+)/([^/?#]+)", (s.url or ""), re.I)
|
||||||
|
if m:
|
||||||
|
repos.add((m.group(1).lower(), m.group(2).lower().removesuffix(".git")))
|
||||||
|
return rows, names, urls, repos
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_of(url):
|
||||||
|
m = re.search(r"github\.com/([^/]+)/([^/?#]+)", url or "", re.I)
|
||||||
|
return (m.group(1).lower(), m.group(2).lower().removesuffix(".git")) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(spec):
|
||||||
|
t0 = time.time()
|
||||||
|
r = net.get(spec["url"], timeout=TIMEOUT)
|
||||||
|
status = {"name": spec["name"], "category": spec["category"], "kind": spec["kind"],
|
||||||
|
"url": spec["url"], "tags": list(spec["tags"]), "adapter": spec.get("adapter", "rss"),
|
||||||
|
"limit": spec.get("limit", 10), "http": getattr(r, "status", 0),
|
||||||
|
"mode": getattr(r, "mode", ""), "bytes": len(getattr(r, "text", "") or ""),
|
||||||
|
"ms": int((time.time() - t0) * 1000), "err": ""}
|
||||||
|
if getattr(r, "error", ""):
|
||||||
|
status["err"] = str(r.error)[:160]
|
||||||
|
if getattr(r, "status", 0) != 200 or not (r.text or "").strip():
|
||||||
|
status["entries"] = 0
|
||||||
|
status["reject"] = "http_%s" % getattr(r, "status", 0)
|
||||||
|
return status
|
||||||
|
parsed = _parse(r.text)
|
||||||
|
good = [(t, u, p) for (t, u, p) in parsed if _valid_url(u) and not _bad_title(t)]
|
||||||
|
status["entries"] = len(parsed)
|
||||||
|
status["valid"] = len(good)
|
||||||
|
status["samples"] = [{"title": t[:140], "url": u[:220], "published": p[:32]} for (t, u, p) in good[:3]]
|
||||||
|
if len(good) < MIN_ITEMS:
|
||||||
|
status["reject"] = "only_%d_valid" % len(good)
|
||||||
|
else:
|
||||||
|
status["reject"] = ""
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_check(spec):
|
||||||
|
"""Что вернул штатный адаптер (документируем эффект бага Atom-link)."""
|
||||||
|
from omni import feeds
|
||||||
|
src = Source(spec["name"], spec["category"], spec["kind"], spec["url"],
|
||||||
|
spec.get("adapter", "rss"), tuple(spec["tags"]), spec.get("limit", 10))
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
items = feeds.fetch(src, spec.get("limit", 10)) or []
|
||||||
|
err = ""
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
items, err = [], "raise:%s" % str(e)[:120]
|
||||||
|
blank = sum(1 for i in items if not _valid_url(i.url))
|
||||||
|
return {"n": len(items), "blank_url": blank, "ms": int((time.time() - t0) * 1000),
|
||||||
|
"err": err, "samples": [{"title": (i.title or "")[:120], "url": (i.url or "")[:200]}
|
||||||
|
for i in items[:3]]}
|
||||||
|
|
||||||
|
|
||||||
|
def _dup_reasons(status, names, urls, repos, accepted):
|
||||||
|
why = []
|
||||||
|
name = (status["name"] or "").lower()
|
||||||
|
if name in names:
|
||||||
|
why.append("name_in_registry")
|
||||||
|
if _norm_url(status["url"]) in urls:
|
||||||
|
why.append("url_in_registry")
|
||||||
|
repo = _repo_of(status["url"])
|
||||||
|
if repo and repo in repos:
|
||||||
|
why.append("repo_mirror:%s/%s" % repo)
|
||||||
|
entry_urls = {_norm_url(s["url"]) for s in status.get("samples", [])}
|
||||||
|
titles = {re.sub(r"\W+", "", s["title"].lower()) for s in status.get("samples", [])}
|
||||||
|
for prev in accepted:
|
||||||
|
p_urls = {_norm_url(s["url"]) for s in prev.get("samples", [])}
|
||||||
|
p_titles = {re.sub(r"\W+", "", s["title"].lower()) for s in prev.get("samples", [])}
|
||||||
|
if entry_urls and p_urls and len(entry_urls & p_urls) / len(entry_urls) >= ENTRY_OVERLAP_REJECT:
|
||||||
|
why.append("entries_overlap:%s" % prev["name"])
|
||||||
|
break
|
||||||
|
if titles and p_titles and len(titles & p_titles) / len(titles) >= TITLE_OVERLAP_REJECT:
|
||||||
|
why.append("titles_overlap:%s" % prev["name"])
|
||||||
|
break
|
||||||
|
return why
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--only", default="")
|
||||||
|
ap.add_argument("--workers", type=int, default=8)
|
||||||
|
ap.add_argument("--limit", type=int, default=10)
|
||||||
|
ap.add_argument("--confirm", action="store_true", help="прогон SPECS из omni.sources_quality")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.confirm:
|
||||||
|
from omni.sources_quality import SPECS as pool
|
||||||
|
else:
|
||||||
|
pool = POOL
|
||||||
|
if args.only:
|
||||||
|
pool = [p for p in pool if args.only in p["name"]]
|
||||||
|
|
||||||
|
rows, names, urls, repos = _registry()
|
||||||
|
# после интеграции quality_sources в all_sources() эти же записи есть в реестре:
|
||||||
|
# себя из множества дублей вычитаем, иначе confirm-прогон «отклонит» все свои же источники
|
||||||
|
names -= {(p["name"] or "").lower() for p in pool}
|
||||||
|
urls -= {_norm_url(p["url"]) for p in pool}
|
||||||
|
repos -= {r for r in (_repo_of(p["url"]) for p in pool) if r}
|
||||||
|
t0 = time.time()
|
||||||
|
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
||||||
|
probed = list(ex.map(_probe, pool))
|
||||||
|
elapsed = round(time.time() - t0, 1)
|
||||||
|
|
||||||
|
accepted, rejected = [], []
|
||||||
|
for st in probed:
|
||||||
|
if st["reject"]:
|
||||||
|
st["dup"] = []
|
||||||
|
rejected.append(st)
|
||||||
|
continue
|
||||||
|
why = _dup_reasons(st, names, urls, repos, accepted)
|
||||||
|
st["dup"] = why
|
||||||
|
(rejected if why else accepted).append(st)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as ex:
|
||||||
|
for st, chk in zip(accepted, ex.map(_fetch_check, accepted)):
|
||||||
|
st["feeds_fetch"] = chk
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"mode": "confirm" if args.confirm else "pool",
|
||||||
|
"pool": len(pool), "raw_pool_size": len(POOL), "registry_sources": len(rows),
|
||||||
|
"module": "omni/sources_quality.py" if args.confirm else None,
|
||||||
|
"elapsed_s": elapsed, "min_items": MIN_ITEMS,
|
||||||
|
"acceptance": {
|
||||||
|
"min_valid_entries": MIN_ITEMS,
|
||||||
|
"valid_url_required": "http(s) с непустым netloc",
|
||||||
|
"bad_title_marks": list(BAD_TITLE_MARKS),
|
||||||
|
"dup_keys": ["name", "normalized_url", "github_repo_mirror",
|
||||||
|
"entries_overlap>=%.1f" % ENTRY_OVERLAP_REJECT,
|
||||||
|
"titles_overlap>=%.1f" % TITLE_OVERLAP_REJECT],
|
||||||
|
},
|
||||||
|
"accepted_n": len(accepted), "rejected_n": len(rejected),
|
||||||
|
"accepted_by_category": {},
|
||||||
|
"accepted": accepted, "rejected": rejected,
|
||||||
|
}
|
||||||
|
for st in accepted:
|
||||||
|
report["accepted_by_category"][st["category"]] = report["accepted_by_category"].get(st["category"], 0) + 1
|
||||||
|
|
||||||
|
target = OUT if args.confirm else OUT_POOL
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
|
||||||
|
print("pool=%d accepted=%d rejected=%d elapsed=%.1fs" % (len(pool), len(accepted), len(rejected), elapsed))
|
||||||
|
for st in accepted:
|
||||||
|
s = st["samples"][0] if st["samples"] else {"title": "", "url": ""}
|
||||||
|
ff = st.get("feeds_fetch") or {}
|
||||||
|
print("OK %-30s %2d/%2d %5dms fetch=%d(blank %d) | %s | %s"
|
||||||
|
% (st["name"], st["valid"], st["entries"], st["ms"], ff.get("n", 0),
|
||||||
|
ff.get("blank_url", 0), s["title"][:60], s["url"][:80]))
|
||||||
|
for st in rejected:
|
||||||
|
print("NO %-30s %s %s" % (st["name"], st.get("reject") or ",".join(st.get("dup") or []),
|
||||||
|
(st.get("samples") or [{}])[0].get("title", "")[:50]))
|
||||||
|
print("report:", target)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,10 +23,16 @@ def main():
|
|||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--limit", type=int, default=10)
|
ap.add_argument("--limit", type=int, default=10)
|
||||||
ap.add_argument("--workers", type=int, default=8)
|
ap.add_argument("--workers", type=int, default=8)
|
||||||
ap.add_argument("--out", default="data/fresh_run.json")
|
ap.add_argument("--wave", choices=("fresh", "quality"), default="fresh")
|
||||||
|
ap.add_argument("--out", default="")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
names = {f.name for f in _fresh(_Fake)}
|
if a.wave == "quality":
|
||||||
|
from omni.sources_quality import quality_sources
|
||||||
|
names = {f.name for f in quality_sources(_Fake)}
|
||||||
|
else:
|
||||||
|
names = {f.name for f in _fresh(_Fake)}
|
||||||
|
output = a.out or "data/%s_run.json" % a.wave
|
||||||
picked = [s for s in sources.all_sources() if s.name in names]
|
picked = [s for s in sources.all_sources() if s.name in names]
|
||||||
print("источников свежей волны:", len(picked), "| лимит", a.limit)
|
print("источников свежей волны:", len(picked), "| лимит", a.limit)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -45,7 +51,8 @@ def main():
|
|||||||
out = {"when": int(t0), "secs": round(time.time() - t0, 1), "picked": len(picked),
|
out = {"when": int(t0), "secs": round(time.time() - t0, 1), "picked": len(picked),
|
||||||
"alive": len(ok), "empty": len(bad), "fetched": len(items), "new": new, "dup": dup,
|
"alive": len(ok), "empty": len(bad), "fetched": len(items), "new": new, "dup": dup,
|
||||||
"by_source": dict(sorted(by_src.items(), key=lambda x: -x[1])), "empty_sources": bad}
|
"by_source": dict(sorted(by_src.items(), key=lambda x: -x[1])), "empty_sources": bad}
|
||||||
json.dump(out, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
with open(output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||||
print("секунд:", out["secs"], "| отдали:", len(ok), "| пустых:", len(bad))
|
print("секунд:", out["secs"], "| отдали:", len(ok), "| пустых:", len(bad))
|
||||||
print("получено записей:", len(items), "| новых в базе:", new, "| дублей:", dup)
|
print("получено записей:", len(items), "| новых в базе:", new, "| дублей:", dup)
|
||||||
print("топ:", list(out["by_source"].items())[:12])
|
print("топ:", list(out["by_source"].items())[:12])
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ with Camoufox(headless=True, geoip=False, humanize=True) as b:
|
|||||||
p = b.new_page(viewport={"width": 1400, "height": 1000})
|
p = b.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
p.goto(URL, wait_until="domcontentloaded", timeout=60000)
|
p.goto(URL, wait_until="domcontentloaded", timeout=60000)
|
||||||
p.wait_for_selector("#view", timeout=60000)
|
p.wait_for_selector("#view", timeout=60000)
|
||||||
p.wait_for_timeout(7000)
|
p.wait_for_timeout(14000)
|
||||||
txt = p.inner_text("#view")[:900]
|
txt = p.inner_text("#view")[:900]
|
||||||
p.screenshot(path=OUT, full_page=False)
|
p.screenshot(path=OUT, full_page=False)
|
||||||
print(txt)
|
print(txt)
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font
|
|||||||
.top{padding:8px 10px}
|
.top{padding:8px 10px}
|
||||||
.crumb{display:none}
|
.crumb{display:none}
|
||||||
}
|
}
|
||||||
|
.pn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:8px}
|
||||||
|
.pn{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;text-decoration:none;color:inherit;font-size:12px}
|
||||||
|
.pn:hover{border-color:var(--acc)}
|
||||||
|
.pn b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.pn .muted{opacity:.6}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -261,6 +266,11 @@ const dt=s=>{if(!s)return"";s=String(s);const m=s.match(/^(\d{4})-(\d{2})-(\d{2}
|
|||||||
const ago=s=>{if(!s)return"";const t=Date.parse(!isNaN(s)?+s:s);if(!t)return dt(s);
|
const ago=s=>{if(!s)return"";const t=Date.parse(!isNaN(s)?+s:s);if(!t)return dt(s);
|
||||||
const d=(Date.now()-t)/1000;if(d<3600)return Math.max(1,Math.round(d/60))+" мин";if(d<86400)return Math.round(d/3600)+" ч";return Math.round(d/86400)+" дн"};
|
const d=(Date.now()-t)/1000;if(d<3600)return Math.max(1,Math.round(d/60))+" мин";if(d<86400)return Math.round(d/3600)+" ч";return Math.round(d/86400)+" дн"};
|
||||||
const debounce=(f,ms=220)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>f(...a),ms)}};
|
const debounce=(f,ms=220)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>f(...a),ms)}};
|
||||||
|
/* статус прогона: "ok*" — успех; "пусто"/"empty" — пустой ответ (не ошибка); "" — нет статуса */
|
||||||
|
const isOkSt=s=>String(s==null?"":s).trim().toLowerCase().startsWith("ok");
|
||||||
|
const isEmpSt=s=>["пусто","empty"].includes(String(s==null?"":s).trim().toLowerCase());
|
||||||
|
const stKey=s=>{const v=String(s==null?"":s).trim();return !v?"none":(isOkSt(v)?"ok":(isEmpSt(v)?"empty":"err"))};
|
||||||
|
const stPill=s=>{const k=stKey(s);return k==="ok"?"ok":(k==="err"?"no":"unk")};
|
||||||
|
|
||||||
let TOAST_T;
|
let TOAST_T;
|
||||||
function toast(msg,bad=false){const el=$("#toast");el.textContent=msg;el.classList.toggle("bad",!!bad);el.classList.add("on");clearTimeout(TOAST_T);TOAST_T=setTimeout(()=>el.classList.remove("on"),2600);}
|
function toast(msg,bad=false){const el=$("#toast");el.textContent=msg;el.classList.toggle("bad",!!bad);el.classList.add("on");clearTimeout(TOAST_T);TOAST_T=setTimeout(()=>el.classList.remove("on"),2600);}
|
||||||
@@ -303,9 +313,9 @@ const NAV=[
|
|||||||
{id:"digest", label:"Дайджест", icon:"M6 3h9l4 4v14H6zM15 3v4h4"},
|
{id:"digest", label:"Дайджест", icon:"M6 3h9l4 4v14H6zM15 3v4h4"},
|
||||||
{id:"watch", label:"Watchlist", icon:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"}
|
{id:"watch", label:"Watchlist", icon:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"}
|
||||||
];
|
];
|
||||||
const S={view:"overview",q:"",cat:"",type:"all",sort:"score",days:0,priced:false,
|
const S={view:"overview",q:"",cat:"",type:"all",sort:"score",days:0,priced:false,src:"",
|
||||||
offset:0,items:[],total:0,auto:localStorage.getItem("omni.auto")==="1",
|
offset:0,items:[],total:0,auto:localStorage.getItem("omni.auto")==="1",
|
||||||
types:[],cats:[]};
|
runQ:"",runF:"all",types:[],cats:[]};
|
||||||
|
|
||||||
function route(){
|
function route(){
|
||||||
const h=location.hash.replace(/^#\/?/,"");
|
const h=location.hash.replace(/^#\/?/,"");
|
||||||
@@ -314,6 +324,7 @@ function route(){
|
|||||||
S.view=NAV.some(n=>n.id===v)?v:"overview";
|
S.view=NAV.some(n=>n.id===v)?v:"overview";
|
||||||
S.q=p.get("q")||""; S.cat=p.get("cat")||""; S.type=p.get("type")||"all";
|
S.q=p.get("q")||""; S.cat=p.get("cat")||""; S.type=p.get("type")||"all";
|
||||||
S.sort=p.get("sort")||"score"; S.days=+(p.get("days")||0); S.priced=p.get("priced")==="1";
|
S.sort=p.get("sort")||"score"; S.days=+(p.get("days")||0); S.priced=p.get("priced")==="1";
|
||||||
|
S.src=p.get("src")||"";
|
||||||
S.offset=0;
|
S.offset=0;
|
||||||
$("#q").value=S.q;
|
$("#q").value=S.q;
|
||||||
$(".crumb").innerHTML="<b>"+esc(NAV.find(n=>n.id===S.view).label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
|
$(".crumb").innerHTML="<b>"+esc(NAV.find(n=>n.id===S.view).label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
|
||||||
@@ -328,6 +339,7 @@ function go(view,params={},replace=false){
|
|||||||
const sort=params.sort??S.sort; if(sort!=="score")p.set("sort",sort);
|
const sort=params.sort??S.sort; if(sort!=="score")p.set("sort",sort);
|
||||||
const days=params.days??S.days; if(days)p.set("days",days);
|
const days=params.days??S.days; if(days)p.set("days",days);
|
||||||
if((params.priced??S.priced))p.set("priced","1");
|
if((params.priced??S.priced))p.set("priced","1");
|
||||||
|
const src=params.src!==undefined?params.src:S.src; if(src)p.set("src",src);
|
||||||
const url="#/"+view+(p.toString()?"?"+p:"");
|
const url="#/"+view+(p.toString()?"?"+p:"");
|
||||||
if(replace)history.replaceState(null,"",url); else location.hash=url;
|
if(replace)history.replaceState(null,"",url); else location.hash=url;
|
||||||
if(replace)route();
|
if(replace)route();
|
||||||
@@ -376,10 +388,12 @@ V.overview=async()=>{
|
|||||||
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
|
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
|
||||||
]);
|
]);
|
||||||
S.stats=st;
|
S.stats=st;
|
||||||
const day=tl.last24||tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
|
// last24 — честное скользящее окно от бэкенда; «сегодня» неполный день, процент не считаем
|
||||||
const dPct=prev?Math.round((day-prev)/prev*100):null;
|
const day=tl.last24??0;
|
||||||
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
|
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
|
||||||
const okRate=st.runs?(st.runs_ok/st.runs*100).toFixed(0):0;
|
const bs=runs.by_status||{};
|
||||||
|
const runAll=(bs.ok||0)+(bs.empty||0)+(bs.err||0)+(bs.none||0);
|
||||||
|
const okRate=runAll?Math.round((bs.ok||0)/runAll*100):(st.runs?Math.round(st.runs_ok/st.runs*100):0);
|
||||||
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
|
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
|
||||||
<div class="kpi num" style="${accent?`color:${accent}`:""}">${val}</div><div class="sub">${sub}</div></div>`;
|
<div class="kpi num" style="${accent?`color:${accent}`:""}">${val}</div><div class="sub">${sub}</div></div>`;
|
||||||
const hits=(watch.rules||[]).reduce((a,r)=>a+(r.count||0),0);
|
const hits=(watch.rules||[]).reduce((a,r)=>a+(r.count||0),0);
|
||||||
@@ -390,7 +404,7 @@ V.overview=async()=>{
|
|||||||
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
|
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="grid g4">
|
<div class="grid g4">
|
||||||
${kpi("Записей всего",num(st.items),`+${num(day)} за сутки импорта${dPct===null?"":` <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`}`)}
|
${kpi("Записей всего",num(st.items),`за последние 24 часа: +${num(day)}`)}
|
||||||
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
|
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
|
||||||
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
|
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
|
||||||
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
|
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
|
||||||
@@ -413,10 +427,10 @@ V.overview=async()=>{
|
|||||||
|
|
||||||
<div class="grid g2" style="margin-top:14px">
|
<div class="grid g2" style="margin-top:14px">
|
||||||
<div class="card"><h3>Свежее <span class="r"><a href="#/feed">вся лента →</a></span></h3><div id="ov-latest">${skel(4)}</div></div>
|
<div class="card"><h3>Свежее <span class="r"><a href="#/feed">вся лента →</a></span></h3><div id="ov-latest">${skel(4)}</div></div>
|
||||||
<div class="card"><h3>Последние прогоны <span class="r"><a href="#/runs">все →</a></span></h3>
|
<div class="card"><h3>Последние прогоны <span class="r">ok ${num(okRate)}% · <a href="#/runs">все →</a></span></h3>
|
||||||
<table><tbody>${(runs.items||[]).map(r=>`<tr><td class="mut mono nowrap">${esc(r.when||"")}</td>
|
<table><tbody>${(runs.items||[]).map(r=>`<tr><td class="mut mono nowrap">${esc(r.when||"")}</td>
|
||||||
<td class="nowrap">${esc(r.source||"—")}</td><td class="right mono">${r.got??""}/${r.kept??""}</td>
|
<td class="nowrap">${esc(r.source||"—")}</td><td class="right mono">${r.got??""}/${r.kept??""}</td>
|
||||||
<td class="right"><span class="pill ${r.status==="ok"?"ok":"no"}"></span><span class="dim">${esc(r.status||"")}</span></td></tr>`).join("")}</tbody></table>
|
<td class="right"><span class="pill ${stPill(r.status)}" title="${esc(r.status||"нет статуса")}"></span><span class="dim">${esc(r.status||"—")}</span></td></tr>`).join("")}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -441,8 +455,8 @@ V.feed=async()=>{
|
|||||||
const box=$("#view");
|
const box=$("#view");
|
||||||
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
|
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">CSV</a>
|
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">CSV</a>
|
||||||
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">JSON</a>
|
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">JSON</a>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||||||
<span class="dim" style="font-size:11.5px">Типы:</span>
|
<span class="dim" style="font-size:11.5px">Типы:</span>
|
||||||
@@ -456,12 +470,15 @@ V.feed=async()=>{
|
|||||||
<div class="card" style="padding:0"><div id="list">${skel(6)}</div><div id="more" class="pager"></div></div>`;
|
<div class="card" style="padding:0"><div id="list">${skel(6)}</div><div id="more" class="pager"></div></div>`;
|
||||||
await loadItems(true);
|
await loadItems(true);
|
||||||
};
|
};
|
||||||
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,...(S.priced?{priced:"1"}:{})}).toString();
|
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,
|
||||||
|
...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),...(S.src?{src:S.src}:{}),
|
||||||
|
limit:"1000"}).toString();
|
||||||
|
|
||||||
async function loadItems(reset){
|
async function loadItems(reset){
|
||||||
if(reset){S.offset=0;S.items=[]}
|
if(reset){S.offset=0;S.items=[]}
|
||||||
const p=new URLSearchParams({limit:"40",offset:String(S.offset),q:S.q,sort:S.sort,type:S.type,
|
const p=new URLSearchParams({limit:"40",offset:String(S.offset),q:S.q,sort:S.sort,type:S.type,
|
||||||
...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{})});
|
...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),
|
||||||
|
...(S.src?{src:S.src}:{})});
|
||||||
const d=await api("/api/items?"+p);
|
const d=await api("/api/items?"+p);
|
||||||
S.total=d.total; S.items=S.items.concat(d.items); S.offset+=d.items.length;
|
S.total=d.total; S.items=S.items.concat(d.items); S.offset+=d.items.length;
|
||||||
const list=$("#list"); if(!list)return;
|
const list=$("#list"); if(!list)return;
|
||||||
@@ -475,8 +492,8 @@ function findChips(f){
|
|||||||
if(!f) return "";
|
if(!f) return "";
|
||||||
const p=[];
|
const p=[];
|
||||||
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
|
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
|
||||||
if(f.amount) p.push(`<span class="chip credits">${esc(f.amount)}</span>`);
|
if(f.amount) p.push(`<span class="chip price" title="объём/сумма">${esc(f.amount)}</span>`);
|
||||||
if(f.term) p.push(`<span class="chip invite">${esc(f.term)}</span>`);
|
if(f.term) p.push(`<span class="chip trial" title="срок">${esc(f.term)}</span>`);
|
||||||
return p.join("");
|
return p.join("");
|
||||||
}
|
}
|
||||||
function itemRow(it,compact){
|
function itemRow(it,compact){
|
||||||
@@ -536,6 +553,7 @@ async function openItem(id){
|
|||||||
function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classList.remove("on");}
|
function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classList.remove("on");}
|
||||||
|
|
||||||
/* ---------- abuse ---------- */
|
/* ---------- abuse ---------- */
|
||||||
|
|
||||||
V.abuse=async()=>{
|
V.abuse=async()=>{
|
||||||
const d=await api("/api/abuse?limit=120");
|
const d=await api("/api/abuse?limit=120");
|
||||||
const tags=Object.entries(d.tags||{}).slice(0,14);
|
const tags=Object.entries(d.tags||{}).slice(0,14);
|
||||||
@@ -547,9 +565,24 @@ V.abuse=async()=>{
|
|||||||
${tags.map(([t,n])=>`<span class="chip click" data-tag="${esc(t)}">${esc(d.labels?.[t]||t)} <span class="n">${num(n)}</span></span>`).join("")}
|
${tags.map(([t,n])=>`<span class="chip click" data-tag="${esc(t)}">${esc(d.labels?.[t]||t)} <span class="n">${num(n)}</span></span>`).join("")}
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="card" style="padding:0"><div id="ab-list">${(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пока пусто</div>`}</div></div>
|
<div class="card" style="padding:0"><div id="ab-list">${(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пока пусто</div>`}</div></div>
|
||||||
|
<div id="pn-box"></div>
|
||||||
<div id="sum-box"></div>`;
|
<div id="sum-box"></div>`;
|
||||||
S.abuse=(d.items||[]);
|
S.abuse=(d.items||[]);
|
||||||
|
loadPanels();
|
||||||
};
|
};
|
||||||
|
async function loadPanels(){
|
||||||
|
const box=$("#pn-box"); if(!box) return;
|
||||||
|
try{
|
||||||
|
const d=await api("/api/panels");
|
||||||
|
const all=(d.panels||[]).filter(p=>(p.free_count||p.free||0)>0);
|
||||||
|
const rows=all.slice(0,40);
|
||||||
|
if(!rows.length){ box.innerHTML=""; return; }
|
||||||
|
box.innerHTML=`<div class="card" style="margin-top:14px"><h3>Free-панели new-api <span class="r">всего ${num(d.total)} · с бесплатными ${num(all.length)}${all.length>rows.length?` · показано ${num(rows.length)}`:""}</span></h3>
|
||||||
|
<div class="pn-grid">${rows.map(p=>`<a class="pn" href="https://${esc(p.host||p.url||"")}" target="_blank" rel="noopener">
|
||||||
|
<b>${esc(p.host||p.url||"?")}</b><span class="mono">${num(p.free_count||p.free||0)} free</span>
|
||||||
|
<span class="muted">${num(p.models||p.model_count||0)} моделей</span></a>`).join("")}</div></div>`;
|
||||||
|
}catch(e){ box.innerHTML=""; }
|
||||||
|
}
|
||||||
async function abuseByTag(tag){
|
async function abuseByTag(tag){
|
||||||
const d=await api("/api/abuse?limit=60"+(tag?"&tag="+encodeURIComponent(tag):""));
|
const d=await api("/api/abuse?limit=60"+(tag?"&tag="+encodeURIComponent(tag):""));
|
||||||
const box=$("#ab-list"); if(box) box.innerHTML=(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пусто</div>`;
|
const box=$("#ab-list"); if(box) box.innerHTML=(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пусто</div>`;
|
||||||
@@ -644,31 +677,36 @@ async function srcDetail(name){
|
|||||||
/* ---------- runs ---------- */
|
/* ---------- runs ---------- */
|
||||||
V.runs=async()=>{
|
V.runs=async()=>{
|
||||||
const d=await api("/api/runs?limit=200");
|
const d=await api("/api/runs?limit=200");
|
||||||
const ok=(d.items||[]).filter(r=>r.status==="ok").length;
|
const bs=d.by_status||{};
|
||||||
|
const items=d.items||[];
|
||||||
|
const ok=bs.ok??items.filter(r=>isOkSt(r.status)).length; // "ok", "ok-304" — успех
|
||||||
|
const empty=bs.empty??items.filter(r=>stKey(r.status)==="empty").length;
|
||||||
|
const err=bs.err??items.filter(r=>stKey(r.status)==="err").length;
|
||||||
|
const none=bs.none??items.filter(r=>stKey(r.status)==="none").length;
|
||||||
|
const rf=(k,l,n)=>`<span class="chip click ${S.runF===k?"on":""}" data-rf="${k}">${l} <span class="n">${num(n)}</span></span>`;
|
||||||
$("#view").innerHTML=`
|
$("#view").innerHTML=`
|
||||||
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
|
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<span class="chip click on" data-rf="all">все <span class="n">${num(d.total)}</span></span>
|
${rf("all","все",d.total)}${rf("ok","ok",ok)}${empty?rf("empty","пусто",empty):""}${rf("err","ошибки",err)}${none?rf("none","без статуса",none):""}</div></div>
|
||||||
<span class="chip click" data-rf="ok">ok <span class="n">${ok}</span></span>
|
|
||||||
<span class="chip click" data-rf="err">ошибки <span class="n">${d.total-ok}</span></span></div></div>
|
|
||||||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||||||
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px">
|
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px" value="${esc(S.runQ||"")}">
|
||||||
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)}</span></div></div>
|
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)} · показано ${num(items.length)}</span></div></div>
|
||||||
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
|
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
|
||||||
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
|
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
|
||||||
<tbody>${(d.items||[]).map(r=>`<tr data-st="${esc(r.status||"")}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
|
<tbody>${items.map(r=>`<tr data-st="${stKey(r.status)}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
|
||||||
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
|
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
|
||||||
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
|
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
|
||||||
<td><span class="pill ${r.status==="ok"?"ok":"no"}"></span>${esc(r.status||"")}</td></tr>`).join("")}</tbody></table></div></div>`;
|
<td><span class="pill ${stPill(r.status)}"></span>${esc(r.status||"—")}</td></tr>`).join("")}</tbody></table></div></div>`;
|
||||||
S.runQ=""; S.runF="all";
|
// S.runQ/S.runF не сбрасываем: автообновление перерисовывает раздел каждые 60 с
|
||||||
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
|
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
|
||||||
$$("#r-tbl tbody tr").forEach(tr=>{
|
$$("#r-tbl tbody tr").forEach(tr=>{
|
||||||
const okT=!v||tr.textContent.toLowerCase().includes(v);
|
const okT=!v||tr.textContent.toLowerCase().includes(v);
|
||||||
const okS=S.runF==="all"||(S.runF==="ok"?tr.dataset.st==="ok":tr.dataset.st!=="ok");
|
const okS=S.runF==="all"||S.runF===tr.dataset.st;
|
||||||
tr.style.display=(okT&&okS)?"":"none";});};
|
tr.style.display=(okT&&okS)?"":"none";});};
|
||||||
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
|
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
|
||||||
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
|
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
|
||||||
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
|
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
|
||||||
|
applyRuns();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- telegram ---------- */
|
/* ---------- telegram ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user