统计
  • 建站日期:2021-03-10
  • 文章总数:9 篇
  • 评论总数:1 条
  • 分类总数:9 个
  • 最后更新:4月28日

python嗅探视频地址

果说
首页 📤 输出端 正文

事件驱动极速版 - 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"]
}

注意事项

  1. Linux 环境:务必添加 --no-sandbox--disable-setuid-sandbox 参数
  2. 内存管理:任务结果即焚,异常任务 300 秒后自动清理
  3. 并发控制:默认 3 个浏览器实例,可通过 browser_sem 调整
  4. 代理支持:通过 SNIFF_PROXY 环境变量设置全局代理

版本: v7.0 | 适配: Linux 服务器


扫描二维码,在手机上阅读

版权说明
文章采用: 《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权。
版权声明:未标注转载均为本站原创,转载时请以链接形式注明文章出处。如有侵权、不妥之处,请联系站长删除。敬请谅解!

-- 展开阅读全文 --
疯狂动物城系列合集在线播放
« 上一篇
方圆八百米_无广告在线观看
下一篇 »

发表评论

HI ! 请登录
我没觉得孤独,说浪漫些,我完全自由