diff --git a/README.md b/README.md index 1ccd1ee..707f527 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ python -m omni export --format jsonl --out data/dump.jsonl ## Реестр источников -`omni/sources.py` + `omni/sources_ext.py` + `omni/sources_auto.py` — **1072 источника в 11 категориях**, у каждого свой адаптер и флаг вкл/выкл. Из них **467 abuse** (включая 46 авто-подключённых `aa_`) и **33 в активной авто-группе `aa_`** (остальные 13 авто-кандидатов совпали по url с уже существующими источниками и были схлопнуты дедупом): +`omni/sources.py` + `omni/sources_ext.py` + `omni/sources_auto.py` — **1072 источника в 11 категориях**, у каждого свой адаптер и флаг вкл/выкл. Из них **467 abuse** (включая 61 авто-подключённый `aa_`) и **37 в активной авто-группе `aa_`** (остальные 24 авто-кандидата совпали по url с уже существующими источниками и были схлопнуты дедупом): - **papers 49** — arxiv по 25+ разделам (cs.AI/LG/CL/CV/RO/IR/CR/SE/NE/MA/DC/DB/HC/SD/CY, stat.ML, eess.AS/SP/IV, math.OC, q-bio.NC, cs.GR, cs.AR, cs.PF), HF daily papers, OpenAlex, Semantic Scholar, Papers with Code, Connected Papers - **company 95** — ленты OpenAI, Google AI/Research/DeepMind, Microsoft Research, NVIDIA, Apple ML, Amazon Science, HF, Meta AI, Mistral, Cohere, Together, Perplexity, Groq, Cerebras, Stability, Databricks, Anyscale, Modal, W&B, LangChain, LlamaIndex, vLLM, Replicate, Fireworks, SambaNova, Scale, xAI, Salesforce, AWS ML, Azure AI, Anthropic, AI2, EleutherAI, LAION, Runway, ElevenLabs, Deepgram, AssemblyAI, Pinecone, Weaviate, Qdrant, Chroma, Unsloth, Axolotl @@ -229,20 +229,27 @@ python tools/auto_cycle.py # полный цикл: probe → Универсальный `generic_json` понимает описание в `extra`: `rows` (путь к списку через точку), `title_key`, `url_key`, `text_key`, `date_key`, `base` + `slug_key` (собрать ссылку из слага), `deref` (спуститься в подобъект). Поэтому большинство новых JSON-источников — одна строка в `data/source_candidates.json`, без нового кода. Отдельные адаптеры только там, где схема нестандартная: `bsky_search` (Bluesky public API, без токена), `mastodon_tag`, `tg_web` (`t.me/s/`), `discourse` (`latest.json`). -Подключено в этом апдейте (46 живых из 52 проверенных, `пусто 0/33 | собрано 458, новых 408`): +Подключено в этом апдейте (61 живой из 81 проверенного; отбор `aa_` = 37 источников после дедупа по url, `пусто 0/37 | собрано 673, новых 234`): - **JSON без ключа** — dev.to, Lobsters, npm search (llm / mcp / free api keys), crates.io, Docker Hub, Stack Overflow (openai / prompt-injection), GitLab (new/llm), Hugging Face spaces + models trending - **Reddit** — через `api.pullpush.io` (архив Reddit, без OAuth): LocalLLaMA, FreeAI, OpenAIAPI, ChatGPT, selfhosted - **Bluesky** — `public.api.bsky.app` searchPosts: free tokens api, openrouter, free api, ai keys, llm api - **Mastodon** — теги ai / openai / llm - **Discourse** — community.openai, discuss.huggingface, forum.cursor, discuss.python, discuss.fly, forum.obsidian -- **RSS** — Habr, The Decoder, MarkTechPost, OpenAI news, Simon Willison, HF blog, TechCrunch AI, 52pojie +- **RSS** — Habr, The Decoder, MarkTechPost, OpenAI news, Simon Willison, HF blog, TechCrunch AI, 52pojie, Latent Space, Interconnects, Import AI, TLDR AI, smol.ai, Snyk, JFrog, Wiz, UpGuard, SecurityWeek, Cloudflare, Bleeping, The Hacker News +- **Проверка живости** — `tools/auto_expand.py --probe` (items>0 обязателен), здоровье в `data/source_health.json`, три нуля подряд → кандидат выкидывается из генерации - **Telegram-превью** — ai_newz, data_secrets, neurohive, gpt4telegram, dataleak, denissexy `--only` в `omni feed` понимает префикс: `python -m omni feed --only aa_` берёт всю авто-группу. Для `github_search` поднимается лимит, если в `.env` есть `GITHUB_TOKEN` (анонимно 10 запросов/мин, с токеном 30). Крон (каждые 6 часов, `tools/auto_cycle.py`): проверка кандидатов → пересборка `sources_auto.py` → докачка только при изменениях → пересборка abuse-индекса → коммит `auto: источники N live` и пуш в gitea/github. Лог — `data/auto_cycle.log`. +Отбор из public-apis (`--discover`) фильтруется по категориям (AI / ML / Security / News / Open Data / Development / Text Analysis / Cryptocurrency) и чёрному списку мусора (waifu/anime/cat facts/insurance/…): из 40 «бесплатных» эндпоинтов публичного каталога реально отдают данные единицы, остальное — витрины без API. Поэтому основной прирост идёт не из каталогов, а из конкретных известных эндпоинтов (`--add`). + +**Условный GET (ETag/Last-Modified)** — в `omni/net.py`: ответ с `ETag`/`Last-Modified` кладётся в `data/http_cache/`, повторный запрос уходит с `If-None-Match`/`If-Modified-Since`, `304` отдаётся из кэша. Замер на `huggingface.co/blog/feed.xml`: `1.73 с` → `0.74 с` при идентичном теле (262 549 байт). Прогон по 60+ RSS/HTML-источникам ускоряется без потери свежести — новые записи приходят, как только меняется сам документ. + +**Новые правила абуз-разбора** (`omni/abuse.py`): форматы ключей `sk-ws-*`, `ek_live_*`, `abl_*`, `xpl_*`, `AIza*`, `ak_*`, `sk-or-v1-*`; теги `farm` (авторег, фермы, пул прокси, ротация ip) и `weights` (abliterated/uncensored/GGUF); в `tool` добавлены `new-api`/`one-api`/`sub2api`/`chat2api`/`gpt-load`/`shim`/`openai-compatible`/`reverse proxy`; в `credits` — «$N free credits». Проверка классификатора: панель new-api → 202 (`trial/tool/credits/freeapi/infra`), авторег-ферма → 96 (`tool/resale/farm/infra`), «погода» → 0. + ## Панель и команды ``` diff --git a/omni/abuse.py b/omni/abuse.py index ba159cc..b6341ae 100644 --- a/omni/abuse.py +++ b/omni/abuse.py @@ -18,6 +18,9 @@ RULES = [ r"sk-[A-Za-z0-9_\-]{20,}", r"gh[pousr]_[A-Za-z0-9]{20,}", r"xox[baprs]-[A-Za-z0-9\-]{10,}", r"(разда|выда|дарю|бесплатн|free|giveaway)[^.]{0,40}(ключ|key|токен|token)", r"(ключ|key|токен)[^.]{0,25}(бесплатн|free|дарю|раздач|giveaway)", + r"sk-ws-[A-Za-z0-9_\-]{16,}", r"ek_live_[A-Za-z0-9_\-]{10,}", r"abl_[A-Za-z0-9]{16,}", + r"xpl_[A-Za-z0-9]{16,}", r"AIza[0-9A-Za-z_\-]{30,}", r"ak_[A-Za-z0-9]{20,}", + r"kr/claude", r"sk-or-v1-[A-Za-z0-9]{20,}", ]), ("promo", "промо", 26, [ r"промокод", r"promo\s?code", r"промо[\s-]?код", r"скидк[аи]\s+\d{1,3}", r"\d{1,3}\s?%\s?(off|скидк)", @@ -37,6 +40,8 @@ RULES = [ ("tool", "софт", 18, [ r"софт", r"кряк", r"crack(ed)?", r"bypass", r"обход\s+(блокиров|бан|защит)", r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт", + r"new-?api", r"one-?api", r"sub2api", r"chat2api", r"gpt-?load", r"api\s*(?:реселл|resell)", + r"openai[\s-]?compatib", r"reverse\s+proxy", r"shim", ]), ("resale", "продажа", 14, [ r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", @@ -54,6 +59,15 @@ RULES = [ r"free\s+(?:api\s+)?credits?", r"\b\d+\s?(?:k|m)?\s?tokens?\s+(?:free|бесплатн)", r"free\s+quota", r"бесплатн\w*\s+(?:лимит|квот|токен)", r"кредит\w*\s+(?:бесплатн|в\s+подарок)", r"赠金", r"额度", r"unlimited\s+(?:api|access|tokens|usage)", r"безлимит\w*\s+(?:api|ключ|доступ|токен)", + r"\$\d{2,4}\s+(?:in\s+)?(?:free\s+)?credits?", r"credits?\s+\$\d{2,4}", r"\$\d{2,4}\s*(?:free|в\s+подарок)", + ]), + ("farm", "фермы/автоматизация", 18, [ + r"авторег", r"autoreg", r"farm(?:er|ing)?", r"много\s*аккаунт", r"массов\w+\s+регистрац", + r"аккаунт\w*\s+оптом", r"прокси[\s-]?пул", r"proxy\s+pool", r"ротаци\w*\s+(?:ip|прокси)", + ]), + ("weights", "модели/веса", 20, [ + r"abliterated", r"uncensored", r"GGUF", r"weights?[^.]{0,25}(?:download|слив|huggingface)", + r"без\s+цензур", r"обход\s+модераци", ]), ("invite", "инвайты/беты", 16, [ r"\binvite\s?(?:code|link|only)\b", r"инвайт", r"\bwaitlist\b", r"лист\s+ожидан", r"early\s+access", @@ -98,7 +112,8 @@ RULES = [ COMPILED = [(tag, label, w, [re.compile(p, re.I) for p in pats]) for tag, label, w, pats in RULES] TAG_LABEL = {t: l for t, l, _, _ in RULES} -STRONG = {"keys", "promo", "trial", "resale", "accs", "credits", "freeapi", "ltd", "combo"} +STRONG = {"keys", "promo", "trial", "resale", "accs", "credits", "freeapi", "ltd", "combo", + "farm", "weights"} # на специализированных абуз-площадках хватает одного признака: сам источник тематический ABUSE_SRC_SC = 14 # категории-источники, которые вообще имеют смысл для абузов diff --git a/omni/net.py b/omni/net.py index 4d92cd8..fa14544 100644 --- a/omni/net.py +++ b/omni/net.py @@ -10,6 +10,78 @@ _cfg = config.load() _lock = threading.Lock() _last = {} # host -> ts +# ---------- условный GET: ETag / If-Modified-Since ---------- +_DATA = __import__("pathlib").Path(__file__).resolve().parents[1] / "data" +_COND = _DATA / "http_cond.json" +_BODIES = _DATA / "http_cache" +_COND_DB = None +_cond_lock = threading.Lock() + + +def _cond_load(): + global _COND_DB + if _COND_DB is None: + try: + import json as _j + _COND_DB = _j.loads(_COND.read_text(encoding="utf-8")) + except Exception: + _COND_DB = {} + return _COND_DB + + +def _cond_save(): + try: + import json as _j + with _cond_lock: + _BODIES.mkdir(parents=True, exist_ok=True) + _COND.write_text(_j.dumps(_COND_DB, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +def _body_path(key): + import hashlib + return _BODIES / (hashlib.sha1(key.encode("utf-8", "replace")).hexdigest() + ".body") + + +def _cond_ask(key, h): + rec = _cond_load().get(key) or {} + if rec.get("etag"): + h["If-None-Match"] = rec["etag"] + if rec.get("lm"): + h["If-Modified-Since"] = rec["lm"] + return bool(rec.get("etag") or rec.get("lm")) + + +def _cond_store(key, resp): + if resp.status != 200 or not resp.text or len(resp.text) > 1_500_000: + return + hdr = {str(k).lower(): v for k, v in (resp.headers or {}).items()} + etag, lm = hdr.get("etag"), hdr.get("last-modified") + if not etag and not lm: + return + try: + _BODIES.mkdir(parents=True, exist_ok=True) + p = _body_path(key) + p.write_text(resp.text, encoding="utf-8") + except Exception: + return + _cond_load()[key] = {"etag": etag, "lm": lm, "body": p.name, + "ts": int(time.time()), "size": len(resp.text)} + _cond_save() + + +def _cond_hit(key): + rec = _cond_load().get(key) + if not rec or not rec.get("body"): + return None + try: + txt = (_BODIES / rec["body"]).read_text(encoding="utf-8") + except Exception: + return None + return Resp(key.split("|")[0], 200, txt, "cache-304", headers={"x-omni-cache": "304"}) + + BLOCK_MARKS = ("just a moment", "cf-browser-verification", "attention required", "ddos-guard", "checking your browser", "enable javascript and cookies") @@ -118,9 +190,11 @@ def post_json(url, payload, headers=None, timeout=None): def get(url, headers=None, timeout=None, json_mode=False, proxy=None): - """Лестница: httpx -> curl_cffi -> curl_cffi с прокси. Возвращает первый осмысленный ответ. + """Лестница: httpx -> curl_cffi -> curl_cffi с прокси + условный GET (ETag/304). proxy=... — принудительный прокси для конкретного источника (напр. CF-форумы через DE). + Повторный запрос неизменного документа отдаётся из data/http_cache (304), это экономит + и трафик, и время прогона (RSS/HTML отдают ETag/Last-Modified почти всегда). """ timeout = timeout or _cfg.get("timeout", 25) host = urllib.parse.urlsplit(url).netloc @@ -135,23 +209,43 @@ def get(url, headers=None, timeout=None, json_mode=False, proxy=None): if ck: h["Cookie"] = ck + key = url + ("|" + proxy if proxy else "") + conditional = _cond_ask(key, h) + + def finish(resp): + if conditional and resp.status in (304,): + hit = _cond_hit(key) + if hit: + return hit + if resp.status == 200: + _cond_store(key, resp) + return resp + if proxy: forced = _try_curl(url, h, timeout, proxy) if forced.ok and not forced.blocked: - return forced + return finish(forced) alt = _try_httpx(url, h, timeout, proxy) - return alt if alt.status == 200 else forced + return finish(alt if alt.status == 200 else forced) first = _try_httpx(url, h, timeout, None) + if conditional and first.status == 304: + hit = _cond_hit(key) + if hit: + return hit if first.ok and not first.blocked: - return first + return finish(first) second = _try_curl(url, h, timeout, None) + if conditional and second.status == 304: + hit = _cond_hit(key) + if hit: + return hit if second.ok and not second.blocked: - return second + return finish(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 \ No newline at end of file + return finish(third) + return finish(third) + return finish(second if second.status else first) diff --git a/omni/sources_auto.py b/omni/sources_auto.py index 508dc2f..04cc5d4 100644 --- a/omni/sources_auto.py +++ b/omni/sources_auto.py @@ -3,7 +3,7 @@ Файл генерируется `tools/auto_expand.py` из `data/source_candidates.json` (живые кандидаты) — правки руками перезапишутся, добавляй кандидатов в JSON. -Обновлено: 2026-10-27 16:41 UTC · живых 46 из 52 +Обновлено: 2026-10-27 17:19 UTC · живых 61 из 81 """ @@ -31,6 +31,7 @@ AUTO = [ A("aa_gitlab_llm", "json", "https://gitlab.com/api/v4/projects?search=llm&sort=desc&order_by=created_at&per_page=25", "generic_json", ("abuse"), extra={"url_key": "web_url"}), A("aa_npm_free_api", "json", "https://registry.npmjs.org/-/v1/search?text=free%20api%20keys&size=25", "generic_json", ("abuse"), extra={"rows": "objects", "deref": "package", "title_key": "name", "text_key": "description", "base": "https://www.npmjs.com/package/", "slug_key": "name"}), A("aa_crates_proxy", "json", "https://crates.io/api/v1/crates?q=openai%20proxy&per_page=20", "generic_json", ("abuse"), extra={"rows": "crates", "base": "https://crates.io/crates/", "slug_key": "name"}), + A("aa_json_gh_advisories", "json", "https://api.github.com/advisories?per_page=30", "generic_json", ("abuse"), extra={"url_key": "html_url", "title_key": "summary", "date_key": "published_at"}), # ---------- Reddit через pullpush.io (без ключа) ---------- A("aa_pp_freeai", "json", "https://api.pullpush.io/reddit/search/submission/?subreddit=FreeAI&size=25", "generic_json", ("abuse"), extra={"rows": "data", "url_key": "url", "title_key": "title", "text_key": "selftext", "date_key": "created_utc"}), @@ -63,6 +64,20 @@ AUTO = [ A("aa_rss_hf_blog", "rss", "https://huggingface.co/blog/feed.xml", "rss", ("abuse")), A("aa_rss_tc_ai", "rss", "https://techcrunch.com/category/artificial-intelligence/feed/", "rss", ("abuse")), A("aa_rss_52pojie", "rss", "https://www.52pojie.cn/forum.php?mod=rss", "rss", ("abuse")), + A("aa_rss_latentspace", "rss", "https://www.latent.space/feed", "rss", ("abuse")), + A("aa_rss_interconnects", "rss", "https://www.interconnects.ai/feed", "rss", ("abuse")), + A("aa_rss_importai", "rss", "https://importai.substack.com/feed", "rss", ("abuse")), + A("aa_rss_tldr_ai", "rss", "https://tldr.tech/api/rss/ai", "rss", ("abuse")), + A("aa_rss_smol", "rss", "https://news.smol.ai/rss.xml", "rss", ("abuse")), + A("aa_rss_aisupremacy", "rss", "https://aisupremacy.substack.com/feed", "rss", ("abuse")), + A("aa_rss_snyk", "rss", "https://snyk.io/blog/feed/", "rss", ("abuse")), + A("aa_rss_jfrog", "rss", "https://jfrog.com/blog/feed/", "rss", ("abuse")), + A("aa_rss_wiz", "rss", "https://www.wiz.io/blog/rss.xml", "rss", ("abuse")), + A("aa_rss_upguard", "rss", "https://www.upguard.com/blog/rss.xml", "rss", ("abuse")), + A("aa_rss_securityweek", "rss", "https://www.securityweek.com/feed/", "rss", ("abuse")), + A("aa_rss_cloudflare", "rss", "https://blog.cloudflare.com/rss/", "rss", ("abuse")), + A("aa_rss_bleeping", "rss", "https://www.bleepingcomputer.com/feed/", "rss", ("abuse")), + A("aa_rss_thn", "rss", "https://feeds.feedburner.com/TheHackersNews", "rss", ("abuse")), # ---------- Telegram-превью ---------- A("aa_tg_ai_newz", "html", "https://t.me/s/ai_newz", "tg_web", ("abuse", "tg")), diff --git a/tests/test_sources_auto.py b/tests/test_sources_auto.py new file mode 100644 index 0000000..df13982 --- /dev/null +++ b/tests/test_sources_auto.py @@ -0,0 +1,53 @@ +"""Санити-тесты авто-реестра источников (без сети). + +Проверяют, что сгенерированный `omni/sources_auto.py` не битый: адаптеры существуют, +имена/url не дублируются, обязательные поля на месте. Сами фиды тут не дергаются — +живость проверяет `tools/auto_expand.py --probe`. +""" +from __future__ import annotations + +import re + +import pytest + +from omni import feeds, sources +from omni.sources_auto import AUTO + + +def test_auto_not_empty(): + assert AUTO, "sources_auto.py пустой — прогони tools/auto_expand.py --probe --write" + + +@pytest.mark.parametrize("entry", AUTO, ids=[d.get("name", "?") for d in AUTO]) +def test_entry_shape(entry): + for key in ("name", "url", "adapter", "kind"): + assert entry.get(key), "у записи нет поля %s: %r" % (key, entry) + assert entry["url"].startswith("http"), entry["url"] + assert entry["adapter"] in feeds.ADAPTERS, "неизвестный адаптер: %s" % entry["adapter"] + assert entry.get("category", "abuse") == "abuse" + + +def test_names_and_urls_unique(): + names = [d["name"] for d in AUTO] + urls = [d["url"] for d in AUTO] + assert len(names) == len(set(names)), "дубли имён в AUTO" + assert len(urls) == len(set(urls)), "дубли url в AUTO" + + +def test_all_auto_registered_in_sources(): + """Каждая живая запись должна попасть в общий реестр (или быть схлопнутой дедупом по url).""" + registered = {s.name for s in sources.SOURCES if s.name.startswith("aa_")} + existing_urls = {s.url for s in sources.SOURCES} + missing = [d["name"] for d in AUTO if d["name"] not in registered and d["url"] not in existing_urls] + assert not missing, "не попали в SOURCES: %s" % missing[:5] + + +def test_generated_file_is_marked_generated(): + from pathlib import Path + + import omni + + path = Path(omni.__file__).parent / "sources_auto.py" + head = path.read_text(encoding="utf-8")[:400] + assert "генерируется" in head, "шапка файла должна предупреждать, что он генерируется" + assert re.search(r"AUTO = \[", path.read_text(encoding="utf-8")), "нет списка AUTO" \ No newline at end of file diff --git a/tools/auto_expand.py b/tools/auto_expand.py index 9b5ec55..2b8b822 100644 --- a/tools/auto_expand.py +++ b/tools/auto_expand.py @@ -27,6 +27,8 @@ CAND = ROOT / "data" / "source_candidates.json" HEALTH = ROOT / "data" / "source_health.json" OUT = ROOT / "omni" / "sources_auto.py" PUBLIC_APIS = "https://raw.githubusercontent.com/public-apis/public-apis/master/README.md" +RE_URL = __import__("re").compile(r"\((https?://[^)\s]+)\)") +RE_LINK = __import__("re").compile(r"\[([^\]]+)\]\([^)]+\)") HEADER = '''"""Авто-расширение: источники на публичных API без ключа. @@ -144,25 +146,48 @@ def write_module(cands, results): print("\nwrote %s: %d live of %d" % (OUT.name, live, len(cands))) +CAT_OK = ("Artificial Intelligence", "Machine Learning", "Security", "News", "Open Data", + "Development", "Text Analysis", "Data Validation", "Sports", "Cryptocurrency") +KEEP_ANY_CAT = ("llm", "gpt", "openai", "threat", "cve", "vuln", "leak", "hack", "malware", + "phish", "proxy", "temp mail", "sms", "telegram", "scrap", "dns", "whois") +BAD = ("waifu", "anime", "cat fact", "dog", "joke", "meme", "pokemon", "quote", "trivia", + "recipe", "cocktail", "game", "3d", "texture", "insurance", "crypto price") +KW_OK = ("ai", "llm", "gpt", "model", "secur", "threat", "cve", "vuln", "leak", "hack", "news", + "feed", "telegram", "proxy", "mail", "sms", "text", "translate", "scrap", "search", + "crypto", "token", "domain", "dns", "ip", "geo") + + def discover(limit=40): - """Добрать бесплатные (Auth=No) API из public-apis.""" + """Добрать бесплатные (Auth=No) API из public-apis: только релевантные категории/слова.""" req = urllib.request.Request(PUBLIC_APIS, headers={"User-Agent": "omni-parse/1.0"}) md = urllib.request.urlopen(req, timeout=40).read().decode("utf-8", "replace") - rows = [] + rows, cat = [], "" for line in md.splitlines(): + if line.startswith("### "): + cat = line[4:].strip() + continue if not line.startswith("|") or "http" not in line: continue cells = [c.strip() for c in line.strip("|").split("|")] - if len(cells) < 6: + if len(cells) < 5: continue - url, auth, https = cells[2], cells[4], cells[5] - if "No" not in auth or "http" not in url: + m = RE_URL.search(cells[0]) + if not m: continue - if any(b in url for b in ("github.com/public-apis", "localhost")): + url = m.group(1) + title = RE_LINK.sub(r"\1", cells[0]).strip() + desc, auth = cells[1], cells[2] + if "No" not in auth: continue - rows.append({"name": "dis_" + url.split("//")[-1].split("/")[0].replace("www.", "").replace(".", "_")[:24], - "url": url, "adapter": "generic_json", "kind": "json", - "tags": ["abuse", "public_api"], "group": "json", "limit": 15}) + blob = (title + " " + desc).lower() + if cat not in CAT_OK and not any(k in blob for k in KEEP_ANY_CAT): + continue + if any(b in blob for b in BAD): + continue + host = url.split("//")[-1].split("/")[0].replace("www.", "").replace(".", "_").replace("-", "_") + rows.append({"name": "dis_" + host[:26], "url": url, "adapter": "generic_json", + "kind": "json", "tags": ["abuse", "public_api"], "group": "json", "limit": 15, + "note": (title + " — " + desc)[:120], "cat": cat}) uniq = {} for r in rows: uniq.setdefault(r["name"], r) @@ -172,6 +197,8 @@ def discover(limit=40): cands.extend(new) save(CAND, cands) print("discover: +%d кандидатов (всего %d)" % (len(new), len(cands))) + for r in new[:12]: + print(" + %-30s %s" % (r["name"], r["note"][:80])) return new