feat(deploy): full one-line install with search API

Bundle static site, FastAPI search, nginx proxy, and systemd unit so
a fresh Ubuntu VPS can run curl install.sh and get /search working.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-29 22:29:10 +03:00
parent 595501b2a4
commit e5ea14f046
15 changed files with 862 additions and 82 deletions

View File

@@ -1,43 +1,59 @@
# Domain Web (PCA Lab) # PCA Lab — Domain Web
Лёгкий статический сайт: кабинет студента (VPN Amnezia), смена региона магазинов, кураторские списки заблокированных в РФ доменов. Сайт PCA Lab: кабинет студента (VPN Amnezia), группы доменов, **поиск доменов** (CT, DNS, reverse IP, geosite/geoip, .bat для Keenetic).
**Без поиска доменов** на сайте (crt.sh и API сканера отключены). Списки — статические пресеты. ## Установка одной командой
## Возможности На свежем Ubuntu VPS (root):
- **Главная** — обложка PCA Lab (исследования AI и кибербезопасности)
- **Кабинет студента** — пошаговое подключение WireGuard / Amnezia VPN
- **VPN по региону** — инструкции смены региона App Store / Google Play / Microsoft Store
- **Группы доменов** — карточки по категориям:
- Медиа (Netflix, YouTube, …)
- Социальные сети (Instagram, Facebook, X, …)
- Искусственный интеллект (ChatGPT, Claude, …)
- Мессенджеры (Telegram, WhatsApp, …)
- Другое / VPN / Dev (GitHub, Steam, …)
- Копирование списка доменов / IP, скачивание **Keenetic .bat** (статические маршруты)
Связанный Telegram-бот (поиск доменов): [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) — на сайте только статика.
## Установка на VPS (одна строка)
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
``` ```
Переменные окружения (опционально): Скрипт автоматически:
- ставит **nginx** и отдаёт сайт на **порту 80** (HTTP);
- разворачивает **поиск доменов** (`/search`) и API (`/api/search`);
- клонирует [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) для логики discovery;
- **не трогает** Amnezia/Docker на **443**, существующий Telegram-бот и другие сервисы.
### Переменные окружения (опционально)
| Переменная | По умолчанию | | Переменная | По умолчанию |
|------------|----------------| |------------|----------------|
| `DOMAIN_WEB_ROOT` | `/var/www/domain-web` | | `DOMAIN_WEB_ROOT` | `/var/www/domain-web` |
| `DOMAIN_WEB_INSTALL_SRC` | `/opt/domain-web-src` | | `DOMAIN_WEB_INSTALL_SRC` | `/opt/domain-web-src` |
| `DOMAIN_WEB_API_DIR` | `/opt/domain-web-api` |
| `DOMAIN_FINDER_BOT_DIR` | `/opt/domain-finder-bot` |
| `DOMAIN_WEB_BRANCH` | `main` | | `DOMAIN_WEB_BRANCH` | `main` |
Скрипт: ## Страницы после установки
- ставит **nginx** и отдаёт каталог `web/` на **порту 80**; | URL | Описание |
- **не останавливает** Amnezia (443), Docker и `domain-finder-bot`; |-----|----------|
- отключает legacy-сервис `domain-scanner` (Node), если был установлен ранее. | `/` | Главная PCA Lab |
| `/connect.html` | Кабинет студента — VPN Amnezia |
| `/connect.html?mode=region` | VPN по региону магазинов |
| `/domains.html` | Группы доменов + выбор + .bat |
| `/search` | Поиск доменов (как в Telegram-боте) |
## Прокси Telegram (редактируется без перезапуска)
```bash
nano /var/www/domain-web/data/site-config.json
```
Пример:
```json
{
"server_ip": "YOUR_SERVER_IP",
"telegram_proxy": "https://t.me/proxy?server=...&port=443&secret=...",
"telegram_proxy_label": "Прокси Telegram"
}
```
Сохраните файл и обновите страницу кабинета — ссылка появится сразу.
## Удаление ## Удаление
@@ -45,22 +61,29 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/instal
curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/uninstall.sh | sudo bash
``` ```
## Локальный просмотр ## Структура репозитория
```bash
cd web && python3 -m http.server 8080
# http://127.0.0.1:8080/
```
## Структура
``` ```
web/ — HTML + React (CDN) + JSX web/ — HTML + React (CDN) + JSX
nginx/ — шаблон vhost api/ — FastAPI search API
install.sh — деплой на сервер nginx/ — vhost + proxy snippet
systemd/ — domain-web-api.service
install.sh — полный деплой
uninstall.sh uninstall.sh
``` ```
## Локальный просмотр (только статика)
```bash
cd web && python3 -m http.server 8080
```
Поиск локально требует API (`/api/search` на сервере).
## Связанные проекты
- [domain-finder-bot](https://github.com/andrey271192/domain-finder-bot) — Telegram-бот с той же логикой поиска
## Лицензия ## Лицензия
См. [LICENSE](LICENSE). См. [LICENSE](LICENSE).

139
api/main.py Normal file
View File

@@ -0,0 +1,139 @@
"""Domain Web search API — reuses domain-finder-bot discovery."""
from __future__ import annotations
import asyncio
import os
import sys
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
BOT_DIR = os.environ.get("DOMAIN_FINDER_BOT_DIR", "/opt/domain-finder-bot")
if BOT_DIR not in sys.path:
sys.path.insert(0, BOT_DIR)
from discovery import ( # noqa: E402
MAX_BATCH_DOMAINS,
discover_batch_with_timeout,
discover_with_timeout,
format_batch_domains_column,
format_batch_full_report,
format_batch_ips_column,
format_batch_v2fly_column,
format_domains_column,
format_full_report,
format_ips_column,
format_v2fly_column,
parse_domains_from_message,
)
from router_bat import ( # noqa: E402
format_router_bat,
format_router_bat_batch,
sanitize_bat_filename,
sanitize_batch_bat_filename,
)
from v2fly_lookup import start_background_sync # noqa: E402
MAX_CONCURRENT = max(1, int(os.environ.get("MAX_CONCURRENT_SEARCHES", "4")))
_SEARCH_SEM = asyncio.Semaphore(MAX_CONCURRENT)
class SearchRequest(BaseModel):
domain: str | None = None
domains: list[str] | None = None
class SearchResponse(BaseModel):
domains: list[str]
is_batch: bool
report: str
domains_column: str
ips_column: str
geosite_geoip: str
bat_content: str
bat_filename: str
def _resolve_domains(body: SearchRequest) -> list[str]:
if body.domains:
raw = "\n".join(body.domains)
elif body.domain:
raw = body.domain
else:
raise HTTPException(status_code=400, detail="Provide domain or domains")
domains = parse_domains_from_message(raw)
if not domains:
raise HTTPException(status_code=400, detail="No valid domains")
if len(domains) > MAX_BATCH_DOMAINS:
raise HTTPException(
status_code=400,
detail=f"Max {MAX_BATCH_DOMAINS} domains per request",
)
return domains
def _build_response(domains: list[str], results: list[Any]) -> SearchResponse:
is_batch = len(domains) > 1
if is_batch:
report = format_batch_full_report(results)
domains_column = format_batch_domains_column(results)
ips_column = format_batch_ips_column(results)
geosite_geoip = format_batch_v2fly_column(results)
bat_content = format_router_bat_batch(results)
bat_filename = sanitize_batch_bat_filename(domains[0], len(domains))
else:
result = results[0]
domain = domains[0]
report = format_full_report(result)
domains_column = format_domains_column(result)
ips_column = format_ips_column(result)
geosite_geoip = format_v2fly_column(result)
bat_content = format_router_bat(domain, result)
bat_filename = sanitize_bat_filename(domain)
return SearchResponse(
domains=domains,
is_batch=is_batch,
report=report,
domains_column=domains_column,
ips_column=ips_column,
geosite_geoip=geosite_geoip,
bat_content=bat_content,
bat_filename=bat_filename,
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
start_background_sync()
yield
app = FastAPI(title="Domain Web Search API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET", "OPTIONS"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/search", response_model=SearchResponse)
async def search(body: SearchRequest) -> SearchResponse:
domains = _resolve_domains(body)
async with _SEARCH_SEM:
if len(domains) == 1:
results = [await discover_with_timeout(domains[0])]
else:
results = await discover_batch_with_timeout(domains)
return _build_response(domains, results)

4
api/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi>=0.115.0,<1.0
uvicorn[standard]>=0.32.0,<1.0
aiohttp>=3.9.0,<4.0
dnspython>=2.6.0,<3.0

View File

@@ -1,13 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Domain Web — lightweight static site (nginx :80) # Domain Web — full deploy: static site + search API (nginx :80)
# curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash # curl -fsSL https://raw.githubusercontent.com/andrey271192/Domain_web/main/install.sh | sudo bash
set -euo pipefail set -euo pipefail
REPO_URL="${DOMAIN_WEB_REPO_URL:-https://github.com/andrey271192/Domain_web.git}" REPO_URL="${DOMAIN_WEB_REPO_URL:-https://github.com/andrey271192/Domain_web.git}"
BOT_REPO_URL="${DOMAIN_FINDER_REPO:-https://github.com/andrey271192/domain-finder-bot.git}"
BRANCH="${DOMAIN_WEB_BRANCH:-main}" BRANCH="${DOMAIN_WEB_BRANCH:-main}"
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}" INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}" WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
API_DIR="${DOMAIN_WEB_API_DIR:-/opt/domain-web-api}"
BOT_DIR="${DOMAIN_FINDER_BOT_DIR:-/opt/domain-finder-bot}"
NGINX_SITE="domain-web" NGINX_SITE="domain-web"
API_SERVICE="domain-web-api"
LEGACY_SERVICES=("domain-scanner" "geoexport-site") LEGACY_SERVICES=("domain-scanner" "geoexport-site")
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
@@ -22,9 +26,19 @@ need_root() {
} }
install_packages() { install_packages() {
log "Installing nginx, git, curl..." log "Installing system packages..."
apt-get update -qq apt-get update -qq
apt-get install -y -qq nginx git curl ca-certificates rsync >/dev/null apt-get install -y -qq \
nginx git curl ca-certificates rsync \
python3 python3-venv python3-pip >/dev/null
}
ensure_domainbot_user() {
if ! id domainbot >/dev/null 2>&1; then
log "Creating system user domainbot..."
useradd --system --home "${BOT_DIR}" --shell /usr/sbin/nologin domainbot
fi
mkdir -p "${BOT_DIR}/data" "${API_DIR}"
} }
stop_legacy_scanner() { stop_legacy_scanner() {
@@ -51,29 +65,78 @@ clone_repo() {
fi fi
} }
clone_discovery_bot() {
log "Fetching discovery modules (${BOT_REPO_URL})..."
if [[ -d "${BOT_DIR}/.git" ]]; then
git -C "${BOT_DIR}" fetch --depth 1 origin main 2>/dev/null || true
git -C "${BOT_DIR}" reset --hard origin/main 2>/dev/null || \
git -C "${BOT_DIR}" pull --ff-only 2>/dev/null || true
else
rm -rf "${BOT_DIR}"
git clone --depth 1 "${BOT_REPO_URL}" "${BOT_DIR}"
fi
if [[ ! -x "${BOT_DIR}/.venv/bin/python" ]]; then
log "Creating domain-finder-bot venv (discovery deps)..."
python3 -m venv "${BOT_DIR}/.venv"
"${BOT_DIR}/.venv/bin/pip" install -q -U pip
"${BOT_DIR}/.venv/bin/pip" install -q -r "${BOT_DIR}/requirements.txt"
fi
}
deploy_web() { deploy_web() {
log "Deploying static files to ${WEB_ROOT}..." log "Deploying static site to ${WEB_ROOT}..."
mkdir -p "${WEB_ROOT}" mkdir -p "${WEB_ROOT}/data"
rsync -a --delete "${INSTALL_SRC}/web/" "${WEB_ROOT}/" rsync -a --delete "${INSTALL_SRC}/web/" "${WEB_ROOT}/"
chown -R www-data:www-data "${WEB_ROOT}" 2>/dev/null || chown -R nginx:nginx "${WEB_ROOT}" 2>/dev/null || true
if [[ ! -f "${WEB_ROOT}/data/site-config.json" ]]; then
cp "${INSTALL_SRC}/web/data/site-config.json.example" "${WEB_ROOT}/data/site-config.json"
fi
chown -R www-data:www-data "${WEB_ROOT}" 2>/dev/null || \
chown -R nginx:nginx "${WEB_ROOT}" 2>/dev/null || true
}
deploy_api() {
log "Deploying search API to ${API_DIR}..."
mkdir -p "${API_DIR}"
rsync -a --delete "${INSTALL_SRC}/api/" "${API_DIR}/"
if [[ ! -x "${API_DIR}/.venv/bin/python" ]]; then
python3 -m venv "${API_DIR}/.venv"
fi
"${API_DIR}/.venv/bin/pip" install -q -U pip
"${API_DIR}/.venv/bin/pip" install -q -r "${API_DIR}/requirements.txt"
chown -R domainbot:domainbot "${API_DIR}" "${BOT_DIR}"
}
install_systemd() {
log "Installing ${API_SERVICE}.service..."
cp "${INSTALL_SRC}/systemd/domain-web-api.service" "/etc/systemd/system/${API_SERVICE}.service"
systemctl daemon-reload
systemctl enable "${API_SERVICE}.service"
systemctl restart "${API_SERVICE}.service"
} }
configure_nginx() { configure_nginx() {
log "Configuring nginx (HTTP :80)..." log "Configuring nginx (HTTP :80 only — port 443 untouched)..."
local conf_src="${INSTALL_SRC}/nginx/domain-web.conf" local conf_src="${INSTALL_SRC}/nginx/domain-web.conf"
if [[ ! -f "${conf_src}" ]]; then local snippet_src="${INSTALL_SRC}/nginx/domain-web-api.conf"
if [[ ! -f "${conf_src}" ]] || [[ ! -f "${snippet_src}" ]]; then
echo "Missing nginx config in repo" >&2 echo "Missing nginx config in repo" >&2
exit 1 exit 1
fi fi
# Backup existing default if it listens on 80 and is not ours
if [[ -f /etc/nginx/sites-enabled/default ]] && ! grep -q "domain-web" /etc/nginx/sites-enabled/default 2>/dev/null; then if [[ -f /etc/nginx/sites-enabled/default ]] && ! grep -q "domain-web" /etc/nginx/sites-enabled/default 2>/dev/null; then
if grep -q "listen 80" /etc/nginx/sites-enabled/default 2>/dev/null; then if grep -q "listen 80" /etc/nginx/sites-enabled/default 2>/dev/null; then
mv /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.bak.$(date +%s) 2>/dev/null || true mv /etc/nginx/sites-enabled/default "/etc/nginx/sites-enabled/default.bak.$(date +%s)" 2>/dev/null || true
fi fi
fi fi
sed "s|/var/www/domain-web|${WEB_ROOT}|g" "${conf_src}" > "/etc/nginx/sites-available/${NGINX_SITE}" cp "${snippet_src}" /etc/nginx/snippets/domain-web-api.conf
sed "s|WEB_ROOT|${WEB_ROOT}|g" "${conf_src}" > "/etc/nginx/sites-available/${NGINX_SITE}"
ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}" ln -sf "/etc/nginx/sites-available/${NGINX_SITE}" "/etc/nginx/sites-enabled/${NGINX_SITE}"
nginx -t nginx -t
@@ -81,26 +144,66 @@ configure_nginx() {
systemctl reload nginx systemctl reload nginx
} }
allow_firewall() {
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
log "Allowing HTTP (80) in ufw..."
ufw allow 80/tcp >/dev/null 2>&1 || true
fi
}
verify() {
log "Verifying deployment..."
sleep 2
if ! systemctl is-active --quiet "${API_SERVICE}.service"; then
journalctl -u "${API_SERVICE}.service" -n 30 --no-pager || true
echo "ERROR: ${API_SERVICE} failed to start" >&2
exit 1
fi
if ! curl -fsS "http://127.0.0.1:8081/api/health" | grep -q '"status"'; then
echo "ERROR: API health check failed" >&2
exit 1
fi
for path in / /search /connect.html /domains.html; do
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${path}")"
if [[ "${code}" != "200" && "${code}" != "301" && "${code}" != "302" ]]; then
echo "WARNING: http://127.0.0.1${path} returned ${code}" >&2
fi
done
}
print_done() { print_done() {
local ip local ip
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'SERVER_IP')" ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'SERVER_IP')"
log "Done." log "Done."
echo "" echo ""
echo " Site root: ${WEB_ROOT}" echo " Site: http://${ip}/"
echo " URL: http://${ip}/" echo " Search: http://${ip}/search"
echo " Pages: /connect.html /domains.html" echo " Connect: http://${ip}/connect.html"
echo " Domains: http://${ip}/domains.html"
echo "" echo ""
echo " Amnezia (443) and Docker containers were not stopped." echo " Telegram proxy config: ${WEB_ROOT}/data/site-config.json"
echo " API service: systemctl status ${API_SERVICE}"
echo ""
echo " Port 443 / Docker / Amnezia were not modified."
echo "" echo ""
} }
main() { main() {
need_root need_root
install_packages install_packages
ensure_domainbot_user
stop_legacy_scanner stop_legacy_scanner
clone_repo clone_repo
clone_discovery_bot
deploy_web deploy_web
deploy_api
install_systemd
configure_nginx configure_nginx
allow_firewall
verify
print_done print_done
} }

11
nginx/domain-web-api.conf Normal file
View File

@@ -0,0 +1,11 @@
# Domain Web API reverse proxy (included from domain-web.conf)
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
}

