ui: локальный Tabler dashboard + 150 проверенных TG-источников и probe 595 каналов
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 02:54:29 +03:00
parent c1d2d1bbda
commit cad71c065c
17 changed files with 1467 additions and 1035 deletions

View File

@@ -220,6 +220,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
@@ -238,6 +239,24 @@ class Handler(BaseHTTPRequestHandler):
return self._send(404, "no ui", "text/plain")
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
if path.startswith("/assets/"):
# Only vendored public assets; never expose data, sessions or arbitrary files.
root = (WEB / "vendor").resolve()
types = {".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml", ".png": "image/png",
".woff": "font/woff", ".woff2": "font/woff2"}
try:
asset = (WEB / urllib.parse.unquote(path[len("/assets/"):])).resolve()
asset.relative_to(root)
ctype = types.get(asset.suffix.lower())
if not ctype or not asset.is_file():
return self._send(404, "not found", "text/plain")
data = asset.read_bytes()
except (OSError, ValueError):
return self._send(404, "not found", "text/plain")
return self._send(200, data, ctype)
if path == "/api/panels":
import json as _json
fp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "panels.json")

View File

@@ -46,4 +46,306 @@ TG_ABUSE = [
"adapter": "tg_web", "tags": ["cve", "abuse"], "limit": 25},
{"name": "tg_hunt_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/hunt_news",
"adapter": "tg_web", "tags": ["security", "news"], "limit": 25},
# ---- автопул из 595 каналов (27.10.26): проверены живыми, items>=10 ----
{"name": "tgp_freegptmaster", "category": "abuse", "kind": "html", "url": "https://t.me/s/freegptmaster",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aibotplus_channel", "category": "abuse", "kind": "html", "url": "https://t.me/s/aibotplus_channel",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_timenewsai", "category": "abuse", "kind": "html", "url": "https://t.me/s/timenewsai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjourney_free_design", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_design",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjourney_free_images", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_images",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjourney_free_anime", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_anime",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_localllama_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/localllama_ru",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjourney_free_logo", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_logo",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_omggpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/omggpt",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_nanoprompthub", "category": "abuse", "kind": "html", "url": "https://t.me/s/nanoprompthub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_for_bussiness", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_for_bussiness",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_in_devops", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_in_devops",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_llm_ml_tech", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_llm_ml_tech",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_n_hardware", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_n_hardware",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_code_stars", "category": "abuse", "kind": "html", "url": "https://t.me/s/code_stars",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_news_cn", "category": "abuse", "kind": "html", "url": "https://t.me/s/AI_News_CN",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_sgcode", "category": "abuse", "kind": "html", "url": "https://t.me/s/sgcode",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_coursevania", "category": "abuse", "kind": "html", "url": "https://t.me/s/Coursevania",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_coursecouponclub", "category": "abuse", "kind": "html", "url": "https://t.me/s/coursecouponclub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_udemyfree4_u", "category": "abuse", "kind": "html", "url": "https://t.me/s/Udemyfree4_u",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aimafiaclub", "category": "abuse", "kind": "html", "url": "https://t.me/s/AImafiaClub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_simplificaconia", "category": "abuse", "kind": "html", "url": "https://t.me/s/simplificaconia",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_artificial_intelligence_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/Artificial_Intelligence_AI",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ucretsiz_kurspaylasim", "category": "abuse", "kind": "html", "url": "https://t.me/s/ucretsiz_kurspaylasim",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ahmetbalatcom", "category": "abuse", "kind": "html", "url": "https://t.me/s/ahmetbalatcom",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_sepaychannel", "category": "abuse", "kind": "html", "url": "https://t.me/s/sepaychannel",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_inet_vn", "category": "abuse", "kind": "html", "url": "https://t.me/s/inet_vn",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_j2team_community_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/j2team_community_news",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_start_in_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/start_in_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_habr_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/habr_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_berloga", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_berloga",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_kanal", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_kanal",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_na_russkom", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_na_russkom",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_novosti", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_novosti",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_nox", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_nox",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_sapiens", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_sapiens",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_sashaa_blog", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_sashaa_blog",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aidailywire", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidailywire",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aidialogs", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidialogs",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aidivision", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidivision",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aiefn", "category": "abuse", "kind": "html", "url": "https://t.me/s/aiefn",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aigrams_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/aigrams_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aihohohoho", "category": "abuse", "kind": "html", "url": "https://t.me/s/aihohohoho",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aii_tools", "category": "abuse", "kind": "html", "url": "https://t.me/s/aii_tools",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aimasef", "category": "abuse", "kind": "html", "url": "https://t.me/s/aimasef",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_airdro_007", "category": "abuse", "kind": "html", "url": "https://t.me/s/airdro_007",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aistudio4u", "category": "abuse", "kind": "html", "url": "https://t.me/s/aistudio4u",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aitexhnology", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitexhnology",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aitoolscollection", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitoolscollection",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aitoolsday", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitoolsday",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_bolshiedannye", "category": "abuse", "kind": "html", "url": "https://t.me/s/bolshiedannye",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_bot_dev_py_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/bot_dev_py_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_chatgptpromptnano", "category": "abuse", "kind": "html", "url": "https://t.me/s/chatgptpromptnano",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_claude_popusk", "category": "abuse", "kind": "html", "url": "https://t.me/s/claude_popusk",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_claudenews", "category": "abuse", "kind": "html", "url": "https://t.me/s/claudenews",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_clorecoin_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/clorecoin_ru",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_codex_api_online", "category": "abuse", "kind": "html", "url": "https://t.me/s/codex_api_online",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_codex_neiro", "category": "abuse", "kind": "html", "url": "https://t.me/s/codex_neiro",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_gri_nada_gpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/gri_nada_gpt",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ideipitona", "category": "abuse", "kind": "html", "url": "https://t.me/s/ideipitona",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ii_papka", "category": "abuse", "kind": "html", "url": "https://t.me/s/ii_papka",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_intelektii1", "category": "abuse", "kind": "html", "url": "https://t.me/s/intelektii1",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_itjobs_nocode", "category": "abuse", "kind": "html", "url": "https://t.me/s/itjobs_nocode",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_kuznicon", "category": "abuse", "kind": "html", "url": "https://t.me/s/kuznicon",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjourney_a1", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_a1",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_nejrokotiki", "category": "abuse", "kind": "html", "url": "https://t.me/s/nejrokotiki",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_nerdhubchannel", "category": "abuse", "kind": "html", "url": "https://t.me/s/nerdhubchannel",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_neurodromo", "category": "abuse", "kind": "html", "url": "https://t.me/s/neurodromo",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_outlinevpnv2rayng", "category": "abuse", "kind": "html", "url": "https://t.me/s/outlinevpnv2rayng",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_photo_ai_free", "category": "abuse", "kind": "html", "url": "https://t.me/s/photo_ai_free",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_pro100_python", "category": "abuse", "kind": "html", "url": "https://t.me/s/pro100_python",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_promptyo", "category": "abuse", "kind": "html", "url": "https://t.me/s/promptyo",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_realaihub", "category": "abuse", "kind": "html", "url": "https://t.me/s/realaihub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_robocounsel", "category": "abuse", "kind": "html", "url": "https://t.me/s/robocounsel",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_smm_top_vacancy", "category": "abuse", "kind": "html", "url": "https://t.me/s/smm_top_vacancy",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_sobolevalizfoto", "category": "abuse", "kind": "html", "url": "https://t.me/s/sobolevalizfoto",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_startup_fairy", "category": "abuse", "kind": "html", "url": "https://t.me/s/startup_fairy",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_teamdemonmain", "category": "abuse", "kind": "html", "url": "https://t.me/s/teamdemonmain",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_udalenrabota59", "category": "abuse", "kind": "html", "url": "https://t.me/s/udalenrabota59",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_umnik_prompts", "category": "abuse", "kind": "html", "url": "https://t.me/s/umnik_prompts",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_vibe_coding_jobs", "category": "abuse", "kind": "html", "url": "https://t.me/s/vibe_coding_jobs",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_vibecodingvibes", "category": "abuse", "kind": "html", "url": "https://t.me/s/vibecodingvibes",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_beautiful_wallpaper2025", "category": "abuse", "kind": "html", "url": "https://t.me/s/beautiful_wallpaper2025",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_cydiaguide", "category": "abuse", "kind": "html", "url": "https://t.me/s/cydiaguide",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_pixoradigitalnew", "category": "abuse", "kind": "html", "url": "https://t.me/s/pixoradigitalnew",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_kokoro_online", "category": "abuse", "kind": "html", "url": "https://t.me/s/kokoro_online",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_connect_hub", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_connect_hub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_cvedetector", "category": "abuse", "kind": "html", "url": "https://t.me/s/CVEDetector",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_exee", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_exee",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_web3_aggregator", "category": "abuse", "kind": "html", "url": "https://t.me/s/WEB3_AGGREGATOR",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_clodex_api", "category": "abuse", "kind": "html", "url": "https://t.me/s/clodex_api",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_curiousscan", "category": "abuse", "kind": "html", "url": "https://t.me/s/curiousscan",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_jcnode", "category": "abuse", "kind": "html", "url": "https://t.me/s/jcnode",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ragnarokzone", "category": "abuse", "kind": "html", "url": "https://t.me/s/ragnarokzone",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_valencylab", "category": "abuse", "kind": "html", "url": "https://t.me/s/valencylab",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_githubradar", "category": "abuse", "kind": "html", "url": "https://t.me/s/GitHubRadar",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_matrixx", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_matrixx",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_unicodeunicode", "category": "abuse", "kind": "html", "url": "https://t.me/s/unicodeunicode",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_kyloai", "category": "abuse", "kind": "html", "url": "https://t.me/s/kyloai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_neuraldvig", "category": "abuse", "kind": "html", "url": "https://t.me/s/neuraldvig",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_hubcryptocis", "category": "abuse", "kind": "html", "url": "https://t.me/s/hubcryptocis",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_by_miracles", "category": "abuse", "kind": "html", "url": "https://t.me/s/by_miracles",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_satu_digital", "category": "abuse", "kind": "html", "url": "https://t.me/s/satu_digital",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_tunvnprehubnoti", "category": "abuse", "kind": "html", "url": "https://t.me/s/tunvnprehubnoti",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_productvc", "category": "abuse", "kind": "html", "url": "https://t.me/s/productVC",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_toolsdealshub", "category": "abuse", "kind": "html", "url": "https://t.me/s/toolsdealshub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_nullscode", "category": "abuse", "kind": "html", "url": "https://t.me/s/nullscode",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_digital_ponos", "category": "abuse", "kind": "html", "url": "https://t.me/s/digital_ponos",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_lachugashop", "category": "abuse", "kind": "html", "url": "https://t.me/s/LachugaShop",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ramikrispinds", "category": "abuse", "kind": "html", "url": "https://t.me/s/ramikrispinds",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_howto_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/Howto_AI",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_neuralprofit", "category": "abuse", "kind": "html", "url": "https://t.me/s/NeuralProfit",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_baza_dannykh_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/baza_dannykh_ru",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_bloombusiness", "category": "abuse", "kind": "html", "url": "https://t.me/s/bloombusiness",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_business_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/business_ru",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_for_dev", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_for_dev",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_business_news_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/business_news_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_omnilogin", "category": "abuse", "kind": "html", "url": "https://t.me/s/omnilogin",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_gleb_pro_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/gleb_pro_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_paragonzone", "category": "abuse", "kind": "html", "url": "https://t.me/s/paragonzone",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_caocongbptg", "category": "abuse", "kind": "html", "url": "https://t.me/s/caocongbptg",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_xanllu", "category": "abuse", "kind": "html", "url": "https://t.me/s/xanllu",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ai_barkov", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_barkov",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aibusines2", "category": "abuse", "kind": "html", "url": "https://t.me/s/aibusines2",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aidevtoolszoomcamp", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidevtoolszoomcamp",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aikonten", "category": "abuse", "kind": "html", "url": "https://t.me/s/aikonten",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_aipythonmoney", "category": "abuse", "kind": "html", "url": "https://t.me/s/aipythonmoney",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_annlatushkina_kanal", "category": "abuse", "kind": "html", "url": "https://t.me/s/annlatushkina_kanal",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_buhaistrikalos", "category": "abuse", "kind": "html", "url": "https://t.me/s/buhaistrikalos",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_claude_mania", "category": "abuse", "kind": "html", "url": "https://t.me/s/claude_mania",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_edvardgrishin27", "category": "abuse", "kind": "html", "url": "https://t.me/s/edvardgrishin27",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ekryukova", "category": "abuse", "kind": "html", "url": "https://t.me/s/ekryukova",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_firstaihub", "category": "abuse", "kind": "html", "url": "https://t.me/s/firstaihub",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_gigachatm", "category": "abuse", "kind": "html", "url": "https://t.me/s/gigachatm",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_gpt_chatu", "category": "abuse", "kind": "html", "url": "https://t.me/s/gpt_chatu",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_hiaimedia", "category": "abuse", "kind": "html", "url": "https://t.me/s/hiaimedia",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_istobzorov", "category": "abuse", "kind": "html", "url": "https://t.me/s/istobzorov",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_midjornium", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjornium",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_n2d2ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/n2d2ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_neirosetr", "category": "abuse", "kind": "html", "url": "https://t.me/s/neirosetr",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_promptgpt_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/promptgpt_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_promtychatgpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/promtychatgpt",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_sky_net_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/sky_net_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_stable_diffusion5", "category": "abuse", "kind": "html", "url": "https://t.me/s/stable_diffusion5",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_the_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/the_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_ultravibecoder", "category": "abuse", "kind": "html", "url": "https://t.me/s/ultravibecoder",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_unterberg_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/unterberg_ai",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_kodjournal", "category": "abuse", "kind": "html", "url": "https://t.me/s/kodjournal",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_productvibe", "category": "abuse", "kind": "html", "url": "https://t.me/s/productvibe",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
{"name": "tgp_nineaws", "category": "abuse", "kind": "html", "url": "https://t.me/s/nineaws",
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
]

View File

@@ -454,10 +454,10 @@ def test_http_exports_match_feed_filters_and_item_find(monkeypatch):
server.server_close()
thread.join(timeout=5)
def test_web_has_free_panel_block():
"""Вкладка абузов обязана показывать free-панели new-api."""
html = open(os.path.join(ROOT, "web", "index.html"), encoding="utf-8").read()
def test_web_has_local_dashboard_and_free_panel_block():
"""The ready-made UI stays local and retains the free-panel discovery feature."""
html = (ROOT / "web" / "index.html").read_text(encoding="utf-8")
assert "loadPanels" in html
assert "/api/panels" in html
assert "pn-grid" in html
assert ".pn-grid{" in html
assert "/assets/vendor/tabler/tabler.min.css" in html
assert "/assets/vendor/tabler/tabler.min.js" in html

View File

@@ -0,0 +1,77 @@
"""Local UI dependencies are served without exposing project data."""
import http.client
import threading
from http.server import ThreadingHTTPServer
import pytest
from omni import serve
@pytest.fixture
def static_server(tmp_path, monkeypatch):
web = tmp_path / "web"
vendor = web / "vendor" / "tabler"
vendor.mkdir(parents=True)
(vendor / "tabler.min.css").write_text("body{color:#123}", encoding="utf-8")
(vendor / "tabler.min.js").write_text("window.tabler={};", encoding="utf-8")
(web / "index.html").write_text("<html>panel</html>", encoding="utf-8")
(web / "private.js").write_text("private", encoding="utf-8")
(vendor / "secret.json").write_text('{"secret":"test-only"}', encoding="utf-8")
(tmp_path / "outside.js").write_text("outside", encoding="utf-8")
monkeypatch.setattr(serve, "WEB", web)
server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def get(path):
connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5)
try:
connection.request("GET", path)
response = connection.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
connection.close()
try:
yield get
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
@pytest.mark.parametrize("name,ctype,expected", [
("tabler.min.css", "text/css; charset=utf-8", b"body{color:#123}"),
("tabler.min.js", "text/javascript; charset=utf-8", b"window.tabler={};"),
])
def test_local_template_assets(static_server, name, ctype, expected):
status, headers, body = static_server("/assets/vendor/tabler/" + name + "?v=1.7.1")
assert status == 200 and body == expected
assert headers["Content-Type"] == ctype
assert headers["X-Content-Type-Options"] == "nosniff"
assert int(headers["Content-Length"]) == len(expected)
@pytest.mark.parametrize("path", [
"/assets/vendor/tabler/missing.js",
"/assets/vendor/tabler/",
"/assets/vendor/tabler/secret.json",
"/assets/private.js",
"/assets/index.html",
"/assets/vendor/../../private.js",
"/assets/vendor/%2e%2e/%2e%2e/outside.js",
"/assets/%2foutside.js",
"/assets/vendor/..%5c..%5cprivate.js",
"/assets/vendor/%00.js",
])
def test_assets_reject_private_files_and_traversal(static_server, path):
status, _, body = static_server(path)
assert status == 404
assert body == b"not found"
def test_index_still_served(static_server):
status, headers, body = static_server("/")
assert status == 200 and body == b"<html>panel</html>"
assert headers["Content-Type"].startswith("text/html")

