"""CLI: providers / search / fanout / cards / top / find / stats / export.""" import argparse import pathlib import concurrent.futures import json import sys from . import config, rank, store from . import feeds, sources 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_sources(args): from . import source_state st = sources.stats() fl = source_state.flags() all_s = sources.all_sources() print("источников:", len(all_s), "| включено:", sum(1 for s in all_s if fl.get(s.name, {}).get("enabled", True)), "| по категориям:", ", ".join(f"{k}={v}" for k, v in st.items())) if args.list: for s in sources.pick(args.category, args.only, args.tags, respect_enabled=not args.all): f = fl.get(s.name, {}) mark = "on " if f.get("enabled", True) else "off" stt = f"{f.get('items')} зап." if (f.get("items") or 0) > 0 else (f.get("note") or "—") print(f" {mark} {s.category:<12} {s.adapter:<18} {s.name:<26} {stt:<12} {s.url[:60]}") return 0 def cmd_feed(args): picked = sources.pick(args.category, args.only, args.tags) if not picked: print("нет источников под фильтр") return 1 print(f"обход {len(picked)} источников, лимит {args.limit} на источник") items, report = feeds.fetch_many(picked, limit=args.limit, workers=args.workers) items = rank.rank(items) new, dup = store.add(items) bad = [(n, c) for n, c in report if c == 0] for name, cnt in sorted(report, key=lambda x: -x[1])[:20]: print(f" {name:<28} {cnt}") print(f"пусто: {len(bad)}/{len(report)} | собрано {len(items)}, новых {new}, дублей {dup}") if bad and args.verbose: print("без отдачи:", ", ".join(n for n, _ in bad)) return 0 def cmd_digest(args): """Сводка по базе: топ записей по категориям за прогон, в markdown.""" rows = store.top(limit=args.limit * 8) by_cat = {} for prov, kind, src, title, sc, price, cur, url in rows: by_cat.setdefault(src or prov, []).append((sc, title, url)) out = [f"# дайджест omni · {__import__('datetime').date.today()}", ""] for cat, entries in sorted(by_cat.items(), key=lambda x: -len(x[1])): out.append(f"## {cat} · {len(entries)}") for sc, title, url in entries[:args.limit]: out.append(f"- [{title[:110]}]({url}) · {sc:.2f}") out.append("") text = chr(10).join(out) path = args.out pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) pathlib.Path(path).write_text(text, encoding="utf-8") print(f"дайджест: {len(by_cat)} категорий, {len(rows)} записей -> {path}") 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("sources", help="реестр источников") sp.add_argument("--category", default=None) sp.add_argument("--only", default=None) sp.add_argument("--tags", default=None) sp.add_argument("--list", action="store_true") sp.add_argument("--all", action="store_true", help="показать и выключенные") sp.set_defaults(fn=cmd_sources) sp = sub.add_parser("feed", help="обход реестра источников без запроса") sp.add_argument("--category", default=None) sp.add_argument("--only", default=None) sp.add_argument("--tags", default=None) sp.add_argument("--limit", type=int, default=15) sp.add_argument("--workers", type=int, default=8) sp.add_argument("--verbose", action="store_true") sp.set_defaults(fn=cmd_feed) sp = sub.add_parser("doctor", help="проверка источников, --fix подбирает рабочие фиды") sp.add_argument("--only", default=None) sp.add_argument("--fix", action="store_true") sp.add_argument("--limit", type=int, default=3) sp.set_defaults(fn=lambda a: (__import__("omni.doctor", fromlist=["run"]).run(a.only, a.fix, limit=a.limit), 0)[1]) sp = sub.add_parser("serve", help="веб-панель + JSON API") sp.add_argument("--port", type=int, default=8077) sp.set_defaults(fn=lambda a: __import__("omni.serve", fromlist=["serve"]).serve(a.port)) sp = sub.add_parser("digest", help="markdown-дайджест из базы") sp.add_argument("--limit", type=int, default=8) sp.add_argument("--out", default="data/digest.md") sp.set_defaults(fn=cmd_digest) 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())