更新:

This commit is contained in:
Your Name
2026-07-17 10:21:18 +08:00
parent 530e7f839d
commit 2f2150d2f6
6 changed files with 596 additions and 30 deletions
@@ -818,7 +818,22 @@ class DouyinImHttpClient:
"""通过 IM API 发送 Protobuf 编码的私信(带接口签名) """通过 IM API 发送 Protobuf 编码的私信(带接口签名)
content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。 content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。
所有出站发送(自动回复/手动 API/脚本)都经过全局限流器,
防止大量托管账号同时发送占满出站带宽或触发平台级风控。
""" """
from .rate_limit import get_send_limiter
async with get_send_limiter():
return await self._send_text_message_unthrottled(
conversation_id, content, conversation_short_id
)
async def _send_text_message_unthrottled(
self,
conversation_id: str,
content: str,
conversation_short_id: str = "",
) -> bool:
from .auth import DouyinAuth from .auth import DouyinAuth
from .proto_builder import ProtoBuilder from .proto_builder import ProtoBuilder
from .reply_payload import ( from .reply_payload import (
+117
View File
@@ -0,0 +1,117 @@
"""全局出站发送限流器。
目的:托管账号数量多(如 1000 个)时,避免自动回复/手动发送在同一时刻
大量并发打向抖音接口,占满服务器出站带宽或触发平台级风控。
机制(两层,先到先过):
1. 并发上限(Semaphore):同时在途的发送请求数不超过 KEFU_SEND_MAX_CONCURRENCY
2. 令牌桶(QPS):平均发送速率不超过 KEFU_SEND_RATE_PER_SEC,允许 KEFU_SEND_BURST 的突发。
限流器按事件循环惰性创建(backend 主循环一个实例;独立脚本各自一个),
所有账号共享同一个实例,因此是跨账号的全局闸门。
环境变量:
KEFU_SEND_MAX_CONCURRENCY 同时在途发送数上限,默认 20,<=0 表示不限
KEFU_SEND_RATE_PER_SEC 平均每秒发送数上限,默认 10,<=0 表示不限
KEFU_SEND_BURST 令牌桶容量(突发上限),默认与并发上限相同
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
logger = logging.getLogger("douyin_im.rate_limit")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, "") or default)
except ValueError:
return default
class GlobalSendLimiter:
"""并发上限 + 令牌桶。用法:async with limiter: await 发送。"""
def __init__(
self,
max_concurrency: int = 20,
rate_per_sec: float = 10.0,
burst: float | None = None,
):
self.max_concurrency = int(max_concurrency)
self.rate_per_sec = float(rate_per_sec)
self.burst = float(burst if burst is not None else max(1, max_concurrency))
self._sem = (
asyncio.Semaphore(self.max_concurrency) if self.max_concurrency > 0 else None
)
self._tokens = self.burst
self._last_refill = time.monotonic()
self._token_lock = asyncio.Lock()
# 统计:便于日志观察限流是否生效
self._waited_total = 0.0
self._acquired_count = 0
async def _take_token(self) -> None:
if self.rate_per_sec <= 0:
return
async with self._token_lock:
while True:
now = time.monotonic()
self._tokens = min(
self.burst, self._tokens + (now - self._last_refill) * self.rate_per_sec
)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
await asyncio.sleep((1.0 - self._tokens) / self.rate_per_sec)
async def __aenter__(self) -> "GlobalSendLimiter":
start = time.monotonic()
if self._sem is not None:
await self._sem.acquire()
try:
await self._take_token()
except BaseException:
if self._sem is not None:
self._sem.release()
raise
waited = time.monotonic() - start
self._waited_total += waited
self._acquired_count += 1
if waited > 1.0:
logger.info(
"send throttled: waited %.1fs (in-flight cap=%s, rate=%s/s)",
waited, self.max_concurrency or "", self.rate_per_sec or "",
)
return self
async def __aexit__(self, *exc) -> None:
if self._sem is not None:
self._sem.release()
# 每个事件循环一个实例(backend 主循环即全局唯一;脚本自用循环互不影响)
_limiters: dict[int, GlobalSendLimiter] = {}
def get_send_limiter() -> GlobalSendLimiter:
loop = asyncio.get_running_loop()
key = id(loop)
limiter = _limiters.get(key)
if limiter is None:
max_conc = int(_env_float("KEFU_SEND_MAX_CONCURRENCY", 20))
rate = _env_float("KEFU_SEND_RATE_PER_SEC", 10.0)
burst = _env_float("KEFU_SEND_BURST", max(1, max_conc))
limiter = GlobalSendLimiter(max_conc, rate, burst)
_limiters[key] = limiter
logger.info(
"global send limiter ready: concurrency=%s, rate=%s/s, burst=%s",
max_conc if max_conc > 0 else "unlimited",
rate if rate > 0 else "unlimited",
burst,
)
return limiter
+21 -1
View File
@@ -1,5 +1,7 @@
import asyncio import asyncio
import logging import logging
import os
import random
import time import time
from typing import Awaitable, Callable, Optional from typing import Awaitable, Callable, Optional
@@ -374,6 +376,22 @@ class DouyinImService:
) )
await self._ws_client.start() await self._ws_client.start()
# 轮询错峰:多账号同时托管时,若所有账号按同一节奏轮询,请求会在同一
# 时刻叠峰。这里给每个账号随机相位偏移 + 每轮 ±20% 抖动,把请求摊平。
# WS 可用时轮询只是兜底,可以适当放缓(KEFU_IM_POLL_INTERVAL_SECONDS 可调)。
try:
poll_interval = float(os.getenv("KEFU_IM_POLL_INTERVAL_SECONDS", "") or 15)
except ValueError:
poll_interval = 15.0
poll_interval = max(5.0, poll_interval)
if has_ws:
poll_interval = max(poll_interval, 30.0)
# 首轮轮询前的随机延迟(相位偏移),批量启动时错开各账号的首波请求
await asyncio.sleep(random.uniform(0.5, min(10.0, poll_interval)))
if not self._running:
return
try: try:
await self._poll_conversations() await self._poll_conversations()
except Exception as e: except Exception as e:
@@ -387,10 +405,12 @@ class DouyinImService:
) )
loop_count = 0 loop_count = 0
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
while self._running: while self._running:
loop_count += 1 loop_count += 1
try: try:
if loop_count % 3 == 1: if time.monotonic() >= next_poll:
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
await self._poll_conversations() await self._poll_conversations()
if loop_count % 6 == 1: if loop_count % 6 == 1:
logger.info(f"IM direct tick #{loop_count} account={self.account_id}") logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
+33
View File
@@ -49,6 +49,36 @@ async def _launch_chromium(pw, args: list[str], headless: Optional[bool] = None)
return await pw.chromium.launch(**launch_kwargs) return await pw.chromium.launch(**launch_kwargs)
# ---------------------------------------------------------------------------
# 启动错峰门:批量启动大量账号时,把各 worker 的启动时刻按固定间隔排开,
# 避免同一瞬间大量凭证校验/WS 建连/首轮拉取叠峰。
# 空闲时单个账号启动无需等待;只有短时间内大量启动才会排队。
# KEFU_WORKER_START_INTERVAL_SECONDS:相邻两个 worker 启动的最小间隔,默认 1.5s,<=0 关闭。
# ---------------------------------------------------------------------------
_start_gate = {"lock": None, "next_at": 0.0}
async def _startup_stagger(account_id: int) -> None:
try:
interval = float(os.getenv("KEFU_WORKER_START_INTERVAL_SECONDS", "") or 1.5)
except ValueError:
interval = 1.5
if interval <= 0:
return
if _start_gate["lock"] is None:
_start_gate["lock"] = asyncio.Lock()
async with _start_gate["lock"]:
now = time.monotonic()
wait = max(0.0, _start_gate["next_at"] - now)
_start_gate["next_at"] = max(now, _start_gate["next_at"]) + interval
if wait > 0:
if wait > 5:
logger.info(
f"Account {account_id}: start queued, waiting {wait:.1f}s to smooth batch startup"
)
await asyncio.sleep(wait)
def format_error(exc: BaseException) -> str: def format_error(exc: BaseException) -> str:
message = str(exc).strip() message = str(exc).strip()
if "Target page, context or browser has been closed" in message: if "Target page, context or browser has been closed" in message:
@@ -995,6 +1025,9 @@ class DouyinWorker:
logger.info(f"Starting worker loop for account {self.account_id}") logger.info(f"Starting worker loop for account {self.account_id}")
try: try:
await _startup_stagger(self.account_id)
if self.stopping:
return
storage_state = await self._load_storage_state() storage_state = await self._load_storage_state()
cookie_info = analyze_cookie( cookie_info = analyze_cookie(
json.dumps(storage_state, ensure_ascii=False) if storage_state else None json.dumps(storage_state, ensure_ascii=False) if storage_state else None
+401 -25
View File
@@ -1,10 +1,12 @@
"""抖音托管客服 · 桌面整合版 """抖音托管客服 · 桌面整合版
一个软件搞定一切: 一个软件搞定一切:
1) 启动后默认打开云端网站(dev.zhenyangtang.com.cn); 1) 启动后先显示「站点选择」界面(内置 dev / dev1,可自行添加更多站点,
2) 页面右下角自动出现「一键本地登录」悬浮按钮 可勾选“记住选择”下次直接进入)
3) 点它 → 弹出你的托管账号列表 → 选一个 → 本机直接打开一个 2) 进入站点后,页面右下角自动出现「一键本地登录」悬浮按钮,
已登录该托管账号的浏览器,进入抖音。 以及「切换站点」按钮可随时换环境;
3) 点「一键本地登录」→ 弹出你的托管账号列表 → 选一个 → 本机直接打开
一个已登录该托管账号的浏览器,进入抖音。
原理:桌面壳(pywebview)把云端网页装进原生窗口,并注入一段脚本。该脚本 原理:桌面壳(pywebview)把云端网页装进原生窗口,并注入一段脚本。该脚本
用网页里已有的登录令牌(localStorage.kefu_token)调用云端接口取账号与凭证, 用网页里已有的登录令牌(localStorage.kefu_token)调用云端接口取账号与凭证,
@@ -18,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import json
import os import os
import subprocess import subprocess
import threading import threading
@@ -29,10 +32,276 @@ from browser_open import normalize_storage_state, open_logged_in_browser
from updater import check_update, download_installer from updater import check_update, download_installer
from version import __version__ from version import __version__
CLOUD_URL = "https://dev.zhenyangtang.com.cn/"
CLOUD_HOST = urlparse(CLOUD_URL).hostname or ""
WINDOW_TITLE = f"抖音托管客服 · 桌面版 v{__version__}" WINDOW_TITLE = f"抖音托管客服 · 桌面版 v{__version__}"
# ---------------------------------------------------------------------------
# 多站点配置:内置站点 + 用户自定义站点(保存在用户目录,升级软件不丢失)。
# 以后要新增内置环境,往 BUILTIN_SITES 里加一行即可。
# ---------------------------------------------------------------------------
BUILTIN_SITES = [
{"name": "节点1", "url": "https://dev.zhenyangtang.com.cn/"},
{"name": "节点2", "url": "https://dev1.zhenyangtang.com.cn/"},
]
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".douyin_desktop_config.json")
def load_config() -> dict:
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception: # noqa: BLE001
return {}
def save_config(cfg: dict) -> None:
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
except Exception: # noqa: BLE001
pass
def normalize_site_url(url: str) -> str:
url = (url or "").strip()
if not url:
return ""
if not url.startswith(("http://", "https://")):
url = "https://" + url
if not url.endswith("/"):
url += "/"
return url
def get_all_sites() -> list[dict]:
"""内置站点 + 自定义站点(去重,按 URL)。"""
cfg = load_config()
sites: list[dict] = []
seen: set[str] = set()
for s in BUILTIN_SITES:
u = normalize_site_url(s["url"])
sites.append({"name": s["name"], "url": u, "builtin": True})
seen.add(u)
for s in cfg.get("custom_sites", []):
u = normalize_site_url(s.get("url", ""))
if u and u not in seen:
sites.append({"name": s.get("name") or urlparse(u).hostname, "url": u, "builtin": False})
seen.add(u)
return sites
def site_hosts() -> set[str]:
return {urlparse(s["url"]).hostname or "" for s in get_all_sites()} - {""}
# ---------------------------------------------------------------------------
# 站点选择界面(软件启动首页)。与注入浮层同一套视觉语言:#fe2c55 主色 + 玻璃拟态。
# ---------------------------------------------------------------------------
PICKER_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>选择站点</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;
font-family:'PingFang SC','Microsoft YaHei',system-ui,-apple-system,'Segoe UI',sans-serif;}
html,body{height:100%;}
body{display:flex;align-items:center;justify-content:center;padding:28px;
background:#0f1023;
background-image:radial-gradient(900px 500px at 15% -10%,rgba(254,44,85,.28),transparent 60%),
radial-gradient(800px 500px at 110% 110%,rgba(99,102,241,.25),transparent 60%);}
.card{width:520px;max-width:100%;max-height:92vh;display:flex;flex-direction:column;
background:rgba(255,255,255,.06);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);
border:1px solid rgba(255,255,255,.14);border-radius:20px;overflow:hidden;
box-shadow:0 30px 80px rgba(0,0,0,.45);}
.head{padding:26px 28px 18px;}
.brand{display:flex;align-items:center;gap:12px;}
.logo{width:42px;height:42px;border-radius:12px;flex:none;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#fe2c55,#ff7a59);box-shadow:0 8px 20px rgba(254,44,85,.4);}
.logo svg{width:22px;height:22px;color:#fff;}
h1{font-size:18px;font-weight:600;color:#fff;line-height:1.3;}
.sub{margin-top:3px;font-size:12.5px;color:rgba(255,255,255,.55);}
.body{padding:4px 20px 8px;overflow:auto;}
.site{display:flex;align-items:center;gap:12px;width:100%;text-align:left;
padding:13px 14px;margin-bottom:10px;border:1px solid rgba(255,255,255,.12);
border-radius:14px;background:rgba(255,255,255,.04);cursor:pointer;
transition:border-color .18s ease,background .18s ease,transform .18s ease;}
.site:hover{border-color:#fe2c55;background:rgba(254,44,85,.12);transform:translateY(-1px);}
.site:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
.dot{width:36px;height:36px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,.08);color:rgba(255,255,255,.85);font-weight:600;font-size:15px;}
.site:hover .dot{background:rgba(254,44,85,.9);color:#fff;}
.si{flex:1;min-width:0;}
.sn{font-size:14.5px;font-weight:600;color:#fff;display:flex;align-items:center;gap:8px;}
.tag{font-size:10.5px;font-weight:500;padding:1px 7px;border-radius:999px;
background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);}
.su{margin-top:3px;font-size:12px;color:rgba(255,255,255,.5);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ping{flex:none;display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
border-radius:999px;font-size:11px;font-weight:500;
background:rgba(255,255,255,.08);color:rgba(255,255,255,.55);}
.ping i{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.35);flex:none;}
.ping.fast{background:rgba(34,197,94,.15);color:#4ade80;}
.ping.fast i{background:#22c55e;}
.ping.mid{background:rgba(245,158,11,.15);color:#fbbf24;}
.ping.mid i{background:#f59e0b;}
.ping.slow{background:rgba(239,68,68,.15);color:#f87171;}
.ping.slow i{background:#ef4444;}
.ping.fail{background:rgba(239,68,68,.15);color:#f87171;}
.ping.fail i{background:#ef4444;}
@keyframes blink{50%{opacity:.35;}}
.ping.wait i{animation:blink 1s ease infinite;}
.arrow{flex:none;color:rgba(255,255,255,.3);transition:color .18s ease,transform .18s ease;}
.site:hover .arrow{color:#fff;transform:translateX(2px);}
.arrow svg{width:18px;height:18px;display:block;}
.del{flex:none;border:none;background:transparent;color:rgba(255,255,255,.35);cursor:pointer;
width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;
transition:background .18s ease,color .18s ease;}
.del:hover{background:rgba(239,68,68,.2);color:#f87171;}
.del svg{width:15px;height:15px;}
.foot{padding:6px 20px 22px;}
.remember{display:flex;align-items:center;gap:8px;padding:6px 2px 14px;
font-size:12.5px;color:rgba(255,255,255,.65);cursor:pointer;user-select:none;}
.remember input{width:15px;height:15px;accent-color:#fe2c55;cursor:pointer;}
.addbar{display:flex;gap:8px;}
.addbar input{flex:1;min-width:0;padding:10px 12px;font-size:13px;color:#fff;
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);border-radius:10px;outline:none;
transition:border-color .18s ease;}
.addbar input::placeholder{color:rgba(255,255,255,.35);}
.addbar input:focus{border-color:#fe2c55;}
.addbar input.name{flex:0 0 120px;}
.addbar button{flex:none;padding:10px 16px;font-size:13px;font-weight:600;color:#fff;
background:#fe2c55;border:none;border-radius:10px;cursor:pointer;
transition:background .18s ease,box-shadow .18s ease;}
.addbar button:hover{background:#e0214a;box-shadow:0 6px 18px rgba(254,44,85,.4);}
.err{margin-top:8px;font-size:12px;color:#f87171;min-height:16px;}
.empty{padding:20px;text-align:center;font-size:13px;color:rgba(255,255,255,.45);}
</style>
</head>
<body>
<div class="card">
<div class="head">
<div class="brand">
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
<div>
<h1>抖音托管客服 · 桌面版</h1>
<div class="sub">请选择要进入的站点,也可以在下方添加新站点</div>
</div>
</div>
</div>
<div class="body" id="list"><div class="empty">正在加载站点…</div></div>
<div class="foot">
<label class="remember"><input type="checkbox" id="remember">记住选择,下次启动直接进入(可在站点内切换)</label>
<div class="addbar">
<input class="name" id="addName" placeholder="名称(选填)" maxlength="20">
<input id="addUrl" placeholder="https://xxx.zhenyangtang.com.cn/" spellcheck="false">
<button id="addBtn">添加</button>
</div>
<div class="err" id="err"></div>
</div>
</div>
<script>
(function () {
var CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
var X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function api() { return window.pywebview && window.pywebview.api; }
function setErr(msg) { document.getElementById('err').textContent = msg || ''; }
function pingSite(url, badge) {
if (!badge) return;
api().ping_site(url).then(function (r) {
if (!r || !r.ok) {
badge.className = 'ping fail';
badge.innerHTML = '<i></i>不可达';
return;
}
var ms = r.ms;
var cls = ms <= 300 ? 'fast' : (ms <= 800 ? 'mid' : 'slow');
badge.className = 'ping ' + cls;
badge.innerHTML = '<i></i>' + ms + ' ms';
}).catch(function () {
badge.className = 'ping fail';
badge.innerHTML = '<i></i>检测失败';
});
}
function render(sites) {
var list = document.getElementById('list');
if (!sites || !sites.length) {
list.innerHTML = '<div class="empty">暂无站点,请在下方添加</div>';
return;
}
list.innerHTML = '';
sites.forEach(function (s) {
var host = '';
try { host = new URL(s.url).hostname; } catch (e) { host = s.url; }
var initial = esc((s.name || host || '?').trim().charAt(0).toUpperCase());
var el = document.createElement('button');
el.className = 'site';
el.type = 'button';
el.innerHTML = '<span class="dot">' + initial + '</span>'
+ '<span class="si"><span class="sn">' + esc(s.name || host)
+ (s.builtin ? '<span class="tag">内置</span>' : '') + '</span>'
+ '<span class="su">' + esc(s.url) + '</span></span>'
+ '<span class="ping wait"><i></i>检测中</span>'
+ (s.builtin ? '' : '<span class="del" title="删除该站点" role="button">' + X + '</span>')
+ '<span class="arrow">' + CHEV + '</span>';
pingSite(s.url, el.querySelector('.ping'));
el.addEventListener('click', function (e) {
var del = e.target.closest && e.target.closest('.del');
if (del) {
e.stopPropagation();
api().remove_site(s.url).then(function (r) { render(r.sites); });
return;
}
var remember = document.getElementById('remember').checked;
el.style.opacity = '.6';
api().open_site(s.url, remember);
});
list.appendChild(el);
});
}
function addSite() {
var name = document.getElementById('addName').value.trim();
var url = document.getElementById('addUrl').value.trim();
if (!url) { setErr('请填写站点网址'); return; }
setErr('');
api().add_site(name, url).then(function (r) {
if (r.error) { setErr(r.error); return; }
document.getElementById('addName').value = '';
document.getElementById('addUrl').value = '';
render(r.sites);
});
}
function init() {
api().get_sites().then(function (r) {
render(r.sites);
document.getElementById('remember').checked = !!r.remember;
});
document.getElementById('addBtn').addEventListener('click', addSite);
document.getElementById('addUrl').addEventListener('keydown', function (e) {
if (e.key === 'Enter') addSite();
});
}
if (api()) init();
else window.addEventListener('pywebviewready', init);
})();
</script>
</body>
</html>
"""
# 注入到云端页面的脚本:加悬浮按钮 + 账号选择浮层,调用本机桥打开浏览器。 # 注入到云端页面的脚本:加悬浮按钮 + 账号选择浮层,调用本机桥打开浏览器。
# 设计:Glassmorphism 浮层 + SVG 图标(无 emoji)+ 悬浮过渡 + 可访问性(焦点/Esc/减少动效)。 # 设计:Glassmorphism 浮层 + SVG 图标(无 emoji)+ 悬浮过渡 + 可访问性(焦点/Esc/减少动效)。
@@ -47,7 +316,8 @@ INJECT_JS = r"""
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>', close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>', chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
monitor: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>', monitor: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>' alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
swap: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>'
}; };
var ROOT = null; // Shadow root,隔离宿主站点 CSS,避免错位/层级冲突 var ROOT = null; // Shadow root,隔离宿主站点 CSS,避免错位/层级冲突
@@ -91,6 +361,16 @@ INJECT_JS = r"""
.${NS}-btn:focus-visible{outline:3px solid rgba(254,44,85,.4);outline-offset:2px;} .${NS}-btn:focus-visible{outline:3px solid rgba(254,44,85,.4);outline-offset:2px;}
.${NS}-btn svg{width:18px;height:18px;} .${NS}-btn svg{width:18px;height:18px;}
.${NS}-switch{position:fixed;right:24px;bottom:76px;z-index:2147483646;display:inline-flex;
align-items:center;gap:6px;background:rgba(15,23,42,.72);color:#fff;
border:1px solid rgba(255,255,255,.18);border-radius:999px;padding:8px 14px 8px 12px;
font-size:12.5px;font-weight:500;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
box-shadow:0 6px 18px rgba(15,23,42,.3);transition:background .2s ease,transform .2s ease;}
.${NS}-switch:hover{background:rgba(15,23,42,.88);}
.${NS}-switch:active{transform:translateY(1px);}
.${NS}-switch:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
.${NS}-switch svg{width:14px;height:14px;}
.${NS}-mask{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.45); .${NS}-mask{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.45);
display:flex;align-items:center;justify-content:center;padding:20px; display:flex;align-items:center;justify-content:center;padding:20px;
animation:${NS}-fade .18s ease;} animation:${NS}-fade .18s ease;}
@@ -316,13 +596,25 @@ INJECT_JS = r"""
function ensureButton() { function ensureButton() {
var root = ensureRoot(); var root = ensureRoot();
if (root.querySelector('.' + NS + '-btn')) return; if (!root.querySelector('.' + NS + '-btn')) {
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.className = NS + '-btn'; btn.className = NS + '-btn';
btn.innerHTML = ICON.login + '<span>一键本地登录</span>'; btn.innerHTML = ICON.login + '<span>一键本地登录</span>';
btn.title = '在本机打开已登录托管账号的抖音浏览器'; btn.title = '在本机打开已登录托管账号的抖音浏览器';
btn.addEventListener('click', showPanel); btn.addEventListener('click', showPanel);
root.appendChild(btn); root.appendChild(btn);
}
if (!root.querySelector('.' + NS + '-switch')) {
var sw = document.createElement('button');
sw.className = NS + '-switch';
sw.innerHTML = ICON.swap + '<span>切换站点</span>';
sw.title = '返回站点选择界面';
sw.addEventListener('click', function () {
var a = api();
if (a && a.go_picker) a.go_picker();
});
root.appendChild(sw);
}
} }
// 供 Python 端回报本地登录错误:右下角弹出可关闭的提示条 // 供 Python 端回报本地登录错误:右下角弹出可关闭的提示条
@@ -415,6 +707,76 @@ class Api:
self._window = None self._window = None
self._update_info: dict | None = None self._update_info: dict | None = None
# ---- 站点选择 ----
def get_sites(self) -> dict:
cfg = load_config()
return {"sites": get_all_sites(), "remember": bool(cfg.get("default_url"))}
def add_site(self, name: str, url: str) -> dict:
u = normalize_site_url(url)
host = urlparse(u).hostname if u else None
if not u or not host or "." not in host:
return {"error": "网址格式不对,示例:https://dev1.zhenyangtang.com.cn/", "sites": get_all_sites()}
if any(s["url"] == u for s in get_all_sites()):
return {"error": "该站点已存在", "sites": get_all_sites()}
cfg = load_config()
custom = cfg.get("custom_sites", [])
custom.append({"name": (name or "").strip() or host, "url": u})
cfg["custom_sites"] = custom
save_config(cfg)
return {"sites": get_all_sites()}
def remove_site(self, url: str) -> dict:
u = normalize_site_url(url)
cfg = load_config()
cfg["custom_sites"] = [
s for s in cfg.get("custom_sites", []) if normalize_site_url(s.get("url", "")) != u
]
if normalize_site_url(cfg.get("default_url", "")) == u:
cfg.pop("default_url", None)
save_config(cfg)
return {"sites": get_all_sites()}
def ping_site(self, url: str) -> dict:
"""测量站点响应时间(毫秒)。服务器有响应即算可达,包括 4xx/5xx。"""
import time
from urllib import error as _err, request as _req
u = normalize_site_url(url)
if not u:
return {"ok": False}
req = _req.Request(u, method="HEAD", headers={"User-Agent": "Mozilla/5.0"})
start = time.perf_counter()
try:
with _req.urlopen(req, timeout=8):
pass
except _err.HTTPError:
pass # 服务器已响应(如 403/405),延迟有效
except Exception: # noqa: BLE001
return {"ok": False}
return {"ok": True, "ms": int((time.perf_counter() - start) * 1000)}
def open_site(self, url: str, remember: bool = False) -> dict:
u = normalize_site_url(url)
cfg = load_config()
if remember:
cfg["default_url"] = u
else:
cfg.pop("default_url", None)
save_config(cfg)
if self._window:
self._window.load_url(u)
return {"ok": True}
def go_picker(self) -> dict:
"""从站点页面返回选择界面,并取消“记住选择”。"""
cfg = load_config()
cfg.pop("default_url", None)
save_config(cfg)
if self._window:
self._window.load_html(PICKER_HTML)
return {"ok": True}
# ---- 本地登录 ---- # ---- 本地登录 ----
def _notify(self, msg: str, ok: bool = False) -> None: def _notify(self, msg: str, ok: bool = False) -> None:
if not self._window: if not self._window:
@@ -547,25 +909,39 @@ def _check_update_async(window, api) -> None:
def main() -> None: def main() -> None:
api = Api() api = Api()
# 直接打开云端主界面,更新检测放到后台线程做,避免启动时同步联网卡住窗口 # 上次勾选了“记住选择”则直接进该站点,否则先显示站点选择界面
window = webview.create_window( # 更新检测放到后台线程做,避免启动时同步联网卡住窗口。
WINDOW_TITLE, cfg = load_config()
CLOUD_URL, default_url = normalize_site_url(cfg.get("default_url", ""))
js_api=api, if default_url and any(s["url"] == default_url for s in get_all_sites()):
width=1280, window = webview.create_window(
height=860, WINDOW_TITLE,
text_select=True, default_url,
) js_api=api,
width=1280,
height=860,
text_select=True,
)
else:
window = webview.create_window(
WINDOW_TITLE,
html=PICKER_HTML,
js_api=api,
width=1280,
height=860,
text_select=True,
)
api._window = window api._window = window
_update_checked = {"done": False} _update_checked = {"done": False}
def on_loaded(): def on_loaded():
# 仅在云端站点页面注入悬浮按钮脚本;about:blank 等不注入。 # 仅在已配置站点页面注入悬浮按钮脚本;选择页/about:blank 等不注入。
try: try:
current = window.get_current_url() or "" current = window.get_current_url() or ""
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
current = "" current = ""
if CLOUD_HOST and CLOUD_HOST in current: host = urlparse(current).hostname or ""
if host and host in site_hosts():
try: try:
window.evaluate_js(INJECT_JS) window.evaluate_js(INJECT_JS)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
+9 -4
View File
@@ -11,10 +11,15 @@
【推荐:桌面整合版 desktop_app.py】——一个软件搞定 【推荐:桌面整合版 desktop_app.py】——一个软件搞定
==================================================================== ====================================================================
效果: 效果:
打开软件 → 默认加载云端网站 https://dev.zhenyangtang.com.cn/ 打开软件 → 先显示「站点选择」界面,内置两个站点:
→ 你在里面正常登录后台 - 节点1 https://dev.zhenyangtang.com.cn/
→ 页面右下角出现「🚀 一键本地登录」按钮 - 节点2 https://dev1.zhenyangtang.com.cn/
→ 点它 → 弹出托管账号列表 → 选一个 以后有新环境,可直接在界面下方填名称+网址「添加」(保存在本机,升级不丢)。
勾选「记住选择」后,下次启动直接进入该站点,跳过选择页。
→ 选一个站点进入 → 你在里面正常登录后台
→ 页面右下角出现「一键本地登录」按钮(上方还有「切换站点」按钮,
点击可随时返回站点选择界面,并自动取消“记住选择”)
→ 点「一键本地登录」→ 弹出托管账号列表 → 选一个
→ 本机直接打开一个“已登录该托管账号”的浏览器进入抖音。 → 本机直接打开一个“已登录该托管账号”的浏览器进入抖音。
运行方式一(推荐,无黑窗): 运行方式一(推荐,无黑窗):