122
tools/tg_pool_probe.py Normal file
View File

@@ -0,0 +1,122 @@
"""Probe public Telegram previews without writing posts to the store.
Run from the repository root: python tools/tg_pool_probe.py
Each of up to 12 coordinator threads runs one isolated adapter subprocess.
The 20-second subprocess deadline includes imports, network fallbacks and parsing;
Future.result(timeout=...) alone would leave hung network threads running.
Results preserve pool order and username spelling; ok means items > 0.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import os
from pathlib import Path
import re
import subprocess
import sys
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
WORKERS = 12
TIMEOUT = 20
LIMIT = 20
def load_channels(path: Path) -> list[str]:
channels = json.loads(path.read_text(encoding="utf-8"))["channels"]
if not isinstance(channels, list):
raise ValueError("channels must be a list")
names = []
for entry in channels:
username = entry.get("username") if isinstance(entry, dict) else entry
if not isinstance(username, str) or not re.fullmatch(r"[A-Za-z0-9_]+", username):
raise ValueError(f"Invalid username in channels: {username!r}")
names.append(username)
return names
def adapter_count(username: str) -> int:
sys.path.insert(0, str(ROOT))
from omni import feeds
src = SimpleNamespace(name=f"ab_tg_auto_{username}", category="abuse",
kind="site", url=f"https://t.me/s/{username}",
adapter="tg_web", tags=("abuse", "abuse", "tg"),
limit=LIMIT, detail=False, extra={})
# Call the actual adapter and validation directly so unexpected exceptions
# reach the subprocess exit status instead of fetch() swallowing them.
return len(feeds._valid_items(feeds.ADAPTERS["tg_web"](src, LIMIT) or []))
def probe(username: str, timeout: float = TIMEOUT) -> dict:
count = 0
try:
env = dict(os.environ, PYTHONPATH="", PYTHONIOENCODING="utf-8",
PYTHONUTF8="1", PYTHONDONTWRITEBYTECODE="1")
result = subprocess.run([sys.executable, str(Path(__file__).resolve()),
"--probe", username],
cwd=str(ROOT), env=env, capture_output=True,
encoding="utf-8", timeout=timeout, check=False)
if result.returncode == 0:
value = json.loads(result.stdout)
if type(value) is int and 0 <= value <= LIMIT:
count = value
else:
print(f"probe error @{username}: exit={result.returncode} "
f"{result.stderr[-300:].strip()}", file=sys.stderr, flush=True)
except subprocess.TimeoutExpired:
print(f"probe timeout @{username}: {timeout:g}s", file=sys.stderr, flush=True)
except (OSError, ValueError) as exc:
print(f"probe error @{username}: {type(exc).__name__}: {exc}",
file=sys.stderr, flush=True)
return {"username": username, "items": count, "ok": count > 0}
def save_results(path: Path, rows: list[dict | None]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps([r for r in rows if r is not None],
ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=ROOT / "data/radar_sources.json")
parser.add_argument("--output", type=Path, default=ROOT / "data/tg_pool_probe.json")
parser.add_argument("--workers", type=int, choices=range(1, WORKERS + 1), default=WORKERS)
parser.add_argument("--timeout", type=float, default=TIMEOUT)
parser.add_argument("--probe", help=argparse.SUPPRESS)
args = parser.parse_args()
if not 0 < args.timeout <= TIMEOUT:
parser.error("timeout must be greater than zero and at most 20 seconds")
if args.probe is not None:
if not re.fullmatch(r"[A-Za-z0-9_]+", args.probe):
parser.error("invalid username")
print(json.dumps(adapter_count(args.probe)))
return 0
if args.input.resolve() == args.output.resolve():
parser.error("output must not overwrite the input pool")
names = load_channels(args.input)
rows = [None] * len(names)
print(f"channels={len(names)} workers={args.workers} timeout={args.timeout:g}s limit={LIMIT}", flush=True)
with ThreadPoolExecutor(max_workers=args.workers) as executor:
pending = {executor.submit(probe, name, args.timeout): i
for i, name in enumerate(names)}
for done, future in enumerate(as_completed(pending), 1):
rows[pending[future]] = future.result()
if done % 25 == 0 or done == len(names):
save_results(args.output, rows)
positive = sum(r is not None and r["ok"] for r in rows)
print(f"checked={done}/{len(names)} positive={positive}", flush=True)
save_results(args.output, rows)
print(json.dumps({"checked": len(rows),
"positive": sum(r["ok"] for r in rows),
"zero": sum(r["items"] == 0 for r in rows),
"items_ge_10": sum(r["items"] >= 10 for r in rows),
"output": str(args.output)}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,95 +1,237 @@
"""Смоук панели в реальном Chromium: скриншоты + ошибки консоли.
"""Read-only Chromium acceptance checks for the black OMNI panel.
python tools/ui_smoke.py [--url http://127.0.0.1:8078/] [--shots data/shots]
python tools/ui_smoke.py [--url http://127.0.0.1:8078/] [--shots data/shots/black_shadcn]
"""
import argparse
import asyncio
import json
import pathlib
import re
import sys
from urllib.parse import parse_qs, quote, urljoin, urlsplit
from playwright.async_api import async_playwright
async def run(url, out):
out.mkdir(parents=True, exist_ok=True)
errors, report = [], {}
async with async_playwright() as p:
b = await p.chromium.launch()
pg = await b.new_page(viewport={"width": 1500, "height": 950})
pg.on("console", lambda m: errors.append([m.type, m.text]) if m.type in ("error",) else None)
pg.on("pageerror", lambda e: errors.append(["pageerror", str(e)]))
await pg.goto(url, wait_until="domcontentloaded", timeout=30000)
await pg.wait_for_selector(".kpi", timeout=20000)
await pg.wait_for_timeout(1200)
report["title"] = await pg.title()
report["nav"] = await pg.locator(".nav-i").count()
report["kpi"] = (await pg.locator(".kpi").all_inner_texts())[:4]
await pg.screenshot(path=str(out / "v2_overview.png"), full_page=False)
errors, assets, external = [], {}, set()
report = {}
origin = urlsplit(url).netloc
async with async_playwright() as playwright:
browser = await playwright.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 960})
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
await pg.click("[data-nav=feed]")
await pg.wait_for_selector("#list .item", timeout=20000)
report["feed_rows"] = await pg.locator(".item").count()
await pg.screenshot(path=str(out / "v2_feed.png"))
await pg.locator(".item").first.click()
await pg.wait_for_timeout(1200)
report["drawer"] = (await pg.locator("#d-title").inner_text())[:100]
await pg.screenshot(path=str(out / "v2_drawer.png"))
await pg.keyboard.press("Escape")
def response_seen(response):
parts = urlsplit(response.url)
if parts.path.startswith("/assets/"):
assets[parts.path] = response.status
if parts.scheme in ("http", "https") and parts.netloc != origin:
external.add(parts.netloc)
await pg.click("[data-nav=sources]")
await pg.wait_for_timeout(1500)
report["src_rows"] = await pg.locator("#src-tbl tbody tr").count()
await pg.screenshot(path=str(out / "v2_sources.png"))
page.on("response", response_seen)
await pg.click("[data-nav=abuse]")
await pg.wait_for_timeout(1500)
report["abuse_rows"] = await pg.locator(".item").count()
await pg.screenshot(path=str(out / "v2_abuse.png"))
async def settled():
await page.wait_for_load_state("networkidle", timeout=25000)
await pg.click("[data-nav=runs]")
await pg.wait_for_timeout(900)
report["run_rows"] = await pg.locator("#r-tbl tbody tr").count()
await pg.screenshot(path=str(out / "v2_runs.png"))
async def navigate(view):
nav = page.locator(f'[data-nav="{view}"]').first
if not await nav.is_visible():
summary = page.locator("#system-nav > summary")
if await summary.count() and await page.locator("#system-nav").get_attribute("open") is None:
await summary.click()
await nav.click()
await page.wait_for_function("v => location.hash.startsWith('#/' + v)", arg=view)
await settled()
await pg.click("[data-nav=tg]")
await pg.wait_for_timeout(2000)
report["tg"] = (await pg.locator(".card").first.inner_text())[:160].replace("\n", " | ")
await pg.screenshot(path=str(out / "v2_tg.png"))
async def assert_no_overflow(label):
size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})")
assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}"
await pg.click("[data-nav=keys]")
await pg.wait_for_timeout(800)
report["key_rows"] = await pg.locator("#k-tbl tbody tr").count()
await pg.screenshot(path=str(out / "v2_keys.png"))
async def assert_dark_theme(label):
colors = await page.evaluate("""() => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d', {willReadFrequently: true});
const selectors = ['body', '.navbar', '.card', '.list-group-item',
'.form-control', '.form-select', '.table', '.table thead',
'.offcanvas.show', '.modal.show .modal-content', '.dropdown-menu.show'];
return selectors.flatMap(selector => [...document.querySelectorAll(selector)]
.filter(el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden')
.slice(0, 12).map(el => {
const bg = getComputedStyle(el).backgroundColor;
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, 1, 1);
return {selector, bg, rgba: [...ctx.getImageData(0, 0, 1, 1).data]};
}));
}""")
assert await page.evaluate("matchMedia('(prefers-color-scheme: dark)').matches || getComputedStyle(document.documentElement).colorScheme.includes('dark')")
assert any(c["selector"] == "body" and c["rgba"][3] == 255 for c in colors), colors
light = [c for c in colors if c["rgba"][3] > 127 and max(c["rgba"][:3]) > 64]
assert not light, f"Light surfaces in {label}: {light}"
report.setdefault("dark_routes", []).append(label)
await pg.click("[data-nav=digest]")
await pg.wait_for_timeout(500)
await pg.click("[data-act=digest]")
await pg.wait_for_timeout(6000)
report["digest_chars"] = len(await pg.locator("#d-out").inner_text())
await pg.screenshot(path=str(out / "v2_digest.png"))
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_selector("#list .item", timeout=25000)
await settled()
assert await page.locator('[data-nav="feed"]').count() == 1
report["default_feed"] = await page.locator("#list .item").count()
report["navigation_routes"] = await page.locator("[data-nav]").count()
assert report["navigation_routes"] == 9
await assert_no_overflow("desktop")
await assert_dark_theme("feed")
await page.screenshot(path=str(out / "black_feed.png"))
await pg.click("[data-nav=watch]")
await pg.wait_for_timeout(600)
await pg.screenshot(path=str(out / "v2_watch.png"))
# A real search must update both results and the total, not just the input.
missing = "zz_omni_smoke_no_match_7f91"
async with page.expect_response(lambda r: "/api/items?" in r.url and missing in r.url) as pending:
await page.locator("#q").fill(missing)
await page.locator("#q").press("Enter")
searched = await (await pending.value).json()
await settled()
assert searched["total"] == 0
assert await page.locator("#list .item").count() == 0
report["search_empty_state"] = True
await page.locator("#q").fill("")
await page.locator("#q").press("Enter")
await page.wait_for_selector("#list .item", timeout=20000)
await settled()
# мобильный вид
await pg.set_viewport_size({"width": 420, "height": 900})
await pg.click("[data-nav=feed]")
await pg.wait_for_timeout(1200)
await pg.screenshot(path=str(out / "v2_mobile.png"))
await b.close()
report["console"] = errors[:12]
print(json.dumps(report, ensure_ascii=False, indent=1))
return 0 if not errors else 1
# Reading panel: real detail response, keyboard focus containment and Escape.
row = page.locator("#list .item").first
opener = row.locator("[data-id]").first if await row.locator("[data-id]").count() else row
async with page.expect_response(re.compile(r"/api/item/\d+")) as detail_pending:
await opener.click()
detail = await (await detail_pending.value).json()
assert detail.get("ok") and detail.get("item", {}).get("title")
await page.wait_for_selector("#drawer.show", timeout=15000)
await settled()
assert (await page.locator("#d-title").inner_text()).strip()
await assert_dark_theme("reader")
await page.screenshot(path=str(out / "black_reader.png"))
focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])')
if await focusables.count():
await focusables.last.focus()
await page.keyboard.press("Tab")
assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)")
await focusables.first.focus()
await page.keyboard.press("Shift+Tab")
assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)")
await page.keyboard.press("Escape")
await page.wait_for_function("!document.querySelector('#drawer').classList.contains('show')")
await page.locator("#drawer").wait_for(state="hidden")
assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus"
await page.locator('[data-act="help"]').first.click()
await page.wait_for_selector("#modal.show")
await settled()
await assert_dark_theme("help modal")
await page.keyboard.press("Escape")
await page.locator("#modal").wait_for(state="hidden")
report["reader_keyboard"] = True
# Source filtering is a real API query, not a dead text field.
await navigate("sources")
await page.wait_for_selector("#src-tbl")
async with page.expect_response(lambda r: "/api/sources?" in r.url and missing in r.url) as src_pending:
await page.locator("#s-q").fill(missing)
source_search = await (await src_pending.value).json()
await settled()
assert not source_search["items"]
report["source_search"] = True
await page.locator("#s-q").fill("")
await settled()
await assert_dark_theme("sources")
await page.screenshot(path=str(out / "black_sources.png"))
await navigate("abuse")
report["find_rows"] = await page.locator("#ab-list .item").count()
await assert_dark_theme("abuse")
await page.screenshot(path=str(out / "black_finds.png"))
async with page.expect_response(lambda r: urlsplit(r.url).path == "/api/panels") as panels_pending:
await page.locator('[data-ab-tab="panels"]').click()
panels = await (await panels_pending.value).json()
await settled()
assert isinstance(panels.get("panels"), list), panels.keys()
assert await page.locator("#pn-box").is_visible()
await assert_dark_theme("api panels")
report["api_panels"] = len(panels["panels"])
for view, selector in (("runs", "#r-tbl"), ("tg", ".card"), ("keys", "#k-tbl"), ("watch", ".card"), ("overview", ".card")):
await navigate(view)
await page.wait_for_selector(selector)
await assert_dark_theme(view)
await page.screenshot(path=str(out / f"black_{view}.png"))
report[view] = "rendered"
await navigate("digest")
async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending:
await page.locator('[data-act="digest"]').click()
digest = await (await digest_pending.value).json()
await settled()
assert digest.get("ok") and len(digest.get("md", "")) > 80
assert len((await page.locator("#d-out").inner_text()).strip()) > 80
report["digest"] = "rendered"
await assert_dark_theme("digest")
await page.screenshot(path=str(out / "black_digest.png"))
# Deep-link source filters and both exported formats must stay in sync.
sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json()
source = sample["items"][0]["source"]
deep_link = url.rstrip("/") + "/#/feed?sort=fresh&days=3650&src=" + quote(source, safe="")
await page.goto(deep_link, wait_until="domcontentloaded")
await page.wait_for_selector("#list .item")
await settled()
for extension in ("json", "csv"):
link = page.locator(f'a[href*="/api/export.{extension}"]').first
href = await link.get_attribute("href")
assert href is not None
query = parse_qs(urlsplit(href).query)
assert query.get("src") == [source] and query.get("days") == ["3650"]
exported = await page.request.get(urljoin(url, href))
assert exported.status == 200
if extension == "json":
data = await exported.json()
assert data["count"] and all(item["source"] == source for item in data["items"])
report["filtered_exports"] = True
# Mobile navigation has labels, a real toggle, and no page overflow.
await page.set_viewport_size({"width": 390, "height": 844})
await page.locator("#sidebar-toggle").click()
await page.locator("#sidebar-menu").wait_for(state="visible")
assert await page.locator('[data-nav="sources"]').is_visible()
await assert_no_overflow("mobile navigation 390")
await assert_dark_theme("mobile navigation")
await page.screenshot(path=str(out / "black_mobile_navigation.png"))
await page.locator('[data-nav="feed"]').click()
await settled()
await assert_no_overflow("mobile 390")
await page.screenshot(path=str(out / "black_mobile.png"))
await page.set_viewport_size({"width": 768, "height": 1024})
await assert_no_overflow("tablet 768")
report["responsive"] = [390, 768, 1440]
assert assets.get("/assets/vendor/tabler/tabler.min.css") == 200
assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200
assert not external, f"External asset/API dependencies: {sorted(external)}"
assert not errors, errors
finally:
report["assets"] = assets
report["external_hosts"] = sorted(external)
report["console_errors"] = errors
(out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
await browser.close()
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="http://127.0.0.1:8078/")
ap.add_argument("--shots", default="data/shots")
a = ap.parse_args()
sys.exit(asyncio.run(run(a.url, pathlib.Path(a.shots))))
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8078/")
parser.add_argument("--shots", default="data/shots/black_shadcn")
args = parser.parse_args()
sys.exit(asyncio.run(run(args.url, pathlib.Path(args.shots))))
if __name__ == "__main__":