View File

@@ -1,14 +1,25 @@
# PCA Lab / Domain Web — static site on port 80 # PCA Lab / Domain Web — HTTP :80 (does not touch :443 / Amnezia)
# Installed to /etc/nginx/sites-available/domain-web # Placeholders WEB_ROOT are replaced by install.sh
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
server_name _; server_name _;
root /var/www/domain-web; root WEB_ROOT;
index index.html; index index.html;
# Clean URL for domain search
location = /search {
try_files /search.html =404;
}
location = /search.html {
return 301 /search;
}
include /etc/nginx/snippets/domain-web-api.conf;
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -18,7 +29,6 @@ server {
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
# Do not proxy — Amnezia and other services stay on 443 separately
access_log /var/log/nginx/domain-web-access.log; access_log /var/log/nginx/domain-web-access.log;
error_log /var/log/nginx/domain-web-error.log; error_log /var/log/nginx/domain-web-error.log;
} }

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Domain Web Search API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=domainbot
Group=domainbot
WorkingDirectory=/opt/domain-web-api
Environment=PYTHONPATH=/opt/domain-finder-bot
Environment=DOMAIN_FINDER_BOT_DIR=/opt/domain-finder-bot
EnvironmentFile=-/opt/domain-finder-bot/.env
ExecStart=/opt/domain-web-api/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8081 --workers 1
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Remove Domain Web nginx site (does not remove Amnezia/Docker) # Remove Domain Web site + search API (does not remove Amnezia/Docker/bot)
set -euo pipefail set -euo pipefail
WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}" WEB_ROOT="${DOMAIN_WEB_ROOT:-/var/www/domain-web}"
INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}" INSTALL_SRC="${DOMAIN_WEB_INSTALL_SRC:-/opt/domain-web-src}"
API_DIR="${DOMAIN_WEB_API_DIR:-/opt/domain-web-api}"
NGINX_SITE="domain-web" NGINX_SITE="domain-web"
API_SERVICE="domain-web-api"
log() { echo "[domain-web-uninstall] $*"; } log() { echo "[domain-web-uninstall] $*"; }
@@ -13,9 +15,16 @@ if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
exit 1 exit 1
fi fi
log "Stopping search API..."
systemctl stop "${API_SERVICE}.service" 2>/dev/null || true
systemctl disable "${API_SERVICE}.service" 2>/dev/null || true
rm -f "/etc/systemd/system/${API_SERVICE}.service"
systemctl daemon-reload 2>/dev/null || true
log "Removing nginx site..." log "Removing nginx site..."
rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}" rm -f "/etc/nginx/sites-enabled/${NGINX_SITE}"
rm -f "/etc/nginx/sites-available/${NGINX_SITE}" rm -f "/etc/nginx/sites-available/${NGINX_SITE}"
rm -f /etc/nginx/snippets/domain-web-api.conf
if [[ ! -f /etc/nginx/sites-enabled/default ]]; then if [[ ! -f /etc/nginx/sites-enabled/default ]]; then
if [[ -f /etc/nginx/sites-available/default ]]; then if [[ -f /etc/nginx/sites-available/default ]]; then
@@ -25,7 +34,7 @@ fi
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
log "Removing web files (optional paths)..." log "Removing deployed files..."
rm -rf "${WEB_ROOT}" "${INSTALL_SRC}" rm -rf "${WEB_ROOT}" "${INSTALL_SRC}" "${API_DIR}"
log "Uninstall complete." log "Uninstall complete. domain-finder-bot at /opt/domain-finder-bot was kept."

