ui: локальный Tabler dashboard + 150 проверенных TG-источников и probe 595 каналов
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
77
tests/test_static_assets.py
Normal file
77
tests/test_static_assets.py
Normal 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
122
tools/tg_pool_probe.py
Normal 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())
|
||||
@@ -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__":
|
||||
|
||||
1526
web/index.html
1526
web/index.html
File diff suppressed because it is too large
Load Diff
93
web/vendor/inter/LICENSE
vendored
Normal file
93
web/vendor/inter/LICENSE
vendored
Normal 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.
|
||||
BIN
web/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-cyrillic-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-cyrillic-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-latin-ext-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-latin-ext-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-latin-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-latin-wght-normal.woff2
vendored
Normal file
Binary file not shown.
17
web/vendor/inter/provenance.json
vendored
Normal file
17
web/vendor/inter/provenance.json
vendored
Normal 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
21
web/vendor/tabler/LICENSE
vendored
Normal 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
17
web/vendor/tabler/provenance.json
vendored
Normal 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
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
8
web/vendor/tabler/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user