абузы: extract промокодов/сумм/ссылок в API и панели, +16 проверенных источников (TG/reddit/discourse/RSS/github/HN), чистка HTML в деталях, клэмп days
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 01:50:05 +03:00
parent 8de421b6af
commit b09cb5f873
22 changed files with 552 additions and 2992 deletions

View File

@@ -1,28 +0,0 @@
import sys, json
sys.path.insert(0,'.')
from concurrent.futures import ThreadPoolExecutor
from omni import net
URLS = [
"https://darknetlive.com/feed/", "https://darknetlive.com/rss/", "https://darknetlive.com/rss.xml",
"https://www.volexity.com/blog/feed/", "https://cxsecurity.com/rss/",
"https://www.debian.org/security/", "https://www.openwall.com/lists/oss-security/",
"https://www.t00ls.com/", "https://xz.aliyun.com/",
"https://t.me/s/TheHackersNews", "https://t.me/s/thehackernews", "https://t.me/s/SecAtor",
"https://t.me/s/true_secator", "https://t.me/s/databreaches", "https://t.me/s/cybersecuritynews",
]
def one(u):
try:
r = net.get(u, timeout=25)
t = r.text or ""
return {"u":u,"st":r.status,"n":len(t),
"ct":(r.headers or {}).get("content-type","")[:40],
"item":t.count("<item"),"entry":t.count("<entry"),
"tgmsg":t.count("tgme_widget_message_wrap"),
"hdr":t.count('tgme_channel_info_header_title'),
"head": " ".join(t[:200].split())[:120]}
except Exception as e:
return {"u":u,"st":0,"err":str(e)[:90]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(one, URLS):
print(json.dumps(r, ensure_ascii=False))

View File

@@ -1,33 +0,0 @@
import sys, json, re
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import net, feeds
from omni.sources import Source
TG = ["thehackernews", "true_secator", "cybersecuritynews", "ransomwatch", "ransomware_news", "leakbase",
"CVEnew", "cvefeed", "hackernews", "cyberdefensemagazine", "securityweek", "therecord",
"ciscotalos", "unit42", "projectzero", "huntress", "sentinelone", "recordedfuture",
"threatmon", "kalilinux", "darkwebinformer", "darkwebinformer_ru", "breachdetector",
"databreach", "ruhack", "expl0it", "infosec_ru", "securitylab_ru", "itsecforu_ru",
"xakep", "XakepRU", "bo0om_ru", "mycopypaste", "cnews_ru", "bdu_fstec", "kaspersky",
"kaspersky_ru", "talos", "smd_ru", "kernelmode", "hitech_crime", "codeby"]
def tg_one(h, url):
try:
r = net.get(url, timeout=25)
t = r.text or ""
title = re.search(r"<title>(.*?)</title>", t, re.S)
src = Source(name="probe_" + h, category="security", kind="html", url=url, adapter="tg_web",
tags=("tg", "leak"), limit=5)
items = feeds.fetch(src, 5)
return {"h": h, "st": r.status, "n": len(t), "msg": t.count("tgme_widget_message_wrap"),
"items": len(items), "title": (title.group(1).strip()[:60] if title else ""),
"first": (items[0].title[:70] if items else "")}
except Exception as e:
return {"h": h, "st": 0, "err": str(e)[:80]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(lambda h: tg_one(h, "https://t.me/s/" + h), TG):
print(json.dumps(r, ensure_ascii=False))

View File

@@ -1,41 +0,0 @@
import sys, json, re, urllib.parse
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import net
from bs4 import BeautifulSoup
URLS = [
"https://www.debian.org/security/",
"https://www.openwall.com/lists/oss-security/",
"https://www.t00ls.com/",
"https://www.anquanke.com/",
"https://www.freebuf.com/",
"https://bbs.kanxue.com/",
"https://www.secrss.com/",
"https://www.ichunqiu.com/",
]
def one(u):
try:
r = net.get(u, timeout=25)
soup = BeautifulSoup(r.text or "", "lxml")
hrefs = []
for a in soup.find_all("a", href=True):
h = urllib.parse.urljoin(u, str(a["href"]))
p = urllib.parse.urlsplit(h).path
hrefs.append(p)
pat = {}
for p in hrefs:
seg = re.sub(r"\d+", "N", p)[:60]
pat[seg] = pat.get(seg, 0) + 1
top = sorted(pat.items(), key=lambda kv: -kv[1])[:12]
return {"u": u, "st": r.status, "n": len(r.text or ""), "links": len(hrefs),
"paths": top}
except Exception as e:
return {"u": u, "st": 0, "err": str(e)[:90]}
with ThreadPoolExecutor(4) as ex:
for r in ex.map(one, URLS):
print(json.dumps(r, ensure_ascii=False))

View File

@@ -1,63 +0,0 @@
import sys, json
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import feeds
from omni.sources import Source
C = [
("debian_dsa", "security", "html", "https://www.debian.org/security/", "html_listing",
{"include_prefixes": ["/tracker/DSA-"]}),
("debian_dsa2", "security", "html", "https://www.debian.org/security/", "html_listing",
{"include_prefixes": ["/debian-security-announce/"]}),
("openwall_a", "security", "html", "https://www.openwall.com/lists/oss-security/", "html_listing",
{"include_prefixes": ["/lists/oss-security/2026/"]}),
("openwall_b", "security", "html", "https://www.openwall.com/lists/oss-security/", "html_listing",
{"include_prefixes": ["/lists/oss-security/"]}),
("t00ls", "forum", "html", "https://www.t00ls.com/", "html_listing",
{"include_prefixes": ["/articles-", "/Vuls-"]}),
("anquanke_html", "security", "html", "https://www.anquanke.com/", "html_listing",
{"include_prefixes": ["/post/id/"]}),
("secrss", "security", "html", "https://www.secrss.com/", "html_listing",
{"include_prefixes": ["/articles/"], "exclude_exact": ["/articles"]}),
("freebuf_root", "security", "html", "https://www.freebuf.com/", "html_listing",
{"include_prefixes": ["/articles/", "/news/"]}),
("freebuf_news", "security", "html", "https://www.freebuf.com/news", "html_listing",
{"include_prefixes": ["/articles/", "/news/"]}),
("freebuf_rss2", "security", "rss", "https://www.freebuf.com/rss", "rss", {}),
("freebuf_rss3", "security", "rss", "https://www.freebuf.com/feed/", "rss", {}),
("securitylab_a", "security", "rss", "https://www.securitylab.ru/rss/news.xml", "rss", {}),
("securitylab_b", "security", "rss", "https://www.securitylab.ru/rss/", "rss", {}),
("kaspersky_a", "security", "rss", "https://www.kaspersky.com/blog/feed/", "rss", {}),
("anti_malware_a", "security", "rss", "https://www.anti-malware.ru/rss/news", "rss", {}),
("xakep_tech", "security", "rss", "https://xakep.ru/feed/", "rss", {}),
("codeby_x", "forum", "html", "https://codeby.net/forums/", "xenforo_latest", {}),
("seebug_retry", "security", "rss", "https://paper.seebug.org/rss/", "rss", {}),
("ransomlook_retry", "abuse", "json", "https://www.ransomlook.io/api/recent", "generic_json", {}),
("ransomware_live_g", "abuse", "json", "https://api.ransomware.live/groups", "generic_json", {}),
("cybersecnews_retry", "security", "rss", "https://cybersecuritynews.com/feed/", "rss", {}),
("gbhackers_retry", "security", "rss", "https://gbhackers.com/feed/", "rss", {}),
("huntress_retry", "security", "rss", "https://www.huntress.com/blog/rss.xml", "rss", {}),
("scmedia_a", "security", "rss", "https://www.scmagazine.com/rss/news", "rss", {}),
("trendmicro_a", "security", "rss", "https://www.trendmicro.com/en_us/research.rss", "rss", {}),
("dragos_a", "security", "rss", "https://www.dragos.com/blog/feed/", "rss", {}),
("arcticwolf_a", "security", "rss", "https://arcticwolf.com/feed/", "rss", {}),
("zscaler_a", "security", "rss", "https://www.zscaler.com/blogs/rss.xml", "rss", {}),
("arch_a", "security", "rss", "https://security.archlinux.org/advisory/feed.atom", "rss", {}),
("gentoo_a", "security", "rss", "https://www.gentoo.org/glsa/feed.xml", "rss", {}),
("cisa_all_retry", "security", "rss", "https://www.cisa.gov/cybersecurity-advisories/all.xml", "rss", {}),
]
def run(t):
name, cat, kind, url, ad, extra = t
src = Source(name=name, category=cat, kind=kind, url=url, adapter=ad, tags=(), limit=5, extra=extra)
try:
items = feeds.fetch(src, 5)
return {"name": name, "n": len(items), "first": (items[0].title[:70] if items else "")}
except Exception as e:
return {"name": name, "n": 0, "err": str(e)[:80]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(run, C):
print(json.dumps(r, ensure_ascii=False))

View File

@@ -1,89 +0,0 @@
import sys, json, urllib.parse
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import feeds, net
from omni.sources import Source
from bs4 import BeautifulSoup
NEW = [
("helpnetsecurity", "https://www.helpnetsecurity.com/feed/"),
("infosecurity_magazine", "https://www.infosecurity-magazine.com/rss/news/"),
("cybersecuritydive", "https://www.cybersecuritydive.com/feeds/news/"),
("theregister_security", "https://www.theregister.com/security/headlines.atom"),
("arstechnica_security", "https://arstechnica.com/security/feed/"),
("csoonline", "https://www.csoonline.com/feed/"),
("hackerone_blog", "https://www.hackerone.com/blog.rss"),
("bugcrowd_blog", "https://www.bugcrowd.com/blog/feed/"),
("semgrep_blog", "https://semgrep.dev/blog/rss.xml"),
("acunetix_blog", "https://www.acunetix.com/blog/feed/"),
("trustwave_spiderlabs", "https://www.trustwave.com/en-us/resources/blogs/spiderlabs-blog/rss.xml"),
("cybereason_blog", "https://www.cybereason.com/blog/rss.xml"),
("sentinelone_blog", "https://www.sentinelone.com/blog/feed/"),
("microsoft_security_blog", "https://www.microsoft.com/en-us/security/blog/feed/"),
("ncsc_uk", "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml"),
("sonatype_blog", "https://www.sonatype.com/blog/rss.xml"),
("invicti_blog", "https://www.invicti.com/blog/feed/"),
("govinfosecurity", "https://www.govinfosecurity.com/rss"),
("bankinfosecurity", "https://www.bankinfosecurity.com/rss"),
("healthcareinfosecurity", "https://www.healthcareinfosecurity.com/rss"),
("cve_circl_alt", "https://cve.circl.lu/api/last/40"),
("vulmon_alt", "https://vulmon.com/rss/latest"),
("packetstorm_files", "https://packetstormsecurity.com/files/rss.xml"),
("exploitdb_rss2", "https://www.exploit-db.com/rss.xml"),
("zdi_blog", "https://www.zerodayinitiative.com/blog?format=rss"),
("hackmag_alt", "https://hackmag.com/rss"),
("securitylab_c", "https://www.securitylab.ru/rss/all.xml"),
("securitylab_d", "https://www.securitylab.ru/news/rss.xml"),
("anquanke_rss2", "https://www.anquanke.com/rss/news"),
("anquanke_rss3", "https://www.anquanke.com/rss?lang=zh"),
("freebuf_alt", "https://www.freebuf.com/"),
("sploitus_rss", "https://sploitus.com/rss"),
("cvefeed_alt", "https://cvefeed.io/rssfeed/latest.xml"),
("reddit_blueteamsec", "https://www.reddit.com/r/blueteamsec/.json?limit=25"),
("reddit_malware", "https://www.reddit.com/r/Malware/.json?limit=25"),
("reddit_reverseengineering", "https://www.reddit.com/r/ReverseEngineering/.json?limit=25"),
("reddit_exploitdev", "https://www.reddit.com/r/ExploitDev/.json?limit=25"),
("reddit_threatintel", "https://www.reddit.com/r/threatintel/.json?limit=25"),
("reddit_purpleteamsec", "https://www.reddit.com/r/purpleteamsec/.json?limit=25"),
("serverfault_sec", "https://security.stackexchange.com/feeds/tag?tagnames=vulnerability&sort=newest"),
]
def probe(url, adapter="rss", extra=None, detail=False):
src = Source(name="p", category="security", kind="rss", url=url, adapter=adapter,
tags=(), limit=5, detail=detail, extra=extra or {})
r = net.get(url, timeout=25)
items = feeds.fetch(src, 5)
return {"u": url, "st": r.status, "ct": (r.headers or {}).get("content-type", "")[:30],
"n": len(items), "first": (items[0].title[:70] if items else "")}
def run(t):
name, url = t
ad = "reddit_listing" if "reddit.com" in url else "rss"
try:
d = probe(url, ad)
except Exception as e:
d = {"u": url, "err": str(e)[:80], "n": 0}
d["name"] = name
return d
with ThreadPoolExecutor(6) as ex:
for r in ex.map(run, NEW):
print(json.dumps(r, ensure_ascii=False))
# debug openwall prefix filter
r = net.get("https://www.openwall.com/lists/oss-security/", timeout=25)
soup = BeautifulSoup(r.text or "", "lxml")
paths = []
for a in soup.find_all("a", href=True)[:40]:
paths.append(urllib.parse.urlsplit(urllib.parse.urljoin("https://www.openwall.com", str(a["href"]))).path)
print(json.dumps({"openwall_sample": paths[:20]}, ensure_ascii=False))
# detail=True variants
for u, extra in (("https://www.t00ls.com/", {"include_prefixes": ["/articles-", "/Vuls-"]}),
("https://www.debian.org/security/", {"include_prefixes": ["/tracker/DSA-"]})):
d = probe(u, "html_listing", extra, detail=True)
d["url"] = u
print(json.dumps(d, ensure_ascii=False))

View File

@@ -1,69 +0,0 @@
import sys, json, re
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import feeds, net
from omni.sources import Source
TG = ["DarkWebInformer", "darkwebinformernews", "ransomwarelive", "ransom_live", "leak_insider",
"breachforum", "cyberpolice_ru", "threatintel", "dfir", "dfir_channel", "malwareanalysis",
"hackernews_ru", "CyberSecurityRu", "cybersecurityru", "ibsecurity", "secnews", "securitynews",
"cve", "cvewatch", "vulns", "0day", "0dayfans", "pentest", "bugbounty", "redteam",
"osint", "cti", "apt", "hackers", "cyberhack", "infosec", "инфобез", "hackersnews"]
RSS = [
("virustotal_blog", "https://blog.virustotal.com/feeds/posts/default"),
("vulncheck_blog", "https://vulncheck.com/blog/rss.xml"),
("industrialcyber", "https://industrialcyber.co/feed/"),
("silentpush_blog", "https://www.silentpush.com/blog/feed/"),
("cybersixgill_blog", "https://www.cybersixgill.com/feed/"),
("malwaretech_blog", "https://www.malwaretech.com/feed"),
("scworld", "https://www.scworld.com/rss.xml"),
("hackread_leaks", "https://hackread.com/category/data-breaches/feed/"),
("securityweek_alt", "https://www.securityweek.com/feed/"),
("reddit_netsec", "https://www.reddit.com/r/netsec/.json?limit=25"),
("reddit_cybersecurity_dup", "https://www.reddit.com/r/cybersecurity/.json?limit=25"),
("reddit_malware2", "https://www.reddit.com/r/Malware/.json?limit=25"),
("reddit_reveng2", "https://www.reddit.com/r/ReverseEngineering/.json?limit=25"),
("reddit_threatintel2", "https://www.reddit.com/r/threatintel/.json?limit=25"),
]
def tg(url):
r = net.get(url, timeout=25)
t = r.text or ""
if t.count("tgme_widget_message_wrap") < 3:
return {"u": url, "n": 0, "why": "no preview", "title": (re.search(r"<title>(.*?)</title>", t, re.S) or [None, ""])[1][:50]}
src = Source(name="p", category="security", kind="html", url=url, adapter="tg_web", tags=("tg",), limit=5)
items = feeds.fetch(src, 5)
return {"u": url, "n": len(items), "first": (items[0].title[:60] if items else "")}
def rss(url):
ad = "reddit_listing" if "reddit.com" in url else "rss"
src = Source(name="p", category="security", kind="rss", url=url, adapter=ad, tags=(), limit=5)
items = feeds.fetch(src, 5)
return {"u": url, "n": len(items), "first": (items[0].title[:60] if items else "")}
jobs = [("tg", "https://t.me/s/" + h) for h in TG] + [("rss", u) for _, u in RSS]
def run(j):
kind, u = j
try:
return tg(u) if kind == "tg" else rss(u)
except Exception as e:
return {"u": u, "n": 0, "err": str(e)[:70]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(run, jobs):
print(json.dumps(r, ensure_ascii=False))
# openwall: почему фильтр не срабатывает
src = Source(name="ow", category="security", kind="html", url="https://www.openwall.com/lists/oss-security/",
adapter="html_listing", tags=(), limit=5,
extra={"include_prefixes": ["/lists/oss-security/"]})
print("openwall direct:", len(feeds._html_listing(src, 5)), len(feeds._html_listing(src, 40)))
r = net.get(src.url, timeout=25)
print("openwall status:", r.status, len(r.text or ""), "count /lists/oss-security/", (r.text or "").count("/lists/oss-security/"))

View File

@@ -1,43 +0,0 @@
import sys, json
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import feeds, net
from omni.sources import Source
C = [
("cisa_news", "https://www.cisa.gov/news-events/news/rss.xml", "rss", {}),
("fortinet_blog", "https://www.fortinet.com/blog/rss.xml", "rss", {}),
("mcafee_blogs", "https://www.mcafee.com/blogs/feed/", "rss", {}),
("avast_blog", "https://www.avast.com/en-us/blog/feed", "rss", {}),
("eset_press", "https://www.eset.com/int/about/newsroom/press-releases/feed/", "rss", {}),
("gti_threat_intel", "https://cloud.google.com/blog/topics/threat-intelligence/rss/", "rss", {}),
("checkpoint_blog", "https://www.checkpoint.com/blog/feed/", "rss", {}),
("anquanke_news", "https://www.anquanke.com/news", "html_listing", {"include_prefixes": ["/post/id/"]}),
("secrss_vuln", "https://www.secrss.com/articles", "html_listing", {"include_prefixes": ["/articles/"]}),
("bleeping_dup_check", "https://www.bleepingcomputer.com/feed/", "rss", {}),
("therecord_dup_check", "https://therecord.media/feed", "rss", {}),
("deepwatch_blog", "https://www.deepwatch.com/feed/", "rss", {}),
("huntress_alt", "https://www.huntress.com/blog/feed", "rss", {}),
("dragos_alt", "https://www.dragos.com/feed/", "rss", {}),
("scmedia_alt", "https://www.scmagazine.com/feed", "rss", {}),
("trendmicro_alt", "https://www.trendmicro.com/en_us/research/feed", "rss", {}),
("zscaler_alt", "https://www.zscaler.com/blogs/security-research/rss", "rss", {}),
("gbhackers_alt", "https://gbhackers.com/feed", "rss", {}),
("cybersecnews_alt", "https://cybersecuritynews.com/feed", "rss", {}),
]
def run(t):
name, url, ad, extra = t
src = Source(name=name, category="security", kind="rss", url=url, adapter=ad, tags=(), limit=5, extra=extra)
try:
r = net.get(url, timeout=25)
items = feeds.fetch(src, 5)
return {"name": name, "st": r.status, "n": len(items), "first": (items[0].title[:60] if items else "")}
except Exception as e:
return {"name": name, "n": 0, "err": str(e)[:70]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(run, C):
print(json.dumps(r, ensure_ascii=False))

View File

@@ -1,79 +0,0 @@
import sys, json, re
sys.path.insert(0, '.')
from concurrent.futures import ThreadPoolExecutor
from omni import feeds, net
from omni.sources import Source
RSS = [
("itsecurityguru", "https://www.itsecurityguru.co.uk/feed/"),
("securityledger", "https://securityledger.com/feed/"),
("zdnet_security", "https://www.zdnet.com/topic/security/rss.xml"),
("grahamcluley", "https://www.grahamcluley.com/feed/"),
("cyberdefensemagazine", "https://www.cyberdefensemagazine.com/feed/"),
("hackersonlineclub", "https://www.hackersonlineclub.com/feed"),
("netspi", "https://www.netspi.com/feed/"),
("hackingarticles", "https://www.hackingarticles.in/feed/"),
("ired_team", "https://www.ired.team/feed.xml"),
("pentestpartners", "https://www.pentestpartners.com/feed/"),
("trustedsec", "https://www.trustedsec.com/feed/"),
("specterops", "https://posts.specterops.io/feed"),
("secjuice", "https://www.secjuice.com/feed/"),
("varonis_blog", "https://www.varonis.com/blog/feed"),
("netenrich", "https://netenrich.com/feed/"),
("opennet_ru", "https://opennet.ru/rss.xml"),
("linuxorg_ru", "https://www.linux.org.ru/feed.jsp"),
("zd3news_security", "https://3dnews.ru/security/rss"),
("securelist_ru", "https://securelist.ru/feed/"),
("hackware_ru", "https://hackware.ru/feed/"),
("cyberint_blog", "https://cyberint.com/blog/feed/"),
("gbhackers_rss", "https://gbhackers.com/rss/"),
("cybersecnews_rss", "https://cybersecuritynews.com/rss/"),
("chaitin_blog", "https://www.chaitin.cn/zh/blog/feed"),
("riskivy_blog", "https://blog.riskivy.com/feed"),
("qianxin_news", "https://www.qianxin.com/news/rss"),
("secwiki_news", "https://www.sec-wiki.com/news/rss"),
("bleeping_ransomware", "https://www.bleepingcomputer.com/tag/ransomware/feed/"),
("hackread_cybercrime", "https://hackread.com/category/cyber-crime/feed/"),
("thecyberwire", "https://thecyberwire.com/feeds/rss.xml"),
("riskybiz", "https://risky.biz/feeds/risky-business-news/"),
]
TG = ["ITSecForU", "CyberSecurityNews", "securitylab", "xakepnews", "exploitdb", "hacktips",
"pentesttools", "sysadmin_ru", "ai_papay", "gonzo_ai", "llm_ru", "malwareunpacked",
"databreachnews", "cve2026", "hackersnewsru", "kasperskyclub", "SecNewsRu", "websec",
"cyber_ru", "hackernews_ru", "redos", "anti_malware", "Antimalware", "sec_news"]
def rss(url):
ad = "rss"
src = Source(name="p", category="security", kind="rss", url=url, adapter=ad, tags=(), limit=5)
r = net.get(url, timeout=25)
items = feeds.fetch(src, 5)
return {"u": url, "st": r.status, "n": len(items), "first": (items[0].title[:60] if items else "")}
def tg(h):
url = "https://t.me/s/" + h
r = net.get(url, timeout=25)
t = r.text or ""
if t.count("tgme_widget_message_wrap") < 3:
return {"u": url, "n": 0, "why": "no preview"}
src = Source(name="p", category="security", kind="html", url=url, adapter="tg_web", tags=("tg",), limit=5)
items = feeds.fetch(src, 5)
return {"u": url, "n": len(items), "first": (items[0].title[:60] if items else "")}
jobs = [("rss", u) for _, u in RSS] + [("tg", h) for h in TG]
def run(j):
kind, x = j
try:
return tg(x) if kind == "tg" else rss(x)
except Exception as e:
return {"u": x, "n": 0, "err": str(e)[:70]}
with ThreadPoolExecutor(6) as ex:
for r in ex.map(run, jobs):
print(json.dumps(r, ensure_ascii=False))

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
21311348-6c5d-4922-b48b-df65f227ec1f

View File

@@ -235,9 +235,11 @@ def _rows(limit=400, tag=None, q=None, since_days=None):
out = []
for r in con.execute(sql, args):
hits = [t for t in (r[6] or "").split(",") if t]
body = (r[4] or "")
out.append({"id": r[0], "source": r[1], "url": r[2], "title": r[3],
"text": (r[4] or "")[:600], "published": r[5],
"tags": hits, "abuse_score": r[8], "rank": r[7]})
"text": body[:600], "published": r[5],
"tags": hits, "abuse_score": r[8], "rank": r[7],
"find": extract((r[3] or "") + " " + body)})
return out
finally:
con.close()
@@ -274,3 +276,42 @@ def scan(limit=120, tag=None, q=None, since_days=None, window=6000):
pass
return {"items": items[:limit], "total": total, "tags": counts,
"labels": TAG_LABEL}
_RE_CODE = re.compile(r"(?:промокод|промо-?код|promo\s*code|coupon|купо[нш]|code)[^\n::]{0,12}[::\s]+([A-Z0-9][A-Z0-9_\-]{3,23})", re.I)
_RE_CODE2 = re.compile(r"\b([A-Z]{2,}[0-9]{2,}[A-Z0-9_\-]{0,14})\b")
_RE_AMT = re.compile(r"(\d[\d\s.,]{0,12})\s*(млрд|млн|тыс|billion|million|k|m|b|токен\w*|tokens?|credits?|\$|₽|юан\w*)", re.I)
_RE_LINK = re.compile(r"(?:https?://|t\.me/)[^\s)\]\"'<>]{4,120}")
_RE_DAYS = re.compile(r"(\d{1,3})\s*(дн\w*|day[s]?|час\w*|hour[s]?|недел\w*|week[s]?)", re.I)
def extract(text):
"""Достаёт из текста абуза полезное: промокоды, объём, ссылки, срок."""
t = (text or "")[:4000]
codes = []
for m in _RE_CODE.finditer(t):
c = (m.group(1) or "").strip(" .,:;")
if c and c.upper() not in {x.upper() for x in codes}:
codes.append(c)
if not codes and re.search(r"промо|promo|code|код|gift|купо|coupon|подар", t, re.I):
for m in _RE_CODE2.finditer(t):
c = m.group(1)
if c and c.upper() not in {x.upper() for x in codes}:
codes.append(c)
if len(codes) >= 3:
break
amt = None
m = _RE_AMT.search(t)
if m:
amt = ("%s %s" % (m.group(1).strip(), m.group(2))).replace(" ", " ")
links = []
for u in _RE_LINK.findall(t):
u = u.rstrip(".,);")
if u not in links:
links.append(u)
if len(links) >= 4:
break
days = None
m = _RE_DAYS.search(t)
if m:
days = "%s %s" % (m.group(1), m.group(2))
return {"codes": codes[:4], "amount": amt, "links": links, "term": days}

View File

@@ -21,9 +21,11 @@ def _clean(v, n=0):
import html as _h
if not v:
return ""
t = re.sub(r"<[^>]{1,200}>", " ", str(v))
for _ in range(2):
t = str(v)
# unescape и снятие тегов в цикле: иначе &lt;b&gt; после unescape снова станет тегом
for _ in range(3):
t = _h.unescape(t)
t = re.sub(r"<[^>]{1,200}>", " ", t)
t = re.sub(r"\s+", " ", t).strip()
return t[:n].rstrip() if n else t
@@ -39,7 +41,8 @@ def timeline(days=30, cat=None, mode="pub"):
mode="pub" — по дате публикации (или дате попадания в базу, если публикации нет);
mode="seen" — строго по времени импорта: «что комбайн принёс за день».
"""
days = max(1, min(365, int(days or 30)))
days = 30 if days is None else int(days or 0)
days = max(1, min(365, days))
bucket = BUCKET_SEEN if mode == "seen" else BUCKET
names = [s.name for s in sources.all_sources() if s.category == cat] if cat not in (None, "", "all") else None
where, args = "", []

View File

@@ -18,6 +18,7 @@ from .abuse_sources import ABUSE as ABUSE_DEEP
from .sources_tg_abuse import TG_ABUSE
from .sources_ext4 import EXTRA5
from .sources_ext5 import EXTRA6
from .sources_fresh import fresh as _fresh_sources
from .sources_x import X_SOURCES
@@ -298,7 +299,16 @@ def _gen_sources():
def all_sources():
"""Реестр + пользовательские источники из панели, без дублей по url."""
"""Реестр + пользовательские источники из панели, без дублей по url и по имени.
Дедуп по двум ключам обязателен:
* url — одна площадка не должна обходиться дважды (двойные записи в базе);
* name — имя источника является первичным ключом в source_flags/runs, поэтому
зеркала одного и того же ресурса (rss.arxiv vs export.arxiv, old.reddit vs
www.reddit) обязаны схлопываться, иначе переключение/состояние «плывут».
Побеждает первое вхождение; порядок: реестр → сгенерированные → свежая волна →
пользовательские → abuse-radar → panels.json.
"""
from . import source_state
extra = []
for d in source_state.custom():
@@ -307,19 +317,20 @@ def all_sources():
d = {k: v for k, v in d.items() if k in
("name", "category", "kind", "url", "adapter", "tags", "limit", "detail", "extra")}
extra.append(Source(**d))
seen, out = set(), []
for s in SOURCES + _gen_sources() + extra + radar_sources() + panel_sources():
# дедуп строго по url: одна площадка = один источник, даже если пришла из
# реестра, radar_sources и panels.json одновременно (иначе двойной обход и
# двойные записи в базе). Побеждает первое вхождение — порядок списка выше.
seen_url, seen_name, out = set(), set(), []
for s in SOURCES + _gen_sources() + _fresh_sources(S) + extra + radar_sources() + panel_sources():
key = (s.url or "").rstrip("/").lower()
if key and key in seen:
name = (s.name or "").strip().lower()
if (key and key in seen_url) or (name and name in seen_name):
continue
if key:
seen.add(key)
seen_url.add(key)
if name:
seen_name.add(name)
out.append(s)
return out
def enabled_sources(include_opt_in=False):
"""Только включённые: выключенные в панели источники не дёргаем.

View File

@@ -71,4 +71,22 @@ ABUSE_LIVE = [
A("ab_hn_account_generator", "abuse", "site", "https://hn.algolia.com/?query=account+generator", "hn_search", ('abuse', 'abuse', 'hn'), limit=15, extra={"q": "account generator"}),
A("ab_hn_api_key_leak", "abuse", "site", "https://hn.algolia.com/?query=api+key+leak", "hn_search", ('abuse', 'abuse', 'hn'), limit=15, extra={"q": "api key leak"}),
A("ab_hn_chatgpt_free", "abuse", "site", "https://hn.algolia.com/?query=chatgpt+free", "hn_search", ('abuse', 'abuse', 'hn'), limit=15, extra={"q": "chatgpt free"}),
# ---- партия 27.10: проверенные вручную (каждый отдаёт >0 items) ----
A("ab_tg_gpt_frees", "abuse", "site", "https://t.me/s/gpt_frees", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_promokody", "abuse", "site", "https://t.me/s/promokody", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_promokodq", "abuse", "site", "https://t.me/s/promokodq", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_cracksoftwux", "abuse", "site", "https://t.me/s/crackSoftiowux", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_taynikstore", "abuse", "site", "https://t.me/s/taynikstoree", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_nodelocrus", "abuse", "site", "https://t.me/s/NODELOCRUS", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_aivision", "abuse", "site", "https://t.me/s/a_vision_ai", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_tg_sms_free", "abuse", "site", "https://t.me/s/freenumbers", "tg_web", ('abuse', 'abuse', 'tg'), limit=20),
A("ab_reddit_freebies2", "abuse", "site", "https://www.reddit.com/r/freebies/.json?limit=25", "reddit_listing", ('abuse', 'abuse', 'reddit'), limit=20),
A("ab_reddit_beermoney", "abuse", "site", "https://www.reddit.com/r/beermoney/.json?limit=25", "reddit_listing", ('abuse', 'abuse', 'reddit'), limit=20),
A("ab_dis_n8n", "abuse", "site", "https://community.n8n.io/latest.json", "discourse", ('abuse', 'abuse'), limit=20),
A("ab_dis_vercel", "abuse", "site", "https://community.vercel.com/latest.json", "discourse", ('abuse', 'abuse'), limit=20),
A("ab_rss_slickdeals2", "abuse", "site", "https://slickdeals.net/newsearch.php?mode=frontpage&searcharea=deals&searchin=first&rss=1", "rss", ('abuse', 'abuse'), limit=20),
A("ab_gh_freeapi_keys2", "abuse", "site", "https://github.com/search?q=free+api+keys+pool&type=repositories", "github_search", ('abuse', 'abuse', 'github'), limit=15, extra={"q": "free api keys pool", "sort": "updated"}),
A("ab_gh_leak_pool2", "abuse", "site", "https://github.com/search?q=leaked+api+keys+checker&type=repositories", "github_search", ('abuse', 'abuse', 'github'), limit=15, extra={"q": "leaked api keys checker", "sort": "updated"}),
A("ab_hn_free_tokens2", "abuse", "site", "https://hn.algolia.com/?query=free+tokens+api", "hn_search", ('abuse', 'abuse', 'hn'), limit=15, extra={"q": "free tokens api"}),
]

View File

@@ -5,6 +5,16 @@
Импортов из omni.sources нет намеренно (циклический импорт).
"""
import datetime as _dt
def _nvd_week():
"""NVD 2.0 принимает обе даты и окно <=120 дней: скользящая неделя."""
now = _dt.datetime.utcnow()
since = (now - _dt.timedelta(days=7)).strftime("%Y-%m-%dT00:00:00.000")
return ("https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=30"
"&lastModStartDate=%s&lastModEndDate=%s" % (since, now.strftime("%Y-%m-%dT%H:%M:%S.000")))
def fresh(S):
"""S — конструктор Source; возвращает только проверенные сетью источники."""
@@ -33,7 +43,6 @@ def fresh(S):
S("citizenlab", "security", "rss", "https://citizenlab.ca/feed/", "rss", ("apt", "research")),
S("csoonline", "security", "rss", "https://www.csoonline.com/feed/", "rss", ("news")),
S("cybersecuritydive", "security", "rss", "https://www.cybersecuritydive.com/feeds/news/", "rss", ("news")),
S("elastic_security_labs", "security", "rss", "https://www.elastic.co/security-labs/rss/feed", "rss", ("research")),
S("github_advisories_api", "security", "json", "https://api.github.com/advisories?per_page=30&sort=published&direction=desc", "ghsa", ("cve", "ghsa")),
S("habr_infosecurity", "security", "rss", "https://habr.com/ru/rss/hub/infosecurity/", "rss", ("ru", "research")),
S("habr_news", "security", "rss", "https://habr.com/ru/rss/news/", "rss", ("ru", "news")),
@@ -45,6 +54,8 @@ def fresh(S):
S("microsoft_security_blog", "security", "rss", "https://www.microsoft.com/en-us/security/blog/feed/", "rss", ("research")),
S("mozilla_advisories", "security", "html", "https://www.mozilla.org/en-US/security/advisories/", "html_listing", ("cve", "browser")),
S("ncsc_uk", "security", "rss", "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml", "rss", ("gov", "cve")),
S("nvd_exploited_kw", "security", "json", "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=25&keywordSearch=exploited%20in%20the%20wild", "nvd", ("cve", "exploited")),
S("nvd_recent", "security", "json", _nvd_week(), "nvd", ("cve", "nvd")),
S("paloalto_blog", "security", "rss", "https://www.paloaltonetworks.com/blog/feed/", "rss", ("research")),
S("seclists_fulldisclosure", "security", "rss", "https://seclists.org/rss/fulldisclosure.rss", "rss", ("mailing", "exploit")),
S("seclists_nmap", "security", "rss", "https://seclists.org/rss/nmap-dev.rss", "rss", ("mailing")),

207
tests/test_panel_api.py Normal file
View File

@@ -0,0 +1,207 @@
"""Панель v2: API-слой panel_api + регистрация роутов в serve. Без сети."""
import inspect
import os
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
def _tmp_item(tag=None):
from omni import store
from omni.model import Item
tag = tag or str(time.time_ns())
it = Item(provider="pytest", kind="post", source="pytest-panel",
title="pytest panel %s" % tag, text="текст <b>%s</b> & xss" % tag,
url="https://example.invalid/p/%s" % tag, published=time.strftime("%Y-%m-%d"))
store.add([it])
with store.db() as c:
row = c.execute("SELECT id FROM items WHERE url=?", (it.url,)).fetchone()
return int(row[0]), tag
# ---------- timeline ----------
def test_timeline_shape_and_modes():
from omni import panel_api
for mode in ("pub", "seen"):
d = panel_api.timeline(days=7, mode=mode)
assert d["ok"] and d["mode"] == mode
assert len(d["points"]) == 7, "ровно N дней, пустые заполнены нулями"
assert d["total"] == sum(p["n"] for p in d["points"])
for p in d["points"]:
assert set(p) == {"day", "n", "cve", "sev"}
assert p["cve"] <= p["n"] and p["sev"] <= p["n"]
assert "last24" in panel_api.timeline(days=2)
def test_timeline_days_clamped():
"""0/отрицательное → 1 день, None → 30, огромное → потолок 365."""
from omni import panel_api
assert len(panel_api.timeline(days=None)["points"]) == 30
assert len(panel_api.timeline(days=0)["points"]) == 1
assert len(panel_api.timeline(days=-5)["points"]) == 1
assert len(panel_api.timeline(days=10_000)["points"]) == 365
# ---------- детали записи ----------
def test_item_detail_cleans_html_and_finds_siblings():
from omni import panel_api
iid, tag = _tmp_item()
d = panel_api.item_detail(iid)
assert d["ok"]
it = d["item"]
text = it["text"]
assert "<b>" not in text and "<" not in text, "сырые теги остались"
assert "&amp;" not in text, "двойное экранирование"
assert tag in text
assert isinstance(it["related_same_source"], list)
assert all(r["source"] == "pytest-panel" for r in it["related_same_source"])
assert panel_api.item_detail(10 ** 12) == {"error": "not found"}
# ---------- подсказки ----------
def test_suggest_needs_two_chars_and_finds_source():
from omni import panel_api
assert panel_api.suggest("a") == {"titles": [], "sources": [], "categories": []}
d = panel_api.suggest("firecrawl")
assert isinstance(d["titles"], list) and isinstance(d["sources"], list)
if d["sources"]:
assert {"name", "category"} <= set(d["sources"][0])
# ---------- watchlist ----------
def test_watch_add_list_remove():
from omni import panel_api
q = "pytest-watch-%d" % time.time_ns()
r = panel_api.watch_add(q)
assert r["ok"] and r["rule"]["q"] == q
rid = r["rule"]["id"]
try:
rules = panel_api.watch_list()["rules"]
assert any(x["id"] == rid for x in rules)
assert panel_api.watch_remove(rid)["ok"]
assert not any(x["id"] == rid for x in panel_api.watch_list()["rules"])
finally:
panel_api.watch_remove(rid)
assert "error" in panel_api.watch_add("x")
# ---------- детали источника ----------
def test_source_detail_shape():
from omni import panel_api, sources
s = sources.all_sources()[0]
d = panel_api.source_detail(s.name)
assert d["ok"] and d["source"]["name"] == s.name
for k in ("category", "adapter", "kind", "url", "tags", "limit"):
assert k in d["source"], "нет поля source." + k
for k in ("state", "stored", "latest", "runs"):
assert k in d, "нет поля " + k
assert isinstance(d["latest"], list) and isinstance(d["runs"], list)
assert d["stored"] >= 0
assert panel_api.source_detail("нет-такого-источника") == {"error": "not found"}
# ---------- health ----------
def test_health_counts():
from omni import panel_api
d = panel_api.health()
assert d["ok"]
assert d["items"] > 0 and d["sources"] > 0
assert {"alive", "runs", "uptime", "db_mb"} <= set(d)
assert 0 <= d["alive"] <= d["sources"], "живых не может быть больше, чем всех"
assert d["db_mb"] >= 0 and d["uptime"] >= 0
# ---------- tg ----------
def test_tg_status_shape_and_flag():
from omni import panel_api
d = panel_api.tg_status()
assert {"enabled", "me", "error", "limit"} <= set(d)
assert isinstance(d["enabled"], bool)
if d["enabled"]:
assert d["session"]
# ---------- digest ----------
def test_digest_shape_and_day_window():
from pathlib import Path
from omni import panel_api
d = panel_api.digest(days=3, limit=10)
assert d["ok"] and d["days"] == 3 and d["n"] >= 0
md = d["md"]
assert md.startswith("# omni · дайджест")
for head in ("## Главное по score", "## Уязвимости", "## Сливы и утечки", "## Абузы"):
assert head in md, "в дайджесте нет раздела " + head
p = Path(d["path"])
assert p.exists() and p.read_text(encoding="utf-8").startswith("# omni · дайджест")
# clamp: 0 → 1 день, 999 → 30
assert panel_api.digest(days=0)["days"] == 1
assert panel_api.digest(days=999)["days"] == 30
# ---------- export ----------
def test_export_respects_filters_and_cap():
from omni import panel_api
d = panel_api.export_json(q="firecrawl", limit=5)
assert d["ok"] and d["count"] == len(d["items"]) <= 5
assert d["total"] >= d["count"], "total — это всё по фильтру, count — срез"
for it in d["items"]:
assert {"id", "title", "url", "source", "published", "types",
"price", "currency", "score"} <= set(it)
d2 = panel_api.export_json(typ="leak", limit=3)
assert d2["ok"] and all("leak" in (it["types"] or "") for it in d2["items"])
# ---------- serve: роуты зарегистрированы ----------
def test_serve_registers_panel_routes():
from omni import serve
src = inspect.getsource(serve)
for path in ("/api/item/", "/api/timeline", "/api/suggest", "/api/health",
"/api/tg/status", "/api/tg/search", "/api/tg/pull", "/api/source",
"/api/watch", "/api/digest", "/api/export.json"):
assert path in src, "роут не зарегистрирован: " + path
assert "browse2" in src, "browse2 не используется в serve"
# ---------- web: панель v2 содержит новые разделы ----------
def test_web_panel_v2_has_new_sections():
html = (ROOT / "web" / "index.html").read_text(encoding="utf-8")
for frag in ('id:"overview"', 'id:"feed"', 'id:"abuse"', 'id:"sources"', 'id:"runs"',
'id:"tg"', 'id:"keys"', 'id:"digest"', 'id:"watch"',
"/api/timeline", "/api/tg/status", "/api/suggest", "watchlist",
"/api/export.json"):
assert frag in html, "в панели нет " + frag
# ---------- store.browse2: since/src ----------
def test_browse2_filters_since_and_src():
from omni import store
rows, total = store.browse2(q="", limit=5, sort="seen", since="2026-01-01")
assert isinstance(rows, list) and total >= len(rows)
rows2, total2 = store.browse2(q="", limit=5, sort="seen", since="2099-01-01")
assert total2 == 0 and rows2 == [], "since не применился"
rows3, _ = store.browse2(q="", limit=200, sort="seen")
assert rows3, "нет строк для проверки src-фильтра"
name = rows3[0]["source"]
rows4, total4 = store.browse2(q="", limit=200, src=name)
assert total4 > 0 and all(x["source"] == name for x in rows4), "src не применился"
# src + since вместе
rows5, total5 = store.browse2(q="", limit=5, src=name, since="2099-01-01")
assert total5 == 0 and rows5 == []

79
tools/check_candidates.py Normal file
View File

@@ -0,0 +1,79 @@
"""Проверка кандидатов в источники абузов: сколько items реально отдаёт адаптер."""
import json
import sys
sys.path.insert(0, ".")
from omni import feeds
from types import SimpleNamespace
CANDS = [
# TG-превью
("ab_tg_free_apis", "tg_web", "https://t.me/s/free_apis", {}),
("ab_tg_besplatno_chatgpt", "tg_web", "https://t.me/s/besplatno_chatgpt", {}),
("ab_tg_gpt_frees", "tg_web", "https://t.me/s/gpt_frees", {}),
("ab_tg_promokody", "tg_web", "https://t.me/s/promokody", {}),
("ab_tg_promokodq", "tg_web", "https://t.me/s/promokodq", {}),
("ab_tg_free_softiki", "tg_web", "https://t.me/s/free_softiki", {}),
("ab_tg_baza_dannykh", "tg_web", "https://t.me/s/baza_dannykh_ru", {}),
("ab_tg_donzo", "tg_web", "https://t.me/s/virtualnyydondon_aktivatsiadonzo", {}),
("ab_tg_cracksoftiki", "tg_web", "https://t.me/s/cracksoftiki", {}),
("ab_tg_cracksoftwux", "tg_web", "https://t.me/s/crackSoftiowux", {}),
("ab_tg_taynikstore", "tg_web", "https://t.me/s/taynikstoree", {}),
("ab_tg_nodelocrus", "tg_web", "https://t.me/s/NODELOCRUS", {}),
("ab_tg_aivision", "tg_web", "https://t.me/s/a_vision_ai", {}),
("ab_tg_freeapi_chat", "tg_web", "https://t.me/s/freeapi_chat", {}),
("ab_tg_vpn_free", "tg_web", "https://t.me/s/vpn_free_servers", {}),
("ab_tg_sms_free", "tg_web", "https://t.me/s/freenumbers", {}),
# reddit
("ab_reddit_freebies2", "reddit_listing", "https://www.reddit.com/r/freebies/.json?limit=25", {}),
("ab_reddit_selfhosted", "reddit_listing", "https://www.reddit.com/r/selfhosted/.json?limit=25", {}),
("ab_reddit_localllama", "reddit_listing", "https://www.reddit.com/r/LocalLLaMA/.json?limit=25", {}),
("ab_reddit_chatgptcoding", "reddit_listing", "https://www.reddit.com/r/ChatGPTCoding/.json?limit=25", {}),
("ab_reddit_beermoney", "reddit_listing", "https://www.reddit.com/r/beermoney/.json?limit=25", {}),
("ab_reddit_sideloaded", "reddit_listing", "https://www.reddit.com/r/sideloaded/.json?limit=25", {}),
("ab_reddit_piracy2", "reddit_listing", "https://www.reddit.com/r/Piracy/.json?limit=25", {}),
("ab_reddit_freegame", "reddit_listing", "https://www.reddit.com/r/FreeGameFindings/.json?limit=25", {}),
# discourse
("ab_dis_n8n", "discourse", "https://community.n8n.io/latest.json", {}),
("ab_dis_jina", "discourse", "https://discourse.jina.ai/latest.json", {}),
("ab_dis_cloudflare", "discourse", "https://community.cloudflare.com/latest.json", {}),
("ab_dis_vercel", "discourse", "https://community.vercel.com/latest.json", {}),
# rss
("ab_rss_slickdeals2", "rss", "https://slickdeals.net/newsearch.php?mode=frontpage&searcharea=deals&searchin=first&rss=1", {}),
("ab_rss_dealnews", "rss", "https://www.dealnews.com/rss/", {}),
("ab_rss_freeStuffTimes", "rss", "https://feeds.feedburner.com/free-stuff-times", {}),
("ab_rss_humble", "rss", "https://www.humblebundle.com/rss", {}),
# sitemap
("ab_sitemap_cracked", "sitemap_recent", "https://cracked.io/sitemap.xml", {"days": 30}),
("ab_sitemap_nulled", "sitemap_recent", "https://nulled.to/sitemap.xml", {"days": 30}),
# github / hn
("ab_gh_freeapi_keys", "github_search", "https://api.github.com/search/repositories", {"q": "free api keys pool", "sort": "updated"}),
("ab_gh_leak_pool", "github_search", "https://api.github.com/search/repositories", {"q": "leaked api keys checker", "sort": "updated"}),
("ab_hn_free_tokens", "hn_search", "https://hn.algolia.com/api/v1/search_by_date", {"q": "free tokens api"}),
]
out = []
for name, adapter, url, extra in CANDS:
fn = feeds.ADAPTERS.get(adapter)
if not fn:
out.append((name, adapter, -1, "нет адаптера"))
continue
src = SimpleNamespace(name=name, category="abuse", kind="site", url=url,
adapter=adapter, tags=("abuse",), limit=8, detail=False,
extra=dict(extra))
try:
items = fn(src, 8)
n = len(items)
sample = (items[0].title or "")[:60] if items else ""
except Exception as e:
n, sample = -2, "ERR %s" % str(e)[:60]
out.append((name, adapter, n, sample))
ok = [x for x in out if x[2] > 0]
print("проверено:", len(out), "| живых:", len(ok))
for name, adapter, n, sample in out:
print("%-28s %-16s %3s %s" % (name, adapter, n, sample))
json.dump([{"name": n, "adapter": a, "url": u, "extra": e, "items": c}
for (n, a, c, _), (_, _, u, e) in zip(out, CANDS)], open("data/_cand_checked.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=1)

View File

@@ -7,9 +7,11 @@
import datetime
def d(name, category, kind, url, adapter="rss", tags=(), limit=15, detail=False, extra=None):
return {"name": name, "category": category, "kind": kind, "url": url, "adapter": adapter,
def d(name, category, kind, url, adapter="rss", tags=(), limit=15, detail=False, extra=None, **kw):
row = {"name": name, "category": category, "kind": kind, "url": url, "adapter": adapter,
"tags": tuple(tags), "limit": limit, "detail": detail, "extra": extra or {}}
row.update(kw)
return row
_NOW = datetime.datetime.utcnow()
@@ -35,7 +37,8 @@ CANDIDATES = [
d("cisco_security_blog", "security", "rss", "https://blogs.cisco.com/security/feed", "rss", ("cti", "research")),
d("checkpoint_research", "security", "rss", "https://research.checkpoint.com/feed/", "rss", ("research",)),
d("redcanary_blog", "security", "rss", "https://redcanary.com/blog/feed/", "rss", ("dfir",)),
d("elastic_security_labs", "security", "rss", "https://www.elastic.co/security-labs/rss/feed", "rss", ("research",)),
# elastic_security_labs — в реестре уже есть https://www.elastic.co/security-labs/rss/feed.xml
d("greynoise_blog", "security", "rss", "https://www.greynoise.io/blog/rss.xml", "rss", ("research",)),
d("sekoia_blog", "security", "rss", "https://blog.sekoia.io/feed/", "rss", ("research",)),
d("bitdefender_labs", "security", "rss", "https://www.bitdefender.com/blog/api/rss/labs/", "rss", ("research",)),
@@ -56,9 +59,10 @@ CANDIDATES = [
d("infosec_writeups", "community", "rss", "https://infosecwriteups.com/feed", "rss", ("writeup",)),
# ---------------- 2. CVE / vulnerabilities ----------------
d("nvd_recent", "security", "json",
"https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=30&lastModStartDate=%s&lastModEndDate=%s"
% (_NVD_SINCE, _NVD_UNTIL), "nvd", ("cve", "nvd")),
# nvd_recent: окно last-modified считается в момент вызова (см. emit -> dynamic),
# статическая дата протухает уже через неделю.
d("nvd_recent", "security", "json", "https://services.nvd.nist.gov/rest/json/cves/2.0", "nvd",
("cve", "nvd"), dynamic="NVD_LAST_WEEK"),
d("nvd_exploited_kw", "security", "json",
"https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=25&keywordSearch=exploited%20in%20the%20wild",
"nvd", ("cve", "exploited")),

View File

@@ -54,7 +54,7 @@ def probe_one(src, limit):
if not items and not err:
err = _diag(src)
return {"name": src.name, "category": src.category, "kind": src.kind, "adapter": src.adapter,
"url": src.url, "tags": list(src.tags), "ok": bool(items), "n": len(items),
"url": src.url, "tags": list(src.tags), "ok": bool(items), "n": len(items), "dynamic": src.extra.get("_dynamic", ""),
"err": err, "ms": ms,
"samples": [{"title": i.title[:160], "url": i.url[:200]} for i in items[:3]]}
@@ -69,11 +69,11 @@ def _norm(u):
def _registry_urls():
from omni.sources import all_sources
out = set()
for s in all_sources():
out.add(_norm(s.url))
out.add(_norm(s.url).split("?")[0])
return out
return {_norm(s.url) for s in all_sources()}
# контент-дубли уже имеющихся в реестре фидов (url отличается, лента та же)
CONTENT_DUP = {"elastic_security_labs", "securityweek_alt", "reddit_netsec"}
def emit(rows, dead, path, dedupe=True):
@@ -84,9 +84,9 @@ def emit(rows, dead, path, dedupe=True):
if dedupe:
have = _registry_urls()
kept, skipped = [], []
for r in rows:
for r in [x for x in rows if x["name"] not in CONTENT_DUP]:
n = _norm(r["url"])
(skipped if (n in have or n.split("?")[0] in have) else kept).append(r)
(skipped if n in have else kept).append(r)
print("emit: уже есть в реестре -> %d: %s" % (len(skipped), ", ".join(r["name"] for r in skipped)))
rows = kept
@@ -98,6 +98,16 @@ def emit(rows, dead, path, dedupe=True):
"Импортов из omni.sources нет намеренно (циклический импорт).",
'"""',
"",
"import datetime as _dt",
"",
"",
"def _nvd_week():",
' """NVD 2.0 принимает обе даты и окно <=120 дней: скользящая неделя."""',
" now = _dt.datetime.utcnow()",
' since = (now - _dt.timedelta(days=7)).strftime("%Y-%m-%dT00:00:00.000")',
' return ("https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=30"',
' "&lastModStartDate=%s&lastModEndDate=%s" % (since, now.strftime("%Y-%m-%dT%H:%M:%S.000")))',
"",
"",
"def fresh(S):",
' """S — конструктор Source; возвращает только проверенные сетью источники."""',
@@ -111,6 +121,10 @@ def emit(rows, dead, path, dedupe=True):
lim = "" if limit == 15 else ", limit=%d" % limit
det = ", detail=True" if r.get("detail") else ""
tags = "(%s)" % ", ".join(q(t) for t in r.get("tags") or ())
if r.get("dynamic") == "NVD_LAST_WEEK" or r.get("name") == "nvd_recent":
lines.append(' S(%s, %s, %s, _nvd_week(), %s, %s%s%s),' % (
q(r["name"]), q(r["category"]), q(r["kind"]), q(r["adapter"]), tags, lim, det))
continue
lines.append(' S(%s, %s, %s, %s, %s, %s%s%s%s),' % (
q(r["name"]), q(r["category"]), q(r["kind"]), q(r["url"]), q(r["adapter"]),
tags, lim, det, extra))
@@ -147,7 +161,9 @@ def main():
srcs = []
for c in cands:
try:
srcs.append(Source(**c))
cc = dict(c)
cc.pop("dynamic", None)
srcs.append(Source(**cc))
except Exception as e: # noqa: BLE001
print("skip %s: %s" % (c.get("name"), e))

57
tools/run_fresh.py Normal file
View File

@@ -0,0 +1,57 @@
"""Прогон свежей волны источников: фактическая отдача и сколько легло в базу.
python tools/run_fresh.py [--limit 10] [--workers 8]
"""
import argparse
import json
import pathlib
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from omni import feeds, rank, sources, source_state, store
from omni.sources_fresh import fresh as _fresh
class _Fake:
def __init__(self, *a, **k):
self.name = a[0]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=10)
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--out", default="data/fresh_run.json")
a = ap.parse_args()
names = {f.name for f in _fresh(_Fake)}
picked = [s for s in sources.all_sources() if s.name in names]
print("источников свежей волны:", len(picked), "| лимит", a.limit)
t0 = time.time()
items, report = feeds.fetch_many(picked, limit=a.limit, workers=a.workers)
items = rank.rank(items)
new, dup = store.add(items)
rep = dict(report)
ok = {k: v for k, v in rep.items() if v}
bad = sorted(k for k, v in rep.items() if not v)
by_src = {}
for it in items:
by_src[it.source] = by_src.get(it.source, 0) + 1
# состояние реестра: что реально отдало записи
for name, n in rep.items():
source_state.record_check(name, 200 if n else 0, n, "ok" if n else "пусто при проверке")
out = {"when": int(t0), "secs": round(time.time() - t0, 1), "picked": len(picked),
"alive": len(ok), "empty": len(bad), "fetched": len(items), "new": new, "dup": dup,
"by_source": dict(sorted(by_src.items(), key=lambda x: -x[1])), "empty_sources": bad}
json.dump(out, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("секунд:", out["secs"], "| отдали:", len(ok), "| пустых:", len(bad))
print("получено записей:", len(items), "| новых в базе:", new, "| дублей:", dup)
print("топ:", list(out["by_source"].items())[:12])
if bad:
print("пустые:", ", ".join(bad))
if __name__ == "__main__":
main()

16
tools/shot_abuse.py Normal file
View File

@@ -0,0 +1,16 @@
"""Скриншот вкладки «Абузы» панели omni-parse."""
import sys
from camoufox.sync_api import Camoufox
OUT = sys.argv[1] if len(sys.argv) > 1 else "data/dash_abuse.png"
URL = "http://127.0.0.1:8078/#/abuse"
with Camoufox(headless=True, geoip=False, humanize=True) as b:
p = b.new_page(viewport={"width": 1400, "height": 1000})
p.goto(URL, wait_until="domcontentloaded", timeout=60000)
p.wait_for_selector("#view", timeout=60000)
p.wait_for_timeout(7000)
txt = p.inner_text("#view")[:900]
p.screenshot(path=OUT, full_page=False)
print(txt)
print("--- saved:", OUT)

View File

@@ -7,7 +7,7 @@
<style>
:root{
--bg:#0a0e13; --bg2:#0c1219; --panel:#111823; --panel2:#0e151e; --line:#1c2735; --line2:#27364a;
--text:#e9eff7; --muted:#93a3b8; --dim:#65758b;
--text:#e9eff7; --muted:#93a3b8; --dim:#7b8ba1;
--acc:#37d67a; --acc2:#5aa9ff; --warn:#f5b544; --bad:#ef5b5b; --violet:#a78bfa;
--r:10px; --r2:14px;
--mono:"Cascadia Mono",Consolas,"SF Mono",monospace;
@@ -105,6 +105,15 @@ nav{display:flex;flex-direction:column;gap:1px;padding:6px 8px;overflow:auto}
.chip.tool{background:#132028;color:#8fd8e8;border-color:#234450}
.chip.model{background:#1b1630;color:#c2b2f7;border-color:#332a52}
.chip.promo{background:#12251a;color:#9fe6b4;border-color:#245035}
.chip.news{background:#151f2b;color:#a9bdd2;border-color:#26374b}
.chip.trial{background:#221a33;color:#cbb3f2;border-color:#3a2c58}
.chip.resale{background:#2a1626;color:#f0a8cf;border-color:#4a2340}
.chip.release{background:#12233a;color:#9dc4f5;border-color:#21405f}
.chip.guide{background:#12282a;color:#96dede;border-color:#1f4a4c}
.chip.dataset{background:#1a2438;color:#a9bdf0;border-color:#2c3c5c}
.chip.job{background:#2a2413;color:#ecd08a;border-color:#4a3f1f}
.chip.price{background:#12251a;color:#9fe6b4;border-color:#245035}
.chip.question{background:#1b2029;color:#b6c2d4;border-color:#2c3746}
.chip.on{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
.chip.off{background:#211517;border-color:#4a2429;color:#f0a9a9}
.chip.click{cursor:pointer}
@@ -127,10 +136,10 @@ td .u{color:var(--dim);font-size:11px;display:block;max-width:420px;overflow:hid
/* feed */
.item{border-bottom:1px solid #151f2b;padding:11px 13px;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}
.item:hover{background:#0f1822}
.item .t{font-size:14px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.item .t{font-size:14.5px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.item .m{display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--dim);font-size:11.5px}
.item .src{color:#9db7d4}
.item .x{color:var(--muted);font-size:12px;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.item .x{color:var(--muted);font-size:12.5px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.item .side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;justify-content:center}
.sc{font:11px var(--mono);color:var(--muted);border:1px solid var(--line2);border-radius:6px;padding:1px 5px}
.sc.hi{color:#b9f5cd;border-color:#1f5c33;background:#12251a}
@@ -188,6 +197,7 @@ label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font
.md code{font-family:var(--mono);font-size:11.5px;background:#132029;padding:1px 5px;border-radius:5px;color:#9fd7c8}
.md a{color:#8fc2ff}
.pager{display:flex;gap:8px;justify-content:center;padding:14px}
.legend{font-size:11px;color:var(--dim);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.hint{font-size:11.5px;color:var(--dim)}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g21{grid-template-columns:1fr}}
@@ -343,6 +353,14 @@ async function boot(){
$("#q").placeholder=`Поиск по ${num(st.items)} записям: CVE, ключи, сливы, модели…`;
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
route();
setInterval(refreshHealth,60000);
}
async function refreshHealth(){
try{
const h=await api("/api/health");
$("#health").innerHTML=`<span class="dot ${h.alive>0?"":"bad"}"></span><span>${num(h.items)} записей · ${num(h.alive)}/${num(h.sources)} живых · аптайм ${Math.round(h.uptime/60)} мин</span>`;
$("#q").placeholder=`Поиск по ${num(h.items)} записям: CVE, ключи, сливы, модели…`;
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
}
/* ============================= views ============================= */
@@ -354,12 +372,12 @@ V.overview=async()=>{
box.innerHTML=`<div class="grid g4">${Array(4).fill(`<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;height:26px;margin-top:10px"></div><div class="skel" style="width:55%;margin-top:10px"></div></div>`).join("")}</div>
<div class="grid g21" style="margin-top:14px"><div class="card"><div class="skel" style="height:130px"></div></div><div class="card"><div class="skel" style="height:130px"></div></div></div>`;
const [st,tl,runs,watch,ab]=await Promise.all([
api("/api/stats"), api("/api/timeline?days=30"), api("/api/runs?limit=6"),
api("/api/stats"), api("/api/timeline?days=30&mode=seen"), api("/api/runs?limit=6"),
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
]);
S.stats=st;
const day=tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
const dPct=prev?Math.round((day-prev)/prev*100):0;
const day=tl.last24||tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
const dPct=prev?Math.round((day-prev)/prev*100):null;
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
const okRate=st.runs?(st.runs_ok/st.runs*100).toFixed(0):0;
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
@@ -372,16 +390,17 @@ V.overview=async()=>{
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
</div></div>
<div class="grid g4">
${kpi("Записей всего",num(st.items),`+${num(day)} за последний день <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`)}
${kpi("Записей всего",num(st.items),`+${num(day)} за сутки импорта${dPct===null?"":` <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`}`)}
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
</div>
<div class="grid g21" style="margin-top:14px">
<div class="card"><h3>Поток за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}</span></h3>
<div class="card"><h3>Импорт за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}/день</span></h3>
${chart(tl.points)}
<div class="chart-x"><span>${dt(tl.points[0]?.day)}</span><span>${dt(tl.points[Math.floor(tl.points.length/2)]?.day)}</span><span>сегодня</span></div>
<div class="legend" style="margin-top:9px"><span>столбец = записей за день импорта</span><span>наведение — дата и число CVE</span><span>переключатель «сутки» в ленте фильтрует по дате публикации</span></div>
</div>
<div class="card"><h3>Источники по категориям</h3>
${(st.categories||[]).slice(0,8).map(c=>{const r=c.total?Math.round((c.alive||0)/c.total*100):0;
@@ -422,8 +441,8 @@ V.feed=async()=>{
const box=$("#view");
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
<div class="right">
<a class="btn sm" href="/api/export.csv?${exQS()}" download>CSV</a>
<a class="btn sm" href="/api/export.json?${exQS()}" download>JSON</a>
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">CSV</a>
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">JSON</a>
</div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<span class="dim" style="font-size:11.5px">Типы:</span>
@@ -452,6 +471,14 @@ async function loadItems(reset){
: `<span class="dim">показано ${num(S.items.length)} из ${num(S.total)}</span>`;
}
function findChips(f){
if(!f) return "";
const p=[];
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
if(f.amount) p.push(`<span class="chip credits">${esc(f.amount)}</span>`);
if(f.term) p.push(`<span class="chip invite">${esc(f.term)}</span>`);
return p.join("");
}
function itemRow(it,compact){
const types=(it.types||"").split(",").filter(Boolean).slice(0,3);
const labels=typeLabels();
@@ -463,7 +490,7 @@ function itemRow(it,compact){
<div class="t">${esc(it.title||"(без заголовка)")}</div>
<div class="m"><span class="src">${esc(it.source||"")}</span>
${types.map(t=>`<span class="chip t ${t}">${esc(labels[t]||t)}</span>`).join("")}
${price}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
${price}${findChips(it.find)}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
${compact?"":`<div class="x">${esc(it.text||"")}</div>`}
</div>
<div class="side">
@@ -510,7 +537,7 @@ function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classL
/* ---------- abuse ---------- */
V.abuse=async()=>{
const d=await api("/api/abuse?limit=60");
const d=await api("/api/abuse?limit=120");
const tags=Object.entries(d.tags||{}).slice(0,14);
$("#view").innerHTML=`
<div class="page-h"><div><h2>Абузы</h2><p>Скам/промо/триалы/ключи в постах каналов и форумов. Вес считается по признакам: обещания, цены, «в личку», свежесть.</p></div>
@@ -620,20 +647,28 @@ V.runs=async()=>{
const ok=(d.items||[]).filter(r=>r.status==="ok").length;
$("#view").innerHTML=`
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
<div class="right"><span class="chip on">ok ${ok}</span><span class="chip off">ошибки ${d.total-ok}</span></div></div>
<div class="right">
<span class="chip click on" data-rf="all">все <span class="n">${num(d.total)}</span></span>
<span class="chip click" data-rf="ok">ok <span class="n">${ok}</span></span>
<span class="chip click" data-rf="err">ошибки <span class="n">${d.total-ok}</span></span></div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px">
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)}</span></div></div>
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
<tbody>${(d.items||[]).map(r=>`<tr><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
<tbody>${(d.items||[]).map(r=>`<tr data-st="${esc(r.status||"")}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
<td><span class="pill ${r.status==="ok"?"ok":"no"}"></span>${esc(r.status||"")}</td></tr>`).join("")}</tbody></table></div></div>`;
$("#r-q").addEventListener("input",debounce(e=>{
const v=e.target.value.toLowerCase();
$$("#r-tbl tbody tr").forEach(tr=>tr.style.display=tr.textContent.toLowerCase().includes(v)?"":"none");
},120));
S.runQ=""; S.runF="all";
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
$$("#r-tbl tbody tr").forEach(tr=>{
const okT=!v||tr.textContent.toLowerCase().includes(v);
const okS=S.runF==="all"||(S.runF==="ok"?tr.dataset.st==="ok":tr.dataset.st!=="ok");
tr.style.display=(okT&&okS)?"":"none";});};
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
};
/* ---------- telegram ---------- */
@@ -749,7 +784,9 @@ V.watch=async()=>{
<div class="card" style="margin-bottom:14px"><div class="row">
<input id="w-q" placeholder="например: CVE-2026, firecrawl, приватка" style="min-width:320px">
<select id="w-type"><option value="all">любой тип</option>${(S.types||[]).map(t=>`<option value="${esc(t.key)}">${esc(t.label)}</option>`).join("")}</select>
<button class="btn primary" data-act="watch-add">Добавить правило</button></div></div>
<button class="btn primary" data-act="watch-add">Добавить правило</button></div>
<div class="row" style="margin-top:10px"><span class="dim">быстро:</span>
${["CVE-2026","ключ","firecrawl","слив","промо","52pojie"].map(x=>`<span class="chip click" data-watch-q="${esc(x)}">${esc(x)}</span>`).join("")}</div></div>
<div class="grid g2">${(d.rules||[]).map(r=>`<div class="card hov">
<div class="row"><b>${esc(r.q)}</b><span class="chip">${esc(r.type)}</span><span class="spacer"></span>
<span class="chip on">${num(r.count)}</span><button class="ico" data-watch-del="${esc(r.id)}">✕</button></div>
@@ -865,6 +902,8 @@ document.addEventListener("click",async e=>{
const it=t.closest("[data-id]"); if(it){e.preventDefault();openItem(+it.dataset.id);return}
const wd=t.closest("[data-watch-del]"); if(wd){e.preventDefault();await post("/api/watch/remove",{id:wd.dataset.watchDel});toast("правило удалено");render();return}
const wt=t.closest("[data-watch]"); if(wt){e.preventDefault();await post("/api/watch/add",{q:wt.dataset.watch,type:"all"});toast("в watchlist");return}
const wq=t.closest("[data-watch-q]"); if(wq){e.preventDefault();
post("/api/watch/add",{q:wq.dataset.watchQ,type:"all"}).then(()=>{toast("правило добавлено: "+wq.dataset.watchQ);render()});return}
const po=t.closest("[data-pull-one]"); if(po){e.preventDefault();tgPull([po.dataset.pullOne]);return}
const ts=t.closest("[data-toggle-src]"); if(ts){e.preventDefault();
await post("/api/sources/toggle",{name:ts.dataset.toggleSrc,enabled:ts.dataset.on==="1"});toast("переключено");srcDetail(ts.dataset.toggleSrc);return}