事件驱动极速版 - Linux 服务器适配版
对外同步直出 + 内部隔离 + 结果即焚 + 并发队列 + 指纹增强 + 多播放器适配 + 日志监控 + 运维面板
目录
概述
基于 Flask + Playwright 构建的视频资源嗅探服务,专为 Linux 服务器环境优化。通过无头浏览器自动化访问目标页面,拦截网络请求提取视频直链(M3U8 / MP4 / TS),并支持多播放器框架适配与反检测指纹模拟。
核心特性
| 特性 | 说明 |
|---|---|
| 对外同步直出 | 主接口 /?url=xxx 同步等待结果,15 秒内直出 |
| 内部隔离 | 每个任务独立 UUID,内存隔离存储 |
| 结果即焚 | 结果一旦被消费立即从内存删除,防止泄漏 |
| 并发队列 | 浏览器信号量限制(默认 3 个槽位),超出排队等待 |
| 指纹增强 | 随机 UA、视口、平台、时区、语言、硬件参数 |
| 多播放器适配 | 支持 DPlayer、Video.js、Hls.js、XgPlayer、JWPlayer、CKPlayer 等 |
| 日志监控 | 按日轮转日志,保留 7 天,支持控制台与文件双输出 |
| 运维面板 | 内置 /monitor 可视化大盘,Chart.js 实时渲染 |
依赖安装
pip install flask playwright
playwright install chromium
配置说明
环境变量
| 变量名 | 作用 | 示例 |
|---|---|---|
SNIFF_PROXY |
全局代理地址 | http://127.0.0.1:7890 |
关键常量
| 常量 | 默认值 | 说明 |
|---|---|---|
browser_sem |
3 |
浏览器并发槽位 |
TIMEOUT_RUNNING |
300 秒 |
任务超时清理时间 |
REFRESH_INTERVAL |
5000 ms |
监控面板刷新间隔 |
API 接口
1. 同步嗅探(直出)
GET /?url=<目标网址>
- 同步等待最多 15 秒
- 成功直出结果,失败返回
task_id供轮询
2. 异步提交
GET /sniff?url=<目标网址>
POST /sniff {'url': '<目标网址>'}
- 立即返回
task_id - 通过
/result?task_id=xxx查询结果
3. 查询结果
GET /result?task_id=<任务ID>
- 若任务完成且未消费,返回结果并销毁
- 若仍在运行,返回进度快照
4. 任务列表
GET /tasks
- 返回所有存活任务的快照(不含结果)
5. 健康检查
GET /health
- 返回运行时长、成功率、平均耗时、队列状态等
6. 运维监控面板
GET /monitor
- 可视化 Dashboard,实时图表 + 任务明细 + 事件日志
代码结构
事件驱动极速版
├── 日志配置 (logging)
│ └── 控制台 + 按日轮转文件 (保留7天)
├── 多播放器探测脚本 (PLAYER_PROBE_SCRIPT)
│ └── DPlayer / Video.js / Hls.js / XgPlayer / JWPlayer / CKPlayer
├── 指纹池 (UA_POOL / VIEWPORT_POOL)
│ └── 随机 UA、视口、平台、硬件参数
├── 反检测脚本 (build_antidetect_script)
│ └── 覆盖 navigator.webdriver / plugins / languages / chrome.runtime 等
├── 统计指标 (stats)
│ └── started / completed / failed / total_elapsed / peak_tasks / peak_queue
├── 任务隔离存储 (tasks)
│ └── create_task / pop_task_result / cleanup_tasks
├── 核心嗅探逻辑 (sniff_task)
│ ├── Playwright 启动无头 Chromium
│ ├── 路由拦截:捕获 .m3u8 / .mp4 / .ts / CDN 视频请求
│ ├── 元数据提取:标题、封面、演员、导演、类型、年份、地区
│ ├── 弹窗自动点击(确定/确认/播放等)
│ ├── 自动播放视频元素
│ ├── 点击播放按钮探测
│ ├── JS 变量探测(window 对象关键词扫描)
│ └── iframe 递归探测
├── 接口层 (Flask)
│ ├── / - 同步直出
│ ├── /sniff - 异步提交
│ ├── /result - 结果查询
│ ├── /tasks - 任务列表
│ ├── /health - 健康检查
│ └── /monitor- 运维面板
└── 启动逻辑
└── 清理守护线程 + Flask 服务 (0.0.0.0:3000)
完整源码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
事件驱动极速版 - Linux 服务器适配版
对外同步直出 + 内部隔离 + 结果即焚 + 并发队列 + 指纹增强 + 多播放器适配 + 日志监控 + 运维面板
"""
import threading
import asyncio
import time
import json
import uuid
import random
import os
import logging
import logging.handlers
from urllib.parse import urlparse
from flask import Flask, request, Response
from playwright.async_api import async_playwright
app = Flask(__name__)
# ==================== 日志配置 ====================
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, "sniff.log")
logger = logging.getLogger("sniff")
logger.setLevel(logging.INFO)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
file_handler = logging.handlers.TimedRotatingFileHandler(
LOG_FILE, when="midnight", interval=1, backupCount=7, encoding="utf-8"
)
file_handler.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
if not logger.handlers:
logger.addHandler(console_handler)
logger.addHandler(file_handler)
BOOT_TIME = time.time()
# ==================== 多播放器探测脚本 ====================
PLAYER_PROBE_SCRIPT = """
() => {
const urls = new Set();
const add = (u) => {
if (u && typeof u === 'string' && (u.startsWith('http') || u.startsWith('//'))) {
urls.add(u.startsWith('//') ? 'https:' + u : u);
}
};
document.querySelectorAll('video').forEach(v => {
add(v.src); add(v.currentSrc);
if (v.dataset && v.dataset.src) add(v.dataset.src);
});
try {
const dp = window.dplayer || window.dp;
if (dp && dp.video) { add(dp.video.src); add(dp.video.currentSrc); }
if (dp && dp.options && dp.options.video) { add(dp.options.video.url); add(dp.options.video.src); }
} catch(e) {}
try {
if (window.videojs) {
const players = window.videojs.getPlayers();
for (let k in players) {
const p = players[k];
if (!p) continue;
if (p.src) add(p.src());
if (p.currentSrc) add(p.currentSrc());
if (p.tech_ && p.tech_.hls && p.tech_.hls.url) add(p.tech_.hls.url);
if (p.tech_ && p.tech_.vhs && p.tech_.vhs.playlists && p.tech_.vhs.playlists.media) add(p.tech_.vhs.playlists.media.uri);
}
}
} catch(e) {}
try {
if (window.hls && window.hls.url) add(window.hls.url);
if (window.Hls && window.Hls.instance && window.Hls.instance.url) add(window.Hls.instance.url);
} catch(e) {}
try {
if (window.player && window.player.source) {
const s = window.player.source;
if (typeof s === 'string') add(s);
if (s.sources) s.sources.forEach(src => add(src.src));
if (s.src) add(s.src);
}
} catch(e) {}
try {
if (window.xgplayer && window.xgplayer.src) add(window.xgplayer.src);
if (window.XgPlayer && window.XgPlayer.src) add(window.XgPlayer.src);
} catch(e) {}
try {
if (window.jwplayer) {
const jwp = window.jwplayer();
if (jwp && jwp.getPlaylist) {
jwp.getPlaylist().forEach(item => {
add(item.file);
if (item.sources) item.sources.forEach(s => add(s.file || s.src));
});
}
}
} catch(e) {}
try {
if (window.ckplayer && window.ckplayer.getMetaData) add(window.ckplayer.getMetaData().video);
} catch(e) {}
const keywords = ['video', 'player', 'hls', 'm3u8', 'mp4', 'url', 'src', 'source', 'stream', 'play'];
for (let key in window) {
if (keywords.some(kw => key.toLowerCase().includes(kw))) {
try {
const val = window[key];
if (typeof val === 'string' && (val.includes('.m3u8') || val.includes('.mp4') || val.includes('.ts'))) add(val);
if (typeof val === 'object' && val !== null) {
if (val.url) add(val.url);
if (val.src) add(val.src);
if (val.video) add(val.video);
if (val.file) add(val.file);
if (val.playUrl) add(val.playUrl);
if (val.m3u8) add(val.m3u8);
}
} catch(e) {}
}
}
return Array.from(urls);
}
"""
# ==================== 指纹池 ====================
UA_POOL = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
]
VIEWPORT_POOL = [
{"width": 1920, "height": 1080},
{"width": 1366, "height": 768},
{"width": 1440, "height": 900},
{"width": 1536, "height": 864},
{"width": 1280, "height": 720},
{"width": 1680, "height": 1050},
{"width": 1600, "height": 900},
{"width": 2560, "height": 1440},
]
def generate_fingerprint():
ua = random.choice(UA_POOL)
viewport = random.choice(VIEWPORT_POOL).copy()
platform = "MacIntel" if "Macintosh" in ua else "Win32"
return {
"ua": ua,
"viewport": viewport,
"platform": platform,
"locale": "zh-CN",
"languages": ["zh-CN", "zh", "en-US", "en"],
"timezone": "Asia/Shanghai",
"hardware_concurrency": random.choice([4, 8, 12, 16]),
"device_memory": random.choice([4, 8, 16]),
"color_scheme": random.choice(["light", "dark"]),
"proxy": os.environ.get("SNIFF_PROXY", "").strip() or None,
}
def build_antidetect_script(platform, languages, hardware_concurrency, device_memory):
return f"""
() => {{
Object.defineProperty(navigator, 'webdriver', {{
get: () => undefined,
configurable: true
}});
Object.defineProperty(navigator, 'plugins', {{
get: () => [
{{
name: "Chrome PDF Plugin",
filename: "internal-pdf-viewer",
description: "Portable Document Format",
length: 1,
item: function(idx) {{ return this[idx]; }},
namedItem: function(name) {{ return null; }}
}},
{{
name: "Chrome PDF Viewer",
filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
description: "Portable Document Format",
length: 1,
item: function(idx) {{ return this[idx]; }},
namedItem: function(name) {{ return null; }}
}},
{{
name: "Native Client",
filename: "internal-nacl-plugin",
description: "",
length: 2,
item: function(idx) {{ return this[idx]; }},
namedItem: function(name) {{ return null; }}
}}
],
configurable: true
}});
Object.defineProperty(navigator, 'languages', {{
get: () => {json.dumps(languages)},
configurable: true
}});
Object.defineProperty(navigator, 'hardwareConcurrency', {{
get: () => {hardware_concurrency},
configurable: true
}});
Object.defineProperty(navigator, 'deviceMemory', {{
get: () => {device_memory},
configurable: true
}});
Object.defineProperty(navigator, 'platform', {{
get: () => "{platform}",
configurable: true
}});
if (!window.chrome) {{ window.chrome = {{}}; }}
if (!window.chrome.runtime) {{
window.chrome.runtime = {{
OnInstalledReason: {{ CHROME_UPDATE: "chrome_update", SHARED_MODULE_UPDATE: "shared_module_update", BROWSER_UPDATE: "browser_update" }},
OnRestartRequiredReason: {{ APP_UPDATE: "app_update", OS_UPDATE: "os_update", PERIODIC: "periodic" }},
PlatformArch: {{ ARM: "arm", ARM64: "arm64", MIPS: "mips", MIPS64: "mips64", X86_32: "x86-32", X86_64: "x86-64" }},
PlatformNaclArch: {{ ARM: "arm", MIPS: "mips", MIPS64: "mips64", MIPS64EL: "mips64el", MIPSEL: "mipsel", X86_32: "x86-32", X86_64: "x86-64" }},
PlatformOs: {{ ANDROID: "android", CROS: "cros", LINUX: "linux", MAC: "mac", OPENBSD: "openbsd", WIN: "win" }},
RequestUpdateCheckStatus: {{ NO_UPDATE: "no_update", THROTTLED: "throttled", UPDATE_AVAILABLE: "update_available" }}
}};
}}
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => {{
if (parameters.name === 'notifications') {{
return Promise.resolve({{ state: Notification.permission }});
}}
return originalQuery(parameters);
}};
}}
"""
# ==================== 统计指标 ====================
stats_lock = threading.Lock()
stats = {
"started": 0,
"completed": 0,
"failed": 0,
"total_elapsed": 0,
"peak_tasks": 0,
"peak_queue": 0,
}
# ==================== 任务隔离存储 ====================
task_lock = threading.Lock()
tasks = {}
browser_sem = threading.Semaphore(3)
TIMEOUT_RUNNING = 300
queue_wait_lock = threading.Lock()
queue_wait_count = 0
def create_task(url: str) -> str:
task_id = str(uuid.uuid4())[:12]
with task_lock:
tasks[task_id] = {
"url": url,
"m3u8": [],
"mp4": [],
"images": [],
"loading": True,
"meta": {},
"elapsed": 0,
"created_at": time.time(),
"done_event": threading.Event(),
}
current_tasks = len(tasks)
with stats_lock:
stats["started"] += 1
if current_tasks > stats["peak_tasks"]:
stats["peak_tasks"] = current_tasks
logger.info("[任务创建] task_id=%s | 当前存活任务=%d", task_id, current_tasks)
return task_id
def pop_task_result(task_id: str):
with task_lock:
task = tasks.get(task_id)
if task is None:
return None
if not task["loading"]:
result = {
"url": task["url"],
"loading": False,
"elapsed": task["elapsed"],
"meta": task["meta"],
"m3u8": task["m3u8"],
"mp4": task["mp4"],
"images": task["images"],
}
del tasks[task_id]
logger.info("[销毁] task_id=%s 结果已交付,内存释放", task_id)
return result
return None
def cleanup_tasks():
now = time.time()
removed = []
with task_lock:
for tid in list(tasks.keys()):
t = tasks[tid]
if t["loading"] and (now - t["created_at"] > TIMEOUT_RUNNING):
removed.append(tid)
del tasks[tid]
if removed:
logger.warning("[清理] 移除卡死任务: %s", ", ".join(removed))
def start_cleanup_worker():
def loop():
while True:
time.sleep(60)
cleanup_tasks()
threading.Thread(target=loop, daemon=True).start()
# ==================== 工具函数 ====================
def is_real_video(url: str, page_url: str) -> bool:
if url == page_url or not url.startswith(("http://", "https://")):
return False
if "vid=" in url and (".php" in url or "player" in url):
return False
url_lower = url.lower()
cdn_hosts = [
"cmecloud.cn", "aliyuncs.com", "myqcloud.com", "qiniudn.com",
"qiniup.com", "bdstatic.com", "s3.amazonaws.com", "cloudfront.net",
"douyincdn.com", "byteimg.com", "youku.com", "hdslb.com",
"bytetos.com", "jiexicn.top", "wki8.com"
]
host = urlparse(url).netloc.lower()
is_cdn = any(cdn in host for cdn in cdn_hosts)
has_video_ext = any(ext in url_lower for ext in [".mp4", ".m3u8", ".mkv", ".flv", ".ts"])
sign_keywords = ["x-amz-", "expires=", "signature=", "token=",
"response-content-disposition", "auth_key=", "x-expires=", "x-signature="]
has_sign = any(kw in url_lower for kw in sign_keywords)
return is_cdn or has_video_ext or has_sign
def run_async(coro):
global queue_wait_count
with queue_wait_lock:
queue_wait_count += 1
if queue_wait_count > stats["peak_queue"]:
with stats_lock:
if queue_wait_count > stats["peak_queue"]:
stats["peak_queue"] = queue_wait_count
logger.debug("[队列] 等待浏览器槽位,当前排队=%d", queue_wait_count)
acquired = False
try:
with browser_sem:
acquired = True
with queue_wait_lock:
queue_wait_count -= 1
logger.debug("[队列] 获得浏览器槽位,当前排队=%d", queue_wait_count)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(coro)
finally:
loop.close()
finally:
if not acquired:
with queue_wait_lock:
queue_wait_count -= 1
logger.warning("[队列] 等待浏览器槽位时异常退出")
def make_response(data):
resp = Response(
json.dumps(data, ensure_ascii=False, indent=2),
mimetype='application/json; charset=utf-8'
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
# ==================== 核心嗅探逻辑 ====================
async def sniff_task(task_id: str, target_url: str):
start_time = time.time()
with task_lock:
task = tasks.get(task_id)
if not task:
logger.error("[错误] task_id=%s 不存在", task_id)
return
task["url"] = target_url
task["m3u8"] = []
task["mp4"] = []
task["images"] = []
task["meta"] = {}
task["loading"] = True
task["done_event"].clear()
logger.info("[开始嗅探] task_id=%s | %s", task_id, target_url)
video_found = asyncio.Event()
success = False
fp = generate_fingerprint()
logger.info("[指纹] task_id=%s | UA=%s... | 视口=%dx%d | 代理=%s",
task_id, fp["ua"][:50], fp["viewport"]["width"], fp["viewport"]["height"],
fp["proxy"] or "无")
try:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
ignore_default_args=["--enable-automation"],
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-blink-features=AutomationControlled',
]
)
context_options = {
"user_agent": fp["ua"],
"viewport": fp["viewport"],
"locale": fp["locale"],
"timezone_id": fp["timezone"],
"color_scheme": fp["color_scheme"],
"geolocation": {"latitude": 31.2304, "longitude": 121.4737},
"permissions": ["geolocation"],
}
if fp["proxy"]:
context_options["proxy"] = {"server": fp["proxy"]}
context = await browser.new_context(**context_options)
page = await context.new_page()
page.on("dialog", lambda dialog: asyncio.create_task(dialog.accept()))
await page.add_init_script(build_antidetect_script(
fp["platform"], fp["languages"], fp["hardware_concurrency"], fp["device_memory"]
))
async def handle_route(route, request):
url = request.url
if ".m3u8" in url or ".m3u" in url:
if url not in task["m3u8"]:
task["m3u8"].append(url)
logger.info("[%s] [M3U8] %s", task_id, url)
video_found.set()
elif ".mp4" in url.lower() or ".ts" in url.lower():
if url not in task["mp4"]:
task["mp4"].append(url)
logger.info("[%s] [MP4/TS] %s...", task_id, url[:80])
video_found.set()
elif is_real_video(url, target_url):
if url not in task["mp4"]:
task["mp4"].append(url)
logger.info("[%s] [视频直链] %s...", task_id, url[:80])
video_found.set()
elif any(ext in url.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]):
if url not in task["images"]:
task["images"].append(url)
await route.continue_()
await context.route("**/*", handle_route)
try:
await page.goto(target_url, wait_until="domcontentloaded", timeout=5000)
except Exception as e:
logger.warning("[%s] [加载警告] %s", task_id, e)
async def extract_meta():
try:
meta = await page.evaluate("""() => {
const data = {};
if (window.player_aaaa && window.player_aaaa.vod_data) {
const vd = window.player_aaaa.vod_data;
data.title = vd.vod_name || '';
data.cover = window.player_aaaa.vod_pic || '';
data.actors = vd.vod_actor || '';
data.director = vd.vod_director || '';
data.type = vd.vod_class || '';
}
if (!data.title) {
const h1 = document.querySelector('h1.title a, h1.title, .stui-content__detail h1');
if (h1) data.title = h1.innerText.trim();
}
if (!data.cover) {
const img = document.querySelector('.stui-vodlist__thumb img, .vod-detail-img img, .lazyload');
if (img) data.cover = img.getAttribute('data-original') || img.src || '';
}
const html = document.body.innerHTML;
const typeMatch = html.match(/类型[sS]*?<a[^>]*>([^<]+)</a>/i);
if (typeMatch && !data.type) data.type = typeMatch[1];
const yearMatch = html.match(/年份[sS]*?<a[^>]*>([^<]+)</a>/i);
if (yearMatch) data.year = yearMatch[1];
const areaMatch = html.match(/地区[sS]*?<a[^>]*>([^<]+)</a>/i);
if (areaMatch) data.area = areaMatch[1];
const descEl = document.querySelector('#desc .col-pd, .stui-pannel_bd > p.col-pd');
if (descEl) data.desc = descEl.innerText.trim().substring(0, 500);
if (!data.title) {
const t = document.title;
if (t) data.title = t.split(/[-_—|]/)[0].trim();
}
return data;
}""")
task["meta"] = meta
logger.info("[%s] [元数据] %s", task_id, meta.get("title", ""))
except Exception as e:
logger.warning("[%s] [元数据失败] %s", task_id, e)
meta_task = asyncio.create_task(extract_meta())
popup_selectors = [
"button:has-text('确定')", "button:has-text('确认')", "button:has-text('我知道了')",
"button:has-text('同意')", "button:has-text('继续')", "button:has-text('播放')",
".layui-layer-btn0", ".van-dialog__confirm", ".btn-confirm", ".btn-ok"
]
for sel in popup_selectors:
try:
loc = page.locator(sel).first
if await loc.is_visible(timeout=50):
await loc.click()
except:
pass
if video_found.is_set():
logger.info("[%s] [页面加载期间已拿到视频]", task_id)
task["loading"] = False
task["elapsed"] = round((time.time() - start_time) * 1000)
task["done_event"].set()
success = True
await meta_task
await browser.close()
return
await page.evaluate("""() => {
document.querySelectorAll('video').forEach(v => {
v.muted = true;
v.play().catch(()=>{});
});
}""")
try:
await asyncio.wait_for(video_found.wait(), timeout=0.5)
logger.info("[%s] [自动播放后已拿到视频]", task_id)
task["loading"] = False
task["elapsed"] = round((time.time() - start_time) * 1000)
task["done_event"].set()
success = True
await meta_task
await browser.close()
return
except asyncio.TimeoutError:
pass
if not video_found.is_set():
play_selectors = [
"video", ".play-btn", ".player-icon", ".dplayer-play-icon",
"#player", ".stui-player__video", ".btn-play", "#play",
"div[class*='play']", "a[class*='play']"
]
for sel in play_selectors:
try:
btn = page.locator(sel).first
if await btn.is_visible(timeout=200):
await btn.click()
logger.info("[%s] [点击播放] %s", task_id, sel)
break
except:
pass
try:
await asyncio.wait_for(video_found.wait(), timeout=1.0)
logger.info("[%s] [点击播放后已拿到视频]", task_id)
except asyncio.TimeoutError:
pass
if not video_found.is_set():
try:
js_urls = await asyncio.wait_for(
page.evaluate(PLAYER_PROBE_SCRIPT),
timeout=0.5
)
found = False
for url in js_urls:
if is_real_video(url, target_url):
if ".m3u8" in url and url not in task["m3u8"]:
task["m3u8"].append(url)
logger.info("[%s] [JS探测-M3U8] %s...", task_id, url[:80])
found = True
elif url not in task["mp4"]:
task["mp4"].append(url)
logger.info("[%s] [JS探测-视频] %s...", task_id, url[:80])
found = True
if found:
video_found.set()
except asyncio.TimeoutError:
pass
except Exception as e:
logger.warning("[%s] [JS探测异常] %s", task_id, e)
if not video_found.is_set():
for frame in page.frames:
try:
js_urls = await asyncio.wait_for(
frame.evaluate(PLAYER_PROBE_SCRIPT),
timeout=0.3
)
found = False
for url in js_urls:
if is_real_video(url, target_url):
if ".m3u8" in url and url not in task["m3u8"]:
task["m3u8"].append(url)
logger.info("[%s] [iframe-JS探测] %s...", task_id, url[:80])
found = True
elif url not in task["mp4"]:
task["mp4"].append(url)
logger.info("[%s] [iframe-JS探测] %s...", task_id, url[:80])
found = True
if found:
video_found.set()
except asyncio.TimeoutError:
pass
except Exception:
pass
elapsed = round((time.time() - start_time) * 1000)
task["elapsed"] = elapsed
task["loading"] = False
logger.info("[%s] [结束] 耗时 %dms | M3U8:%d MP4:%d",
task_id, elapsed, len(task["m3u8"]), len(task["mp4"]))
task["done_event"].set()
success = True
await meta_task
await browser.close()
except Exception as e:
logger.exception("[%s] [任务异常中断] %s", task_id, e)
with task_lock:
t = tasks.get(task_id)
if t:
t["loading"] = False
t["elapsed"] = round((time.time() - start_time) * 1000)
t["done_event"].set()
finally:
elapsed = round((time.time() - start_time) * 1000)
with stats_lock:
if success:
stats["completed"] += 1
stats["total_elapsed"] += elapsed
logger.info("[统计] 成功 | task_id=%s | 耗时=%dms | 累计完成=%d | 平均=%.1fms",
task_id, elapsed, stats["completed"],
stats["total_elapsed"] / stats["completed"])
else:
stats["failed"] += 1
logger.warning("[统计] 失败 | task_id=%s | 累计失败=%d", task_id, stats["failed"])
# ==================== 接口层 ====================
@app.route('/')
def index():
url = request.args.get('url', '').strip()
if not url:
return make_response({
"success": True,
"message": "请提供 ?url=xxx"
})
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
task_id = create_task(url)
t = threading.Thread(target=run_async, args=(sniff_task(task_id, url),), daemon=True)
t.start()
with task_lock:
task = tasks.get(task_id)
if task and task["done_event"].wait(timeout=15):
result = pop_task_result(task_id)
if result:
return make_response({
"success": True,
**result
})
with task_lock:
task = tasks.get(task_id)
if task:
return make_response({
"success": True,
"task_id": task_id,
"message": "嗅探超时,请通过 /result?task_id=xxx 查询最终结果",
"loading": task["loading"],
"elapsed": task["elapsed"],
"url": task["url"],
"meta": task["meta"],
"m3u8": task["m3u8"],
"mp4": task["mp4"],
"images": task["images"]
})
return make_response({"success": False, "message": "任务异常丢失"}), 500
@app.route('/sniff', methods=['GET', 'POST'])
def start_sniff():
url = request.values.get('url', '').strip()
if not url:
return make_response({"success": False, "message": "缺少 url 参数"}), 400
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
task_id = create_task(url)
t = threading.Thread(target=run_async, args=(sniff_task(task_id, url),), daemon=True)
t.start()
return make_response({
"success": True,
"task_id": task_id,
"message": "任务已提交",
"check_url": f"/result?task_id={task_id}",
"status": "pending"
})
@app.route('/result')
def get_result():
task_id = request.args.get('task_id', '').strip()
if not task_id:
return make_response({"success": False, "message": "缺少 task_id 参数"}), 400
result = pop_task_result(task_id)
if result:
return make_response({"success": True, **result})
with task_lock:
task = tasks.get(task_id)
if task:
return make_response({
"success": True,
"task_id": task_id,
"loading": task["loading"],
"elapsed": task["elapsed"],
"url": task["url"],
"meta": task["meta"],
"m3u8": task["m3u8"],
"mp4": task["mp4"],
"images": task["images"]
})
return make_response({"success": False, "message": "任务不存在或结果已被消费"}), 404
@app.route('/tasks')
def list_tasks():
with task_lock:
snapshot = []
for tid, t in tasks.items():
snapshot.append({
"task_id": tid,
"url": t["url"],
"loading": t["loading"],
"elapsed": t["elapsed"],
"m3u8_count": len(t["m3u8"]),
"mp4_count": len(t["mp4"]),
"created_at": t["created_at"],
})
return make_response({"success": True, "count": len(snapshot), "tasks": snapshot})
@app.route('/health')
def health():
uptime = int(time.time() - BOOT_TIME)
with task_lock:
active = len([t for t in tasks.values() if t["loading"]])
total_alive = len(tasks)
with stats_lock:
total = stats["completed"] + stats["failed"]
success_rate = (stats["completed"] / total * 100) if total > 0 else 0.0
avg_time = (stats["total_elapsed"] / stats["completed"]) if stats["completed"] > 0 else 0.0
peak_t = stats["peak_tasks"]
peak_q = stats["peak_queue"]
with queue_wait_lock:
waiting = queue_wait_count
available = browser_sem._value
status = "healthy" if available > 0 else "degraded"
logger.info("[健康检查] status=%s | active=%d | waiting=%d | available=%d/%d | success_rate=%.1f%% | avg=%.1fms",
status, active, waiting, available, 3, success_rate, avg_time)
return make_response({
"success": True,
"status": status,
"uptime_seconds": uptime,
"active_tasks": active,
"total_alive_tasks": total_alive,
"queue_waiting": waiting,
"browser_available": available,
"browser_total": 3,
"success_rate_percent": round(success_rate, 1),
"avg_elapsed_ms": round(avg_time, 1),
"peak_tasks": peak_t,
"peak_queue": peak_q,
"version": "7.0"
})
# ==================== 运维监控面板 HTML ====================
MONITOR_HTML = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sniff Monitor | 视频嗅探运维大盘</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: #020617;
color: #94a3b8;
}
.font-mono { font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace; }
/* 极细网格背景 */
.grid-bg {
background-image:
linear-gradient(rgba(148,163,184,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,163,184,0.03) 1px, transparent 1px);
background-size: 32px 32px;
}
/* 玻璃卡片 */
.glass-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(51, 65, 85, 0.5);
backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.glass-card:hover {
border-color: rgba(56, 189, 248, 0.2);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.05);
}
/* 状态脉冲 */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.5; }
100% { transform: scale(2); opacity: 0; }
}
.pulse-ring::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid currentColor;
animation: pulse-ring 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* 滚动条 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* 表格行 */
.table-row { transition: background 0.15s; }
.table-row:hover { background: rgba(56, 189, 248, 0.04); }
/* 日志时间戳 */
.log-time { color: #64748b; font-size: 0.75rem; }
.log-info { color: #38bdf8; }
.log-success { color: #34d399; }
.log-warn { color: #fbbf24; }
.log-error { color: #fb7185; }
.chart-container { position: relative; height: 260px; }
</style>
</head>
<body class="grid-bg min-h-screen">
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<header class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<div>
<h1 class="text-xl font-bold text-slate-100 tracking-tight">Sniff Monitor</h1>
<p class="text-xs text-slate-500 mt-0.5">视频嗅探服务运维大盘 <span class="text-slate-700 mx-1">|</span> v7.0</p>
</div>
</div>
<div class="flex items-center gap-3">
<div id="statusBadge" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/80 border border-slate-700 text-xs font-medium">
<span class="relative flex h-2.5 w-2.5">
<span id="statusDot" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span id="statusDotCore" class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span id="statusText" class="text-emerald-400 uppercase tracking-wider">HEALTHY</span>
</div>
<button id="toggleBtn" onclick="toggleAutoRefresh()" class="px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 hover:border-cyan-500/50 text-xs text-slate-300 transition-colors">
⏸ 暂停刷新
</button>
<div class="text-xs text-slate-600 font-mono" id="lastUpdate">--:--:--</div>
</div>
</header>
<!-- KPI Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">运行时长</div>
<div id="kpiUptime" class="text-lg font-bold text-slate-100 font-mono">0s</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">成功率</div>
<div class="flex items-end gap-2">
<span id="kpiRate" class="text-lg font-bold text-emerald-400 font-mono">0.0%</span>
</div>
<div class="mt-2 h-1 bg-slate-800 rounded-full overflow-hidden">
<div id="kpiRateBar" class="h-full bg-emerald-500 rounded-full transition-all duration-500" style="width:0%"></div>
</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">平均耗时</div>
<div id="kpiAvg" class="text-lg font-bold text-cyan-400 font-mono">0ms</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">活跃任务</div>
<div id="kpiActive" class="text-lg font-bold text-slate-100 font-mono">0</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">存活任务</div>
<div id="kpiAlive" class="text-lg font-bold text-slate-400 font-mono">0</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">浏览器槽位</div>
<div class="flex items-end gap-1">
<span id="kpiBrowser" class="text-lg font-bold text-amber-400 font-mono">0</span>
<span class="text-xs text-slate-600 mb-1">/ 3</span>
</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">峰值任务</div>
<div id="kpiPeakTasks" class="text-lg font-bold text-slate-400 font-mono">0</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">峰值队列</div>
<div id="kpiPeakQueue" class="text-lg font-bold text-slate-400 font-mono">0</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<div class="glass-card rounded-xl p-4 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider">任务与队列趋势</h3>
<span class="text-xs text-slate-600 font-mono">最近 30 个采样点</span>
</div>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
<div class="glass-card rounded-xl p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider">资源占用</h3>
<span class="text-xs text-slate-600 font-mono">浏览器实例</span>
</div>
<div class="chart-container" style="height:200px">
<canvas id="resourceChart"></canvas>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-center">
<div class="bg-slate-800/50 rounded-lg py-2">
<div id="resUsed" class="text-lg font-bold text-rose-400 font-mono">0</div>
<div class="text-[10px] text-slate-500 uppercase">已占用</div>
</div>
<div class="bg-slate-800/50 rounded-lg py-2">
<div id="resFree" class="text-lg font-bold text-emerald-400 font-mono">3</div>
<div class="text-[10px] text-slate-500 uppercase">空闲</div>
</div>
</div>
</div>
</div>
<!-- Bottom Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Tasks Table -->
<div class="glass-card rounded-xl p-4 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider">存活任务明细</h3>
<span id="taskCount" class="text-xs px-2 py-1 rounded bg-slate-800 text-slate-400 font-mono">0 tasks</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="border-b border-slate-800 text-slate-500 uppercase tracking-wider">
<th class="pb-2 font-medium">Task ID</th>
<th class="pb-2 font-medium">Target</th>
<th class="pb-2 font-medium">Status</th>
<th class="pb-2 font-medium text-right">Elapsed</th>
<th class="pb-2 font-medium text-right">M3U8</th>
<th class="pb-2 font-medium text-right">MP4</th>
<th class="pb-2 font-medium text-right">Age</th>
</tr>
</thead>
<tbody id="tasksBody" class="font-mono">
<tr><td colspan="7" class="py-8 text-center text-slate-600 italic">暂无存活任务</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Event Log -->
<div class="glass-card rounded-xl p-4 flex flex-col h-[420px]">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider">前端事件流</h3>
<button onclick="clearLogs()" class="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">清空</button>
</div>
<div id="logPanel" class="flex-1 overflow-y-auto font-mono text-xs space-y-1 pr-1">
<!-- logs -->
</div>
</div>
</div>
</div>
<script>
// ==================== 配置 ====================
const REFRESH_INTERVAL = 5000;
const MAX_HISTORY = 30;
let autoRefresh = true;
let timer = null;
let trendChart, resourceChart;
const history = {
labels: [],
active: [],
queue: [],
rate: []
};
// ==================== 工具 ====================
function fmtTime(sec) {
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec/60) + 'm ' + (sec%60) + 's';
const h = Math.floor(sec/3600);
const m = Math.floor((sec%3600)/60);
return h + 'h ' + m + 'm';
}
function fmtAge(ts) {
const s = Math.floor((Date.now()/1000) - ts);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm';
return Math.floor(s/3600) + 'h';
}
function nowStr() {
const d = new Date();
return d.toTimeString().split(' ')[0];
}
function addLog(msg, type='info') {
const panel = document.getElementById('logPanel');
const div = document.createElement('div');
const cls = type==='success'?'log-success':type==='warn'?'log-warn':type==='error'?'log-error':'log-info';
div.innerHTML = `<span class="log-time">[${nowStr()}]</span> <span class="${cls}">${msg}</span>`;
panel.appendChild(div);
panel.scrollTop = panel.scrollHeight;
// 限制行数
while (panel.children.length > 100) panel.removeChild(panel.firstChild);
}
function clearLogs() {
document.getElementById('logPanel').innerHTML = '';
}
// ==================== 图表初始化 ====================
function initCharts() {
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
grid: { color: 'rgba(51,65,85,0.3)', drawBorder: false },
ticks: { color: '#64748b', font: { size: 10, family: 'JetBrains Mono' } }
},
y: {
grid: { color: 'rgba(51,65,85,0.3)', drawBorder: false },
ticks: { color: '#64748b', font: { size: 10, family: 'JetBrains Mono' }, stepSize: 1 },
beginAtZero: true
}
},
elements: {
point: { radius: 0, hitRadius: 6, hoverRadius: 4 },
line: { tension: 0.3, borderWidth: 2 }
}
};
trendChart = new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Active Tasks',
data: [],
borderColor: '#38bdf8',
backgroundColor: 'rgba(56,189,248,0.08)',
fill: true,
stepped: true
},
{
label: 'Queue Wait',
data: [],
borderColor: '#fbbf24',
backgroundColor: 'rgba(251,191,36,0.05)',
fill: true,
stepped: true
}
]
},
options: commonOptions
});
resourceChart = new Chart(document.getElementById('resourceChart'), {
type: 'doughnut',
data: {
labels: ['Used', 'Free'],
datasets: [{
data: [0, 3],
backgroundColor: ['#fb7185', '#34d399'],
borderColor: '#0f172a',
borderWidth: 2,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: {
position: 'bottom',
labels: { color: '#64748b', font: { size: 10, family: 'Inter' }, boxWidth: 10 }
}
}
}
});
}
// ==================== 数据更新 ====================
async function fetchData() {
try {
const [healthRes, tasksRes] = await Promise.all([
fetch('/health'),
fetch('/tasks')
]);
const health = await healthRes.json();
const tasks = await tasksRes.json();
if (!health.success) throw new Error('health failed');
updateDashboard(health, tasks);
document.getElementById('lastUpdate').textContent = nowStr();
addLog('数据刷新成功', 'success');
} catch (err) {
addLog('刷新失败: ' + err.message, 'error');
setStatus('critical');
}
}
function updateDashboard(h, t) {
// Status
setStatus(h.status);
// KPIs
document.getElementById('kpiUptime').textContent = fmtTime(h.uptime_seconds);
document.getElementById('kpiRate').textContent = h.success_rate_percent.toFixed(1) + '%';
document.getElementById('kpiRateBar').style.width = h.success_rate_percent + '%';
document.getElementById('kpiAvg').textContent = h.avg_elapsed_ms.toFixed(0) + 'ms';
document.getElementById('kpiActive').textContent = h.active_tasks;
document.getElementById('kpiAlive').textContent = h.total_alive_tasks;
document.getElementById('kpiBrowser').textContent = h.browser_available;
document.getElementById('kpiPeakTasks').textContent = h.peak_tasks;
document.getElementById('kpiPeakQueue').textContent = h.peak_queue;
// Resource donut
const used = h.browser_total - h.browser_available;
resourceChart.data.datasets[0].data = [used, h.browser_available];
resourceChart.update('none');
document.getElementById('resUsed').textContent = used;
document.getElementById('resFree').textContent = h.browser_available;
// Trend chart history
const label = nowStr();
history.labels.push(label);
history.active.push(h.active_tasks);
history.queue.push(h.queue_waiting);
if (history.labels.length > MAX_HISTORY) {
history.labels.shift();
history.active.shift();
history.queue.shift();
}
trendChart.data.labels = history.labels;
trendChart.data.datasets[0].data = history.active;
trendChart.data.datasets[1].data = history.queue;
trendChart.update('none');
// Tasks table
const tbody = document.getElementById('tasksBody');
document.getElementById('taskCount').textContent = (t.count || 0) + ' tasks';
if (!t.tasks || t.tasks.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="py-8 text-center text-slate-600 italic">暂无存活任务</td></tr>';
} else {
tbody.innerHTML = t.tasks.map(task => {
const domain = task.url ? new URL(task.url).hostname.replace('www.','') : '-';
const status = task.loading
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">RUNNING</span>'
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-700 text-slate-400 border border-slate-600">IDLE</span>';
return `<tr class="table-row border-b border-slate-800/50">
<td class="py-2.5 text-slate-300">${task.task_id}</td>
<td class="py-2.5 text-slate-400 truncate max-w-[140px]" title="${task.url || ''}">${domain}</td>
<td class="py-2.5">${status}</td>
<td class="py-2.5 text-right text-slate-300">${task.elapsed}ms</td>
<td class="py-2.5 text-right ${task.m3u8_count?'text-emerald-400':'text-slate-600'}">${task.m3u8_count}</td>
<td class="py-2.5 text-right ${task.mp4_count?'text-cyan-400':'text-slate-600'}">${task.mp4_count}</td>
<td class="py-2.5 text-right text-slate-500">${fmtAge(task.created_at)}</td>
</tr>`;
}).join('');
}
}
function setStatus(status) {
const dot = document.getElementById('statusDot');
const core = document.getElementById('statusDotCore');
const text = document.getElementById('statusText');
const badge = document.getElementById('statusBadge');
const map = {
healthy: { dot: 'bg-emerald-400', core: 'bg-emerald-500', text: 'text-emerald-400', label: 'HEALTHY', border: 'border-emerald-500/30' },
degraded: { dot: 'bg-amber-400', core: 'bg-amber-500', text: 'text-amber-400', label: 'DEGRADED', border: 'border-amber-500/30' },
critical: { dot: 'bg-rose-400', core: 'bg-rose-500', text: 'text-rose-400', label: 'CRITICAL', border: 'border-rose-500/30' }
};
const s = map[status] || map.critical;
dot.className = `animate-ping absolute inline-flex h-full w-full rounded-full ${s.dot} opacity-75`;
core.className = `relative inline-flex rounded-full h-2.5 w-2.5 ${s.core}`;
text.className = `${s.text} uppercase tracking-wider`;
text.textContent = s.label;
badge.className = `flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/80 border ${s.border} text-xs font-medium`;
}
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
const btn = document.getElementById('toggleBtn');
if (autoRefresh) {
btn.innerHTML = '⏸ 暂停刷新';
btn.classList.remove('text-amber-400');
timer = setInterval(fetchData, REFRESH_INTERVAL);
addLog('自动刷新已恢复', 'info');
} else {
btn.innerHTML = '▶ 继续刷新';
btn.classList.add('text-amber-400');
clearInterval(timer);
addLog('自动刷新已暂停', 'warn');
}
}
// ==================== 启动 ====================
initCharts();
addLog('监控面板初始化完成', 'info');
fetchData();
timer = setInterval(fetchData, REFRESH_INTERVAL);
</script>
</body>
</html>
"""
@app.route('/monitor')
def monitor():
"""运维可视化监控面板"""
return Response(MONITOR_HTML, mimetype='text/html; charset=utf-8')
# ==================== 启动 ====================
if __name__ == '__main__':
start_cleanup_worker()
logger.info("=" * 60)
logger.info("Linux 适配版 - 运维面板 + 多播放器适配 + 指纹增强")
logger.info("=" * 60)
logger.info("主接口: GET /?url=xxx")
logger.info("监控面板: GET /monitor")
logger.info("辅助接口: /sniff /result /tasks /health")
proxy = os.environ.get("SNIFF_PROXY", "").strip()
logger.info("当前代理: %s", proxy if proxy else "无")
logger.info("=" * 60)
app.run(host='0.0.0.0', port=3000, debug=False, threaded=True)
运维监控面板
访问 http://<host>:3000/monitor 即可打开可视化大盘:
- KPI 卡片:运行时长、成功率、平均耗时、活跃任务、浏览器槽位、峰值统计
- 趋势图:任务数与队列等待数的时序折线图(最近 30 个采样点)
- 资源占用:浏览器实例的甜甜圈图(已占用 vs 空闲)
- 任务明细:存活任务的 ID、目标域名、状态、耗时、M3U8/MP4 数量、存活时长
- 事件流:前端实时日志,支持暂停/恢复刷新
使用示例
启动服务
export SNIFF_PROXY="http://127.0.0.1:7890" # 可选
python3 sniff.py
请求示例
# 同步直出
curl "http://localhost:3000/?url=https://example.com/video/123"
# 异步提交
curl "http://localhost:3000/sniff?url=https://example.com/video/123"
# 查询结果
curl "http://localhost:3000/result?task_id=abc123"
# 健康检查
curl "http://localhost:3000/health"
响应格式
{
"success": true,
"url": "https://example.com/video/123",
"loading": false,
"elapsed": 2345,
"meta": {
"title": "视频标题",
"cover": "https://.../cover.jpg",
"actors": "演员名",
"type": "类型"
},
"m3u8": ["https://.../index.m3u8"],
"mp4": ["https://.../video.mp4"],
"images": ["https://.../thumb.jpg"]
}
注意事项
- Linux 环境:务必添加
--no-sandbox和--disable-setuid-sandbox参数 - 内存管理:任务结果即焚,异常任务 300 秒后自动清理
- 并发控制:默认 3 个浏览器实例,可通过
browser_sem调整 - 代理支持:通过
SNIFF_PROXY环境变量设置全局代理
版本: v7.0 | 适配: Linux 服务器
扫描二维码,在手机上阅读
版权说明
文章采用: 《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权。版权声明:未标注转载均为本站原创,转载时请以链接形式注明文章出处。如有侵权、不妥之处,请联系站长删除。敬请谅解!
这是系统生成的演示评论