"""Живой прогон пула quality-кандидатов (официальные release/security фиды). python tools/probe_quality_sources.py # широкий пул -> data/quality_sources_pool.json python tools/probe_quality_sources.py --only gh_rel_ # фильтр по имени python tools/probe_quality_sources.py --confirm # сверить SPECS модуля -> data/quality_sources_probe.json Что проверяется (acceptance): 1. прямой запрос net.get с жёстким timeout, XML парсится локально (RSS и Atom ); 2. >= MIN_ITEMS записей с непустым http(s)-URL; 3. ни одна из первых записей не является challenge/error-страницей (заголовок-заглушка); 4. нет дублей с основным реестром omni.sources: имя, нормализованный URL, зеркало репозитория (в --confirm свои 37 SPECS из этого множества вычитаются: после интеграции они уже в all_sources(), иначе прогон «отклонил» бы сам себя; остальной реестр сверяется как есть); 5. нет дублей между кандидатами: пересечение URL записей и заголовков (>= 60%). Плюс фиксируется, что вернул штатный адаптер feeds.fetch: Atom-link/relative-link/DC-creator в feeds.py починены, поэтому у принятых источников ожидается n >= MIN_ITEMS и blank_url == 0; ненулевой blank_url — сигнал регрессии адаптера, а не норма. Отчёт: data/quality_sources_probe.json (data/ в .gitignore). В прод-БД ничего не пишется. """ import argparse import json import re import sys import time import urllib.parse import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from omni import net # noqa: E402 from omni.sources import Source # noqa: E402 OUT = ROOT / "data" / "quality_sources_probe.json" OUT_POOL = ROOT / "data" / "quality_sources_pool.json" MIN_ITEMS = 2 TITLE_OVERLAP_REJECT = 0.6 ENTRY_OVERLAP_REJECT = 0.5 TIMEOUT = 12 GH = """huggingface/diffusers huggingface/peft huggingface/accelerate huggingface/text-generation-inference huggingface/tokenizers huggingface/candle pytorch/pytorch pytorch/torchao NVIDIA/TensorRT-LLM NVIDIA/nvidia-container-toolkit flashinfer-ai/flashinfer microsoft/onnxruntime microsoft/DeepSpeed ml-explore/mlx ggml-org/whisper.cpp openai/whisper AUTOMATIC1111/stable-diffusion-webui exo-explore/exo vllm-project/vllm-ascend caddyserver/caddy keycloak/keycloak grafana/grafana tailscale/tailscale envoyproxy/envoy hashicorp/vault hashicorp/terraform hashicorp/consul sigstore/cosign aquasecurity/trivy anchore/grype projectdiscovery/nuclei zaproxy/zaproxy moby/moby containerd/containerd opencontainers/runc kubernetes/kubernetes istio/istio argoproj/argo-cd cert-manager/cert-manager traefik/traefik prometheus/prometheus opensearch-project/OpenSearch elastic/elasticsearch minio/minio go-gitea/gitea nextcloud/server portainer/portainer wazuh/wazuh OISF/suricata zeek/zeek fail2ban/fail2ban openssl/openssl openssh/openssh-portable curl/curl spring-projects/spring-boot quarkusio/quarkus jenkinsci/jenkins immich-app/immich netbox-community/netbox open-telemetry/opentelemetry-collector modelcontextprotocol/python-sdk openai/openai-agents-python pydantic/pydantic-ai langchain-ai/langgraph""" # имя = gh_rel_ без дефисов/точек (как в реестре: gh_rel_llamacpp, gh_rel_transformers) def _gh_name(repo): return "gh_rel_" + repo.split("/")[1].lower().replace("-", "").replace(".", "").replace("_", "") _AI_TAG = ("release", "github", "ai") _SEC_TAG = ("security", "release", "github") POOL = [ {"name": _gh_name(r), "category": "release", "kind": "atom", "url": "https://github.com/%s/releases.atom" % r, "tags": _AI_TAG, "limit": 10} for r in GH.split() ] POOL += [ # ---------- официальные security-фиды вендоров/проектов ---------- {"name": "chrome_releases", "category": "security", "kind": "atom", "url": "https://chromereleases.googleblog.com/feeds/posts/default", "tags": ("security", "vendor", "chrome"), "limit": 15}, {"name": "project_zero", "category": "security", "kind": "atom", "url": "https://googleprojectzero.blogspot.com/feeds/posts/default", "tags": ("security", "research", "google"), "limit": 10}, {"name": "ubuntu_usn", "category": "security", "kind": "rss", "url": "https://ubuntu.com/security/notices/rss.xml", "tags": ("security", "vendor", "ubuntu"), "limit": 20}, {"name": "amazon_linux_alas", "category": "security", "kind": "rss", "url": "https://alas.aws.amazon.com/alas.rss", "tags": ("security", "vendor", "aws"), "limit": 20}, {"name": "msrc_blog", "category": "security", "kind": "rss", "url": "https://msrc.microsoft.com/blog/feed", "tags": ("security", "vendor", "microsoft"), "limit": 15}, {"name": "ms_security_blog", "category": "security", "kind": "rss", "url": "https://www.microsoft.com/en-us/security/blog/feed/", "tags": ("security", "vendor", "microsoft"), "limit": 15}, {"name": "cisco_talos", "category": "security", "kind": "rss", "url": "https://blog.talosintelligence.com/rss/", "tags": ("security", "research", "cisco"), "limit": 15}, {"name": "portswigger_research", "category": "security", "kind": "rss", "url": "https://portswigger.net/research/rss", "tags": ("security", "research", "web"), "limit": 15}, {"name": "trail_of_bits", "category": "security", "kind": "rss", "url": "https://blog.trailofbits.com/feed/", "tags": ("security", "research"), "limit": 15}, {"name": "cisa_advisories", "category": "security", "kind": "rss", "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", "tags": ("security", "vendor", "cisa"), "limit": 20}, {"name": "sans_isc", "category": "security", "kind": "rss", "url": "https://isc.sans.edu/rssfeed.xml", "tags": ("security", "research", "sans"), "limit": 15}, {"name": "nodejs_blog", "category": "release", "kind": "rss", "url": "https://nodejs.org/en/feed/blog.xml", "tags": ("release", "vendor", "nodejs"), "limit": 15}, {"name": "rust_blog", "category": "release", "kind": "atom", "url": "https://blog.rust-lang.org/feed.xml", "tags": ("release", "vendor", "rust"), "limit": 15}, {"name": "go_blog", "category": "release", "kind": "atom", "url": "https://go.dev/blog/feed.atom", "tags": ("release", "vendor", "go"), "limit": 15}, {"name": "php_news", "category": "release", "kind": "atom", "url": "https://www.php.net/feed.atom", "tags": ("release", "vendor", "php"), "limit": 15}, {"name": "python_insider", "category": "release", "kind": "atom", "url": "https://pythoninsider.blogspot.com/feeds/posts/default", "tags": ("release", "vendor", "python"), "limit": 15}, {"name": "gitlab_releases", "category": "release", "kind": "atom", "url": "https://about.gitlab.com/atom.xml", "tags": ("release", "vendor", "gitlab"), "limit": 15}, {"name": "cloudflare_security", "category": "security", "kind": "rss", "url": "https://blog.cloudflare.com/tag/security/rss/", "tags": ("security", "vendor", "cloudflare"), "limit": 15}, {"name": "aws_security_bulletins", "category": "security", "kind": "rss", "url": "https://aws.amazon.com/security/security-bulletins/rss/feed/", "tags": ("security", "vendor", "aws"), "limit": 20}, {"name": "hashicorp_blog", "category": "release", "kind": "rss", "url": "https://www.hashicorp.com/blog/feed.xml", "tags": ("release", "vendor", "hashicorp"), "limit": 15}, {"name": "kubernetes_blog", "category": "release", "kind": "rss", "url": "https://kubernetes.io/feed.xml", "tags": ("release", "vendor", "kubernetes"), "limit": 15}, {"name": "docker_blog", "category": "release", "kind": "rss", "url": "https://www.docker.com/feed/", "tags": ("release", "vendor", "docker"), "limit": 15}, # ---------- технические/AI release-блоги вендоров ---------- {"name": "nvidia_dev_blog", "category": "release", "kind": "rss", "url": "https://developer.nvidia.com/blog/feed/", "tags": ("release", "vendor", "nvidia", "ai"), "limit": 15}, {"name": "pytorch_blog", "category": "release", "kind": "rss", "url": "https://pytorch.org/feed/", "tags": ("release", "vendor", "pytorch", "ai"), "limit": 15}, {"name": "vllm_blog", "category": "release", "kind": "rss", "url": "https://blog.vllm.ai/feed.xml", "tags": ("release", "vendor", "vllm", "ai"), "limit": 15}, {"name": "ray_blog", "category": "release", "kind": "rss", "url": "https://www.anyscale.com/blog/rss.xml", "tags": ("release", "vendor", "ray", "ai"), "limit": 15}, {"name": "langchain_blog", "category": "release", "kind": "rss", "url": "https://blog.langchain.dev/rss/", "tags": ("release", "vendor", "langchain", "ai"), "limit": 15}, {"name": "modal_blog", "category": "release", "kind": "rss", "url": "https://modal.com/blog/feed.xml", "tags": ("release", "vendor", "modal", "ai"), "limit": 15}, {"name": "ollama_blog", "category": "release", "kind": "rss", "url": "https://ollama.com/blog/rss.xml", "tags": ("release", "vendor", "ollama", "ai"), "limit": 15}, ] BAD_TITLE_MARKS = ("just a moment", "attention required", "access denied", "403 forbidden", "404 not found", "not found", "rate limit", "too many requests", "checking your browser", "ddos-guard", "domain not found", "privacy error", "service unavailable", "bad gateway", "error") def _norm_url(u): u = (u or "").strip().lower().rstrip("/") for pre in ("https://", "http://"): if u.startswith(pre): u = u[len(pre):] return u[4:] if u.startswith("www.") else u def _valid_url(u): try: p = urllib.parse.urlsplit(u or "") except Exception: return False return p.scheme in ("http", "https") and bool(p.netloc) def _bad_title(t): low = (t or "").strip().lower() if len(low) < 3: return True return any(m in low for m in BAD_TITLE_MARKS) and len(low) < 60 def _parse(text): """Локальный парсер RSS/Atom -> [(title, link, published)].""" out = [] try: root = ET.fromstring(text.encode("utf-8", "ignore")) except Exception: return out ns = {"a": "http://www.w3.org/2005/Atom"} items = root.findall(".//item") if items: for e in items: link = (e.findtext("link") or "").strip() if not link: ge = e.find("{http://www.w3.org/2005/Atom}link") if ge is not None: link = (ge.get("href") or "").strip() out.append(((e.findtext("title") or "").strip(), link, (e.findtext("pubDate") or e.findtext("published") or "").strip())) return out for e in root.findall(".//a:entry", ns): link = "" for le in e.findall("a:link", ns): if le.get("rel") in (None, "alternate") and le.get("href"): link = le.get("href").strip() if le.get("rel") == "alternate": break out.append(((e.findtext("a:title", namespaces=ns) or "").strip(), link, (e.findtext("a:published", namespaces=ns) or e.findtext("a:updated", namespaces=ns) or "").strip())) return out def _registry(): from omni import sources as reg rows = reg.all_sources() names, urls, repos = set(), set(), set() for s in rows: names.add((s.name or "").strip().lower()) urls.add(_norm_url(s.url)) m = re.search(r"github\.com/([^/]+)/([^/?#]+)", (s.url or ""), re.I) if m: repos.add((m.group(1).lower(), m.group(2).lower().removesuffix(".git"))) return rows, names, urls, repos def _repo_of(url): m = re.search(r"github\.com/([^/]+)/([^/?#]+)", url or "", re.I) return (m.group(1).lower(), m.group(2).lower().removesuffix(".git")) if m else None def _probe(spec): t0 = time.time() r = net.get(spec["url"], timeout=TIMEOUT) status = {"name": spec["name"], "category": spec["category"], "kind": spec["kind"], "url": spec["url"], "tags": list(spec["tags"]), "adapter": spec.get("adapter", "rss"), "limit": spec.get("limit", 10), "http": getattr(r, "status", 0), "mode": getattr(r, "mode", ""), "bytes": len(getattr(r, "text", "") or ""), "ms": int((time.time() - t0) * 1000), "err": ""} if getattr(r, "error", ""): status["err"] = str(r.error)[:160] if getattr(r, "status", 0) != 200 or not (r.text or "").strip(): status["entries"] = 0 status["reject"] = "http_%s" % getattr(r, "status", 0) return status parsed = _parse(r.text) good = [(t, u, p) for (t, u, p) in parsed if _valid_url(u) and not _bad_title(t)] status["entries"] = len(parsed) status["valid"] = len(good) status["samples"] = [{"title": t[:140], "url": u[:220], "published": p[:32]} for (t, u, p) in good[:3]] if len(good) < MIN_ITEMS: status["reject"] = "only_%d_valid" % len(good) else: status["reject"] = "" return status def _fetch_check(spec): """Что вернул штатный адаптер (документируем эффект бага Atom-link).""" from omni import feeds src = Source(spec["name"], spec["category"], spec["kind"], spec["url"], spec.get("adapter", "rss"), tuple(spec["tags"]), spec.get("limit", 10)) t0 = time.time() try: items = feeds.fetch(src, spec.get("limit", 10)) or [] err = "" except Exception as e: # noqa: BLE001 items, err = [], "raise:%s" % str(e)[:120] blank = sum(1 for i in items if not _valid_url(i.url)) return {"n": len(items), "blank_url": blank, "ms": int((time.time() - t0) * 1000), "err": err, "samples": [{"title": (i.title or "")[:120], "url": (i.url or "")[:200]} for i in items[:3]]} def _dup_reasons(status, names, urls, repos, accepted): why = [] name = (status["name"] or "").lower() if name in names: why.append("name_in_registry") if _norm_url(status["url"]) in urls: why.append("url_in_registry") repo = _repo_of(status["url"]) if repo and repo in repos: why.append("repo_mirror:%s/%s" % repo) entry_urls = {_norm_url(s["url"]) for s in status.get("samples", [])} titles = {re.sub(r"\W+", "", s["title"].lower()) for s in status.get("samples", [])} for prev in accepted: p_urls = {_norm_url(s["url"]) for s in prev.get("samples", [])} p_titles = {re.sub(r"\W+", "", s["title"].lower()) for s in prev.get("samples", [])} if entry_urls and p_urls and len(entry_urls & p_urls) / len(entry_urls) >= ENTRY_OVERLAP_REJECT: why.append("entries_overlap:%s" % prev["name"]) break if titles and p_titles and len(titles & p_titles) / len(titles) >= TITLE_OVERLAP_REJECT: why.append("titles_overlap:%s" % prev["name"]) break return why def main(): ap = argparse.ArgumentParser() ap.add_argument("--only", default="") ap.add_argument("--workers", type=int, default=8) ap.add_argument("--limit", type=int, default=10) ap.add_argument("--confirm", action="store_true", help="прогон SPECS из omni.sources_quality") args = ap.parse_args() if args.confirm: from omni.sources_quality import SPECS as pool else: pool = POOL if args.only: pool = [p for p in pool if args.only in p["name"]] rows, names, urls, repos = _registry() # после интеграции quality_sources в all_sources() эти же записи есть в реестре: # себя из множества дублей вычитаем, иначе confirm-прогон «отклонит» все свои же источники names -= {(p["name"] or "").lower() for p in pool} urls -= {_norm_url(p["url"]) for p in pool} repos -= {r for r in (_repo_of(p["url"]) for p in pool) if r} t0 = time.time() with ThreadPoolExecutor(max_workers=args.workers) as ex: probed = list(ex.map(_probe, pool)) elapsed = round(time.time() - t0, 1) accepted, rejected = [], [] for st in probed: if st["reject"]: st["dup"] = [] rejected.append(st) continue why = _dup_reasons(st, names, urls, repos, accepted) st["dup"] = why (rejected if why else accepted).append(st) with ThreadPoolExecutor(max_workers=4) as ex: for st, chk in zip(accepted, ex.map(_fetch_check, accepted)): st["feeds_fetch"] = chk report = { "generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "mode": "confirm" if args.confirm else "pool", "pool": len(pool), "raw_pool_size": len(POOL), "registry_sources": len(rows), "module": "omni/sources_quality.py" if args.confirm else None, "elapsed_s": elapsed, "min_items": MIN_ITEMS, "acceptance": { "min_valid_entries": MIN_ITEMS, "valid_url_required": "http(s) с непустым netloc", "bad_title_marks": list(BAD_TITLE_MARKS), "dup_keys": ["name", "normalized_url", "github_repo_mirror", "entries_overlap>=%.1f" % ENTRY_OVERLAP_REJECT, "titles_overlap>=%.1f" % TITLE_OVERLAP_REJECT], }, "accepted_n": len(accepted), "rejected_n": len(rejected), "accepted_by_category": {}, "accepted": accepted, "rejected": rejected, } for st in accepted: report["accepted_by_category"][st["category"]] = report["accepted_by_category"].get(st["category"], 0) + 1 target = OUT if args.confirm else OUT_POOL target.parent.mkdir(parents=True, exist_ok=True) target.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") print("pool=%d accepted=%d rejected=%d elapsed=%.1fs" % (len(pool), len(accepted), len(rejected), elapsed)) for st in accepted: s = st["samples"][0] if st["samples"] else {"title": "", "url": ""} ff = st.get("feeds_fetch") or {} print("OK %-30s %2d/%2d %5dms fetch=%d(blank %d) | %s | %s" % (st["name"], st["valid"], st["entries"], st["ms"], ff.get("n", 0), ff.get("blank_url", 0), s["title"][:60], s["url"][:80])) for st in rejected: print("NO %-30s %s %s" % (st["name"], st.get("reject") or ",".join(st.get("dup") or []), (st.get("samples") or [{}])[0].get("title", "")[:50])) print("report:", target) if __name__ == "__main__": main()