chore(auto): самоаудит 2026-10-26T16:19:01Z — selfcheck_exit=0

This commit is contained in:
Vlad Gothbreach
2026-10-26 19:19:03 +03:00
parent 7b3d708402
commit 2a6799d92d
5 changed files with 293 additions and 1 deletions

49
tools/autocommit.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Автоцикл: самоаудит -> (опц.) подкачка источников -> коммит изменений -> пуш в Gitea и GitHub.
# Использование: bash tools/autocommit.sh [--feed] [--quiet]
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
PY="/c/Users/User/AppData/Local/Programs/Python/Python311/python.exe"
FEED=0
for a in "$@"; do [ "$a" = "--feed" ] && FEED=1; done
export PYTHONIOENCODING=utf-8
unset PYTHONPATH
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LOG="data/autocommit.log"
mkdir -p data
echo "== $STAMP ==" >> "$LOG"
# 1) подкачка новых записей (необязательно)
if [ "$FEED" = "1" ]; then
"$PY" -m omni feed --limit 8 --workers 6 >> "$LOG" 2>&1
echo "feed_exit=$?" >> "$LOG"
fi
# 2) самоаудит + REPORT.md
"$PY" tools/selfcheck.py >> "$LOG" 2>&1
echo "selfcheck_exit=$?" >> "$LOG"
# 3) коммит, только если есть реальные изменения кода/отчёта
git add -A
if git diff --cached --quiet; then
echo "no_changes" >> "$LOG"
exit 0
fi
STATS="$(tail -1 "$LOG" | sed 's/^/ /')"
git commit -q -m "chore(auto): самоаудит $STAMP —$(printf '%s' "$STATS" | cut -c1-160)"
COMMIT="$(git rev-parse --short HEAD)"
echo "commit=$COMMIT" >> "$LOG"
# 4) пуш в оба ремоута
if git push -q gitea main >> "$LOG" 2>&1; then echo "gitea=OK" >> "$LOG"; else echo "gitea=FAIL" >> "$LOG"; fi
if [ -x /c/Users/User/tmp/push_omni_gh.sh ] || [ -f /c/Users/User/tmp/push_omni_gh.sh ]; then
if bash /c/Users/User/tmp/push_omni_gh.sh >> "$LOG" 2>&1; then echo "github=OK" >> "$LOG"; else echo "github=FAIL" >> "$LOG"; fi
fi
echo "done $COMMIT" >> "$LOG"
echo "autocommit $COMMIT"

172
tools/selfcheck.py Normal file
View File

