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
This commit is contained in:
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal 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()))"
|
||||||
24
README.md
24
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
|
- **типы записей: 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
|
||||||
|
|||||||
13
REPORT.md
13
REPORT.md
@@ -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 -->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
omni/bot.py
18
omni/bot.py
@@ -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")
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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():
|
||||||
"""Только включённые: выключенные в панели источники не дёргаем."""
|
"""Только включённые: выключенные в панели источники не дёргаем."""
|
||||||
|
|||||||
@@ -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):
|
||||||
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):
|
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
140
tests/test_regress.py
Normal 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
199
tools/_port_fixes.py
Normal 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
85
tools/_port_serve.py
Normal 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
34
tools/_verify2.py
Normal 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
142
tools/_verify_claims.py
Normal 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))
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user