parser: ETag/304-кэш, 61 авто-источник из 81 кандидата (+RSS AI/security), правила farm/weights, тесты sources_auto
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled

This commit is contained in:
Vlad Gothbreach
2026-10-27 20:34:52 +03:00
parent 31a8dd8c4d
commit d6ad507d7e
6 changed files with 233 additions and 22 deletions

View File

@@ -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 - **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 - **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/<channel>`), `discourse` (`latest.json`). Универсальный `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/<channel>`), `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 - **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 - **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 - **Bluesky** — `public.api.bsky.app` searchPosts: free tokens api, openrouter, free api, ai keys, llm api
- **Mastodon** — теги ai / openai / llm - **Mastodon** — теги ai / openai / llm
- **Discourse** — community.openai, discuss.huggingface, forum.cursor, discuss.python, discuss.fly, forum.obsidian - **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 - **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). `--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`. Крон (каждые 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.
## Панель и команды ## Панель и команды
``` ```

View File

@@ -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"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"(разда|выда|дарю|бесплатн|free|giveaway)[^.]{0,40}(ключ|key|токен|token)",
r"(ключ|key|токен)[^.]{0,25}(бесплатн|free|дарю|раздач|giveaway)", 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, [ ("promo", "промо", 26, [
r"промокод", r"promo\s?code", r"промо[\s-]?код", r"скидк[аи]\s+\d{1,3}", r"\d{1,3}\s?%\s?(off|скидк)", 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, [ ("tool", "софт", 18, [
r"софт", r"кряк", r"crack(ed)?", r"bypass", r"обход\s+(блокиров|бан|защит)", r"софт", r"кряк", r"crack(ed)?", r"bypass", r"обход\s+(блокиров|бан|защит)",
r"авторег", r"autoreg", r"panel", r"панел[ьи]\s", r"рассылк", r"sender", r"скрипт", 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, [ ("resale", "продажа", 14, [
r"продаю", r"продам", r"куплю\s+аккаунт", r"\bfor\s+sale\b", r"цена\s*[:$]", r"\$\d+\s?/\s?(мес|mo|1k|1000)", 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"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"бесплатн\w*\s+(?:лимит|квот|токен)", r"кредит\w*\s+(?:бесплатн|в\s+подарок)", r"赠金", r"额度",
r"unlimited\s+(?:api|access|tokens|usage)", r"безлимит\w*\s+(?:api|ключ|доступ|токен)", 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, [ ("invite", "инвайты/беты", 16, [
r"\binvite\s?(?:code|link|only)\b", r"инвайт", r"\bwaitlist\b", r"лист\s+ожидан", r"early\s+access", 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] 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} 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 ABUSE_SRC_SC = 14
# категории-источники, которые вообще имеют смысл для абузов # категории-источники, которые вообще имеют смысл для абузов

View File

@@ -10,6 +10,78 @@ _cfg = config.load()
_lock = threading.Lock() _lock = threading.Lock()
_last = {} # host -> ts _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", BLOCK_MARKS = ("just a moment", "cf-browser-verification", "attention required",
"ddos-guard", "checking your browser", "enable javascript and cookies") "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): 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). proxy=... — принудительный прокси для конкретного источника (напр. CF-форумы через DE).
Повторный запрос неизменного документа отдаётся из data/http_cache (304), это экономит
и трафик, и время прогона (RSS/HTML отдают ETag/Last-Modified почти всегда).
""" """
timeout = timeout or _cfg.get("timeout", 25) timeout = timeout or _cfg.get("timeout", 25)
host = urllib.parse.urlsplit(url).netloc host = urllib.parse.urlsplit(url).netloc
@@ -135,23 +209,43 @@ def get(url, headers=None, timeout=None, json_mode=False, proxy=None):
if ck: if ck:
h["Cookie"] = 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: if proxy:
forced = _try_curl(url, h, timeout, proxy) forced = _try_curl(url, h, timeout, proxy)
if forced.ok and not forced.blocked: if forced.ok and not forced.blocked:
return forced return finish(forced)
alt = _try_httpx(url, h, timeout, proxy) 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) 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: if first.ok and not first.blocked:
return first return finish(first)
second = _try_curl(url, h, timeout, None) 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: if second.ok and not second.blocked:
return second return finish(second)
proxy = _pick_proxy() proxy = _pick_proxy()
if proxy: if proxy:
third = _try_curl(url, h, timeout, proxy) third = _try_curl(url, h, timeout, proxy)
if third.status == 200: if third.status == 200:
return third return finish(third)
return third return finish(third)
return second if second.status else first return finish(second if second.status else first)

View File

@@ -3,7 +3,7 @@
Файл генерируется `tools/auto_expand.py` из `data/source_candidates.json` Файл генерируется `tools/auto_expand.py` из `data/source_candidates.json`
(живые кандидаты) — правки руками перезапишутся, добавляй кандидатов в 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_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_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_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 (без ключа) ---------- # ---------- 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"}), 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_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_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_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-превью ---------- # ---------- Telegram-превью ----------
A("aa_tg_ai_newz", "html", "https://t.me/s/ai_newz", "tg_web", ("abuse", "tg")), A("aa_tg_ai_newz", "html", "https://t.me/s/ai_newz", "tg_web", ("abuse", "tg")),

View File

@@ -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"

View File

@@ -27,6 +27,8 @@ CAND = ROOT / "data" / "source_candidates.json"
HEALTH = ROOT / "data" / "source_health.json" HEALTH = ROOT / "data" / "source_health.json"
OUT = ROOT / "omni" / "sources_auto.py" OUT = ROOT / "omni" / "sources_auto.py"
PUBLIC_APIS = "https://raw.githubusercontent.com/public-apis/public-apis/master/README.md" 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 без ключа. HEADER = '''"""Авто-расширение: источники на публичных API без ключа.
@@ -144,25 +146,48 @@ def write_module(cands, results):
print("\nwrote %s: %d live of %d" % (OUT.name, live, len(cands))) 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): 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"}) 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") md = urllib.request.urlopen(req, timeout=40).read().decode("utf-8", "replace")
rows = [] rows, cat = [], ""
for line in md.splitlines(): for line in md.splitlines():
if line.startswith("### "):
cat = line[4:].strip()
continue
if not line.startswith("|") or "http" not in line: if not line.startswith("|") or "http" not in line:
continue continue
cells = [c.strip() for c in line.strip("|").split("|")] cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 6: if len(cells) < 5:
continue continue
url, auth, https = cells[2], cells[4], cells[5] m = RE_URL.search(cells[0])
if "No" not in auth or "http" not in url: if not m:
continue 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 continue
rows.append({"name": "dis_" + url.split("//")[-1].split("/")[0].replace("www.", "").replace(".", "_")[:24], blob = (title + " " + desc).lower()
"url": url, "adapter": "generic_json", "kind": "json", if cat not in CAT_OK and not any(k in blob for k in KEEP_ANY_CAT):
"tags": ["abuse", "public_api"], "group": "json", "limit": 15}) 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 = {} uniq = {}
for r in rows: for r in rows:
uniq.setdefault(r["name"], r) uniq.setdefault(r["name"], r)
@@ -172,6 +197,8 @@ def discover(limit=40):
cands.extend(new) cands.extend(new)
save(CAND, cands) save(CAND, cands)
print("discover: +%d кандидатов (всего %d)" % (len(new), len(cands))) print("discover: +%d кандидатов (всего %d)" % (len(new), len(cands)))
for r in new[:12]:
print(" + %-30s %s" % (r["name"], r["note"][:80]))
return new return new