File diff suppressed because it is too large Load Diff

93
web/vendor/inter/LICENSE vendored Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
web/vendor/inter/provenance.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@fontsource-variable/inter",
"version": "5.3.0",
"repository": "https://github.com/fontsource/font-files",
"license": "OFL-1.1",
"tarball": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"retrieved": "2026-10-27",
"modifications": "None to vendor font binaries; OMNI self-hosts Latin/Cyrillic variable WOFF2 subsets.",
"files": {
"files/inter-latin-wght-normal.woff2": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62",
"files/inter-latin-ext-wght-normal.woff2": "34b9c504cab7a73e37b746343a449132e56cf7b5481af2cb81dc74dcff25c956",
"files/inter-cyrillic-wght-normal.woff2": "71d5ee93cc1e9f1d520a3a8b66456de18c7879d8df09d57fcd2eaff75fef0075",
"files/inter-cyrillic-ext-wght-normal.woff2": "ca157063339ac4ad418f214f3abfed119b0798ab4d377386ce5c9e5a7a435ebd",
"LICENSE": "3b0a5fca3d17942cde889069889dedbbbd075e9b599968c82a95f4d944e9b345"
}
}

21
web/vendor/tabler/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018-2026 The Tabler Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

17
web/vendor/tabler/provenance.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@tabler/core",
"version": "1.7.1",
"repository": "https://github.com/tabler/tabler",
"commit": "07f60c07da13d8ff957a370434b7ef5c86f5ab94",
"release": "https://github.com/tabler/tabler/releases/tag/%40tabler/core%401.7.1",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@tabler/core/-/core-1.7.1.tgz",
"integrity": "sha512-sC42sr/uslr7kIZwyBPESWJKRm1E4hYAMMZyLDoyG0ZlEOlglRW0L5c1gf8x6i+QApT+AtHm10fDP1j2WgQJpg==",
"retrieved": "2026-10-27",
"modifications": "None to vendored CSS/JS. Application adapts upstream vertical layout and email inbox patterns.",
"files": {
"LICENSE": "4f88a82d13be3c5c63a12c5631eae914aa4381b6dc17641bf1ab85f3f8f6c8a5",
"tabler.min.css": "dcf84d7b4319c29ab33450bfe2a7bbf607458d994ef71023f7509e912cafdbd5",
"tabler.min.js": "2a3b9ecae44d821b21ba4521da33fca1bdc692454745536418c4e078e8ba6abf"
}
}

8
web/vendor/tabler/tabler.min.css vendored Normal file

File diff suppressed because one or more lines are too long

8
web/vendor/tabler/tabler.min.js vendored Normal file

File diff suppressed because one or more lines are too long