fix: 10 пунктов ревью — /run (кортеж store.add), валидация limit/offset в панели, close() соединений SQLite, троттлинг под локом, read-only бот без OMNI_BOT_OWNERS, eviction JOBS, push_new отдаёт свежие записи, ai.py не спит после последней попытки, дедуп реестра, подключён abuse-реестр (+191 источник); tests/test_regress.py 16 проверок + CI py3.10-3.12
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled

This commit is contained in:
ENI
2026-10-26 20:15:50 +03:00
parent 136aa88dd6
commit c9c0a11ef7
16 changed files with 806 additions and 54 deletions

31
.github/workflows/ci.yml vendored Normal file
View File

@@ -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()))"

View File

@@ -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 - **типы записей: 14** — каждая запись размечена по смыслу: новость 3693 · софт 516 · уязвимость 402 · релиз 361 · модель 331 · вопрос 265 · цена 205 · гайд 194 · датасет 126 · слив 90 · триал 41 · промо 34 · вакансия 21 · перепродажа 10
- **X/Twitter: 30 аккаунтов** читаются без API-ключей через `api.fxtwitter.com` (313 твитов в базе) - **X/Twitter: 30 аккаунтов** читаются без API-ключей через `api.fxtwitter.com` (313 твитов в базе)
- вкладки панели: `Лента` · `Источники` · `Прогоны` · `Абузы`; полоса чипов «тип», фильтр «только с ценой», сортировка `score/fresh/price`, экспорт CSV - вкладки панели: `Лента` · `Источники` · `Прогоны` · `Абузы`; полоса чипов «тип», фильтр «только с ценой», сортировка `score/fresh/price`, экспорт CSV
- health-аудит: живой/мёртвый/непроверенный по каждому источнику, причина в примечании - health-аудит: живой/мёртвый/непроверенный по каждому источнику, причина в примечании
- Telegram-бот на той же базе: `/new`, `/abuse`, `/find`, `/src`, `/run`, `/add`, `/auto`, `/sum`, `/tr`, `/stats` - 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` печатает, что реально поднялось в этом окружении. `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 ```bash

View File

@@ -1,19 +1,19 @@
# omni-parse · автоотчёт # omni-parse · автоотчёт
<!-- STATS:START --> <!-- STATS:START -->
_обновлено 2026-10-26T16:37:00+00:00_ _обновлено 2026-10-26T17:14:27+00:00_
| метрика | значение | | метрика | значение |
|---|---| |---|---|
| источников | 665 | | источников | 853 |
| категорий | 11 | | категорий | 11 |
| записей | 5594 | | записей | 5597 |
| с ценой | 224 | | с ценой | 224 |
| типизировано | 5594 | | типизировано | 5597 |
| прогонов | 120 | | прогонов | 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_altenens_is | 119 |
| ab_afflift_com | 119 | | ab_afflift_com | 119 |
тесты: 16 passed in 12.31s
самоаудит: OK самоаудит: OK
<!-- STATS:END --> <!-- STATS:END -->

View File

@@ -117,7 +117,8 @@ def chat(messages, model=None, max_tokens=700, temperature=0.2):
if out: if out:
return out return out
except Exception: except Exception:
time.sleep(1.5 + attempt) if attempt == 0: # после последней попытки спим в пустоту
time.sleep(1.5)
return None return None

View File

@@ -1,7 +1,7 @@
"""Telegram-бот поверх парсера: лента, абузы, источники, прогон, автодобавление. """Telegram-бот поверх парсера: лента, абузы, источники, прогон, автодобавление.
Запуск: OMNI_BOT_TOKEN=... python -m omni.bot Запуск: OMNI_BOT_TOKEN=... python -m omni.bot
Доступ: OMNI_BOT_OWNERS=7448683285,123 (если пусто — только первый написавший) Доступ: OMNI_BOT_OWNERS=<id>,<id> (пусто = read-only: /new /abuse /find /src /stats)
Бот не постит никуда сам: только отвечает в личный чат тем, кто в списке. Бот не постит никуда сам: только отвечает в личный чат тем, кто в списке.
Команды: Команды:
@@ -157,7 +157,8 @@ def cmd_run(chat_id, arg):
items = [] items = []
got += len(items) got += len(items)
if items: if items:
new += store.add(items) n_new, _dup = store.add(items)
new += n_new
_send(chat_id, "готово: собрано %d, новых %d" % (got, 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)] fresh = [i for i in items if (i.get("id") or 0) > (last or 0)]
if not fresh: if not fresh:
continue continue
chunk = fresh[-per_chat:] chunk = fresh[:per_chat] # раньше уходили САМЫЕ СТАРЫЕ из окна
fmt = prefs_get(target) if isinstance(target, int) else "short" fmt = prefs_get(target) if isinstance(target, int) else "short"
tr = sm = 0 tr = sm = 0
if isinstance(target, int): if isinstance(target, int):
@@ -489,6 +490,8 @@ def pusher(every=300):
print("pusher fail", e, flush=True) 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, 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, "/find": cmd_find, "/src": cmd_src, "/add": cmd_add, "/run": cmd_run,
"/auto": cmd_auto, "/stats": cmd_stats, "/auto": cmd_auto, "/stats": cmd_stats,
@@ -504,13 +507,14 @@ def handle(update):
uid = (msg.get("from") or {}).get("id") uid = (msg.get("from") or {}).get("id")
if not chat_id or not text: if not chat_id or not text:
return return
if OWNERS and uid not in OWNERS:
return
if not OWNERS:
OWNERS.add(uid) # первый написавший становится владельцем до рестарта
parts = text.split(None, 1) parts = text.split(None, 1)
cmd = parts[0].lower().split("@")[0] cmd = parts[0].lower().split("@")[0]
arg = parts[1] if len(parts) > 1 else "" 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) fn = CMDS.get(cmd)
if not fn: if not fn:
return _send(chat_id, "не знаю такую команду. /help") return _send(chat_id, "не знаю такую команду. /help")

View File

@@ -44,14 +44,16 @@ class Resp:
def _throttle(host): def _throttle(host):
"""Слот резервируется под локом ДО сна: иначе N потоков проходят одновременно.
Замер до фикса: 24 вызова на хост при 0.3с == 0.9с вместо 6.9с."""
delay = float(_cfg.get("per_host_delay", 0.6)) delay = float(_cfg.get("per_host_delay", 0.6))
with _lock: with _lock:
now = time.time()
prev = _last.get(host, 0) prev = _last.get(host, 0)
wait = delay - (time.time() - prev) wait = max(0.0, delay - (now - prev))
_last[host] = now + wait
if wait > 0: if wait > 0:
time.sleep(wait) time.sleep(wait)
with _lock:
_last[host] = time.time()
def _pick_proxy(): def _pick_proxy():

View File

@@ -3,6 +3,7 @@
Запуск: python -m omni serve --port 8077 Запуск: python -m omni serve --port 8077
""" """
import json import json
import os
import pathlib import pathlib
import re import re
import threading import threading
@@ -25,11 +26,36 @@ def _jid(prefix):
return "%s%d" % (prefix, SEQ[0]) 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): def _job(jid, **kw):
with _LOCK: with _LOCK:
cur = JOBS.get(jid) or {} cur = JOBS.get(jid) or {}
cur.update(kw) cur.update(kw)
cur["_ts"] = time.time()
JOBS[jid] = cur 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(): def _cat_health():
@@ -212,7 +238,7 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/runs": if path == "/api/runs":
try: try:
limit = max(1, min(500, int((q.get("limit") or ["80"])[0]))) limit = _intv(q, "limit", 80, 1, 500)
except ValueError: except ValueError:
limit = 80 limit = 80
import time as _t import time as _t
@@ -241,7 +267,7 @@ class Handler(BaseHTTPRequestHandler):
if tag in ("all", "все"): if tag in ("all", "все"):
tag = None tag = None
try: try:
lim = max(1, min(200, int((q.get("limit") or ["40"])[0]))) lim = _intv(q, "limit", 40, 1, 200)
except ValueError: except ValueError:
lim = 40 lim = 40
return self._json(_ab.scan(limit=lim, tag=tag)) return self._json(_ab.scan(limit=lim, tag=tag))
@@ -251,7 +277,7 @@ class Handler(BaseHTTPRequestHandler):
if tag in ("all", "все"): if tag in ("all", "все"):
tag = None tag = None
try: try:
lim = max(1, min(40, int((q.get("limit") or ["14"])[0]))) lim = _intv(q, "limit", 14, 1, 40)
except ValueError: except ValueError:
lim = 14 lim = 14
try: try:
@@ -302,8 +328,8 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/items": if path == "/api/items":
cat = (q.get("category") or [None])[0] cat = (q.get("category") or [None])[0]
query = (q.get("q") or [""])[0].strip() query = (q.get("q") or [""])[0].strip()
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)
sort = (q.get("sort") or ["score"])[0] sort = (q.get("sort") or ["score"])[0]
priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on") priced = (q.get("priced") or ["0"])[0] in ("1", "true", "on")
typ = (q.get("type") or ["all"])[0] typ = (q.get("type") or ["all"])[0]
@@ -349,7 +375,9 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
u = urllib.parse.urlsplit(self.path) 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 = {} body = {}
if n: if n:
try: try:
@@ -360,8 +388,8 @@ class Handler(BaseHTTPRequestHandler):
if u.path == "/api/discover": if u.path == "/api/discover":
from . import discover as _d from . import discover as _d
q = urllib.parse.parse_qs(u.query) q = urllib.parse.parse_qs(u.query)
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)
dry = (q.get("dry") or ["0"])[0] not in ("0", "false", "") dry = (q.get("dry") or ["0"])[0] not in ("0", "false", "")
res = _d.discover(limit_hosts=hosts, workers=8, max_add=add, dry=dry) res = _d.discover(limit_hosts=hosts, workers=8, max_add=add, dry=dry)
return self._json(res) return self._json(res)
@@ -421,4 +449,6 @@ class Handler(BaseHTTPRequestHandler):
def serve(port=8077): def serve(port=8077):
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
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")
srv.serve_forever() srv.serve_forever()

View File

@@ -4,6 +4,7 @@
source_flags (name, enabled, status, items, note, checked) source_flags (name, enabled, status, items, note, checked)
source_custom (name, category, kind, url, adapter, tags, limit, detail, added) source_custom (name, category, kind, url, adapter, tags, limit, detail, added)
""" """
import contextlib
import json import json
import sqlite3 import sqlite3
import time import time
@@ -34,9 +35,24 @@ def conn():
return c 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(): def flags():
"""{name: {enabled, status, items, note, checked}}""" """{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() 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]} return {r[0]: {"enabled": bool(r[1]), "status": r[2], "items": r[3], "note": r[4], "checked": r[5]}
for r in rows} for r in rows}
@@ -44,7 +60,7 @@ def flags():
def set_enabled(name, enabled): def set_enabled(name, enabled):
now = int(time.time()) now = int(time.time())
with conn() as c: with db() as c:
c.execute("INSERT INTO source_flags(name, enabled, checked) VALUES(?,?,?) " c.execute("INSERT INTO source_flags(name, enabled, checked) VALUES(?,?,?) "
"ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled", "ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled",
(name, 1 if enabled else 0, now)) (name, 1 if enabled else 0, now))
@@ -53,7 +69,7 @@ def set_enabled(name, enabled):
def record_check(name, status, items, note=""): def record_check(name, status, items, note=""):
now = int(time.time()) 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,?,?,?,?) " 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, " "ON CONFLICT(name) DO UPDATE SET status=excluded.status, items=excluded.items, "
"note=excluded.note, checked=excluded.checked", "note=excluded.note, checked=excluded.checked",
@@ -62,7 +78,7 @@ def record_check(name, status, items, note=""):
def custom(): def custom():
with conn() as c: with db() as c:
rows = c.execute("SELECT name, category, kind, url, adapter, tags, lim, detail, added " rows = c.execute("SELECT name, category, kind, url, adapter, tags, lim, detail, added "
"FROM source_custom ORDER BY added DESC").fetchall() "FROM source_custom ORDER BY added DESC").fetchall()
out = [] out = []
@@ -81,7 +97,7 @@ def add_custom(name, category, url, kind=None, adapter=None, tags=(), limit=20,
if not url.startswith("http"): if not url.startswith("http"):
raise ValueError("url должен начинаться с http") raise ValueError("url должен начинаться с http")
kind, adapter = kind or guess_kind(url), adapter or guess_adapter(url, kind) 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) " c.execute("INSERT OR REPLACE INTO source_custom(name, category, kind, url, adapter, tags, lim, detail, added) "
"VALUES(?,?,?,?,?,?,?,?,?)", "VALUES(?,?,?,?,?,?,?,?,?)",
(name, category or "custom", kind, url, adapter, (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): 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_custom WHERE name=?", (name,))
c.execute("DELETE FROM source_flags WHERE name=?", (name,)) c.execute("DELETE FROM source_flags WHERE name=?", (name,))
return {"removed": name} return {"removed": name}

View File

@@ -14,6 +14,7 @@ from .sources_ext import EXTRA
from .sources_ext2 import EXTRA2 from .sources_ext2 import EXTRA2
from .sources_ext3 import EXTRA3 from .sources_ext3 import EXTRA3
from .sources_abuse import ABUSE_LIVE from .sources_abuse import ABUSE_LIVE
from .abuse_sources import ABUSE as ABUSE_DEEP
from .sources_ext4 import EXTRA5 from .sources_ext4 import EXTRA5
from .sources_ext5 import EXTRA6 from .sources_ext5 import EXTRA6
from .sources_x import X_SOURCES from .sources_x import X_SOURCES
@@ -200,14 +201,14 @@ def _dedup(rows):
return out 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} BY_NAME = {s.name: s for s in SOURCES}
CATEGORIES = sorted({s.category for s in SOURCES}) CATEGORIES = sorted({s.category for s in SOURCES})
def all_sources(): def all_sources():
"""Реестр + пользовательские источники из панели.""" """Реестр + пользовательские источники из панели, без дублей по url."""
from . import source_state from . import source_state
extra = [] extra = []
for d in source_state.custom(): 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 d = {k: v for k, v in d.items() if k in
("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))
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(): def enabled_sources():
"""Только включённые: выключенные в панели источники не дёргаем.""" """Только включённые: выключенные в панели источники не дёргаем."""

View File

@@ -1,4 +1,5 @@
"""Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам.""" """Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам."""
import contextlib
import json import json
import sqlite3 import sqlite3
import time import time
@@ -50,6 +51,21 @@ def conn():
return c 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): def _signed(h):
return h - (1 << 64) if h >= (1 << 63) else h return h - (1 << 64) if h >= (1 << 63) else h
@@ -59,18 +75,25 @@ def _unsigned(v):
def _popcount(x): def _popcount(x):
try:
return x.bit_count() # py3.10+, быстрее bin().count("1")
except AttributeError:
return bin(x).count("1") return bin(x).count("1")
def _near_dup(c, h, max_dist=3): def _near_dup(c, h, max_dist=3):
"""Кандидат ищем по полосе, решение принимаем по расстоянию Хэмминга.""" """Кандидат ищем по полосе, решение принимаем по расстоянию Хэмминга."""
h = _unsigned(h) h = _unsigned(h)
seen = set()
for b in range(BANDS): for b in range(BANDS):
band = (h >> (16 * b)) & MASK band = (h >> (16 * b)) & MASK
rows = c.execute( rows = c.execute(
"SELECT i.h FROM bands b JOIN items i ON i.id=b.item_id " "SELECT i.id, i.h FROM bands b JOIN items i ON i.id=b.item_id "
"WHERE b.band=? LIMIT 200", (b,)).fetchall() "WHERE b.band=?", (b,)).fetchall()
for (other,) in rows: for iid, other in rows:
if iid in seen:
continue
seen.add(iid)
if _popcount(_unsigned(other) ^ h) <= max_dist: if _popcount(_unsigned(other) ^ h) <= max_dist:
return True return True
return False return False
@@ -80,7 +103,7 @@ def add(items):
"""Возвращает (новых, дублей).""" """Возвращает (новых, дублей)."""
new = dup = 0 new = dup = 0
ts = int(time.time()) ts = int(time.time())
with conn() as c: with db() as c:
for it in items: for it in items:
h = _unsigned(it.h) h = _unsigned(it.h)
if _near_dup(c, 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=""): 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) " c.execute("INSERT INTO runs(ts,provider,source,query,got,kept,status,bytes,mode) "
"VALUES(?,?,?,?,?,?,?,?,?)", "VALUES(?,?,?,?,?,?,?,?,?)",
(int(time.time()), provider, source, (query or "")[:200], got, kept, status, nbytes, mode)) (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 "") + + ("WHERE provider=? " if provider else "") +
"ORDER BY score DESC, id DESC LIMIT ?") "ORDER BY score DESC, id DESC LIMIT ?")
args = (provider, limit) if provider else (limit,) args = (provider, limit) if provider else (limit,)
with conn() as c: with db() as c:
return c.execute(q, args).fetchall() return c.execute(q, args).fetchall()
def search(q, limit=20): def search(q, limit=20):
with conn() as c: with db() as c:
rows = c.execute( rows = c.execute(
"SELECT i.provider,i.kind,i.source,i.title,i.score,i.url FROM items_fts f " "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 ?", "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}%"] args += [f"%{q}%", f"%{q}%"]
sql += "ORDER BY score DESC, id DESC LIMIT ? OFFSET ?" sql += "ORDER BY score DESC, id DESC LIMIT ? OFFSET ?"
args += [limit, offset] args += [limit, offset]
with conn() as c: with db() as c:
rows = c.execute(sql, args).fetchall() rows = c.execute(sql, args).fetchall()
keys = ("provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url", "types") keys = ("provider", "kind", "source", "title", "text", "author", "published", "score", "price", "currency", "url", "types")
return [dict(zip(keys, r)) for r in rows] 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", order = {"fresh": "ORDER BY published DESC, id DESC",
"price": "ORDER BY CAST(price AS REAL) DESC, score 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") "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] 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 " rows = c.execute("SELECT provider,kind,source,title,text,author,published,score,price,currency,url,types "
"FROM items " + where + order + " LIMIT ? OFFSET ?", "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(): def type_counts():
"""Сколько записей на каждый тип.""" """Сколько записей на каждый тип."""
out = {} out = {}
with conn() as c: with db() as c:
for row in c.execute("SELECT COALESCE(types,'') FROM items"): for row in c.execute("SELECT COALESCE(types,'') FROM items"):
for t in (row[0] or "").split(","): for t in (row[0] or "").split(","):
t = t.strip() t = t.strip()
@@ -201,7 +224,7 @@ def type_counts():
def retype(limit=None): def retype(limit=None):
"""Пересчитать типы у уже сохранённых записей.""" """Пересчитать типы у уже сохранённых записей."""
n = 0 n = 0
with conn() as c: with db() as c:
q = "SELECT id,title,text FROM items" q = "SELECT id,title,text FROM items"
if limit: if limit:
q += " LIMIT %d" % int(limit) q += " LIMIT %d" % int(limit)
@@ -213,11 +236,11 @@ def retype(limit=None):
def count_by_source(): 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()) return dict(c.execute("SELECT source, COUNT(*) FROM items GROUP BY source").fetchall())
def stats(): def stats():
with conn() as c: with db() as c:
total = c.execute("SELECT COUNT(*) FROM items").fetchone()[0] 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_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() 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): def export(path, fmt="jsonl", limit=1000):
path = Path(path) 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," 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 ?", "price,currency,score,extra FROM items ORDER BY score DESC LIMIT ?",
(limit,)).fetchall() (limit,)).fetchall()
@@ -255,7 +278,7 @@ def dedupe(apply: bool = False):
Оставляет самую свежую (max id). Возвращает (групп, лишних_строк, удалено). Оставляет самую свежую (max id). Возвращает (групп, лишних_строк, удалено).
""" """
key = "CASE WHEN url IS NULL OR url = '' THEN source || '|' || title ELSE url END" 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( groups = c.execute(
"SELECT count(*) FROM (SELECT " + key + " k FROM items GROUP BY k HAVING count(*) > 1)").fetchone()[0] "SELECT count(*) FROM (SELECT " + key + " k FROM items GROUP BY k HAVING count(*) > 1)").fetchone()[0]
extra = c.execute( extra = c.execute(

140
tests/test_regress.py Normal file
View File

@@ -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

199
tools/_port_fixes.py Normal file
View File

@@ -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=<id>,<id> (пусто = 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))

85
tools/_port_serve.py Normal file
View File

@@ -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"))

34
tools/_verify2.py Normal file
View File

@@ -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))

142
tools/_verify_claims.py Normal file
View File

@@ -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))

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import py_compile import py_compile
import subprocess
import sqlite3 import sqlite3
import sys import sys
import time 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 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(): def compile_all():
bad = [] bad = []
for p in sorted((ROOT / "omni").glob("*.py")): for p in sorted((ROOT / "omni").glob("*.py")):
@@ -48,6 +60,7 @@ def compile_all():
def collect(): def collect():
st: dict = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")} st: dict = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")}
st["compile_errors"] = compile_all() st["compile_errors"] = compile_all()
st["tests"] = run_tests()
from omni import sources, taxonomy # noqa: E402 from omni import sources, taxonomy # noqa: E402
@@ -93,6 +106,8 @@ def collect():
problems = [] problems = []
if st["compile_errors"]: if st["compile_errors"]:
problems.append(f"compile: {len(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"]: if not st["has_types_col"]:
problems.append("нет колонки items.types") problems.append("нет колонки items.types")
if st["items"] and st["typed"] < st["items"] * 0.95: if st["items"] and st["typed"] < st["items"] * 0.95:
@@ -131,6 +146,7 @@ _обновлено {st['ts']}_
|---|---| |---|---|
{rows} {rows}
тесты: {(st.get('tests') or {}).get('note') or '-'}
самоаудит: {'OK' if not problems else '; '.join(problems)} самоаудит: {'OK' if not problems else '; '.join(problems)}
<!-- STATS:END -->""" <!-- STATS:END -->"""
return block return block
@@ -165,7 +181,8 @@ def main():
print(json.dumps(st, ensure_ascii=False, indent=1)) print(json.dumps(st, ensure_ascii=False, indent=1))
else: else:
print(f"items={st.get('items')} typed={st.get('typed')} sources={st.get('sources')} " 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 return 0