This commit is contained in:
2026-04-27 01:56:43 +08:00
parent dfb5fe0c89
commit d4378afbc9
2 changed files with 98 additions and 55 deletions

View File

@@ -32,6 +32,25 @@ os.environ.setdefault("TELEGRAM_WEB_UI", "1")
logger = logging.getLogger("uvicorn.error")
def web_url_prefix() -> str:
"""反代子路径时设置 WEB_URL_PREFIX=/前缀(勿尾斜杠),与 nginx location 一致。"""
return (os.getenv("WEB_URL_PREFIX") or "").strip().rstrip("/")
def with_url_prefix(path: str) -> str:
path = path if path.startswith("/") else f"/{path}"
pre = web_url_prefix()
return f"{pre}{path}" if pre else path
def app_home_url(*, needauth: bool = False) -> str:
pre = web_url_prefix()
base = f"{pre}/" if pre else "/"
if needauth:
return f"{base}?needauth=1"
return base
WEB_CONSOLE_AUTH_KEY = "web_console_authenticated"
@@ -53,7 +72,7 @@ def is_console_authed(request: Request) -> bool:
def redirect_if_console_unauthed(request: Request) -> Optional[RedirectResponse]:
if is_console_authed(request):
return None
return RedirectResponse(url="/?needauth=1", status_code=303)
return RedirectResponse(url=app_home_url(needauth=True), status_code=303)
def json_if_console_unauthed(request: Request) -> Optional[JSONResponse]:
@@ -179,6 +198,7 @@ class WebScraperService:
self.logs: deque[str] = deque(maxlen=1200)
self.continuous_task: Optional[asyncio.Task] = None
self.continuous_started_at: Optional[float] = None
self.continuous_start_lock = asyncio.Lock()
def _append(self, text: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S")
@@ -425,25 +445,26 @@ class WebScraperService:
return await self.start_job(f"补抓媒体({len(channels)}个频道)", runner())
async def start_continuous(self) -> str:
await self.ensure_ready()
if self.is_continuous_running():
return "持续抓取已在运行中"
if not self.scraper.state.get("channels"):
raise ValueError("当前没有监控频道,请先添加频道。")
async with self.continuous_start_lock:
await self.ensure_ready()
if self.is_continuous_running():
return "持续抓取已在运行中"
if not self.scraper.state.get("channels"):
raise ValueError("当前没有监控频道,请先添加频道。")
async def runner():
try:
await self._run_and_capture(self.scraper.continuous_scraping())
except asyncio.CancelledError:
self._append("持续抓取任务已取消。")
raise
except Exception as e:
self._append(f"持续抓取异常:{e}")
async def runner():
try:
await self._run_and_capture(self.scraper.continuous_scraping())
except asyncio.CancelledError:
self._append("持续抓取任务已取消。")
raise
except Exception as e:
self._append(f"持续抓取异常:{e}")
self.continuous_started_at = time.time()
self.continuous_task = asyncio.create_task(runner())
self._append("已启动持续抓取(含心跳逻辑)。")
return "持续抓取已启动"
self.continuous_started_at = time.time()
self.continuous_task = asyncio.create_task(runner())
self._append("已启动持续抓取(含心跳逻辑)。")
return "持续抓取已启动"
async def stop_continuous(self) -> str:
if not self.is_continuous_running():
@@ -678,7 +699,7 @@ async def auth_console_login(request: Request):
@app.post("/auth/console/logout")
async def auth_console_logout(request: Request):
request.session.pop(WEB_CONSOLE_AUTH_KEY, None)
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.get("/api/stats/overview")
@@ -727,6 +748,9 @@ async def index(request: Request):
"account_channels": account_channels,
"console_authed": authed,
"need_auth_banner": request.query_params.get("needauth") == "1",
"app_url": with_url_prefix,
"url_prefix": web_url_prefix(),
"app_home": app_home_url(),
"connected": service.is_connected(),
"job_running": service.is_job_running(),
"job_name": service.job_name,
@@ -773,7 +797,7 @@ async def save_config(request: Request):
else:
updates[k] = str(form.get(k, "")).strip()
write_env_updates(updates)
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/start")
@@ -785,7 +809,7 @@ async def start_scraper(request: Request):
await service.ensure_ready()
except Exception as e:
service._append(f"初始化失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/stop")
@@ -794,7 +818,7 @@ async def stop_scraper(request: Request):
if redir:
return redir
await service.disconnect()
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/channels/add")
@@ -807,7 +831,7 @@ async def add_channel(request: Request, channel_spec: str = Form(...)):
service._append(msg)
except Exception as e:
service._append(f"添加频道失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/channels/add-selected")
@@ -820,14 +844,14 @@ async def add_channels_selected(request: Request):
ids = [str(x).strip() for x in raw_ids if str(x).strip()]
if not ids:
service._append("未选择任何频道,请在列表中勾选后再提交。")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
for spec in ids:
try:
msg = await service.add_channel(spec)
service._append(msg)
except Exception as e:
service._append(f"添加失败({spec}{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/channels/remove")
@@ -840,7 +864,7 @@ async def remove_channel(request: Request, channel_spec: str = Form(...)):
service._append(msg)
except Exception as e:
service._append(f"移除频道失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/scrape")
@@ -853,7 +877,7 @@ async def start_scrape(request: Request, selection: str = Form("all")):
service._append(msg)
except Exception as e:
service._append(f"启动抓取失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/export")
@@ -866,7 +890,7 @@ async def start_export(request: Request, selection: str = Form("all")):
service._append(msg)
except Exception as e:
service._append(f"启动导出失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/rescrape")
@@ -879,20 +903,24 @@ async def start_rescrape(request: Request, selection: str = Form("all")):
service._append(msg)
except Exception as e:
service._append(f"启动补抓失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/continuous/start")
async def start_continuous(request: Request):
async def start_continuous_route(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try:
msg = await service.start_continuous()
service._append(msg)
except Exception as e:
service._append(f"启动持续抓取失败:{e}")
return RedirectResponse(url="/", status_code=303)
async def _run_start_continuous() -> None:
try:
await service.start_continuous()
except Exception as e:
service._append(f"启动持续抓取失败:{e}")
asyncio.create_task(_run_start_continuous())
service._append("已接收「启动持续抓取」:正在后台连接 Telegram 并启动(请勿重复点击;进度见运行日志)。")
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/continuous/stop")
@@ -905,7 +933,7 @@ async def stop_continuous(request: Request):
service._append(msg)
except Exception as e:
service._append(f"停止持续抓取失败:{e}")
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=app_home_url(), status_code=303)
@app.get("/status")

View File

@@ -764,7 +764,7 @@
<button type="button" class="btn-ghost" data-open="config">环境配置</button>
<button type="button" class="btn-ghost" data-open="logs">运行日志</button>
{% if console_authed %}
<form method="post" action="/auth/console/logout" style="margin:0;">
<form method="post" action="{{ app_url('/auth/console/logout') }}" style="margin:0;">
<button type="submit" class="btn-ghost">退出验证</button>
</form>
{% endif %}
@@ -859,16 +859,16 @@
<p class="alert-warn" role="alert">客户端:{{ error }}</p>
{% endif %}
<div class="btn-row">
<form method="post" action="/start">
<form method="post" action="{{ app_url('/start') }}">
<button class="btn-primary" type="submit">初始化 / 连接 Telegram</button>
</form>
<form method="post" action="/stop">
<form method="post" action="{{ app_url('/stop') }}">
<button class="btn-danger" type="submit">断开 Telegram</button>
</form>
<form method="post" action="/jobs/continuous/start">
<form method="post" action="{{ app_url('/jobs/continuous/start') }}">
<button class="btn-primary" type="submit">启动持续抓取(含心跳)</button>
</form>
<form method="post" action="/jobs/continuous/stop">
<form method="post" action="{{ app_url('/jobs/continuous/stop') }}">
<button class="btn-danger" type="submit">停止持续抓取</button>
</form>
</div>
@@ -892,14 +892,14 @@
<strong>添加</strong><code>@用户名</code><code>-100…</code><code>https://t.me/xxx</code>
不支持仅靠群内显示标题。
</p>
<form method="post" action="/channels/add" class="form-inline">
<form method="post" action="{{ app_url('/channels/add') }}" class="form-inline">
<input type="text" name="channel_spec" placeholder="@channel 或 -100… 或 https://t.me/…" aria-label="要添加的频道" />
<button class="btn-primary" type="submit">添加</button>
</form>
<p class="hint">
<strong>移除</strong><code>all</code><code>1,2</code><code>-100…</code><code>@用户名</code> / t.me须已在监控中
</p>
<form method="post" action="/channels/remove" class="form-inline">
<form method="post" action="{{ app_url('/channels/remove') }}" class="form-inline">
<input type="text" name="channel_spec" placeholder="all 或 1,2 或 -100… 或 @name" aria-label="要移除的频道" />
<button class="btn-danger" type="submit">移除</button>
</form>
@@ -907,15 +907,15 @@
<div class="ops-block">
<h4>任务(抓取 / 导出 / 补媒体)</h4>
<p class="hint">范围:<code>all</code><code>1,3</code><code>-100…</code> 或已在监控的 <code>@</code> / 链接。</p>
<form method="post" action="/jobs/scrape" class="form-inline">
<form method="post" action="{{ app_url('/jobs/scrape') }}" class="form-inline">
<input type="text" name="selection" value="all" aria-label="抓取范围" />
<button class="btn-primary" type="submit">开始抓取</button>
</form>
<form method="post" action="/jobs/export" class="form-inline">
<form method="post" action="{{ app_url('/jobs/export') }}" class="form-inline">
<input type="text" name="selection" value="all" aria-label="导出范围" />
<button class="btn-primary" type="submit">导出 CSV+JSON</button>
</form>
<form method="post" action="/jobs/rescrape" class="form-inline">
<form method="post" action="{{ app_url('/jobs/rescrape') }}" class="form-inline">
<input type="text" name="selection" value="all" aria-label="补抓范围" />
<button class="btn-primary" type="submit">补抓媒体</button>
</form>
@@ -946,7 +946,7 @@
<div>
<p class="hint">账号可见频道:勾选后加入监控。标题包含配置项「账号列表隐藏」中任一子串的会话不会出现在此列表(默认隐藏含「远程-到岗-技术招聘」的群,避免选到自有招聘群)。</p>
{% if account_channels %}
<form method="post" action="/channels/add-selected" class="account-pick-form">
<form method="post" action="{{ app_url('/channels/add-selected') }}" class="account-pick-form">
<div class="account-pick-toolbar">
<button type="button" class="text-btn" id="account-select-all">全选可选项</button>
<button type="button" class="text-btn" id="account-select-none">全不选</button>
@@ -988,7 +988,7 @@
</div>
<div class="modal-body">
<p class="hint">保存后建议断开并重新连接 Telegram。</p>
<form method="post" action="/config" class="config-form">
<form method="post" action="{{ app_url('/config') }}" class="config-form">
{% for f in fields %}
{% if f.key in binary_env_keys %}
<div class="field-row field-row-toggle">
@@ -1037,6 +1037,21 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script>
window.__URL_PREFIX__ = {{ url_prefix|tojson }};
function appPath(p) {
p = p.startsWith("/") ? p : ("/" + p);
var pre = window.__URL_PREFIX__ || "";
return pre ? (pre + p) : p;
}
function appHomeWithQuery(tail) {
var pre = window.__URL_PREFIX__ || "";
var base = pre ? (pre + "/") : "/";
base = base.replace(/\/*$/, "/");
if (!tail) return base;
if (tail.charAt(0) !== "?") tail = "?" + tail;
return base + tail;
}
function formatInt(n) {
if (n == null || isNaN(n)) return "—";
return Number(n).toLocaleString("zh-CN");
@@ -1049,7 +1064,7 @@
async function consoleAuthStatus() {
try {
var r = await fetch("/auth/console/status", { credentials: "same-origin" });
var r = await fetch(appPath("/auth/console/status"), { credentials: "same-origin" });
if (!r.ok) return false;
var j = await r.json();
return j.ok === true;
@@ -1086,7 +1101,7 @@
}
var pw = authPwd ? authPwd.value : "";
try {
var res = await fetch("/auth/console/login", {
var res = await fetch(appPath("/auth/console/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
@@ -1102,7 +1117,7 @@
pendingOpenDlgId = null;
}
if (authPwd) authPwd.value = "";
window.location.href = "/" + tail;
window.location.href = appHomeWithQuery(tail);
} else {
if (authErr) {
authErr.textContent = (data && data.error) ? data.error : "验证失败";
@@ -1266,7 +1281,7 @@
var qs = new URLSearchParams({ days: String(days) });
if (kw) qs.set("keywords", kw);
try {
var res = await fetch("/api/stats/overview?" + qs.toString());
var res = await fetch(appPath("/api/stats/overview") + "?" + qs.toString());
if (!res.ok) throw new Error("HTTP " + res.status);
var data = await res.json();
applyStats(data);
@@ -1307,7 +1322,7 @@
async function refreshStatus() {
try {
var statusResp = await fetch("/api/jobs/status", { credentials: "same-origin" });
var statusResp = await fetch(appPath("/api/jobs/status"), { credentials: "same-origin" });
if (statusResp.ok) {
var status = await statusResp.json();
var runningEl = document.getElementById("job-running");
@@ -1355,7 +1370,7 @@
}
}
var channelsResp = await fetch("/api/channels/monitored", { credentials: "same-origin" });
var channelsResp = await fetch(appPath("/api/channels/monitored"), { credentials: "same-origin" });
if (channelsResp.ok) {
var payload = await channelsResp.json();
var wrap = document.getElementById("monitored-list");