View File

@@ -27,14 +27,41 @@ function Hero(){
); );
} }
const DEFAULT_SERVER_IP = "SERVER_IP";
// ───────── Server info card ───────── // ───────── Server info card ─────────
function ServerCard(){ function ServerCard(){
const [copied,setCopied] = useStateC(false); const [copied,setCopied] = useStateC(false);
function copy(){ const [copiedProxy,setCopiedProxy] = useStateC(false);
navigator.clipboard?.writeText("SERVER_IP"); const [config,setConfig] = useStateC({
server_ip: DEFAULT_SERVER_IP,
telegram_proxy: "",
telegram_proxy_label: "Прокси Telegram",
});
useEffectC(() => {
fetch("/data/site-config.json?t=" + Date.now())
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setConfig(prev => ({ ...prev, ...data })); })
.catch(() => {});
}, []);
const serverIp = (config.server_ip || DEFAULT_SERVER_IP).trim();
const proxyLabel = config.telegram_proxy_label || "Прокси Telegram";
const proxyUrl = (config.telegram_proxy || "").trim();
function copyIp(){
navigator.clipboard?.writeText(serverIp);
setCopied(true); setCopied(true);
setTimeout(()=>setCopied(false), 1600); setTimeout(()=>setCopied(false), 1600);
} }
function copyProxy(){
if (!proxyUrl) return;
navigator.clipboard?.writeText(proxyUrl);
setCopiedProxy(true);
setTimeout(()=>setCopiedProxy(false), 1600);
}
return ( return (
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}> <Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
<div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)", <div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)",
@@ -45,11 +72,46 @@ function ServerCard(){
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14, background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
fontFamily:"var(--mono)", fontSize:18, fontWeight:600, fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
letterSpacing:"-0.005em", color:"var(--ink)"}}> letterSpacing:"-0.005em", color:"var(--ink)"}}>
SERVER_IP {serverIp}
</div> </div>
<Button variant={copied?"success":"primary"} size="lg" onClick={copy} <Button variant={copied?"success":"primary"} size="lg" onClick={copyIp}
icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button> icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button>
</div> </div>
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)"}}>
<div style={{fontSize:13, fontWeight:500, color:"var(--ink-3)"}}>{proxyLabel}</div>
{proxyUrl ? (
<div style={{marginTop:8, display:"flex", gap:10}}>
<div style={{flex:1, minHeight:54, display:"flex", alignItems:"center", padding:"12px 18px",
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
fontFamily:"var(--mono)", fontSize:14, fontWeight:500,
letterSpacing:"-0.005em", color:"var(--ink)", wordBreak:"break-all"}}>
<a href={proxyUrl} target="_blank" rel="noreferrer"
style={{color:"var(--accent-2)", fontWeight:600, textDecoration:"underline",
textUnderlineOffset:3}}>
{proxyUrl}
</a>
</div>
<Button variant={copiedProxy?"success":"primary"} size="lg" onClick={copyProxy}
icon={copiedProxy?"check":"clipboard"}>{copiedProxy?"Скопировано":"Копировать"}</Button>
</div>
) : (
<div style={{marginTop:8, padding:"14px 18px", background:"var(--bg-tint)",
border:"1px dashed var(--line)", borderRadius:14}}>
<div style={{fontSize:15, fontWeight:600, color:"var(--ink-2)"}}>Не настроено</div>
<p style={{margin:"8px 0 0", fontSize:12.5, lineHeight:1.5, color:"var(--ink-3)"}}>
Администратор может добавить ссылку в{" "}
<code style={{fontSize:12, background:"#fff", padding:"2px 6px", borderRadius:6,
border:"1px solid var(--line-2)"}}>/var/www/domain-web/data/site-config.json</code>
{" "} поле <code style={{fontSize:12, background:"#fff", padding:"2px 6px",
borderRadius:6, border:"1px solid var(--line-2)"}}>telegram_proxy</code>.
Подробнее в <code style={{fontSize:12, background:"#fff", padding:"2px 6px",
borderRadius:6, border:"1px solid var(--line-2)"}}>data/README.txt</code> на сервере.
</p>
</div>
)}
</div>
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)", <div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)",
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}> display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span> <span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>

