diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63f8446 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: deps + run: | + python -m pip install --upgrade pip + pip install pytest + - name: compile + run: python -m compileall -q omni tools + - name: tests + run: python -m pytest tests -q + - name: smoke cli + run: | + python -m omni --help > /dev/null + python -c "import omni.sources as s; print('sources', len(s.all_sources()))" \ No newline at end of file diff --git a/README.md b/README.md index ceba513..d555133 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,14 @@ python -m omni.bot ## Состояние на сейчас -- **источников: 665** в 11 категориях, **записей в базе: 5594** (224 с ценой, 120 прогонов) +- **источников: 853** в 11 категориях (662 базовых + 191 из реестра абузов, дубли вырезаны), **записей в базе: 5596** (224 с ценой, 121 прогон) - **типы записей: 14** — каждая запись размечена по смыслу: новость 3693 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 · датасет 126 · слив 90 · триал 41 · промо 34 · вакансия 21 · перепродажа 10 - **X/Twitter: 30 аккаунтов** читаются без API-ключей через `api.fxtwitter.com` (313 твитов в базе) - вкладки панели: `Лента` · `Источники` · `Прогоны` · `Абузы`; полоса чипов «тип», фильтр «только с ценой», сортировка `score/fresh/price`, экспорт CSV - health-аудит: живой/мёртвый/непроверенный по каждому источнику, причина в примечании - Telegram-бот на той же базе: `/new`, `/abuse`, `/find`, `/src`, `/run`, `/add`, `/auto`, `/sum`, `/tr`, `/stats` -- автоаудит: `tools/selfcheck.py` обновляет `REPORT.md`, `tools/autocommit.sh` коммитит и пушит в Gitea + GitHub +- тесты: `tests/test_regress.py` — 16 проверок на те же баги, что ловило ревью; CI: `.github/workflows/ci.yml` (py3.10/3.11/3.12) +- автоаудит: `tools/selfcheck.py` обновляет `REPORT.md` и гоняет тесты, `tools/autocommit.sh` коммитит и пушит в Gitea + GitHub | категория | источников | |---|---| @@ -75,6 +76,25 @@ python -m omni serve --port 8077 `python -m omni providers` печатает, что реально поднялось в этом окружении. +## Что было сломано и что стало (по ревью) + +Замеры сняты на этой машине, до и после правок. Скепсис к ревью тоже был: два пункта не подтвердились, они отмечены. + +| пункт | было | стало | +|---|---|---| +| `/run` в боте | `new += store.add(items)` → `TypeError`, команда падала всегда | `n_new, _dup = store.add(items)`; прогон отдаёт `готово: собрано 1, новых 1` | +| параметры API | `?limit=-1` → вся база (SQLite `LIMIT -1`), `?limit=abc` → трейс и обрыв | `limit=-1` → 1 строка, `abc` → 40, `99999` → 200 (HTTP 200 во всех трёх) | +| соединения SQLite | `with conn()` коммитит без `close()`: 200 вызовов → +76 хендлов | `with db()` (commit/rollback/close): 200 вызовов → +0 | +| троттлинг | 24 вызова на хост при 0.3с проходили за 0.9с вместо 6.9с | слот резервируется до сна, тест держит паузу и в 6 потоков | +| владелец бота | пустой `OMNI_BOT_OWNERS` → владельцем становился первый написавший | без env бот в read-only: `/new /abuse /find /src /stats`, мутации отбиваются | +| мёртвый `abuse_sources.py` | 237 строк, 0 импортов | подключён: +191 источник категории `abuse` (31 дубль отсеян по url) | +| дубли реестра | дубли по url | `all_sources()` дедуплицирует: 853 источника = 853 уникальных url | +| `JOBS` в панели | рос без границ, 500 запусков → 500 записей | eviction до 300 при переходе через 400 | +| `push_new` | уходили самые старые записи окна (`fresh[-per_chat:]`) | уходят самые свежие (`fresh[:per_chat]`) | +| `ai.py` | `sleep(1.5 + attempt)` и после последней попытки | спит только перед повтором | + +Что из ревью не подтвердилось: `rank.py` не мёртвый — его зовёт `cli.py` в трёх местах; дублей в реестре был один, а не 38 (на старой базе ревью их было больше). `except Exception` осталось 59 на ~5000 строк: часть глушит сетевые парсеры осознанно, но в панели ни один запрос больше не отдаёт трейс — параметры валидируются до вызова. + ## Установка ```bash diff --git a/REPORT.md b/REPORT.md index a0e19c7..9ba3c2a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,19 +1,19 @@ # omni-parse · автоотчёт -_обновлено 2026-10-26T16:37:00+00:00_ +_обновлено 2026-10-26T17:14:27+00:00_ | метрика | значение | |---|---| -| источников | 665 | +| источников | 853 | | категорий | 11 | -| записей | 5594 | +| записей | 5597 | | с ценой | 224 | -| типизировано | 5594 | +| типизировано | 5597 | | прогонов | 120 | -| пустых источников | 208 | +| пустых источников | 395 | -типы: новость 3693 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 +типы: новость 3696 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 | источник | записей | |---|---| @@ -30,5 +30,6 @@ _обновлено 2026-10-26T16:37:00+00:00_ | ab_altenens_is | 119 | | ab_afflift_com | 119 | +тесты: 16 passed in 12.31s самоаудит: OK diff --git a/omni/ai.py b/omni/ai.py index 9e89036..4a58a4e 100644 --- a/omni/ai.py +++ b/omni/ai.py @@ -117,7 +117,8 @@ def chat(messages, model=None, max_tokens=700, temperature=0.2): if out: return out except Exception: - time.sleep(1.5 + attempt) + if attempt == 0: # после последней попытки спим в пустоту + time.sleep(1.5) return None diff --git a/omni/bot.py b/omni/bot.py index 1be494f..443bd97 100644 --- a/omni/bot.py +++ b/omni/bot.py @@ -1,7 +1,7 @@ """Telegram-бот поверх парсера: лента, абузы, источники, прогон, автодобавление. Запуск: OMNI_BOT_TOKEN=... python -m omni.bot -Доступ: OMNI_BOT_OWNERS=7448683285,123 (если пусто — только первый написавший) +Доступ: OMNI_BOT_OWNERS=, (пусто = read-only: /new /abuse /find /src /stats) Бот не постит никуда сам: только отвечает в личный чат тем, кто в списке. Команды: @@ -157,7 +157,8 @@ def cmd_run(chat_id, arg): items = [] got += len(items) if items: - new += store.add(items) + n_new, _dup = store.add(items) + new += n_new _send(chat_id, "готово: собрано %d, новых %d" % (got, new)) @@ -455,7 +456,7 @@ def push_new(per_chat=5, window=2000): fresh = [i for i in items if (i.get("id") or 0) > (last or 0)] if not fresh: continue - chunk = fresh[-per_chat:] + chunk = fresh[:per_chat] # раньше уходили САМЫЕ СТАРЫЕ из окна fmt = prefs_get(target) if isinstance(target, int) else "short" tr = sm = 0 if isinstance(target, int): @@ -489,6 +490,8 @@ def pusher(every=300): print("pusher fail", e, flush=True) +READONLY_CMDS = {"/start", "/help", "/new", "/abuse", "/find", "/src", "/stats"} + CMDS = {"/start": cmd_start, "/help": cmd_start, "/new": cmd_new, "/abuse": cmd_abuse, "/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run, "/auto": cmd_auto, "/stats": cmd_stats, @@ -504,13 +507,14 @@ def handle(update): uid = (msg.get("from") or {}).get("id") if not chat_id or not text: return - if OWNERS and uid not in OWNERS: - return - if not OWNERS: - OWNERS.add(uid) # первый написавший становится владельцем до рестарта parts = text.split(None, 1) cmd = parts[0].lower().split("@")[0] arg = parts[1] if len(parts) > 1 else "" + if OWNERS and uid not in OWNERS: + return + if not OWNERS and cmd not in READONLY_CMDS: + # раньше первый написавший становился владельцем: любой, кто нашёл бота + return _send(chat_id, "read-only: задай OMNI_BOT_OWNERS и перезапусти бота") fn = CMDS.get(cmd) if not fn: return _send(chat_id, "не знаю такую команду. /help") diff --git a/omni/net.py b/omni/net.py index c1f9145..3644e93 100644 --- a/omni/net.py +++ b/omni/net.py @@ -44,14 +44,16 @@ class Resp: def _throttle(host): + """Слот резервируется под локом ДО сна: иначе N потоков проходят одновременно. + Замер до фикса: 24 вызова на хост при 0.3с == 0.9с вместо 6.9с.""" delay = float(_cfg.get("per_host_delay", 0.6)) with _lock: + now = time.time() prev = _last.get(host, 0) - wait = delay - (time.time() - prev) + wait = max(0.0, delay - (now - prev)) + _last[host] = now + wait if wait > 0: time.sleep(wait) - with _lock: - _last[host] = time.time() def _pick_proxy(): diff --git a/omni/serve.py b/omni/serve.py index 162f4dd..e48a62f 100644 --- a/omni/serve.py +++ b/omni/serve.py @@ -3,6 +3,7 @@ Запуск: python -m omni serve --port 8077 """ import json +import os import pathlib import re import threading @@ -25,11 +26,36 @@ def _jid(prefix): return "%s%d" % (prefix, SEQ[0]) +UI_TOKEN = os.environ.get("OMNI_UI_TOKEN", "") + + +def _ui_auth(req): + """Панель наружу: без токена мутации открыты любому, кто дошёл до порта.""" + if not UI_TOKEN: + return True + return req.headers.get("X-Omni-Token") == UI_TOKEN + + +def _intv(q, key, default, lo, hi): + """int из query без падений: ?limit=abc раньше давал трейс и обрыв соединения, + ?limit=-1 отдавал всю базу (SQLite LIMIT -1 = без лимита).""" + try: + v = int((q.get(key) or [str(default)])[0]) + except (TypeError, ValueError): + return default + return max(lo, min(hi, v)) + + def _job(jid, **kw): with _LOCK: cur = JOBS.get(jid) or {} cur.update(kw) + cur["_ts"] = time.time() JOBS[jid] = cur + if len(JOBS) > 400: # раньше росло без границ (500 запусков -> 500 записей) + dead = sorted(JOBS.items(), key=lambda kv: kv[1].get("_ts", 0))[:len(JOBS) - 300] + for k, _v in dead: + JOBS.pop(k, None) def _cat_health(): @@ -212,7 +238,7 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/runs": try: - limit = max(1, min(500, int((q.get("limit") or ["80"])[0]))) + limit = _intv(q, "limit", 80, 1, 500) except ValueError: limit = 80 import time as _t @@ -241,7 +267,7 @@ class Handler(BaseHTTPRequestHandler): if tag in ("all", "все"): tag = None try: - lim = max(1, min(200, int((q.get("limit") or ["40"])[0]))) + lim = _intv(q, "limit", 40, 1, 200) except ValueError: lim = 40 return self._json(_ab.scan(limit=lim, tag=tag)) @@ -251,7 +277,7 @@ class Handler(BaseHTTPRequestHandler): if tag in ("all", "все"): tag = None try: - lim = max(1, min(40, int((q.get("limit") or ["14"])[0]))) + lim = _intv(q, "limit", 14, 1, 40) except ValueError: lim = 14 try: @@ -302,8 +328,8 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/items": cat = (q.get("category") or [None])[0] query = (q.get("q") or [""])[0].strip() - limit = min(int((q.get("limit") or ["40"])[0]), 200) - offset = int((q.get("offset") or ["0"])[0]) + limit = _intv(q, "limit", 40, 1, 200) + offset = _intv(q, "offset", 0, 0, 1_000_000) sort = (q.get("sort") or ["score"])[0] priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on") typ = (q.get("type") or ["all"])[0] @@ -349,7 +375,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): u = urllib.parse.urlsplit(self.path) - n = int(self.headers.get("Content-Length") or 0) + if not _ui_auth(self): + return self._json({"error": "unauthorized"}, 401) + n = min(int(self.headers.get("Content-Length") or 0), 2_000_000) body = {} if n: try: @@ -360,8 +388,8 @@ class Handler(BaseHTTPRequestHandler): if u.path == "/api/discover": from . import discover as _d q = urllib.parse.parse_qs(u.query) - hosts = max(5, min(120, int((q.get("hosts") or ["40"])[0]))) - add = max(1, min(60, int((q.get("add") or ["25"])[0]))) + hosts = _intv(q, "hosts", 40, 5, 120) + add = _intv(q, "add", 25, 1, 60) dry = (q.get("dry") or ["0"])[0] not in ("0", "false", "") res = _d.discover(limit_hosts=hosts, workers=8, max_add=add, dry=dry) return self._json(res) @@ -421,4 +449,6 @@ class Handler(BaseHTTPRequestHandler): def serve(port=8077): srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) print(f"omni ui: http://127.0.0.1:{port} (источников {len(sources.all_sources())})") + if not UI_TOKEN: + print("внимание: OMNI_UI_TOKEN не задан — POST /api/* без авторизации; наружу только за nginx с basic-auth") srv.serve_forever() \ No newline at end of file diff --git a/omni/source_state.py b/omni/source_state.py index 2ae0686..993f7d8 100644 --- a/omni/source_state.py +++ b/omni/source_state.py @@ -4,6 +4,7 @@ source_flags (name, enabled, status, items, note, checked) source_custom (name, category, kind, url, adapter, tags, limit, detail, added) """ +import contextlib import json import sqlite3 import time @@ -34,9 +35,24 @@ def conn(): return c +@contextlib.contextmanager +def db(): + """Соединение с гарантированным close(): `with conn()` коммитит, но не закрывает, + и хендлы копятся (замер: 200 вызовов == +76 handles).""" + c = conn() + try: + yield c + c.commit() + except Exception: + c.rollback() + raise + finally: + c.close() + + def flags(): """{name: {enabled, status, items, note, checked}}""" - with conn() as c: + with db() as c: rows = c.execute("SELECT name, enabled, status, items, note, checked FROM source_flags").fetchall() return {r[0]: {"enabled": bool(r[1]), "status": r[2], "items": r[3], "note": r[4], "checked": r[5]} for r in rows} @@ -44,7 +60,7 @@ def flags(): def set_enabled(name, enabled): now = int(time.time()) - with conn() as c: + with db() as c: c.execute("INSERT INTO source_flags(name, enabled, checked) VALUES(?,?,?) " "ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled", (name, 1 if enabled else 0, now)) @@ -53,7 +69,7 @@ def set_enabled(name, enabled): def record_check(name, status, items, note=""): now = int(time.time()) - with conn() as c: + with db() as c: c.execute("INSERT INTO source_flags(name, enabled, status, items, note, checked) VALUES(?,1,?,?,?,?) " "ON CONFLICT(name) DO UPDATE SET status=excluded.status, items=excluded.items, " "note=excluded.note, checked=excluded.checked", @@ -62,7 +78,7 @@ def record_check(name, status, items, note=""): def custom(): - with conn() as c: + with db() as c: rows = c.execute("SELECT name, category, kind, url, adapter, tags, lim, detail, added " "FROM source_custom ORDER BY added DESC").fetchall() out = [] @@ -81,7 +97,7 @@ def add_custom(name, category, url, kind=None, adapter=None, tags=(), limit=20, if not url.startswith("http"): raise ValueError("url должен начинаться с http") kind, adapter = kind or guess_kind(url), adapter or guess_adapter(url, kind) - with conn() as c: + with db() as c: c.execute("INSERT OR REPLACE INTO source_custom(name, category, kind, url, adapter, tags, lim, detail, added) " "VALUES(?,?,?,?,?,?,?,?,?)", (name, category or "custom", kind, url, adapter, @@ -91,7 +107,7 @@ def add_custom(name, category, url, kind=None, adapter=None, tags=(), limit=20, def remove_custom(name): - with conn() as c: + with db() as c: c.execute("DELETE FROM source_custom WHERE name=?", (name,)) c.execute("DELETE FROM source_flags WHERE name=?", (name,)) return {"removed": name} diff --git a/omni/sources.py b/omni/sources.py index 5bc5850..e5920ea 100644 --- a/omni/sources.py +++ b/omni/sources.py @@ -14,6 +14,7 @@ from .sources_ext import EXTRA from .sources_ext2 import EXTRA2 from .sources_ext3 import EXTRA3 from .sources_abuse import ABUSE_LIVE +from .abuse_sources import ABUSE as ABUSE_DEEP from .sources_ext4 import EXTRA5 from .sources_ext5 import EXTRA6 from .sources_x import X_SOURCES @@ -200,14 +201,14 @@ def _dedup(rows): return out -SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6] + [Source(**d) for d in X_SOURCES]) +SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6] + [Source(**d) for d in ABUSE_DEEP] + [Source(**d) for d in X_SOURCES]) BY_NAME = {s.name: s for s in SOURCES} CATEGORIES = sorted({s.category for s in SOURCES}) def all_sources(): - """Реестр + пользовательские источники из панели.""" + """Реестр + пользовательские источники из панели, без дублей по url.""" from . import source_state extra = [] for d in source_state.custom(): @@ -216,8 +217,14 @@ def all_sources(): d = {k: v for k, v in d.items() if k in ("name", "category", "kind", "url", "adapter", "tags", "limit", "detail", "extra")} extra.append(Source(**d)) - return SOURCES + extra - + seen, out = set(), [] + for s in SOURCES + extra: + key = (s.url or "").rstrip("/").lower() + if key and key in seen: + continue + seen.add(key) + out.append(s) + return out def enabled_sources(): """Только включённые: выключенные в панели источники не дёргаем.""" diff --git a/omni/store.py b/omni/store.py index d7a6e1b..7ec5c3d 100644 --- a/omni/store.py +++ b/omni/store.py @@ -1,4 +1,5 @@ """Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам.""" +import contextlib import json import sqlite3 import time @@ -50,6 +51,21 @@ def conn(): return c +@contextlib.contextmanager +def db(): + """Соединение с гарантированным close(): `with conn()` коммитит, но не закрывает, + и хендлы копятся (замер: 200 вызовов == +76 handles).""" + c = conn() + try: + yield c + c.commit() + except Exception: + c.rollback() + raise + finally: + c.close() + + def _signed(h): return h - (1 << 64) if h >= (1 << 63) else h @@ -59,18 +75,25 @@ def _unsigned(v): def _popcount(x): - return bin(x).count("1") + try: + return x.bit_count() # py3.10+, быстрее bin().count("1") + except AttributeError: + return bin(x).count("1") def _near_dup(c, h, max_dist=3): """Кандидат ищем по полосе, решение принимаем по расстоянию Хэмминга.""" h = _unsigned(h) + seen = set() for b in range(BANDS): band = (h >> (16 * b)) & MASK rows = c.execute( - "SELECT i.h FROM bands b JOIN items i ON i.id=b.item_id " - "WHERE b.band=? LIMIT 200", (b,)).fetchall() - for (other,) in rows: + "SELECT i.id, i.h FROM bands b JOIN items i ON i.id=b.item_id " + "WHERE b.band=?", (b,)).fetchall() + for iid, other in rows: + if iid in seen: + continue + seen.add(iid) if _popcount(_unsigned(other) ^ h) <= max_dist: return True return False @@ -80,7 +103,7 @@ def add(items): """Возвращает (новых, дублей).""" new = dup = 0 ts = int(time.time()) - with conn() as c: + with db() as c: for it in items: h = _unsigned(it.h) if _near_dup(c, h): @@ -111,7 +134,7 @@ def add(items): def log_run(provider, source, query, got, kept, status, nbytes=0, mode=""): - with conn() as c: + with db() as c: c.execute("INSERT INTO runs(ts,provider,source,query,got,kept,status,bytes,mode) " "VALUES(?,?,?,?,?,?,?,?,?)", (int(time.time()), provider, source, (query or "")[:200], got, kept, status, nbytes, mode)) @@ -122,12 +145,12 @@ def top(limit=20, provider=None): + ("WHERE provider=? " if provider else "") + "ORDER BY score DESC, id DESC LIMIT ?") args = (provider, limit) if provider else (limit,) - with conn() as c: + with db() as c: return c.execute(q, args).fetchall() def search(q, limit=20): - with conn() as c: + with db() as c: rows = c.execute( "SELECT i.provider,i.kind,i.source,i.title,i.score,i.url FROM items_fts f " "JOIN items i ON i.id=f.rowid WHERE items_fts MATCH ? ORDER BY i.score DESC LIMIT ?", @@ -153,7 +176,7 @@ def browse(category=None, q="", limit=60, offset=0): args += [f"%{q}%", f"%{q}%"] sql += "ORDER BY score DESC, id DESC LIMIT ? OFFSET ?" args += [limit, offset] - with conn() as c: + with db() as c: rows = c.execute(sql, args).fetchall() keys = ("provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url", "types") return [dict(zip(keys, r)) for r in rows] @@ -177,7 +200,7 @@ def browse2(q="", names=None, limit=60, offset=0, sort="score", priced=False, ty order = {"fresh": "ORDER BY published DESC, id DESC", "price": "ORDER BY CAST(price AS REAL) DESC, score DESC", "old": "ORDER BY published ASC, id ASC"}.get(sort, "ORDER BY score DESC, id DESC") - with conn() as c: + with db() as c: total = c.execute("SELECT COUNT(*) FROM items " + where, args).fetchone()[0] rows = c.execute("SELECT provider,kind,source,title,text,author,published,score,price,currency,url,types " "FROM items " + where + order + " LIMIT ? OFFSET ?", @@ -189,7 +212,7 @@ def browse2(q="", names=None, limit=60, offset=0, sort="score", priced=False, ty def type_counts(): """Сколько записей на каждый тип.""" out = {} - with conn() as c: + with db() as c: for row in c.execute("SELECT COALESCE(types,'') FROM items"): for t in (row[0] or "").split(","): t = t.strip() @@ -201,7 +224,7 @@ def type_counts(): def retype(limit=None): """Пересчитать типы у уже сохранённых записей.""" n = 0 - with conn() as c: + with db() as c: q = "SELECT id,title,text FROM items" if limit: q += " LIMIT %d" % int(limit) @@ -213,11 +236,11 @@ def retype(limit=None): def count_by_source(): - with conn() as c: + with db() as c: return dict(c.execute("SELECT source, COUNT(*) FROM items GROUP BY source").fetchall()) def stats(): - with conn() as c: + with db() as c: total = c.execute("SELECT COUNT(*) FROM items").fetchone()[0] by_prov = c.execute("SELECT provider, COUNT(*) FROM items GROUP BY provider ORDER BY 2 DESC").fetchall() by_kind = c.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY 2 DESC").fetchall() @@ -230,7 +253,7 @@ def stats(): def export(path, fmt="jsonl", limit=1000): path = Path(path) - with conn() as c: + with db() as c: rows = c.execute("SELECT provider,kind,source,url,title,text,author,published,lang,tags," "price,currency,score,extra FROM items ORDER BY score DESC LIMIT ?", (limit,)).fetchall() @@ -255,7 +278,7 @@ def dedupe(apply: bool = False): Оставляет самую свежую (max id). Возвращает (групп, лишних_строк, удалено). """ key = "CASE WHEN url IS NULL OR url = '' THEN source || '|' || title ELSE url END" - with conn() as c: + with db() as c: groups = c.execute( "SELECT count(*) FROM (SELECT " + key + " k FROM items GROUP BY k HAVING count(*) > 1)").fetchone()[0] extra = c.execute( diff --git a/tests/test_regress.py b/tests/test_regress.py new file mode 100644 index 0000000..3cd1a5a --- /dev/null +++ b/tests/test_regress.py @@ -0,0 +1,140 @@ +"""Регресс-тесты под пункты прожарки. Без сети: только чистые функции и локальная база. + +Запуск: python -m pytest tests -q +""" +import gc +import os +import sqlite3 +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + +# ---------- 1. store.add возвращает пару, /run больше не падает ---------- +def test_store_add_returns_pair(): + from omni import store + from omni.model import Item + + it = Item(provider="t", kind="post", source="pytest-src", title="pytest %d" % time.time_ns(), + url="https://example.invalid/%d" % time.time_ns()) + res = store.add([it]) + assert isinstance(res, tuple) and len(res) == 2, "store.add обязан возвращать (new, dup)" + new, dup = res + assert isinstance(new, int) and isinstance(dup, int) + + +def test_bot_run_unpacks_pair(): + import inspect + + from omni import bot + src = inspect.getsource(bot.cmd_run) + assert "new += store.add(items)" not in src, "старый баг: += по кортежу" + assert "n_new" in src + + +# ---------- 2. параметры API ---------- +@pytest.mark.parametrize("raw,default,lo,hi,expect", [ + ("abc", 40, 1, 200, 40), + ("-1", 40, 1, 200, 1), + ("99999", 40, 1, 200, 200), + ("25", 40, 1, 200, 25), + (None, 40, 1, 200, 40), + ("", 40, 1, 200, 40), +]) +def test_intv(raw, default, lo, hi, expect): + from omni.serve import _intv + q = {} if raw is None else {"limit": [raw]} + assert _intv(q, "limit", default, lo, hi) == expect + + +def test_no_raw_int_parsing_in_serve(): + src = (ROOT / "omni" / "serve.py").read_text(encoding="utf-8") + assert "min(int((q.get" not in src, "сырой int() по query-параметру вернулся" + + +# ---------- 3. соединения закрываются ---------- +def test_no_sqlite_conn_leak(): + from omni import store + + def live(): + gc.collect() + return sum(1 for o in gc.get_objects() if isinstance(o, sqlite3.Connection)) + + for _ in range(5): + store.top(limit=1) + base = live() + for _ in range(40): + store.top(limit=1) + assert live() - base <= 2, "соединения копятся: db() не закрывает" + + +# ---------- 4. троттлинг ---------- +def test_throttle_serialises(): + from omni import net + old = net._cfg.get("per_host_delay") + net._cfg["per_host_delay"] = 0.05 + net._last.clear() + try: + t0 = time.time() + for _ in range(10): + net._throttle("pytest.example") + el = time.time() - t0 + finally: + net._cfg["per_host_delay"] = old if old is not None else 0.6 + assert el >= 0.40, "троттлинг не держит паузу: %.2fs на 10 вызовов" % el + + +def test_throttle_threads(): + import threading + + from omni import net + old = net._cfg.get("per_host_delay") + net._cfg["per_host_delay"] = 0.05 + net._last.clear() + try: + t0 = time.time() + ths = [threading.Thread(target=lambda: [net._throttle("pytest.mt") for _ in range(3)]) for _ in range(6)] + [t.start() for t in ths] + [t.join() for t in ths] + el = time.time() - t0 + finally: + net._cfg["per_host_delay"] = old if old is not None else 0.6 + assert el >= 0.6, "8 потоков проскакивают хост одновременно: %.2fs" % el + + +# ---------- 5. владелец бота ---------- +def test_bot_not_claimable_by_first_writer(monkeypatch): + from omni import bot + sent = [] + monkeypatch.setattr(bot, "OWNERS", set()) + monkeypatch.setattr(bot, "_send", lambda cid, text: sent.append(text)) + bot.handle({"message": {"chat": {"id": 1}, "from": {"id": 999999}, "text": "/run abuse"}}) + assert not bot.OWNERS, "бот снова назначает владельца по первому сообщению" + assert sent and "read-only" in sent[0] + + +# ---------- 6. реестр без дублей ---------- +def test_sources_unique(): + from omni import sources + urls = [s.url.rstrip("/").lower() for s in sources.all_sources() if s.url] + assert len(urls) == len(set(urls)), "в реестре дубли по url" + + +# ---------- 7. JOBS не растёт бесконечно ---------- +def test_jobs_evicted(): + from omni import serve + for i in range(500): + serve._job("t%d" % i, status="done") + assert len(serve.JOBS) <= 400, "JOBS растёт без границ: %d" % len(serve.JOBS) + + +# ---------- 8. ai.py не спит после последней попытки ---------- +def test_ai_does_not_sleep_after_last_attempt(): + src = (ROOT / "omni" / "ai.py").read_text(encoding="utf-8") + assert "time.sleep(1.5 + attempt)" not in src \ No newline at end of file diff --git a/tools/_port_fixes.py b/tools/_port_fixes.py new file mode 100644 index 0000000..8ebf6ce --- /dev/null +++ b/tools/_port_fixes.py @@ -0,0 +1,199 @@ +"""Портирование проверенных фиксов из ревью (без снятия /abuse) + свои правки. + +Каждая замена ассертится: если шаблон не найден — падаем, а не молча пропускаем. +""" +import io +import sys +from pathlib import Path + +ROOT = Path(r"C:/Users/User/tmp/omni-parse") +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +report = [] + + +def patch(rel, pairs, count_check=True): + p = ROOT / rel + s = p.read_text(encoding="utf-8") + orig = s + for old, new in pairs: + n = s.count(old) + if n == 0: + raise SystemExit(f"НЕ НАЙДЕНО в {rel}: {old[:70]!r}") + s = s.replace(old, new) + report.append(f"{rel}: заменено {n}x {old.strip().splitlines()[0][:60]}") + p.write_text(s, encoding="utf-8", newline="\n") + return orig != s + + +DB_CTX = '''@contextlib.contextmanager +def db(): + """Соединение с гарантированным close(): `with conn()` коммитит, но не закрывает, + и хендлы копятся (замер: 200 вызовов == +76 handles).""" + c = conn() + try: + yield c + c.commit() + except Exception: + c.rollback() + raise + finally: + c.close() + + +''' + +# ---------- store.py ---------- +p = ROOT / "omni/store.py" +s = p.read_text(encoding="utf-8") +s = s.replace('"""Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам."""\n', + '"""Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам."""\nimport contextlib\n', 1) +anchor = "def _signed(h):" +assert anchor in s +s = s.replace(anchor, DB_CTX + anchor, 1) +n = s.count("with conn() as c:") +s = s.replace("with conn() as c:", "with db() as c:") +report.append(f"store.py: db() добавлен, {n} вызовов переведены на close()") + +s = s.replace('''def _popcount(x): + return bin(x).count("1")''', +'''def _popcount(x): + try: + return x.bit_count() # py3.10+, быстрее bin().count("1") + except AttributeError: + return bin(x).count("1")''') + +old_near = ''' h = _unsigned(h) + for b in range(BANDS): + band = (h >> (16 * b)) & MASK + rows = c.execute( + "SELECT i.h FROM bands b JOIN items i ON i.id=b.item_id " + "WHERE b.band=? LIMIT 200", (b,)).fetchall() + for (other,) in rows: + if _popcount(_unsigned(other) ^ h) <= max_dist: + return True + return False''' +new_near = ''' h = _unsigned(h) + seen = set() + for b in range(BANDS): + band = (h >> (16 * b)) & MASK + rows = c.execute( + "SELECT i.id, i.h FROM bands b JOIN items i ON i.id=b.item_id " + "WHERE b.band=?", (b,)).fetchall() + for iid, other in rows: + if iid in seen: + continue + seen.add(iid) + if _popcount(_unsigned(other) ^ h) <= max_dist: + return True + return False''' +assert old_near in s, "near_dup шаблон не найден" +s = s.replace(old_near, new_near) +report.append("store.py: _near_dup без LIMIT 200 (полный проход по полосе)") +p.write_text(s, encoding="utf-8", newline="\n") + +# ---------- source_state.py ---------- +patch("omni/source_state.py", [ + (' source_custom (name, category, kind, url, adapter, tags, limit, detail, added)\n"""\n', + ' source_custom (name, category, kind, url, adapter, tags, limit, detail, added)\n"""\nimport contextlib\n'), + ("def flags():", DB_CTX + "def flags():"), + ("with conn() as c:", "with db() as c:"), +]) + +# ---------- net.py: троттлинг ---------- +patch("omni/net.py", [ + ('''def _throttle(host): + delay = float(_cfg.get("per_host_delay", 0.6)) + with _lock: + prev = _last.get(host, 0) + wait = delay - (time.time() - prev) + if wait > 0: + time.sleep(wait) + with _lock: + _last[host] = time.time()''', + '''def _throttle(host): + """Слот резервируется под локом ДО сна: иначе N потоков проходят одновременно. + Замер до фикса: 24 вызова на хост при 0.3с == 0.9с вместо 6.9с.""" + delay = float(_cfg.get("per_host_delay", 0.6)) + with _lock: + now = time.time() + prev = _last.get(host, 0) + wait = max(0.0, delay - (now - prev)) + _last[host] = now + wait + if wait > 0: + time.sleep(wait)'''), +]) + +# ---------- ai.py: не спать в пустоту ---------- +patch("omni/ai.py", [ + (''' except Exception: + time.sleep(1.5 + attempt)''', + ''' except Exception: + if attempt == 0: # после последней попытки спим в пустоту + time.sleep(1.5)'''), +]) + +# ---------- bot.py ---------- +patch("omni/bot.py", [ + (" new += store.add(items)", + " n_new, _dup = store.add(items)\n new += n_new"), + (" chunk = fresh[-per_chat:]", + " chunk = fresh[:per_chat] # раньше уходили САМЫЕ СТАРЫЕ из окна"), + ('''CMDS = {"/start": cmd_start,''', + '''READONLY_CMDS = {"/start", "/help", "/new", "/abuse", "/find", "/src", "/stats"} + +CMDS = {"/start": cmd_start,'''), + (''' if OWNERS and uid not in OWNERS: + return + if not OWNERS: + OWNERS.add(uid) # первый написавший становится владельцем до рестарта + parts = text.split(None, 1) + cmd = parts[0].lower().split("@")[0] + arg = parts[1] if len(parts) > 1 else ""''', + ''' parts = text.split(None, 1) + cmd = parts[0].lower().split("@")[0] + arg = parts[1] if len(parts) > 1 else "" + if OWNERS and uid not in OWNERS: + return + if not OWNERS and cmd not in READONLY_CMDS: + # раньше первый написавший становился владельцем: любой, кто нашёл бота + return _send(chat_id, "read-only: задай OMNI_BOT_OWNERS и перезапусти бота")'''), + ("Доступ: OMNI_BOT_OWNERS=7448683285,123 (если пусто — только первый написавший)", + "Доступ: OMNI_BOT_OWNERS=, (пусто = read-only: /new /abuse /find /src /stats)"), +]) + +# ---------- sources.py: дедуп по url ---------- +p = ROOT / "omni/sources.py" +s = p.read_text(encoding="utf-8") +if "def all_sources():" in s: + s2 = s.replace("def all_sources():", '''def all_sources(): + """Реестр без дублей по url: одинаковые площадки приходили из разных наборов."""''', 1) + # добавляем дедуп в возврат: ищем return внутри all_sources + idx = s2.index("def all_sources():") + tail = s2[idx:] + if "return _dedup(" in tail: + report.append("sources.py: дедуп уже есть") + else: + # оборачиваем последний return списка + import re + m = list(re.finditer(r"\n return (\[\]|\w+|\w+ \+|.*?)\n(?=\n|$)", tail)) + report.append(f"sources.py: return-кандидатов {len(m)}") + # используем _dedup через хелпер, определённый перед all_sources + s2 = s2.replace("def all_sources():", '''def _dedup(items): + seen, out = set(), [] + for it in items: + key = (it.url or "").rstrip("/") + if key and key in seen: + continue + seen.add(key) + out.append(it) + return out + + +def all_sources():''', 1) + s2 = re.sub(r"(\n return )(?!_dedup)(\[[^\n]*\]|\w+(?: \+ \w+)*)\n", r"\1_dedup(\2)\n", s2, count=1) + report.append("sources.py: _dedup + обёртка return") + s = s2 +p.write_text(s, encoding="utf-8", newline="\n") + +print("\n".join(report)) \ No newline at end of file diff --git a/tools/_port_serve.py b/tools/_port_serve.py new file mode 100644 index 0000000..49a1f29 --- /dev/null +++ b/tools/_port_serve.py @@ -0,0 +1,85 @@ +"""serve.py: валидация int-параметров, eviction JOBS, токен на POST, лимит тела.""" +from pathlib import Path + +p = Path(r"C:/Users/User/tmp/omni-parse/omni/serve.py") +s = p.read_text(encoding="utf-8") +rep = [] + +if "\nimport os\n" not in s: + s = s.replace("import json\n", "import json\nimport os\n", 1) + rep.append("+ import os") + +helper = ''' + +UI_TOKEN = os.environ.get("OMNI_UI_TOKEN", "") + + +def _ui_auth(req): + """Панель наружу: без токена мутации открыты любому, кто дошёл до порта.""" + if not UI_TOKEN: + return True + return req.headers.get("X-Omni-Token") == UI_TOKEN + + +def _intv(q, key, default, lo, hi): + """int из query без падений: ?limit=abc раньше давал трейс и обрыв соединения, + ?limit=-1 отдавал всю базу (SQLite LIMIT -1 = без лимита).""" + try: + v = int((q.get(key) or [str(default)])[0]) + except (TypeError, ValueError): + return default + return max(lo, min(hi, v)) +''' + +assert "def _job(jid, **kw):" in s +s = s.replace("def _job(jid, **kw):", helper.strip("\n") + "\n\n\ndef _job(jid, **kw):", 1) + +s = s.replace(''' with _LOCK: + cur = JOBS.get(jid) or {} + cur.update(kw) + JOBS[jid] = cur''', +''' with _LOCK: + cur = JOBS.get(jid) or {} + cur.update(kw) + cur["_ts"] = time.time() + JOBS[jid] = cur + if len(JOBS) > 400: # раньше росло без границ (500 запусков -> 500 записей) + dead = sorted(JOBS.items(), key=lambda kv: kv[1].get("_ts", 0))[:len(JOBS) - 300] + for k, _v in dead: + JOBS.pop(k, None)''') +rep.append("JOBS eviction") + +pairs = [ + ('limit = max(1, min(500, int((q.get("limit") or ["80"])[0])))', 'limit = _intv(q, "limit", 80, 1, 500)'), + ('lim = max(1, min(200, int((q.get("limit") or ["40"])[0])))', 'lim = _intv(q, "limit", 40, 1, 200)'), + ('lim = max(1, min(40, int((q.get("limit") or ["14"])[0])))', 'lim = _intv(q, "limit", 14, 1, 40)'), + ('limit = min(int((q.get("limit") or ["40"])[0]), 200)', 'limit = _intv(q, "limit", 40, 1, 200)'), + ('offset = int((q.get("offset") or ["0"])[0])', 'offset = _intv(q, "offset", 0, 0, 1_000_000)'), + ('hosts = max(5, min(120, int((q.get("hosts") or ["40"])[0])))', 'hosts = _intv(q, "hosts", 40, 5, 120)'), + ('add = max(1, min(60, int((q.get("add") or ["25"])[0])))', 'add = _intv(q, "add", 25, 1, 60)'), +] +for old, new in pairs: + if old in s: + s = s.replace(old, new) + rep.append("int -> " + new.split(" = ")[0] + " " + new.split("_intv")[1][:26]) + +old_post = ''' def do_POST(self): + u = urllib.parse.urlsplit(self.path) + n = int(self.headers.get("Content-Length") or 0)''' +new_post = ''' def do_POST(self): + u = urllib.parse.urlsplit(self.path) + if not _ui_auth(self): + return self._json({"error": "unauthorized"}, 401) + n = min(int(self.headers.get("Content-Length") or 0), 2_000_000)''' +assert old_post in s, "do_POST шаблон не найден" +s = s.replace(old_post, new_post) +rep.append("POST: токен + лимит тела") + +s = s.replace(''' print(f"omni ui: http://127.0.0.1:{port} (источников {len(sources.all_sources())})")''', +''' print(f"omni ui: http://127.0.0.1:{port} (источников {len(sources.all_sources())})") + if not UI_TOKEN: + print("внимание: OMNI_UI_TOKEN не задан — POST /api/* без авторизации; наружу только за nginx с basic-auth")''') + +p.write_text(s, encoding="utf-8", newline="\n") +print("\n".join(rep)) +print("остались сырые int((q.get:", s.count("int((q.get")) \ No newline at end of file diff --git a/tools/_verify2.py b/tools/_verify2.py new file mode 100644 index 0000000..249e5b8 --- /dev/null +++ b/tools/_verify2.py @@ -0,0 +1,34 @@ +"""Точные замеры: живые sqlite-соединения, логика владельца бота, реальность rank.py.""" +import gc +import os +import re +import sqlite3 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + +def count_conns(): + gc.collect() + return sum(1 for o in gc.get_objects() if isinstance(o, sqlite3.Connection)) + + +from omni import store # noqa: E402 + +base = count_conns() +for _ in range(50): + store.top(limit=1) +print("sqlite conn живых: до", base, "после 50 вызовов", count_conns()) + +src = (ROOT / "omni" / "bot.py").read_text(encoding="utf-8") +m = re.search(r"if not OWNERS:.*?(?=\n\s*fn = |\n\s*cmd = )", src, re.S) +print("owner-блок:\n", (m.group(0).strip()[:400] if m else "не найден")) +print("OMNI_BOT_OWNERS в коде:", "OMNI_BOT_OWNERS" in src) + +cli = (ROOT / "omni" / "cli.py").read_text(encoding="utf-8") +print("rank в cli.py:", [ln.strip()[:80] for ln in cli.splitlines() if "rank" in ln][:4]) +rank = (ROOT / "omni" / "rank.py").read_text(encoding="utf-8") +print("rank.py функций:", re.findall(r"def (\w+)", rank)) \ No newline at end of file diff --git a/tools/_verify_claims.py b/tools/_verify_claims.py new file mode 100644 index 0000000..15b5a4b --- /dev/null +++ b/tools/_verify_claims.py @@ -0,0 +1,142 @@ +"""Проверка пунктов прожарки на текущем коде (до патчей). Только чтение, ничего не пишет в базу.""" +import gc +import json +import os +import re +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +import psutil # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +OUT = {} + + +def p(k, v): + OUT[k] = v + print(f"{k}: {v}") + + +# 2. валидация limit: эмулируем то, что делает serve для /api/items +def check_limit(): + def parse(raw): + try: + return min(int(raw), 200) + except ValueError as e: + return f"ValueError: {e}" + + p("limit[-1]", parse("-1")) + p("limit[abc]", parse("abc")) + p("limit[999999]", parse("999999")) + + +# 3. утечка соединений +def check_conn_leak(): + from omni import store + gc.collect() + before = len(psutil.Process().open_files()) + for _ in range(50): + store.top(limit=1) + gc.collect() + after = len(psutil.Process().open_files()) + p("fd_before", before) + p("fd_after_50_calls", after) + p("fd_leak", after - before) + + +# 4. троттлинг +def check_throttle(): + from omni import net + net._cfg["per_host_delay"] = 0.3 + net._last.clear() + t0 = time.time() + threads = [threading.Thread(target=lambda: [net._throttle("example.org") for _ in range(3)]) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + el = time.time() - t0 + expected = 0.3 * (8 * 3 - 1) + p("throttle_elapsed", round(el, 2)) + p("throttle_expected_min", round(expected, 2)) + p("throttle_broken", el < expected * 0.5) + + +# 6. мёртвые файлы +def check_dead(): + names = ["abuse_sources", "rank", "sources_abuse"] + for n in names: + hits = [] + for f in (ROOT / "omni").glob("*.py"): + if f.stem == n: + continue + txt = f.read_text(encoding="utf-8", errors="replace") + if re.search(rf"(import|from)\s+.*\b{n}\b", txt): + hits.append(f.name) + p(f"imports_{n}", hits) + + +# 7. дубли источников +def check_dupe_sources(): + from omni import sources + urls = [s.url for s in sources.all_sources()] + p("sources_total", len(urls)) + p("urls_unique", len(set(urls))) + p("dupe_groups", len(urls) - len(set(urls))) + + +# 8. except Exception +def check_except(): + total = 0 + for f in (ROOT / "omni").rglob("*.py"): + total += len(re.findall(r"except\s+Exception", f.read_text(encoding="utf-8", errors="replace"))) + p("except_exception_total", total) + + +# 9. JOBS без eviction +def check_jobs(): + from omni import serve + src = (ROOT / "omni" / "serve.py").read_text(encoding="utf-8") + p("jobs_has_eviction", "JOBS.pop" in src or "del JOBS" in src) + for i in range(500): + serve._job(f"j{i}", status="done") + p("jobs_len_after_500", len(serve.JOBS)) + + +# 10. sleep в ai.py +def check_ai_sleep(): + src = (ROOT / "omni" / "ai.py").read_text(encoding="utf-8") + m = re.search(r"except Exception:\s*\n\s*time\.sleep\(([^)]*)\)", src) + p("ai_sleep_line", m.group(0).replace("\n", " ") if m else None) + + +# 1. /run в боте +def check_bot_run(): + src = (ROOT / "omni" / "bot.py").read_text(encoding="utf-8") + p("bot_new_plus_equals_add", "new += store.add(items)" in src) + p("bot_cmd_abuse_present", "def cmd_abuse" in src) + p("bot_cmds_abuse_present", "cmd_abuse" in src) + + +if __name__ == "__main__": + import contextlib + import io + + check_limit() + with contextlib.redirect_stdout(io.StringIO()): + check_bot_run() + check_bot_run() + check_throttle() + check_dead() + check_dupe_sources() + check_except() + with contextlib.redirect_stderr(io.StringIO()): + check_jobs() + check_ai_sleep() + check_conn_leak() + print("\nJSON:", json.dumps(OUT, ensure_ascii=False, default=str)) \ No newline at end of file diff --git a/tools/selfcheck.py b/tools/selfcheck.py index 2adc595..37c706b 100644 --- a/tools/selfcheck.py +++ b/tools/selfcheck.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os import py_compile +import subprocess import sqlite3 import sys import time @@ -35,6 +36,17 @@ def one(con, sql, args=(), default=0): return r[0][0] if r and r[0][0] is not None else default +def run_tests(): + """Тесты — часть самоаудита: без них «работает» держится на памяти.""" + try: + r = subprocess.run([sys.executable, "-m", "pytest", "tests", "-q"], + cwd=str(ROOT), capture_output=True, text=True, timeout=300) + except Exception as e: + return {"ok": False, "note": type(e).__name__} + tail = (r.stdout or "").strip().splitlines() + return {"ok": r.returncode == 0, "note": tail[-1][:120] if tail else ""} + + def compile_all(): bad = [] for p in sorted((ROOT / "omni").glob("*.py")): @@ -48,6 +60,7 @@ def compile_all(): def collect(): st: dict = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")} st["compile_errors"] = compile_all() + st["tests"] = run_tests() from omni import sources, taxonomy # noqa: E402 @@ -93,6 +106,8 @@ def collect(): problems = [] if st["compile_errors"]: problems.append(f"compile: {len(st['compile_errors'])} файлов") + if not (st.get("tests") or {}).get("ok"): + problems.append(f"тесты: {(st.get('tests') or {}).get('note', 'fail')}") if not st["has_types_col"]: problems.append("нет колонки items.types") if st["items"] and st["typed"] < st["items"] * 0.95: @@ -131,6 +146,7 @@ _обновлено {st['ts']}_ |---|---| {rows} +тесты: {(st.get('tests') or {}).get('note') or '-'} самоаудит: {'OK' if not problems else '; '.join(problems)} """ return block @@ -165,7 +181,8 @@ def main(): print(json.dumps(st, ensure_ascii=False, indent=1)) else: print(f"items={st.get('items')} typed={st.get('typed')} sources={st.get('sources')} " - f"priced={st.get('priced')} runs={st.get('runs')} problems={st.get('problems') or 'OK'}") + f"priced={st.get('priced')} runs={st.get('runs')} " + f"tests={(st.get('tests') or {}).get('ok')} problems={st.get('problems') or 'OK'}") return 0