mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-09-20 14:41:58 +00:00
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:
@@ -27,14 +27,41 @@ function Hero(){
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_IP = "SERVER_IP";
|
||||
|
||||
// ───────── Server info card ─────────
|
||||
function ServerCard(){
|
||||
const [copied,setCopied] = useStateC(false);
|
||||
function copy(){
|
||||
navigator.clipboard?.writeText("SERVER_IP");
|
||||
const [copiedProxy,setCopiedProxy] = useStateC(false);
|
||||
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);
|
||||
setTimeout(()=>setCopied(false), 1600);
|
||||
}
|
||||
function copyProxy(){
|
||||
if (!proxyUrl) return;
|
||||
navigator.clipboard?.writeText(proxyUrl);
|
||||
setCopiedProxy(true);
|
||||
setTimeout(()=>setCopiedProxy(false), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
|
||||
<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,
|
||||
fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
|
||||
letterSpacing:"-0.005em", color:"var(--ink)"}}>
|
||||
SERVER_IP
|
||||
{serverIp}
|
||||
</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>
|
||||
</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)",
|
||||
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
|
||||
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>
|
||||
|
||||
5
web/data/README.txt
Normal file
5
web/data/README.txt
Normal 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 — готово, обновите страницу
|
||||
5
web/data/site-config.json.example
Normal file
5
web/data/site-config.json.example
Normal 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"
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
// Curated blocked domains — static lists (no search)
|
||||
|
||||
const { useState, useEffect, useMemo } = React;
|
||||
|
||||
function cidrToMask(bits) {
|
||||
const n = parseInt(bits || "32", 10);
|
||||
if (n === 32) return "255.255.255.255";
|
||||
@@ -27,6 +25,29 @@ function buildKeeneticBat(group, allGroups) {
|
||||
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) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -52,18 +73,19 @@ async function copyText(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [copied, setCopied] = useState(null);
|
||||
function DomainGroupCard({ group, selectedDomains, onToggleDomain, onToggleGroup, onDownloadBat }) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(null);
|
||||
const domainText = group.domains.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) {
|
||||
const text = kind === "domains" ? domainText : ipText;
|
||||
if (!text) return;
|
||||
await copyText(text);
|
||||
setCopied(kind);
|
||||
onCopyDomains?.(kind);
|
||||
setTimeout(() => setCopied(null), 1800);
|
||||
}
|
||||
|
||||
@@ -82,6 +104,9 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
|
||||
</div>
|
||||
@@ -96,16 +121,28 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
))}
|
||||
</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={{
|
||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 8, marginBottom: 16,
|
||||
}}>
|
||||
{group.domains.map((d) => (
|
||||
<div key={d} style={{
|
||||
padding: "10px 12px", borderRadius: 10, background: "var(--bg-tint)",
|
||||
border: "1px solid var(--line-2)", fontFamily: "var(--mono)", fontSize: 12.5,
|
||||
}}>{d}</div>
|
||||
))}
|
||||
{group.domains.map((d) => {
|
||||
const selected = selectedDomains.has(d);
|
||||
return (
|
||||
<button key={d} type="button" onClick={() => onToggleDomain(d)} style={{
|
||||
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>
|
||||
{(group.ips || []).length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -133,7 +170,7 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
)}
|
||||
<Button variant="primary" size="sm" icon="download"
|
||||
onClick={() => onDownloadBat(group)}>
|
||||
Keenetic .bat
|
||||
Keenetic .bat (вся группа)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,18 +180,20 @@ function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
}
|
||||
|
||||
function DomainsApp() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [groups, setGroups] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
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")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setGroups(data.groups || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return groups;
|
||||
return groups.map((g) => ({
|
||||
@@ -165,6 +204,34 @@ function DomainsApp() {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
|
||||
@@ -174,10 +241,10 @@ function DomainsApp() {
|
||||
Популярные заблокированные домены
|
||||
</h1>
|
||||
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
|
||||
Кликните по доменам для выбора, затем скачайте .bat только для выбранных.
|
||||
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
|
||||
Поиск по crt.sh на сайте отключён — только готовые пресеты по категориям.
|
||||
</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="Фильтр по домену…"
|
||||
style={{
|
||||
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
|
||||
@@ -191,6 +258,25 @@ function DomainsApp() {
|
||||
</Button>
|
||||
</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 && filtered.length === 0 && (
|
||||
<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 }}>
|
||||
{filtered.map((g) => (
|
||||
<DomainGroupCard key={g.id} group={g}
|
||||
selectedDomains={selectedDomains}
|
||||
onToggleDomain={toggleDomain}
|
||||
onToggleGroup={toggleGroup}
|
||||
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
|
||||
))}
|
||||
</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)" }}>
|
||||
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
|
||||
{" · "}
|
||||
<a href="/search">Поиск доменов</a>
|
||||
{" · "}
|
||||
<a href="index.html">На главную</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Shared site navigation
|
||||
|
||||
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?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) },
|
||||
];
|
||||
|
||||
|
||||
262
web/search-app.jsx
Normal file
262
web/search-app.jsx
Normal 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
34
web/search.html
Normal 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>
|
||||
Reference in New Issue
Block a user