5
web/data/README.txt Normal file
View File

@@ -0,0 +1,5 @@
Как изменить прокси Telegram:
1. ssh root@SERVER_IP
2. nano /var/www/domain-web/data/site-config.json
3. Измените "telegram_proxy": "ваша ссылка"
4. Ctrl+O, Enter, Ctrl+X — готово, обновите страницу

View File

@@ -0,0 +1,5 @@
{
"server_ip": "SERVER_IP",
"telegram_proxy": "https://t.me/proxy?server=YOUR_SERVER&port=443&secret=YOUR_SECRET",
"telegram_proxy_label": "Прокси Telegram"
}

View File

@@ -1,7 +1,5 @@
// Curated blocked domains — static lists (no search) // Curated blocked domains — static lists (no search)
const { useState, useEffect, useMemo } = React;
function cidrToMask(bits) { function cidrToMask(bits) {
const n = parseInt(bits || "32", 10); const n = parseInt(bits || "32", 10);
if (n === 32) return "255.255.255.255"; if (n === 32) return "255.255.255.255";
@@ -27,6 +25,29 @@ function buildKeeneticBat(group, allGroups) {
return lines.join("\r\n") + "\r\n"; return lines.join("\r\n") + "\r\n";
} }
function buildBatForSelectedDomains(selectedSet, allGroups) {
const selected = [...selectedSet];
const lines = ["@echo off", "rem Keenetic — выбранные домены (" + selected.length + ")", ""];
const seen = new Set();
allGroups.forEach((g) => {
const picked = g.domains.filter((d) => selectedSet.has(d));
if (!picked.length) return;
(g.ips || []).forEach((cidr) => {
const [ip, bits] = cidr.split("/");
if (!ip || seen.has(cidr)) return;
seen.add(cidr);
lines.push(`route ADD ${ip} MASK ${cidrToMask(bits)} 0.0.0.0 & rem ${g.title}: ${picked.join(", ")}`);
});
});
if (lines.length <= 3) {
lines.push("rem Нет IP/CIDR для выбранных доменов — настройте VPN вручную");
selected.forEach((d) => lines.push(`rem ${d}`));
}
return lines.join("\r\n") + "\r\n";
}
function downloadText(filename, text) { function downloadText(filename, text) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -52,18 +73,19 @@ async function copyText(text) {
} }
} }
function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) { function DomainGroupCard({ group, selectedDomains, onToggleDomain, onToggleGroup, onDownloadBat }) {
const [open, setOpen] = useState(true); const [open, setOpen] = React.useState(true);
const [copied, setCopied] = useState(null); const [copied, setCopied] = React.useState(null);
const domainText = group.domains.join("\n"); const domainText = group.domains.join("\n");
const ipText = (group.ips || []).join("\n"); const ipText = (group.ips || []).join("\n");
const selectedInGroup = group.domains.filter((d) => selectedDomains.has(d));
const allSelected = group.domains.length > 0 && selectedInGroup.length === group.domains.length;
async function handleCopy(kind) { async function handleCopy(kind) {
const text = kind === "domains" ? domainText : ipText; const text = kind === "domains" ? domainText : ipText;
if (!text) return; if (!text) return;
await copyText(text); await copyText(text);
setCopied(kind); setCopied(kind);
onCopyDomains?.(kind);
setTimeout(() => setCopied(null), 1800); setTimeout(() => setCopied(null), 1800);
} }
@@ -82,6 +104,9 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{selectedInGroup.length > 0 && (
<Badge tone="blue" size="sm">{selectedInGroup.length} выбрано</Badge>
)}
<Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge> <Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge>
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" /> <Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
</div> </div>
@@ -96,16 +121,28 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
))} ))}
</div> </div>
)} )}
<div style={{ marginBottom: 10, display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button variant="secondary" size="sm" onClick={() => onToggleGroup(group, !allSelected)}>
{allSelected ? "Снять выделение группы" : "Выбрать все в группе"}
</Button>
</div>
<div style={{ <div style={{
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 8, marginBottom: 16, gap: 8, marginBottom: 16,
}}> }}>
{group.domains.map((d) => ( {group.domains.map((d) => {
<div key={d} style={{ const selected = selectedDomains.has(d);
padding: "10px 12px", borderRadius: 10, background: "var(--bg-tint)", return (
border: "1px solid var(--line-2)", fontFamily: "var(--mono)", fontSize: 12.5, <button key={d} type="button" onClick={() => onToggleDomain(d)} style={{
}}>{d}</div> padding: "10px 12px", borderRadius: 10, textAlign: "left", cursor: "pointer",
))} fontFamily: "var(--mono)", fontSize: 12.5,
background: selected ? "var(--brand-soft)" : "var(--bg-tint)",
border: selected ? "2px solid var(--brand)" : "1px solid var(--line-2)",
color: selected ? "var(--brand)" : "var(--ink)",
fontWeight: selected ? 600 : 400,
}}>{d}</button>
);
})}
</div> </div>
{(group.ips || []).length > 0 && ( {(group.ips || []).length > 0 && (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -133,7 +170,7 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
)} )}
<Button variant="primary" size="sm" icon="download" <Button variant="primary" size="sm" icon="download"
onClick={() => onDownloadBat(group)}> onClick={() => onDownloadBat(group)}>
Keenetic .bat Keenetic .bat (вся группа)
</Button> </Button>
</div> </div>
</div> </div>
@@ -143,18 +180,20 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
} }
function DomainsApp() { function DomainsApp() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = React.useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = React.useState(true);
const [filter, setFilter] = useState(""); const [filter, setFilter] = React.useState("");
const [selectedDomains, setSelectedDomains] = React.useState(new Set());
const [copiedSelected, setCopiedSelected] = React.useState(false);
useEffect(() => { React.useEffect(() => {
fetch("data/blocked-domains.json") fetch("data/blocked-domains.json")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { setGroups(data.groups || []); setLoading(false); }) .then((data) => { setGroups(data.groups || []); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, []); }, []);
const filtered = useMemo(() => { const filtered = React.useMemo(() => {
const q = filter.trim().toLowerCase(); const q = filter.trim().toLowerCase();
if (!q) return groups; if (!q) return groups;
return groups.map((g) => ({ return groups.map((g) => ({
@@ -165,6 +204,34 @@ function DomainsApp() {
const allDomains = groups.flatMap((g) => g.domains).join("\n"); const allDomains = groups.flatMap((g) => g.domains).join("\n");
const toggleDomain = (d) => {
setSelectedDomains((prev) => {
const next = new Set(prev);
if (next.has(d)) next.delete(d); else next.add(d);
return next;
});
};
const toggleGroup = (group, select) => {
setSelectedDomains((prev) => {
const next = new Set(prev);
group.domains.forEach((d) => {
if (select) next.add(d); else next.delete(d);
});
return next;
});
};
const copySelectedDomains = async () => {
await copyText([...selectedDomains].join("\n"));
setCopiedSelected(true);
setTimeout(() => setCopiedSelected(false), 1800);
};
const downloadSelectedBat = () => {
downloadText("keenetic-selected.bat", buildBatForSelectedDomains(selectedDomains, groups));
};
return ( return (
<div> <div>
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" /> <SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
@@ -174,10 +241,10 @@ function DomainsApp() {
Популярные заблокированные домены Популярные заблокированные домены
</h1> </h1>
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}> <p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
Кликните по доменам для выбора, затем скачайте .bat только для выбранных.
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic. Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
Поиск по crt.sh на сайте отключён только готовые пресеты по категориям.
</p> </p>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 28 }}> <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 16 }}>
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…" <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…"
style={{ style={{
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12, flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
@@ -191,6 +258,25 @@ function DomainsApp() {
</Button> </Button>
</div> </div>
{selectedDomains.size > 0 && (
<div style={{
display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 24, padding: "14px 16px",
background: "var(--brand-soft)", borderRadius: 14, border: "1px solid rgba(15,93,255,.15)",
alignItems: "center",
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Выбрано: {selectedDomains.size}</span>
<Button variant="primary" size="sm" icon="download" onClick={downloadSelectedBat}>
Скачать .bat для выбранных
</Button>
<Button variant="secondary" size="sm" icon="clipboard" onClick={copySelectedDomains}>
{copiedSelected ? "Скопировано" : "Копировать выбранные домены"}
</Button>
<Button variant="secondary" size="sm" onClick={() => setSelectedDomains(new Set())}>
Снять выделение
</Button>
</div>
)}
{loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков</p>} {loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков</p>}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card> <Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card>
@@ -198,6 +284,9 @@ function DomainsApp() {
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{filtered.map((g) => ( {filtered.map((g) => (
<DomainGroupCard key={g.id} group={g} <DomainGroupCard key={g.id} group={g}
selectedDomains={selectedDomains}
onToggleDomain={toggleDomain}
onToggleGroup={toggleGroup}
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} /> onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
))} ))}
</div> </div>
@@ -205,6 +294,8 @@ function DomainsApp() {
<footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}> <footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}>
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a> <a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
{" · "} {" · "}
<a href="/search">Поиск доменов</a>
{" · "}
<a href="index.html">На главную</a> <a href="index.html">На главную</a>
</footer> </footer>
</div> </div>

View File

@@ -1,9 +1,10 @@
// Shared site navigation // Shared site navigation
const SITE_NAV = [ const SITE_NAV = [
{ href: "index.html", label: "Главная", match: (p) => /index\.html$/.test(p) || p === "/" || /\/$/.test(p) && !/connect|domains/.test(p) }, { href: "index.html", label: "Главная", match: (p) => /index\.html$/.test(p) || (p === "/" || /\/$/.test(p)) && !/connect|domains|search/.test(p) },
{ href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html/.test(p) && !/mode=region/.test(p) }, { href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html/.test(p) && !/mode=region/.test(p) },
{ href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(p) }, { href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(p) },
{ href: "/search", label: "Поиск доменов", match: (p) => /\/search(\.html)?\/?$/.test(p) },
{ href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html/.test(p) }, { href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html/.test(p) },
]; ];

262
web/search-app.jsx Normal file
View File

@@ -0,0 +1,262 @@
function cidrToMask(bits) {
const n = parseInt(bits || "32", 10);
if (n === 32) return "255.255.255.255";
return Array.from({ length: 4 }, (_, i) => (65280 >> Math.min(8, Math.max(0, n - i * 8))) & 255).join(".");
}
function parseDomainsColumn(text) {
if (!text) return [];
return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("Все домены"));
}
function parseIpsColumn(text) {
if (!text) return [];
const lines = text.split("\n");
const ips = [];
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith("Все IP") || line.startsWith("IPv6")) break;
const cidr = line.replace(/\s*\(диапазон\)\s*$/, "");
if (/^\d+\.\d+\.\d+\.\d+(\/\d+)?$/.test(cidr)) ips.push(cidr);
}
return ips;
}
function buildBatFromIps(ips) {
const lines = ["@echo off", "rem Keenetic — выбранные IP из поиска", ""];
const seen = new Set();
ips.forEach((entry) => {
if (entry.includes("/")) {
const [ip, bits] = entry.split("/");
if (!ip || seen.has(entry)) return;
seen.add(entry);
lines.push(`route ADD ${ip} MASK ${cidrToMask(bits)} 0.0.0.0`);
} else if (/^\d+\.\d+\.\d+\.\d+$/.test(entry) && !seen.has(entry)) {
seen.add(entry);
lines.push(`route ADD ${entry} MASK 255.255.0.0 0.0.0.0`);
}
});
if (lines.length <= 3) lines.push("rem Нет IP для выбранных записей");
return lines.join("\r\n") + "\r\n";
}
function filterBatContent(batContent, selectedIps) {
if (!batContent || !selectedIps.size) return "";
const selected = new Set([...selectedIps].map((ip) => ip.split("/")[0]));
return batContent.split("\n").filter((line) => {
const m = line.match(/route add (\S+)/i);
return m && selected.has(m[1]);
}).join("\n") + (selected.size ? "\n" : "");
}
function CopyBlock({ title, text, filename }) {
const [copied, setCopied] = React.useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(text || "");
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (_) {}
};
const download = () => {
const blob = new Blob([text || ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "export.txt";
a.click();
URL.revokeObjectURL(url);
};
if (!text) return null;
return (
<div style={{ marginTop: 16, background: "var(--bg-elev)", border: "1px solid var(--line)", borderRadius: 16, padding: 16, boxShadow: "var(--shadow-card)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, gap: 8, flexWrap: "wrap" }}>
<strong>{title}</strong>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={copy} style={btnStyle}>{copied ? "Скопировано" : "Копировать"}</button>
<button onClick={download} style={btnStyle}>Скачать</button>
</div>
</div>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "var(--mono)", fontSize: 12, maxHeight: 320, overflow: "auto", background: "#f9fafb", padding: 12, borderRadius: 10 }}>{text}</pre>
</div>
);
}
function SelectableChips({ items, selected, onToggle, label }) {
if (!items.length) return null;
return (
<div style={{ marginTop: 16, background: "var(--bg-elev)", border: "1px solid var(--line)", borderRadius: 16, padding: 16, boxShadow: "var(--shadow-card)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, gap: 8, flexWrap: "wrap" }}>
<strong>{label} клик для выбора ({selected.size}/{items.length})</strong>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => items.forEach((i) => !selected.has(i) && onToggle(i))} style={btnStyle}>Выбрать все</button>
<button type="button" onClick={() => items.forEach((i) => selected.has(i) && onToggle(i))} style={btnStyle}>Снять все</button>
</div>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{items.map((item) => {
const on = selected.has(item);
return (
<button key={item} type="button" onClick={() => onToggle(item)} style={{
padding: "8px 12px", borderRadius: 10, cursor: "pointer", fontFamily: "var(--mono)", fontSize: 12,
border: on ? "2px solid var(--brand)" : "1px solid var(--line)",
background: on ? "var(--brand-soft)" : "#fff",
color: on ? "var(--brand)" : "var(--ink)",
fontWeight: on ? 600 : 400,
}}>{item}</button>
);
})}
</div>
</div>
);
}
const btnStyle = {
border: "1px solid var(--line)",
background: "#fff",
borderRadius: 10,
padding: "8px 12px",
cursor: "pointer",
fontSize: 13,
};
function SearchApp() {
const [query, setQuery] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [result, setResult] = React.useState(null);
const [selectedDomains, setSelectedDomains] = React.useState(new Set());
const [selectedIps, setSelectedIps] = React.useState(new Set());
const resultDomains = React.useMemo(() => parseDomainsColumn(result?.domains_column), [result]);
const resultIps = React.useMemo(() => parseIpsColumn(result?.ips_column), [result]);
const toggleDomain = (d) => {
setSelectedDomains((prev) => {
const next = new Set(prev);
if (next.has(d)) next.delete(d); else next.add(d);
return next;
});
};
const toggleIp = (ip) => {
setSelectedIps((prev) => {
const next = new Set(prev);
if (next.has(ip)) next.delete(ip); else next.add(ip);
return next;
});
};
const runSearch = async (e) => {
e.preventDefault();
setError("");
setResult(null);
setSelectedDomains(new Set());
setSelectedIps(new Set());
const trimmed = query.trim();
if (!trimmed) return;
setLoading(true);
try {
const isMulti = /[\n,]/.test(trimmed) || trimmed.split(/\s+/).length > 1;
const body = isMulti ? { domains: trimmed.split(/[\s,\n]+/).filter(Boolean) } : { domain: trimmed };
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
setResult(data);
setSelectedDomains(new Set(parseDomainsColumn(data.domains_column)));
setSelectedIps(new Set(parseIpsColumn(data.ips_column)));
} catch (err) {
setError(err.message || "Ошибка поиска");
} finally {
setLoading(false);
}
};
const downloadBat = (content, filename) => {
if (!content) return;
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "routes.bat";
a.click();
URL.revokeObjectURL(url);
};
const downloadSelectedBat = () => {
let bat = filterBatContent(result?.bat_content, selectedIps);
if (!bat.trim()) bat = buildBatFromIps([...selectedIps]);
downloadBat(bat, (result?.domains?.[0] || "selected") + "-selected.bat");
};
const copySelectedDomains = async () => {
try {
await navigator.clipboard.writeText([...selectedDomains].join("\n"));
} catch (_) {}
};
return (
<div style={{ maxWidth: 960, margin: "0 auto", padding: "24px 20px 64px" }}>
<SiteNav brand="PCA Lab" brandSub="DOMAIN SEARCH · SERVER" />
<h1 style={{ fontSize: 32, margin: "24px 0 8px" }}>Поиск доменов</h1>
<p style={{ color: "var(--ink-3)", marginTop: 0 }}>
CT, DNS, reverse IP, geosite/geoip как в Telegram-боте. Один домен или пакет через пробел/запятую/новую строку.
</p>
<form onSubmit={runSearch} style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 20 }}>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="example.com или chatgpt.com openai.com"
rows={2}
style={{ flex: "1 1 280px", padding: 12, borderRadius: 12, border: "1px solid var(--line)", fontFamily: "var(--mono)", fontSize: 14 }}
/>
<button type="submit" disabled={loading} style={{ ...btnStyle, background: "var(--brand)", color: "#fff", border: "none", padding: "12px 20px", fontWeight: 600 }}>
{loading ? "Ищем…" : "Найти"}
</button>
</form>
{error && <p style={{ color: "#b42318", marginTop: 12 }}>{error}</p>}
{loading && <p style={{ color: "var(--ink-3)", marginTop: 16 }}>Поиск может занять до 90 секунд</p>}
{result && (
<div style={{ marginTop: 24 }}>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
<strong>{result.is_batch ? `Пакет: ${result.domains.length} доменов` : result.domains[0]}</strong>
{result.bat_content ? (
<button type="button" onClick={() => downloadBat(result.bat_content, result.bat_filename)} style={{ ...btnStyle, background: "var(--brand-soft)", borderColor: "transparent" }}>
Скачать {result.bat_filename}
</button>
) : null}
</div>
<SelectableChips items={resultDomains} selected={selectedDomains} onToggle={toggleDomain} label="Домены" />
<SelectableChips items={resultIps} selected={selectedIps} onToggle={toggleIp} label="IP / CIDR" />
{(selectedDomains.size > 0 || selectedIps.size > 0) && (
<div style={{ marginTop: 16, display: "flex", gap: 8, flexWrap: "wrap" }}>
{selectedDomains.size > 0 && (
<button type="button" onClick={copySelectedDomains} style={{ ...btnStyle, background: "var(--brand-soft)", borderColor: "transparent" }}>
Копировать выбранные домены ({selectedDomains.size})
</button>
)}
{selectedIps.size > 0 && (
<button type="button" onClick={downloadSelectedBat} style={{ ...btnStyle, background: "var(--brand)", color: "#fff", border: "none" }}>
Скачать .bat для выбранных IP ({selectedIps.size})
</button>
)}
</div>
)}
<CopyBlock title="Полный отчёт" text={result.report} filename={(result.domains[0] || "report") + "-report.txt"} />
<CopyBlock title="Домены (столбик)" text={result.domains_column} filename={(result.domains[0] || "domains") + "-domains.txt"} />
<CopyBlock title="IP (столбик)" text={result.ips_column} filename={(result.domains[0] || "ips") + "-ips.txt"} />
<CopyBlock title="Geosite / GeoIP" text={result.geosite_geoip} filename={(result.domains[0] || "v2fly") + "-geosite.txt"} />
</div>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SearchApp />);

34
web/search.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Поиск доменов — PCA Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#f4f5f7; --bg-elev:#fff; --ink:#1d1d1f; --ink-3:#6e6e73;
--line:rgba(0,0,0,.07); --brand:#0f5dff; --brand-soft:#e6f0ff;
--radius-card:22px; --shadow-card:0 8px 24px rgba(16,24,40,.05);
--font:"Inter",-apple-system,BlinkMacSystemFont,sans-serif;
--mono:"JetBrains Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font)}
body{background:radial-gradient(900px 500px at 100% 0%,#e7f0ff 0%,transparent 55%),var(--bg);min-height:100vh}
#root{min-height:100vh}
a{color:inherit;text-decoration:none}
button{font-family:inherit}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-presets="react" src="nav.jsx"></script>
<script type="text/babel" data-presets="react" src="search-app.jsx"></script>
</body>
</html>