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") 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" 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]: def redirect_if_console_unauthed(request: Request) -> Optional[RedirectResponse]:
if is_console_authed(request): if is_console_authed(request):
return None 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]: def json_if_console_unauthed(request: Request) -> Optional[JSONResponse]:
@@ -179,6 +198,7 @@ class WebScraperService:
self.logs: deque[str] = deque(maxlen=1200) self.logs: deque[str] = deque(maxlen=1200)
self.continuous_task: Optional[asyncio.Task] = None self.continuous_task: Optional[asyncio.Task] = None
self.continuous_started_at: Optional[float] = None self.continuous_started_at: Optional[float] = None
self.continuous_start_lock = asyncio.Lock()
def _append(self, text: str) -> None: def _append(self, text: str) -> None:
ts = time.strftime("%Y-%m-%d %H:%M:%S") ts = time.strftime("%Y-%m-%d %H:%M:%S")
@@ -425,6 +445,7 @@ class WebScraperService:
return await self.start_job(f"补抓媒体({len(channels)}个频道)", runner()) return await self.start_job(f"补抓媒体({len(channels)}个频道)", runner())
async def start_continuous(self) -> str: async def start_continuous(self) -> str:
async with self.continuous_start_lock:
await self.ensure_ready() await self.ensure_ready()
if self.is_continuous_running(): if self.is_continuous_running():
return "持续抓取已在运行中" return "持续抓取已在运行中"
@@ -678,7 +699,7 @@ async def auth_console_login(request: Request):
@app.post("/auth/console/logout") @app.post("/auth/console/logout")
async def auth_console_logout(request: Request): async def auth_console_logout(request: Request):
request.session.pop(WEB_CONSOLE_AUTH_KEY, None) 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") @app.get("/api/stats/overview")
@@ -727,6 +748,9 @@ async def index(request: Request):
"account_channels": account_channels, "account_channels": account_channels,
"console_authed": authed, "console_authed": authed,
"need_auth_banner": request.query_params.get("needauth") == "1", "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(), "connected": service.is_connected(),
"job_running": service.is_job_running(), "job_running": service.is_job_running(),
"job_name": service.job_name, "job_name": service.job_name,
@@ -773,7 +797,7 @@ async def save_config(request: Request):
else: else:
updates[k] = str(form.get(k, "")).strip() updates[k] = str(form.get(k, "")).strip()
write_env_updates(updates) write_env_updates(updates)
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/start") @app.post("/start")
@@ -785,7 +809,7 @@ async def start_scraper(request: Request):
await service.ensure_ready() await service.ensure_ready()
except Exception as e: except Exception as e:
service._append(f"初始化失败:{e}") service._append(f"初始化失败:{e}")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/stop") @app.post("/stop")
@@ -794,7 +818,7 @@ async def stop_scraper(request: Request):
if redir: if redir:
return redir return redir
await service.disconnect() await service.disconnect()
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/channels/add") @app.post("/channels/add")
@@ -807,7 +831,7 @@ async def add_channel(request: Request, channel_spec: str = Form(...)):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"添加频道失败:{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") @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()] ids = [str(x).strip() for x in raw_ids if str(x).strip()]
if not ids: if not ids:
service._append("未选择任何频道,请在列表中勾选后再提交。") service._append("未选择任何频道,请在列表中勾选后再提交。")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
for spec in ids: for spec in ids:
try: try:
msg = await service.add_channel(spec) msg = await service.add_channel(spec)
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"添加失败({spec}{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") @app.post("/channels/remove")
@@ -840,7 +864,7 @@ async def remove_channel(request: Request, channel_spec: str = Form(...)):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"移除频道失败:{e}") service._append(f"移除频道失败:{e}")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/scrape") @app.post("/jobs/scrape")
@@ -853,7 +877,7 @@ async def start_scrape(request: Request, selection: str = Form("all")):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"启动抓取失败:{e}") service._append(f"启动抓取失败:{e}")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/export") @app.post("/jobs/export")
@@ -866,7 +890,7 @@ async def start_export(request: Request, selection: str = Form("all")):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"启动导出失败:{e}") service._append(f"启动导出失败:{e}")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/rescrape") @app.post("/jobs/rescrape")
@@ -879,20 +903,24 @@ async def start_rescrape(request: Request, selection: str = Form("all")):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"启动补抓失败:{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") @app.post("/jobs/continuous/start")
async def start_continuous(request: Request): async def start_continuous_route(request: Request):
redir = redirect_if_console_unauthed(request) redir = redirect_if_console_unauthed(request)
if redir: if redir:
return redir return redir
async def _run_start_continuous() -> None:
try: try:
msg = await service.start_continuous() await service.start_continuous()
service._append(msg)
except Exception as e: except Exception as e:
service._append(f"启动持续抓取失败:{e}") service._append(f"启动持续抓取失败:{e}")
return RedirectResponse(url="/", status_code=303)
asyncio.create_task(_run_start_continuous())
service._append("已接收「启动持续抓取」:正在后台连接 Telegram 并启动(请勿重复点击;进度见运行日志)。")
return RedirectResponse(url=app_home_url(), status_code=303)
@app.post("/jobs/continuous/stop") @app.post("/jobs/continuous/stop")
@@ -905,7 +933,7 @@ async def stop_continuous(request: Request):
service._append(msg) service._append(msg)
except Exception as e: except Exception as e:
service._append(f"停止持续抓取失败:{e}") service._append(f"停止持续抓取失败:{e}")
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url=app_home_url(), status_code=303)
@app.get("/status") @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="config">环境配置</button>
<button type="button" class="btn-ghost" data-open="logs">运行日志</button> <button type="button" class="btn-ghost" data-open="logs">运行日志</button>
{% if console_authed %} {% 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> <button type="submit" class="btn-ghost">退出验证</button>
</form> </form>
{% endif %} {% endif %}
@@ -859,16 +859,16 @@
<p class="alert-warn" role="alert">客户端:{{ error }}</p> <p class="alert-warn" role="alert">客户端:{{ error }}</p>
{% endif %} {% endif %}
<div class="btn-row"> <div class="btn-row">
<form method="post" action="/start"> <form method="post" action="{{ app_url('/start') }}">
<button class="btn-primary" type="submit">初始化 / 连接 Telegram</button> <button class="btn-primary" type="submit">初始化 / 连接 Telegram</button>
</form> </form>
<form method="post" action="/stop"> <form method="post" action="{{ app_url('/stop') }}">
<button class="btn-danger" type="submit">断开 Telegram</button> <button class="btn-danger" type="submit">断开 Telegram</button>
</form> </form>
<form method="post" action="/jobs/continuous/start"> <form method="post" action="{{ app_url('/jobs/continuous/start') }}">
<button class="btn-primary" type="submit">启动持续抓取(含心跳)</button> <button class="btn-primary" type="submit">启动持续抓取(含心跳)</button>
</form> </form>
<form method="post" action="/jobs/continuous/stop"> <form method="post" action="{{ app_url('/jobs/continuous/stop') }}">
<button class="btn-danger" type="submit">停止持续抓取</button> <button class="btn-danger" type="submit">停止持续抓取</button>
</form> </form>
</div> </div>
@@ -892,14 +892,14 @@
<strong>添加</strong><code>@用户名</code><code>-100…</code><code>https://t.me/xxx</code> <strong>添加</strong><code>@用户名</code><code>-100…</code><code>https://t.me/xxx</code>
不支持仅靠群内显示标题。 不支持仅靠群内显示标题。
</p> </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="要添加的频道" /> <input type="text" name="channel_spec" placeholder="@channel 或 -100… 或 https://t.me/…" aria-label="要添加的频道" />
<button class="btn-primary" type="submit">添加</button> <button class="btn-primary" type="submit">添加</button>
</form> </form>
<p class="hint"> <p class="hint">
<strong>移除</strong><code>all</code><code>1,2</code><code>-100…</code><code>@用户名</code> / t.me须已在监控中 <strong>移除</strong><code>all</code><code>1,2</code><code>-100…</code><code>@用户名</code> / t.me须已在监控中
</p> </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="要移除的频道" /> <input type="text" name="channel_spec" placeholder="all 或 1,2 或 -100… 或 @name" aria-label="要移除的频道" />
<button class="btn-danger" type="submit">移除</button> <button class="btn-danger" type="submit">移除</button>
</form> </form>
@@ -907,15 +907,15 @@
<div class="ops-block"> <div class="ops-block">
<h4>任务(抓取 / 导出 / 补媒体)</h4> <h4>任务(抓取 / 导出 / 补媒体)</h4>
<p class="hint">范围:<code>all</code><code>1,3</code><code>-100…</code> 或已在监控的 <code>@</code> / 链接。</p> <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="抓取范围" /> <input type="text" name="selection" value="all" aria-label="抓取范围" />
<button class="btn-primary" type="submit">开始抓取</button> <button class="btn-primary" type="submit">开始抓取</button>
</form> </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="导出范围" /> <input type="text" name="selection" value="all" aria-label="导出范围" />
<button class="btn-primary" type="submit">导出 CSV+JSON</button> <button class="btn-primary" type="submit">导出 CSV+JSON</button>
</form> </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="补抓范围" /> <input type="text" name="selection" value="all" aria-label="补抓范围" />
<button class="btn-primary" type="submit">补抓媒体</button> <button class="btn-primary" type="submit">补抓媒体</button>
</form> </form>
@@ -946,7 +946,7 @@
<div> <div>
<p class="hint">账号可见频道:勾选后加入监控。标题包含配置项「账号列表隐藏」中任一子串的会话不会出现在此列表(默认隐藏含「远程-到岗-技术招聘」的群,避免选到自有招聘群)。</p> <p class="hint">账号可见频道:勾选后加入监控。标题包含配置项「账号列表隐藏」中任一子串的会话不会出现在此列表(默认隐藏含「远程-到岗-技术招聘」的群,避免选到自有招聘群)。</p>
{% if account_channels %} {% 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"> <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-all">全选可选项</button>
<button type="button" class="text-btn" id="account-select-none">全不选</button> <button type="button" class="text-btn" id="account-select-none">全不选</button>
@@ -988,7 +988,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="hint">保存后建议断开并重新连接 Telegram。</p> <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 %} {% for f in fields %}
{% if f.key in binary_env_keys %} {% if f.key in binary_env_keys %}
<div class="field-row field-row-toggle"> <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 src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<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) { function formatInt(n) {
if (n == null || isNaN(n)) return "—"; if (n == null || isNaN(n)) return "—";
return Number(n).toLocaleString("zh-CN"); return Number(n).toLocaleString("zh-CN");
@@ -1049,7 +1064,7 @@
async function consoleAuthStatus() { async function consoleAuthStatus() {
try { 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; if (!r.ok) return false;
var j = await r.json(); var j = await r.json();
return j.ok === true; return j.ok === true;
@@ -1086,7 +1101,7 @@
} }
var pw = authPwd ? authPwd.value : ""; var pw = authPwd ? authPwd.value : "";
try { try {
var res = await fetch("/auth/console/login", { var res = await fetch(appPath("/auth/console/login"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
@@ -1102,7 +1117,7 @@
pendingOpenDlgId = null; pendingOpenDlgId = null;
} }
if (authPwd) authPwd.value = ""; if (authPwd) authPwd.value = "";
window.location.href = "/" + tail; window.location.href = appHomeWithQuery(tail);
} else { } else {
if (authErr) { if (authErr) {
authErr.textContent = (data && data.error) ? data.error : "验证失败"; authErr.textContent = (data && data.error) ? data.error : "验证失败";
@@ -1266,7 +1281,7 @@
var qs = new URLSearchParams({ days: String(days) }); var qs = new URLSearchParams({ days: String(days) });
if (kw) qs.set("keywords", kw); if (kw) qs.set("keywords", kw);
try { 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); if (!res.ok) throw new Error("HTTP " + res.status);
var data = await res.json(); var data = await res.json();
applyStats(data); applyStats(data);
@@ -1307,7 +1322,7 @@
async function refreshStatus() { async function refreshStatus() {
try { 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) { if (statusResp.ok) {
var status = await statusResp.json(); var status = await statusResp.json();
var runningEl = document.getElementById("job-running"); 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) { if (channelsResp.ok) {
var payload = await channelsResp.json(); var payload = await channelsResp.json();
var wrap = document.getElementById("monitored-list"); var wrap = document.getElementById("monitored-list");