diff --git a/omni/serve.py b/omni/serve.py index 74d418f..d075afa 100644 --- a/omni/serve.py +++ b/omni/serve.py @@ -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") diff --git a/omni/sources_tg_abuse.py b/omni/sources_tg_abuse.py index e302bac..0720bfd 100644 --- a/omni/sources_tg_abuse.py +++ b/omni/sources_tg_abuse.py @@ -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}, ] diff --git a/tests/test_panel_api.py b/tests/test_panel_api.py index ce82eb1..ed7fcb7 100644 --- a/tests/test_panel_api.py +++ b/tests/test_panel_api.py @@ -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 diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py new file mode 100644 index 0000000..edfc212 --- /dev/null +++ b/tests/test_static_assets.py @@ -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("panel", 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"panel" + assert headers["Content-Type"].startswith("text/html") diff --git a/tools/tg_pool_probe.py b/tools/tg_pool_probe.py new file mode 100644 index 0000000..8b0eef8 --- /dev/null +++ b/tools/tg_pool_probe.py @@ -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()) diff --git a/tools/ui_smoke.py b/tools/ui_smoke.py index d67b8c7..e1535bc 100644 --- a/tools/ui_smoke.py +++ b/tools/ui_smoke.py @@ -1,96 +1,238 @@ -"""Смоук панели в реальном 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__": - main() \ No newline at end of file + main() diff --git a/web/index.html b/web/index.html index 442a2d6..00bdea9 100644 --- a/web/index.html +++ b/web/index.html @@ -1,1024 +1,630 @@ - +
-