omni-parse: 12 провайдеров, sqlite+fts5, simhash-дедуп, фильтр релевантности

This commit is contained in:
dev
2026-10-25 13:16:56 +03:00
commit 02816cacfb
22 changed files with 1613 additions and 0 deletions

6
omni/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""omni — универсальный парсер тем: соцсети, форумы, поисковые API, карточки.
Один вход (тема) -> много источников -> одна база с FTS5.
Ключи только из окружения или .env. В коде секретов нет.
"""
__version__ = "1.0.0"

3
omni/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from .cli import main
import sys
sys.exit(main())

215
omni/cli.py Normal file
View File

@@ -0,0 +1,215 @@
"""CLI: providers / search / fanout / cards / top / find / stats / export."""
import argparse
import concurrent.futures
import json
import sys
from . import config, rank, store
from . import filter as flt
from .providers import FUNCS, MODULES, get, status
ALL_FREE = ["reddit", "x", "forums", "ddg", "mojeek", "bing"]
ALL_KEYED = ["tavily", "exa", "you", "firecrawl"]
def _default_providers():
out = list(ALL_FREE)
for name in ALL_KEYED:
ok, _ = MODULES[name].available(name)
if ok:
out.append(name)
return out
def _run_one(name, query, limit, extra):
fn = get(name)
if not fn:
return name, [], "нет провайдера"
try:
if name == "cards":
items = fn(extra.get("urls") or [])
elif name == "x":
items = fn(query, limit=limit, handles=extra.get("handles"))
elif name == "reddit":
items = fn(query, limit=limit, subs=extra.get("subs"))
elif name == "forums":
items = fn(query, limit=limit, hosts=extra.get("hosts"), channels=extra.get("channels"))
else:
items = fn(query, limit=limit)
raw = items or []
if name == "cards":
return name, raw, "ok" if raw else "пусто"
kept = flt.apply(query, raw)
for it in kept:
it.extra["q"] = query
note = "ok" if kept else "пусто"
if len(kept) < len(raw):
note = f"ok {len(kept)}/{len(raw)}"
return name, kept, note
except Exception as e:
return name, [], f"{type(e).__name__}: {e}"[:120]
def cmd_providers(args):
for name, ok, note in status():
print(f"{'OK ' if ok else 'НЕТ'} {name:<10} {note}")
return 0
def cmd_search(args):
provs = [p.strip() for p in (args.providers or ",".join(_default_providers())).split(",") if p.strip()]
extra = {"subs": args.subs.split(",") if args.subs else None,
"channels": args.channels.split(",") if args.channels else None,
"handles": args.handles.split(",") if args.handles else None}
results, report = [], []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(8, len(provs) or 1)) as ex:
futs = [ex.submit(_run_one, p, args.query, args.limit, extra) for p in provs]
for f in concurrent.futures.as_completed(futs):
name, items, st = f.result()
report.append((name, len(items), st))
results += items
store.log_run(name, "", args.query, len(items), 0, st)
results = rank.rank(results)
new, dup = store.add(results)
for name, got, st in sorted(report):
print(f" {name:<10} {got:>3} записей {st}")
print(f"\nсобрано {len(results)}, новых в базе {new}, дублей {dup}\n")
for it in results[:args.show]:
price = f" {it.price}{it.currency or ''}" if it.price else ""
print(f"[{it.score:5.2f}] {it.provider:<9} {it.kind:<8}{price} {it.title[:90]}")
print(f" {it.url[:110]}")
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
for it in results:
f.write(json.dumps(it.as_dict(), ensure_ascii=False) + "\n")
print(f"\njsonl: {args.json}")
return 0
def cmd_fanout(args):
themes = [t.strip() for t in open(args.themes, encoding="utf-8") if t.strip() and not t.startswith("#")]
if args.limit_themes:
themes = themes[:args.limit_themes]
provs = [p.strip() for p in (args.providers or ",".join(_default_providers())).split(",") if p.strip()]
total_new = total_dup = 0
for th in themes:
results, got = [], {}
with concurrent.futures.ThreadPoolExecutor(max_workers=min(8, len(provs) or 1)) as ex:
futs = [ex.submit(_run_one, p, th, args.limit, {}) for p in provs]
for f in concurrent.futures.as_completed(futs):
name, items, st = f.result()
got[name] = len(items)
results += items
store.log_run(name, "", th, len(items), 0, st)
results = rank.rank(results)
new, dup = store.add(results)
total_new += new
total_dup += dup
print(f"{th[:48]:<50} собрано {len(results):>3} новых {new:>3} дублей {dup:>3}")
if args.verbose:
for it in results[:3]:
print(f" [{it.score:4.1f}] {it.provider} {it.title[:80]}")
print(f"\nитого новых {total_new}, дублей {total_dup}")
return 0
def cmd_cards(args):
urls = [u.strip() for u in args.urls.split(",") if u.strip()]
items = rank.rank(__import__("omni.providers.cards", fromlist=["extract_cards"]).extract_cards(urls, limit=len(urls)))
new, dup = store.add(items)
for it in items:
print(f"{it.title[:80]}")
print(f" цена: {it.price} {it.currency or ''} | {it.extra.get('availability', '')}")
print(f" {it.url[:110]}")
print(f"\nновых {new}, дублей {dup}")
return 0
def cmd_top(args):
for prov, kind, src, title, sc, price, cur, url in store.top(args.limit, args.provider):
p = f" {price}{cur or ''}" if price else ""
print(f"[{sc:5.2f}] {prov:<9} {kind:<8}{p} {title[:80]}")
return 0
def cmd_find(args):
for prov, kind, src, title, sc, url in store.search(args.query, args.limit):
print(f"[{sc:5.2f}] {prov:<9} {kind:<8} {title[:80]}\n {url[:110]}")
return 0
def cmd_stats(args):
s = store.stats()
print(f"записей: {s['items']} | с ценником: {s['priced']} | прогонов: {s['runs']} (ok {s['runs_ok']})")
print("по провайдерам:", ", ".join(f"{p}={n}" for p, n in s["by_provider"]))
print("по типам:", ", ".join(f"{k}={n}" for k, n in s["by_kind"]))
return 0
def cmd_export(args):
n, path = store.export(args.out, args.fmt, args.limit)
print(f"выгружено {n} записей -> {path}")
return 0
def main(argv=None):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
config.load()
p = argparse.ArgumentParser(prog="omni", description="универсальный парсер тем: соцсети, форумы, поиск, карточки")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("providers", help="что доступно и с какими ключами")
sp.set_defaults(fn=cmd_providers)
sp = sub.add_parser("search", help="один запрос по всем источникам")
sp.add_argument("query")
sp.add_argument("--providers", default="")
sp.add_argument("--limit", type=int, default=15)
sp.add_argument("--show", type=int, default=15)
sp.add_argument("--json", default="")
sp.add_argument("--subs", default="")
sp.add_argument("--channels", default="")
sp.add_argument("--handles", default="")
sp.set_defaults(fn=cmd_search)
sp = sub.add_parser("fanout", help="список тем -> полный обход")
sp.add_argument("--themes", default="themes.txt")
sp.add_argument("--providers", default="")
sp.add_argument("--limit", type=int, default=12)
sp.add_argument("--limit-themes", type=int, default=0)
sp.add_argument("--verbose", action="store_true")
sp.set_defaults(fn=cmd_fanout)
sp = sub.add_parser("cards", help="карточки товаров по URL")
sp.add_argument("urls")
sp.set_defaults(fn=cmd_cards)
sp = sub.add_parser("top", help="топ базы по скору")
sp.add_argument("--limit", type=int, default=20)
sp.add_argument("--provider", default=None)
sp.set_defaults(fn=cmd_top)
sp = sub.add_parser("find", help="поиск по своей базе")
sp.add_argument("query")
sp.add_argument("--limit", type=int, default=20)
sp.set_defaults(fn=cmd_find)
sp = sub.add_parser("stats", help="сводка базы")
sp.set_defaults(fn=cmd_stats)
sp = sub.add_parser("export", help="выгрузка jsonl/csv")
sp.add_argument("--out", default="data/top.jsonl")
sp.add_argument("--fmt", default="jsonl", choices=["jsonl", "csv"])
sp.add_argument("--limit", type=int, default=1000)
sp.set_defaults(fn=cmd_export)
args = p.parse_args(argv)
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())

73
omni/config.py Normal file
View File

@@ -0,0 +1,73 @@
"""Конфиг: пути, лимиты, ключи из env/.env. Никаких секретов в коде."""
import json
import os
import pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
DATA = pathlib.Path(os.environ.get("OMNI_DATA") or (ROOT / "data"))
DB = pathlib.Path(os.environ.get("OMNI_DB") or (DATA / "omni.sqlite3"))
THEMES = ROOT / "themes.txt"
DEFAULTS = {
"timeout": 25,
"retries": 2,
"per_host_delay": 0.6,
"max_bytes": 4_000_000,
"limit": 20,
"lang": "ru",
"ua": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"),
"proxy_mode": "off", # off | list | env
"proxies": [],
}
KEYS = {} # имя -> значение
CONFIG = {} # прочие настройки
def _load_env_file(path):
p = pathlib.Path(path)
if not p.exists():
return {}
out = {}
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip()] = v.strip().strip('"').strip("'")
return out
def load():
"""Читает .env рядом с проектом и config.json (если есть), собирает ключи."""
global KEYS, CONFIG
envf = _load_env_file(ROOT / ".env")
for k, v in envf.items():
os.environ.setdefault(k, v)
cfg = dict(DEFAULTS)
cf = ROOT / "config.json"
if cf.exists():
try:
cfg.update(json.loads(cf.read_text(encoding="utf-8")))
except Exception:
pass
CONFIG = cfg
for name in ("TAVILY_API_KEY", "EXA_API_KEY", "YOU_API_KEY", "FIRECRAWL_API_KEY",
"JINA_API_KEY", "SERPER_API_KEY", "BRAVE_API_KEY", "X_BEARER",
"REDDIT_CLIENT_ID", "REDDIT_SECRET", "TREG_TOKEN", "GITHUB_TOKEN"):
v = os.environ.get(name)
if v:
KEYS[name] = v
DATA.mkdir(parents=True, exist_ok=True)
return cfg
def proxies():
mode = CONFIG.get("proxy_mode", "off")
if mode == "env":
v = os.environ.get("OMNI_PROXIES", "")
return [x.strip() for x in v.split(",") if x.strip()]
if mode == "list":
return list(CONFIG.get("proxies") or [])
return []

34
omni/filter.py Normal file
View File

@@ -0,0 +1,34 @@
"""Релевантность: один фильтр для всех провайдеров, чтобы мусор не лез в базу."""
import re
STOP = {"without", "2026", "2025", "how", "the", "and", "for", "with", "without", "free", "best"}
def tokens(query):
return [t for t in re.findall(r"[\w\-]{3,}", (query or "").lower()) if t not in STOP]
def _has(tok, body):
"""Матч с учётом русской морфологии: сравниваем по 5-символьному префиксу."""
if tok in body:
return True
stem = tok[:5] if len(tok) > 5 else tok[:4]
return len(stem) >= 4 and stem in body
def relevance(query, item, min_cov=0.6):
"""True, если запрос реально покрыт текстом: и в заголовке есть хоть один термин."""
toks = tokens(query)
if not toks:
return True
title = (item.title or "").lower()
body = title + " " + (item.text or "").lower()
hit = sum(1 for t in toks if _has(t, body))
title_hit = sum(1 for t in toks if _has(t, title))
if len(toks) == 1:
return hit == 1
return hit >= max(2, int(len(toks) * min_cov + 0.5)) and title_hit >= 1
def apply(query, items, min_cov=0.6):
return [i for i in items if relevance(query, i, min_cov)]

99
omni/model.py Normal file
View File

@@ -0,0 +1,99 @@
"""Модель записи: единая схема для всех источников + simhash + разбор цены."""
import hashlib
import re
import urllib.parse
PRICE_RE = re.compile(r"(?:(?:[$€£₽¥])\s?(\d{1,6}(?:[.,]\d{1,2})?)|(\d{1,6}(?:[.,]\d{1,2})?)\s?(?:usd|eur|rub|грн|\$|€|₽|руб))",
re.IGNORECASE)
CYR = re.compile(r"[а-яё]", re.IGNORECASE)
TAGS = ("abuse", "proxy", "combo", "api", "jailbreak", "leak", "traffic", "crypto", "tg", "bot")
def canon(url):
if not url:
return ""
u = urllib.parse.urlsplit(url.strip())
q = [(k, v) for k, v in urllib.parse.parse_qsl(u.query) if not k.startswith(("utm_", "ref", "fbclid"))]
path = u.path.rstrip("/") or "/"
return urllib.parse.urlunsplit((u.scheme or "https", u.netloc.lower(), path, urllib.parse.urlencode(q), ""))
def shingles(text, k=4):
words = re.findall(r"[\w]+", (text or "").lower())[:400]
if len(words) < k:
return {" ".join(words)} if words else set()
return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}
def simhash(text):
bits = [0] * 64
for sh in shingles(text):
h = int(hashlib.blake2b(sh.encode(), digest_size=8).hexdigest(), 16)
for i in range(64):
bits[i] += 1 if (h >> i) & 1 else -1
out = 0
for i in range(64):
if bits[i] > 0:
out |= 1 << i
return out
def price_of(text):
m = PRICE_RE.search(text or "")
if not m:
return None, None
raw = (m.group(1) or m.group(2) or "").replace(",", ".")
try:
val = float(raw)
except Exception:
return None, None
if val <= 0 or val > 1_000_000:
return None, None
cur = ""
frag = m.group(0).lower()
for sym, code in (("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("£", "GBP"), ("usd", "USD"),
("eur", "EUR"), ("rub", "RUB"), ("руб", "RUB"), ("грн", "UAH"), ("¥", "CNY")):
if sym in frag:
cur = code
break
return val, cur
def lang_of(text):
t = (text or "")[:400]
cyr = len(CYR.findall(t))
return "ru" if cyr > max(6, len(t) * 0.05) else "en"
class Item:
__slots__ = ("provider", "source", "kind", "url", "title", "text", "author", "published",
"score", "price", "currency", "lang", "tags", "extra", "h")
def __init__(self, provider, kind, url="", title="", text="", source="", author="",
published="", score=0.0, extra=None):
self.provider = provider
self.kind = kind
self.url = url
self.title = (title or "").strip()[:400]
self.text = (text or "").strip()[:6000]
self.source = source or urllib.parse.urlsplit(url).netloc
self.author = author or ""
self.published = (published or "")[:32]
self.score = float(score or 0)
self.lang = lang_of(self.title + " " + self.text)
body = (self.title + " " + self.text).lower()
self.tags = [t for t in TAGS if t in body][:6]
self.price, self.currency = price_of(self.title + " " + self.text)
self.extra = extra or {}
self.h = simhash(self.title + " " + self.text) if self.text or self.title else 0
self.url = canon(self.url)
def key(self):
return hashlib.sha1((self.provider + "|" + (self.url or self.title + self.text[:120]))
.encode("utf-8", "ignore")).hexdigest()
def as_dict(self):
return {k: getattr(self, k) for k in self.__slots__ if k != "h"} | {"h": self.h}
def __repr__(self):
return f"<{self.provider}/{self.kind} {self.title[:48]!r}>"

142
omni/net.py Normal file
View File

@@ -0,0 +1,142 @@
"""Сетевой слой: httpx -> curl_cffi (TLS Chrome) -> прокси. Троттлинг по хосту."""
import random
import threading
import time
import urllib.parse
from . import config
_cfg = config.load()
_lock = threading.Lock()
_last = {} # host -> ts
BLOCK_MARKS = ("just a moment", "cf-browser-verification", "attention required",
"ddos-guard", "checking your browser", "enable javascript and cookies")
class Resp:
__slots__ = ("url", "status", "text", "mode", "error", "headers", "elapsed")
def __init__(self, url, status=0, text="", mode="", error="", headers=None, elapsed=0.0):
self.url, self.status, self.text = url, status, text
self.mode, self.error, self.headers, self.elapsed = mode, error, headers or {}, elapsed
@property
def ok(self):
return self.status == 200 and len(self.text) > 50
@property
def blocked(self):
if self.status in (403, 429, 503):
return True
low = self.text[:4000].lower()
return any(m in low for m in BLOCK_MARKS)
def json(self):
try:
import json
return json.loads(self.text)
except Exception:
return None
def __repr__(self):
return f"<Resp {self.status} {self.mode} {len(self.text)}b {self.url[:60]}>"
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 _pick_proxy():
ps = config.proxies()
return random.choice(ps) if ps else None
def _try_httpx(url, headers, timeout, proxy):
try:
import httpx
kw = {"headers": headers, "timeout": timeout, "follow_redirects": True}
if proxy:
kw["proxy"] = proxy
with httpx.Client(**kw) as c:
r = c.get(url)
return Resp(url, r.status_code, r.text[: _cfg.get("max_bytes", 4_000_000)],
"proxy+httpx" if proxy else "httpx", headers=dict(r.headers))
except Exception as e:
return Resp(url, 0, "", "httpx", f"{type(e).__name__}: {e}"[:160])
def _try_curl(url, headers, timeout, proxy, impersonate="chrome131"):
try:
from curl_cffi import requests as cr
kw = {"headers": headers, "timeout": timeout, "impersonate": impersonate, "allow_redirects": True}
if proxy:
kw["proxies"] = {"http": proxy, "https": proxy}
r = cr.get(url, **kw)
return Resp(url, r.status_code, (r.text or "")[: _cfg.get("max_bytes", 4_000_000)],
"proxy+curl" if proxy else "curl", headers=dict(r.headers))
except Exception as e:
return Resp(url, 0, "", "curl", f"{type(e).__name__}: {e}"[:160])
def post_json(url, payload, headers=None, timeout=None):
"""POST JSON: httpx, при блоке/ошибке повтор через curl_cffi."""
import json
timeout = timeout or _cfg.get("timeout", 25)
host = urllib.parse.urlsplit(url).netloc
_throttle(host)
h = {"User-Agent": _cfg["ua"], "Content-Type": "application/json", "Accept": "application/json"}
h.update(headers or {})
body = json.dumps(payload).encode()
try:
import httpx
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
r = c.post(url, content=body, headers=h)
txt = r.text[:_cfg.get("max_bytes", 4_000_000)]
if r.status_code == 200:
return Resp(url, r.status_code, txt, "httpx-post", headers=dict(r.headers))
except Exception as e:
txt, code = "", 0
err = f"{type(e).__name__}: {e}"[:160]
_ = err
try:
from curl_cffi import requests as cr
r = cr.post(url, data=body, headers=h, timeout=timeout, impersonate="chrome131")
return Resp(url, r.status_code, (r.text or "")[:_cfg.get("max_bytes", 4_000_000)], "curl-post",
headers=dict(r.headers))
except Exception as e:
return Resp(url, 0, "", "curl-post", f"{type(e).__name__}: {e}"[:160])
def get(url, headers=None, timeout=None, json_mode=False):
"""Лестница: httpx -> curl_cffi -> curl_cffi с прокси. Возвращает первый осмысленный ответ."""
timeout = timeout or _cfg.get("timeout", 25)
host = urllib.parse.urlsplit(url).netloc
_throttle(host)
h = {"User-Agent": _cfg["ua"], "Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
"Accept-Language": "ru,en;q=0.8"}
if json_mode:
h["Accept"] = "application/json"
h["Content-Type"] = "application/json"
h.update(headers or {})
first = _try_httpx(url, h, timeout, None)
if first.ok and not first.blocked:
return first
second = _try_curl(url, h, timeout, None)
if second.ok and not second.blocked:
return second
proxy = _pick_proxy()
if proxy:
third = _try_curl(url, h, timeout, proxy)
if third.status == 200:
return third
return third
return second if second.status else first

View File

@@ -0,0 +1,49 @@
"""Реестр провайдеров: имя -> (search, available, note)."""
from . import cards, forums, reddit, searchers, treg, x
MODULES = {
"reddit": reddit,
"x": x,
"forums": forums,
"tavily": searchers,
"exa": searchers,
"you": searchers,
"firecrawl": searchers,
"ddg": searchers,
"mojeek": searchers,
"bing": searchers,
"cards": cards,
"treg": treg,
}
# какой функцией звать внутри модуля
FUNCS = {
"reddit": "search_reddit",
"x": "search_x",
"forums": "search_forums",
"tavily": "search_tavily",
"exa": "search_exa",
"you": "search_you",
"firecrawl": "search_firecrawl",
"ddg": "search_ddg",
"mojeek": "search_mojeek",
"bing": "search_bing",
"cards": "extract_cards",
"treg": "search_treg",
}
def get(name):
mod = MODULES.get(name)
if not mod:
return None
return getattr(mod, FUNCS[name], None)
def status():
rows = []
for name, mod in MODULES.items():
fn = getattr(mod, "available", None)
ok, note = fn(name) if callable(fn) else (True, "")
rows.append((name, ok, note))
return rows

102
omni/providers/cards.py Normal file
View File

@@ -0,0 +1,102 @@
"""Карточки товаров: JSON-LD Product/Offer, og-теги, микроразметка, fallback по цене."""
import json
import re
from .. import net
from ..model import Item, price_of
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36"}
JSONLD = re.compile(r'<script[^>]+type="application/ld\+json"[^>]*>(.*?)</script>', re.S)
RULES = [
("title", [r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', r"<title>(.*?)</title>"]),
("price", [r'<meta[^>]+property="product:price:amount"[^>]+content="([^"]+)"',
r'"price"\s*:\s*"?([0-9][0-9.,]*)"?']),
("currency", [r'<meta[^>]+property="product:price:currency"[^>]+content="([^"]+)"',
r'"priceCurrency"\s*:\s*"([A-Z]{3})"']),
("avail", [r'"availability"\s*:\s*"[^"]*?([A-Za-z]+)"?\s*$']),
]
def available(name="cards"):
return True, "забирает карточку по URL, ключи не нужны"
def _ld_product(html_text):
for m in JSONLD.finditer(html_text):
raw = m.group(1).strip()
try:
data = json.loads(raw)
except Exception:
continue
stack = data if isinstance(data, list) else [data]
while stack:
node = stack.pop()
if isinstance(node, dict):
if node.get("@graph"):
stack.extend(node["@graph"])
t = node.get("@type")
types = t if isinstance(t, list) else [t]
if any(str(x).lower() in ("product", "offer") for x in types if x):
yield node
for v in node.values():
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(node, list):
stack.extend(node)
def _num(s):
try:
return float(str(s).replace(",", ".").replace(" ", ""))
except Exception:
return None
def extract_cards(urls, limit=10):
items = []
for u in (urls if isinstance(urls, (list, tuple)) else [urls])[:limit]:
r = net.get(u, headers=UA)
if r.status != 200:
continue
title = ""
price = cur = ""
avail = ""
for prod in _ld_product(r.text):
offers = prod.get("offers") or {}
if isinstance(offers, list):
offers = offers[0] if offers else {}
price = offers.get("price") or offers.get("lowPrice") or price
cur = offers.get("priceCurrency") or cur
avail = (offers.get("availability") or avail)
title = prod.get("name") or title
if price:
break
for field, pats in RULES:
for p in pats:
m = re.search(p, r.text, re.I | re.S)
if not m:
continue
val = re.sub(r"<[^>]+>", "", m.group(1)).strip()
if field == "title" and not title:
title = val
elif field == "price" and not price:
price = val
elif field == "currency" and not cur:
cur = val
elif field == "avail" and not avail:
avail = val
break
text = r.text
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", text, flags=re.S)
body = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body))[:2500]
pval = _num(price)
if not pval:
pval, cur2 = price_of(body)
cur = cur or (cur2 or "")
it = Item("cards", "card", url=u, title=title[:300], text=body, source=u.split("/")[2] if "://" in u else "",
extra={"availability": avail, "raw_price": price})
it.price = pval
it.currency = (cur or "")[:4].upper() or None
items.append(it)
return items

151
omni/providers/forums.py Normal file
View File

@@ -0,0 +1,151 @@
"""Форумы без ключей: Discourse, HN (Algolia), Lobsters, XenForo, Telegram-веб."""
import html
import json
import re
import urllib.parse
from .. import net
from ..model import Item
DISCOURSE = [
"meta.discourse.org", "discuss.python.org", "community.openai.com", "forum.cursor.com",
"discuss.huggingface.co", "community.cloudflare.com", "forum.gitlab.com", "discuss.elastic.co",
]
XENFORO = ["www.blackhatworld.com", "xenforo.com/community"]
TG_CHANNELS = ["Abuz_ai_soft", "AlStack", "iishkogatewaybot"]
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36"}
def available(name="forums"):
return True, "Discourse/HN/Lobsters/TG-веб, ключи не нужны"
def _strip(s):
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", s or ""))).strip()
def _discourse(host, query, limit=8):
out = []
url = f"https://{host}/search.json?q={urllib.parse.quote(query)}"
r = net.get(url, headers=UA, json_mode=True)
if r.status != 200:
return out
d = r.json() or {}
for t in (d.get("topics") or [])[:limit]:
slug = t.get("slug") or "t"
tid = t.get("id")
out.append(Item("forums", "thread", url=f"https://{host}/t/{slug}/{tid}",
title=t.get("title", ""), text=_strip(t.get("blurb") or ""),
source=host, published=str(t.get("created_at") or ""),
score=float(t.get("posts_count") or 0) ** 0.4 +
float(t.get("like_count") or 0) ** 0.3,
extra={"posts": t.get("posts_count"), "views": t.get("views")}))
for p in (d.get("posts") or [])[:limit]:
if len(out) >= limit:
break
out.append(Item("forums", "post", url=f"https://{host}/t/{p.get('topic_id')}/{p.get('post_number', 1)}",
title=(p.get("topic_title") or "")[:200], text=_strip(p.get("blurb") or ""),
source=host, author=p.get("username", ""), published=str(p.get("created_at") or "")))
return out
def _hn(query, limit=10):
out = []
url = ("https://hn.algolia.com/api/v1/search?query=" + urllib.parse.quote(query)
+ f"&tags=story&hitsPerPage={min(limit, 30)}")
r = net.get(url, headers=UA, json_mode=True)
for h in ((r.json() or {}).get("hits") or []):
title = h.get("title") or h.get("story_title") or ""
if not title:
continue
it = Item("forums", "thread", url=h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID')}",
title=title, text=(h.get("story_text") or "")[:1500], source="news.ycombinator.com",
author=h.get("author", ""), published=h.get("created_at", ""),
extra={"points": h.get("points"), "comments": h.get("num_comments")})
it.score = float(h.get("points") or 0) ** 0.5 + float(h.get("num_comments") or 0) ** 0.4
out.append(it)
return out
def _lobsters(query, limit=8):
out = []
r = net.get("https://lobste.rs/search.json?q=" + urllib.parse.quote(query) + "&what=stories&order=newest",
headers=UA, json_mode=True)
for s in ((r.json() if r.status == 200 else []) or [])[:limit]:
it = Item("forums", "thread", url=s.get("comments_url") or s.get("url") or "",
title=s.get("title", ""), text=_strip(s.get("description") or ""),
source="lobste.rs", author=s.get("submitter_user", "") if isinstance(s.get("submitter_user"), str)
else (s.get("submitter_user") or {}).get("username", ""),
published=s.get("created_at", ""),
extra={"score": s.get("score"), "comments": s.get("comment_count")})
it.score = float(s.get("score") or 0) ** 0.5
out.append(it)
return out
def _xenforo(host, query, limit=6):
out = []
base = f"https://{host}" if "//" not in host else host
url = f"{base}/search/?q={urllib.parse.quote(query)}&o=date"
r = net.get(url, headers=UA)
if r.status != 200:
return out
for m in re.finditer(r'<h3 class="contentRow-title">\s*<a href="([^"]+)"[^>]*>(.*?)</a>', r.text, re.S):
href, title = m.group(1), _strip(m.group(2))
if not title:
continue
link = href if href.startswith("http") else base + href
out.append(Item("forums", "thread", url=link, title=title, source=host))
if len(out) >= limit:
break
return out
def _tg(channel, query, limit=8):
out = []
r = net.get(f"https://t.me/s/{channel}", headers=UA)
if r.status != 200:
return out
blocks = re.split(r'<div class="tgme_widget_message_wrap', r.text)[1:]
ql = query.lower()
for b in reversed(blocks):
mtext = re.search(r'<div class="tgme_widget_message_text[^"]*"[^>]*>(.*?)</div>', b, re.S)
if not mtext:
continue
text = _strip(mtext.group(1))
if ql not in text.lower():
continue
mlink = re.search(r'data-post="([^"]+)"', b)
link = f"https://t.me/{mlink.group(1)}" if mlink else f"https://t.me/s/{channel}"
it = Item("forums", "tg_post", url=link, title=text[:120], text=text, source="t.me/" + channel,
published=(re.search(r'datetime="([^"]+)"', b).group(1) if re.search(r'datetime="([^"]+)"', b) else ""))
out.append(it)
if len(out) >= limit:
break
return out
def _relevant(query, item):
"""Дискурс любит отдавать мусор по одному совпавшему слову: требуем покрытие терминов."""
toks = [t for t in re.findall(r"\w{3,}", (query or "").lower())]
if not toks:
return True
body = ((item.title or "") + " " + (item.text or "")).lower()
hit = sum(1 for t in toks if t in body)
title_hit = sum(1 for t in toks if t in (item.title or "").lower())
return (hit >= max(2, int(len(toks) * 0.7))) and title_hit >= 1 if len(toks) > 1 else hit == 1
def search_forums(query, limit=20, hosts=None, channels=None):
items = []
for host in (hosts or DISCOURSE)[:5]:
items += _discourse(host, query, limit=6)
items += _hn(query, limit=10)
items += _lobsters(query, limit=6)
for ch in (channels or TG_CHANNELS)[:3]:
items += _tg(ch, query, limit=6)
if len(items) < limit:
for host in (hosts or []) and XENFORO[:1]:
items += _xenforo(host, query, limit=5)
items = [i for i in items if _relevant(query, i) or i.kind == "tg_post"]
return items[:limit * 2]

99
omni/providers/reddit.py Normal file
View File

@@ -0,0 +1,99 @@
"""Reddit без ключей: поиск по всему реддиту и по сабам, сортировка new/top."""
import urllib.parse
from .. import net
from ..model import Item
DEFAULT_SUBS = ["netsec", "cybersecurity", "webscraping", "selfhosted", "webdev", "Python"]
UA = {"User-Agent": "omni-parse/1.0 (research; contact: local)"}
def available(name="reddit"):
return True, "pullpush (архив) + публичный json, ключи не нужны"
def _pullpush(query, limit=25, subreddit=None, kind="submission"):
q = urllib.parse.quote(query or "")
url = f"https://api.pullpush.io/reddit/search/{kind}/?q={q}&size={min(limit, 100)}&sort=desc"
if subreddit:
url += f"&subreddit={subreddit}"
r = net.get(url, headers=UA, json_mode=True)
data = (r.json() or {}).get("data") or []
out = []
for d in data:
if not (d.get("title") or d.get("body")):
continue
it = _mk(d, subreddit)
it.kind = "thread" if kind == "submission" else "comment"
if kind == "comment":
it.title = (d.get("link_title") or it.text[:100])
out.append(it)
return out
def _rows(payload):
out = []
for ch in (payload or {}).get("data", {}).get("children", []):
d = ch.get("data") or {}
if not d.get("title"):
continue
out.append(d)
return out
def _mk(d, sub=None):
perm = d.get("permalink") or ""
url = "https://www.reddit.com" + perm if perm else d.get("url", "")
body = (d.get("selftext") or "")[:1800]
it = Item("reddit", "thread", url=url, title=d.get("title", ""), text=body,
source="r/" + (d.get("subreddit") or sub or ""), author=d.get("author", ""),
published=str(int(d.get("created_utc") or 0)),
extra={"ups": d.get("ups"), "comments": d.get("num_comments"),
"sub": d.get("subreddit"), "ext": d.get("url", "")[:300]})
it.score = float(d.get("ups") or 0) ** 0.5 + float(d.get("num_comments") or 0) ** 0.4
return it
def _reddit_native(query, limit=20):
q = urllib.parse.quote(query)
r = net.get(f"https://www.reddit.com/search.json?q={q}&limit={min(limit, 25)}&sort=relevance&t=month",
headers={**UA, "Accept": "application/json"}, json_mode=True)
if r.status != 200:
return []
return [_mk(d) for d in _rows(r.json())]
def _relevant(query, item):
toks = [t for t in __import__("re").findall(r"\w{3,}", (query or "").lower())]
if not toks:
return True
body = ((item.title or "") + " " + (item.text or "")).lower()
hit = sum(1 for t in toks if t in body)
title_hit = sum(1 for t in toks if t in (item.title or "").lower())
return hit >= max(2, int(len(toks) * 0.7)) and title_hit >= 1
def search_reddit(query, limit=20, subs=None, include_comments=True):
items = _pullpush(query, limit=limit)
if len(items) < 5:
items += _reddit_native(query, limit=limit)
for sub in (subs or [])[:3]:
items += _pullpush(query, limit=10, subreddit=sub)
if include_comments and len(items) < limit:
items += _pullpush(query, limit=10, kind="comment")[:8]
items = [i for i in items if _relevant(query, i)]
uniq, seen = [], set()
for it in items:
k = it.url or it.title
if k in seen:
continue
seen.add(k)
uniq.append(it)
return uniq[:limit * 2]
def hot(subs=None, limit=20):
out = []
for sub in (subs or DEFAULT_SUBS)[:6]:
out += _pullpush("", limit=min(limit, 15), subreddit=sub)
return out

153
omni/providers/searchers.py Normal file
View File

@@ -0,0 +1,153 @@
"""Поисковые API и бесплатные движки: Tavily, Exa, You, Firecrawl, DDG, Mojeek, Bing."""
import html
import re
import urllib.parse
from .. import config, net
from ..model import Item
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36"}
def _mk(provider, title, url, text="", score=0.0, published="", extra=None):
return Item(provider, "result", url=url, title=title, text=text, score=score,
published=published, extra=extra or {})
def _key(name):
return config.KEYS.get(name)
def available(name):
need = {"tavily": "TAVILY_API_KEY", "exa": "EXA_API_KEY", "you": "YOU_API_KEY",
"firecrawl": "FIRECRAWL_API_KEY"}.get(name)
if not need:
return True, "без ключей"
return bool(_key(need)), ("готов" if _key(need) else f"нужен {need}")
def search_tavily(query, limit=15, depth="basic"):
k = _key("TAVILY_API_KEY")
if not k:
return []
r = net.post_json("https://api.tavily.com/search",
{"api_key": k, "query": query, "max_results": min(max(limit, 5), 20),
"search_depth": depth, "include_answer": False, "include_raw_content": False})
d = r.json() or {}
return [_mk("tavily", x.get("title", ""), x.get("url", ""), (x.get("content") or "")[:2000],
float(x.get("score") or 0)) for x in (d.get("results") or [])]
def search_exa(query, limit=15):
k = _key("EXA_API_KEY")
if not k:
return []
r = net.post_json("https://api.exa.ai/search",
{"query": query, "numResults": min(max(limit, 5), 25),
"contents": {"text": {"maxCharacters": 1200}}},
headers={"x-api-key": k})
d = r.json() or {}
return [_mk("exa", x.get("title") or "", x.get("url", ""), (x.get("text") or "")[:2000],
float(x.get("score") or 0), x.get("publishedDate") or "") for x in (d.get("results") or [])]
def search_you(query, limit=15):
k = _key("YOU_API_KEY")
if not k:
return []
url = f"https://ydc-index.io/v1/search?query={urllib.parse.quote(query)}&count={min(limit, 20)}"
r = net.get(url, headers={**UA, "X-API-Key": k}, json_mode=True)
d = r.json() or {}
out = []
for section in ("hits", "web"):
for x in (d.get(section) or []):
snips = x.get("snippets") or []
txt = " ".join(snips) if isinstance(snips, list) else (x.get("description") or "")
out.append(_mk("you", x.get("title") or "", x.get("url") or "", txt[:2000],
float(x.get("score") or 0), x.get("page_age") or ""))
if out:
break
return out[:limit]
def search_firecrawl(query, limit=15):
k = _key("FIRECRAWL_API_KEY")
if not k:
return []
r = net.post_json("https://api.firecrawl.dev/v1/search",
{"query": query, "limit": min(max(limit, 3), 20)},
headers={"Authorization": f"Bearer {k}"})
d = r.json() or {}
return [_mk("firecrawl", x.get("title") or "", x.get("url", ""),
(x.get("description") or "")[:2000]) for x in (d.get("data") or [])]
def search_ddg(query, limit=15):
url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query) + "&kl=ru-ru"
r = net.get(url, headers=UA)
if r.status != 200:
return []
out = []
blocks = re.split(r'<div class="result results_links', r.text)[1:]
for b in blocks:
m = re.search(r'class="result__a" href="([^"]+)"[^>]*>(.*?)</a>', b, re.S)
if not m:
continue
href = html.unescape(m.group(1))
if "uddg=" in href:
href = urllib.parse.unquote(href.split("uddg=")[1].split("&")[0])
title = html.unescape(re.sub(r"<[^>]+>", "", m.group(2))).strip()
sn = re.search(r'class="result__snippet"[^>]*>(.*?)</a>', b, re.S)
snip = html.unescape(re.sub(r"<[^>]+>", "", sn.group(1))).strip() if sn else ""
out.append(_mk("ddg", title, href, snip[:1500]))
if len(out) >= limit:
break
return out
def search_mojeek(query, limit=15):
"""Mojeek: собственный индекс, html без капчи."""
url = "https://www.mojeek.com/search?q=" + urllib.parse.quote(query)
r = net.get(url, headers=UA)
if r.status != 200:
return []
out = []
for m in re.finditer(r'<a class="ob"[^>]*href="([^"]+)"[^>]*>(.*?)</a>.*?<p class="s">(.*?)</p>',
r.text, re.S):
href, title, snip = m.group(1), m.group(2), m.group(3)
out.append(_mk("mojeek", html.unescape(re.sub(r"<[^>]+>", "", title)).strip(),
html.unescape(href), html.unescape(re.sub(r"<[^>]+>", "", snip))[:1500]))
if len(out) >= limit:
break
return out
def _bing_decode(href):
"""Bing прячет ссылку в ck/a?...&u=a1<base64url>."""
if "u=a1" not in href:
return href
import base64
raw = href.split("u=a1", 1)[1].split("&")[0]
raw += "=" * (-len(raw) % 4)
try:
return base64.urlsafe_b64decode(raw).decode("utf-8", "ignore")
except Exception:
return href
def search_bing(query, limit=15):
"""Bing html: без ключа, первая страница. Ссылки приходят в base64."""
url = "https://www.bing.com/search?q=" + urllib.parse.quote(query) + f"&count={min(limit, 30)}"
r = net.get(url, headers={**UA, "Accept-Language": "ru,en;q=0.8"})
if r.status != 200:
return []
out = []
for m in re.finditer(r'<li class="b_algo".*?<h2>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>', r.text, re.S):
href = _bing_decode(html.unescape(m.group(1)))
title = html.unescape(re.sub(r"<[^>]+>", "", m.group(2))).strip()
if not href.startswith("http") or "bing.com/ck" in href:
continue
out.append(_mk("bing", title, href))
if len(out) >= limit:
break
return out

37
omni/providers/treg.py Normal file
View File

@@ -0,0 +1,37 @@
"""treg.to: публичного поискового API нет, работаем только со своим токеном.
Провайдер существует, чтобы в общем списке было видно: источник требует доступ,
а не молча отсутствует. Если TREG_TOKEN задан и у аккаунта есть веб-поиск,
провайдер отдаст результаты, иначе сразу скажет причину.
"""
import urllib.parse
from .. import config, net
from ..model import Item
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36"}
def available(name="treg"):
if not config.KEYS.get("TREG_TOKEN"):
return False, "нужен TREG_TOKEN (публичного поиска нет)"
return True, "токен есть"
def search_treg(query, limit=10):
tok = config.KEYS.get("TREG_TOKEN")
if not tok:
return []
# основной путь: внутренний поиск по каталогу моделей/задач
url = f"https://treg.to/api/search?q={urllib.parse.quote(query)}&limit={min(limit, 20)}"
r = net.get(url, headers={**UA, "Authorization": f"Bearer {tok}"}, json_mode=True)
d = r.json() or {}
rows = d.get("results") or d.get("data") or []
out = []
for x in rows[:limit]:
if not isinstance(x, dict):
continue
out.append(Item("treg", "result", url=x.get("url") or x.get("link") or "https://treg.to",
title=x.get("title") or x.get("name") or "", text=(x.get("description") or "")[:1500],
score=float(x.get("score") or 0)))
return out

106
omni/providers/x.py Normal file
View File

@@ -0,0 +1,106 @@
"""X/Twitter без платного API.
Три пути:
1. X_BEARER есть -> официальный /2/tweets/search/recent.
2. Иначе -> ищем ссылки на твиты через поисковики, текст достаём из publish.twitter.com/oembed (без ключей).
3. Профиль целиком -> syndication.twitter.com/srv/timeline-profile (отдаёт JSON в HTML, без ключей).
"""
import json
import re
import urllib.parse
from .. import config, net
from ..model import Item
TWEET_RE = re.compile(r"https?://(?:www\.)?(?:twitter\.com|x\.com)/([A-Za-z0-9_]{1,15})/status/(\d+)")
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/141.0.0.0 Safari/537.36"}
def available(name="x"):
if config.KEYS.get("X_BEARER"):
return True, "официальный API v2 (X_BEARER)"
return True, "без ключей: oembed + syndication timeline"
def _oembed(url):
api = "https://publish.twitter.com/oembed?omit_script=1&dnt=1&url=" + urllib.parse.quote(url, safe="")
r = net.get(api, headers=UA, json_mode=True)
if r.status != 200:
return None
d = r.json() or {}
html = d.get("html") or ""
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).replace("—", "-").strip()
return {"author": d.get("author_name") or "", "text": text,
"url": d.get("url") or url, "handle": (d.get("author_url") or "").rstrip("/").split("/")[-1]}
def search_x(query, limit=15, handles=None):
items = []
bearer = config.KEYS.get("X_BEARER")
if bearer:
q = urllib.parse.quote(f"{query} -is:retweet lang:ru OR lang:en")
url = (f"https://api.x.com/2/tweets/search/recent?query={q}"
f"&max_results={min(max(limit, 10), 100)}&tweet.fields=created_at,public_metrics,lang")
r = net.get(url, headers={"Authorization": f"Bearer {bearer}"}, json_mode=True)
for t in ((r.json() or {}).get("data") or []):
m = t.get("public_metrics") or {}
it = Item("x", "tweet", url=f"https://x.com/i/status/{t['id']}", title=t["text"][:120],
text=t["text"], author="", published=t.get("created_at", ""),
extra={"likes": m.get("like_count"), "rt": m.get("retweet_count")})
it.score = (m.get("like_count") or 0) ** 0.5
items.append(it)
else:
# путь 2: находим ссылки поисковиками, текст тянем oembed-ом
from . import searchers
found = []
for eng in ("ddg", "mojeek", "bing", "tavily"):
try:
res = getattr(searchers, "search_" + eng)(f"{query} site:x.com OR site:twitter.com", limit=limit)
except Exception:
res = []
for r0 in res:
for m in TWEET_RE.finditer(r0.url or ""):
found.append(m.group(0))
if len(found) >= limit:
break
seen = set()
for u in found[:limit * 2]:
if u in seen:
continue
seen.add(u)
d = _oembed(u)
if not d or len(d["text"]) < 20:
continue
it = Item("x", "tweet", url=d["url"], title=d["text"][:120], text=d["text"],
author=d["author"], source="x.com/" + (d["handle"] or ""),
extra={"handle": d["handle"]})
items.append(it)
# путь 3: конкретные профили
for h in (handles or [])[:4]:
r = net.get(f"https://syndication.twitter.com/srv/timeline-profile/screen-name/{h}",
headers=UA)
if r.status != 200:
continue
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', r.text, re.S)
if not m:
continue
try:
data = json.loads(m.group(1))
except Exception:
continue
entries = (((data.get("props") or {}).get("pageProps") or {}).get("timeline") or {}).get("entries") or []
for e in entries[:12]:
tw = ((e.get("content") or {}).get("tweet") or {})
txt = tw.get("full_text") or tw.get("text") or ""
if not txt:
continue
tid = tw.get("id_str") or ""
it = Item("x", "tweet", url=f"https://x.com/{h}/status/{tid}" if tid else f"https://x.com/{h}",
title=txt[:120], text=txt, author=h, source="x.com/" + h,
published=tw.get("created_at", ""),
extra={"likes": tw.get("favorite_count"), "rt": tw.get("retweet_count")})
it.score = float(tw.get("favorite_count") or 0) ** 0.5
items.append(it)
return items[:limit * 2]

41
omni/rank.py Normal file
View File

@@ -0,0 +1,41 @@
"""Скор записи: вес провайдера + вовлечённость + свежесть + ценник + объём текста."""
import re
import time
WEIGHT = {"reddit": 1.15, "x": 1.10, "forums": 1.0, "tavily": 1.0, "exa": 1.0,
"firecrawl": 0.9, "you": 0.9, "ddg": 0.75, "cards": 0.35, "treg": 0.8}
KIND_BONUS = {"tweet": 0.15, "thread": 0.25, "tg_post": 0.2, "card": 0.05, "result": 0.1, "post": 0.1}
def _fresh(published):
"""published: unix-строка или ISO. Возвращает 0..1."""
if not published:
return 0.3
try:
if published.isdigit():
ts = int(published)
if ts > 10**12:
ts //= 1000
else:
ts = time.mktime(time.strptime(published[:19], "%Y-%m-%dT%H:%M:%S"))
days = max(0.0, (time.time() - ts) / 86400)
return max(0.0, 1.0 - days / 30.0)
except Exception:
return 0.3
def score(it):
base = it.score if it.score else 0.0
w = WEIGHT.get(it.provider, 0.7)
fresh = _fresh(it.published)
priced = 0.35 if it.price else 0.0
body = min(1.0, len(it.text) / 1200.0)
eng = min(3.0, base ** 0.5) if base > 1 else base
total = (w * (1.0 + eng) + fresh * 1.2 + priced + body + KIND_BONUS.get(it.kind, 0.0))
it.score = round(total, 4)
return it
def rank(items):
return sorted((score(i) for i in items), key=lambda x: -x.score)

165
omni/store.py Normal file
View File

@@ -0,0 +1,165 @@
"""Склад: sqlite WAL + FTS5. Таблицы items, runs; дедуп по ключу и simhash-полосам."""
import json
import sqlite3
import time
from pathlib import Path
from . import config
SCHEMA = """
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
k TEXT UNIQUE,
provider TEXT, kind TEXT, source TEXT, url TEXT, title TEXT, text TEXT,
author TEXT, published TEXT, lang TEXT, tags TEXT, price REAL, currency TEXT,
score REAL, h INTEGER, extra TEXT, seen INTEGER
);
CREATE INDEX IF NOT EXISTS ix_items_prov ON items(provider, kind);
CREATE INDEX IF NOT EXISTS ix_items_seen ON items(seen);
CREATE INDEX IF NOT EXISTS ix_items_h ON items(h);
CREATE TABLE IF NOT EXISTS bands (band INTEGER, h INTEGER, item_id INTEGER, PRIMARY KEY(band,h,item_id));
CREATE INDEX IF NOT EXISTS ix_bands_h ON bands(h);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY, ts INTEGER, provider TEXT, source TEXT, query TEXT,
got INTEGER, kept INTEGER, status TEXT, bytes INTEGER, mode TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
title, text, content='items', content_rowid='id', tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid,title,text) VALUES (new.id,new.title,new.text);
END;
CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts,rowid,title,text) VALUES('delete',old.id,old.title,old.text);
END;
"""
BANDS = 4 # 4 полосы по 16 бит
MASK = 0xFFFF
def conn():
c = sqlite3.connect(str(config.DB), timeout=30)
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.executescript(SCHEMA)
return c
def _signed(h):
return h - (1 << 64) if h >= (1 << 63) else h
def _unsigned(v):
return v & 0xFFFFFFFFFFFFFFFF
def _popcount(x):
return bin(x).count("1")
def _near_dup(c, h, max_dist=3):
"""Кандидат ищем по полосе, решение принимаем по расстоянию Хэмминга."""
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
def add(items):
"""Возвращает (новых, дублей)."""
new = dup = 0
ts = int(time.time())
with conn() as c:
for it in items:
h = _unsigned(it.h)
if _near_dup(c, h):
dup += 1
continue
try:
cur = c.execute(
"INSERT INTO items(k,provider,kind,source,url,title,text,author,published,lang,"
"tags,price,currency,score,h,extra,seen) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(it.key(), it.provider, it.kind, it.source, it.url, it.title, it.text, it.author,
it.published, it.lang, ",".join(it.tags), it.price, it.currency, it.score,
_signed(h), json.dumps(it.extra, ensure_ascii=False)[:2000], ts))
except sqlite3.IntegrityError:
dup += 1
continue
iid = cur.lastrowid
for b in range(BANDS):
band = (h >> (16 * b)) & MASK
c.execute("INSERT OR IGNORE INTO bands(band,h,item_id) VALUES(?,?,?)",
(band, _signed(h), iid))
new += 1
return new, dup
def log_run(provider, source, query, got, kept, status, nbytes=0, mode=""):
with conn() as c:
c.execute("INSERT INTO runs(ts,provider,source,query,got,kept,status,bytes,mode) "
"VALUES(?,?,?,?,?,?,?,?,?)",
(int(time.time()), provider, source, (query or "")[:200], got, kept, status, nbytes, mode))
def top(limit=20, provider=None):
q = ("SELECT provider,kind,source,title,score,price,currency,url FROM items "
+ ("WHERE provider=? " if provider else "") +
"ORDER BY score DESC, id DESC LIMIT ?")
args = (provider, limit) if provider else (limit,)
with conn() as c:
return c.execute(q, args).fetchall()
def search(q, limit=20):
with conn() as c:
rows = c.execute(
"SELECT i.provider,i.kind,i.source,i.title,i.score,i.url FROM items_fts f "
"JOIN items i ON i.id=f.rowid WHERE items_fts MATCH ? ORDER BY i.score DESC LIMIT ?",
(q, limit)).fetchall()
if rows:
return rows
like = f"%{q}%"
return c.execute(
"SELECT provider,kind,source,title,score,url FROM items WHERE title LIKE ? OR text LIKE ? "
"ORDER BY score DESC LIMIT ?", (like, like, limit)).fetchall()
def stats():
with conn() as c:
total = c.execute("SELECT COUNT(*) FROM items").fetchone()[0]
by_prov = c.execute("SELECT provider, COUNT(*) FROM items GROUP BY provider ORDER BY 2 DESC").fetchall()
by_kind = c.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY 2 DESC").fetchall()
priced = c.execute("SELECT COUNT(*) FROM items WHERE price IS NOT NULL").fetchone()[0]
runs = c.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
ok_runs = c.execute("SELECT COUNT(*) FROM runs WHERE status='ok'").fetchone()[0]
return {"items": total, "priced": priced, "runs": runs, "runs_ok": ok_runs,
"by_provider": by_prov, "by_kind": by_kind}
def export(path, fmt="jsonl", limit=1000):
path = Path(path)
with conn() as c:
rows = c.execute("SELECT provider,kind,source,url,title,text,author,published,lang,tags,"
"price,currency,score,extra FROM items ORDER BY score DESC LIMIT ?",
(limit,)).fetchall()
cols = ["provider", "kind", "source", "url", "title", "text", "author", "published", "lang",
"tags", "price", "currency", "score", "extra"]
if fmt == "csv":
import csv
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
for r in rows:
w.writerow(dict(zip(cols, r)))
else:
with path.open("w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(dict(zip(cols, r)), ensure_ascii=False) + "\n")
return len(rows), str(path)