@@ -0,0 +1,172 @@
"""Непрерывный самоаудит omni-parse: собирает метрики, правит REPORT.md, копит аномалии.
Запуск: python tools/selfcheck.py [--json]
Ничего не меняет в базе, только читает и пишет REPORT.md + data/selfcheck.jsonl.
"""
from __future__ import annotations
import json
import os
import py_compile
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
DB = ROOT / "data" / "omni.sqlite3"
REPORT = ROOT / "REPORT.md"
HIST = ROOT / "data" / "selfcheck.jsonl"
def q(con, sql, args=()):
try:
return con.execute(sql, args).fetchall()
except sqlite3.Error:
return []
def one(con, sql, args=(), default=0):
r = q(con, sql, args)
return r[0][0] if r and r[0][0] is not None else default
def compile_all():
bad = []
for p in sorted((ROOT / "omni").glob("*.py")):
try:
py_compile.compile(str(p), doraise=True)
except py_compile.PyCompileError as e:
bad.append(f"{p.name}: {str(e).splitlines()[0][:120]}")
return bad
def collect():
st: dict = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")}
st["compile_errors"] = compile_all()
from omni import sources, taxonomy # noqa: E402
st["sources"] = len(sources.all_sources())
st["categories"] = len(sources.CATEGORIES)
if not DB.exists():
st["error"] = "нет базы"
return st, None
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=20)
try:
st["items"] = one(con, "SELECT count(*) FROM items")
st["priced"] = one(con, "SELECT count(*) FROM items WHERE price IS NOT NULL AND price > 0")
st["typed"] = one(con, "SELECT count(*) FROM items WHERE types IS NOT NULL AND types != ''")
st["runs"] = one(con, "SELECT count(*) FROM runs")
cols = [r[1] for r in q(con, "PRAGMA table_info(items)")]
st["has_types_col"] = "types" in cols
st["last_run"] = (q(con, "SELECT max(started_at) FROM runs") or [[None]])[0][0]
st["types"] = {k: v for k, v in q(
con, "SELECT types, count(*) FROM items WHERE types != '' GROUP BY types ORDER BY 2 DESC")}
flat = {}
for combo, n in st["types"].items():
for t in str(combo).split(","):
t = t.strip()
if t:
flat[t] = flat.get(t, 0) + n
st["type_flat"] = dict(sorted(flat.items(), key=lambda kv: -kv[1]))
st["type_labels"] = {k: taxonomy.LABELS.get(k, k) for k in st["type_flat"]}
st["by_source_top"] = [{"name": r[0], "n": r[1]} for r in q(
con, "SELECT source, count(*) FROM items GROUP BY 1 ORDER BY 2 DESC LIMIT 12")]
dupes = q(con, "SELECT url, count(*) c FROM items GROUP BY url HAVING c > 1 LIMIT 5")
st["dupe_urls_sample"] = len(dupes)
st["empty_sources"] = st["sources"] - one(con, "SELECT count(DISTINCT source) FROM items")
st["abuse_stored"] = one(con, "SELECT count(*) FROM items WHERE category = 'abuse'") \
if "category" in cols else None
finally:
con.close()
problems = []
if st["compile_errors"]:
problems.append(f"compile: {len(st['compile_errors'])} файлов")
if not st["has_types_col"]:
problems.append("нет колонки items.types")
if st["items"] and st["typed"] < st["items"] * 0.95:
problems.append(f"не типизировано {st['items'] - st['typed']} записей")
if st["dupe_urls_sample"]:
problems.append(f"дубли url: {st['dupe_urls_sample']}")
if st["sources"] and st["empty_sources"] > st["sources"] * 0.5:
problems.append(f"пустых источников {st['empty_sources']}/{st['sources']}")
st["problems"] = problems
return st, con
def render(st):
t = st.get("type_flat") or {}
labels = st.get("type_labels") or {}
types_line = " · ".join(f"{labels.get(k, k)} {v}" for k, v in list(t.items())[:8]) or "—"
top = st.get("by_source_top") or []
rows = "\n".join(f"| {r['name']} | {r['n']} |" for r in top) or "| — | 0 |"
problems = st.get("problems") or []
block = f"""<!-- STATS:START -->
_обновлено {st['ts']}_
| метрика | значение |
|---|---|
| источников | {st.get('sources', 0)} |
| категорий | {st.get('categories', 0)} |
| записей | {st.get('items', 0)} |
| с ценой | {st.get('priced', 0)} |
| типизировано | {st.get('typed', 0)} |
| прогонов | {st.get('runs', 0)} |
| пустых источников | {st.get('empty_sources', 0)} |
типы: {types_line}
| источник | записей |
|---|---|
{rows}
самоаудит: {'OK' if not problems else '; '.join(problems)}
<!-- STATS:END -->"""
return block
def main():
st, _ = collect()
hist: list = []
HIST.parent.mkdir(parents=True, exist_ok=True)
if HIST.exists():
try:
hist = [json.loads(x) for x in HIST.read_text(encoding="utf-8").splitlines() if x.strip()]
except Exception:
hist = []
hist.append({k: st.get(k) for k in ("ts", "items", "priced", "typed", "runs", "sources", "problems")})
HIST.write_text("\n".join(json.dumps(x, ensure_ascii=False) for x in hist[-500:]) + "\n", encoding="utf-8")
block = render(st)
if REPORT.exists():
old = REPORT.read_text(encoding="utf-8")
if "<!-- STATS:START -->" in old and "<!-- STATS:END -->" in old:
head = old.split("<!-- STATS:START -->")[0]
tail = old.split("<!-- STATS:END -->")[1]
new = head + block + tail
else:
new = old.rstrip() + "\n\n" + block + "\n"
else:
new = "# omni-parse · автоотчёт\n\n" + block + "\n"
REPORT.write_text(new, encoding="utf-8")
if "--json" in sys.argv:
print(json.dumps(st, ensure_ascii=False, indent=1))
else:
print(f"items={st.get('items')} typed={st.get('typed')} sources={st.get('sources')} "
f"priced={st.get('priced')} runs={st.get('runs')} problems={st.get('problems') or 'OK'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())