260 lines
10 KiB
Python
260 lines
10 KiB
Python
"""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 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):
|
||
st = sources.stats()
|
||
print("источников:", len(sources.SOURCES), "| по категориям:",
|
||
", ".join(f"{k}={v}" for k, v in st.items()))
|
||
if args.list:
|
||
for s in sources.pick(args.category, args.only, args.tags):
|
||
print(f" {s.category:<12} {s.adapter:<18} {s.name:<26} {s.url[:70]}")
|
||
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_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.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("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()) |