Google Ad Timing Probe

检测任意网站谷歌广告的加载与渲染时间(支持 AdSense / GPT / AdX)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Google Ad Timing Probe
// @namespace    https://github.com/your-name/ad-timing-probe
// @version      0.7.0
// @description  检测任意网站谷歌广告的加载与渲染时间(支持 AdSense / GPT / AdX)
// @author       You
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @run-at       document-start
// @license MIT
// @noframes
// ==/UserScript==

(function () {
  "use strict";

  if (window !== window.top) return;

  const state = {
    type: "检测中...",
    slots: {},
    requests: [],
    iframes: [],
    gptHooked: false,
    expanded: {},
    hasCmp: false,
  };

  const ms = (v) => (v == null ? "–" : Math.round(v) + "ms");
  const $ = (id) => document.getElementById(id);
  const siteUrl = location.hostname;

  // 相对时间 → 绝对时刻字符串 HH:MM:SS.mmm
  const absTime = (relMs) => {
    const d = new Date(performance.timeOrigin + relMs);
    return d.toLocaleTimeString("zh-CN", { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0");
  };

  // ── html2canvas ───────────────────────────────────────
  function loadHtml2Canvas(cb) {
    if (typeof unsafeWindow.html2canvas !== "undefined") {
      cb();
      return;
    }
    const s = document.createElement("script");
    s.src = "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js";
    s.onload = () => cb();
    s.onerror = () => toast("截图库加载失败");
    (document.head || document.documentElement).appendChild(s);
  }

  // ── 样式 ─────────────────────────────────────────────
  GM_addStyle(`
    #__adp_wrap {
      position:fixed;z-index:2147483647;
      bottom:16px;right:16px;width:600px;
      background:#fff;border:1px solid #dadce0;
      border-radius:10px;
      box-shadow:0 4px 20px rgba(0,0,0,.15);
      font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
      font-size:12px;color:#202124;overflow:hidden;
    }
    #__adp_head {
      background:#1a73e8;color:#fff;
      padding:9px 14px;display:flex;
      align-items:center;justify-content:space-between;
      cursor:move;user-select:none;
    }
    #__adp_head .adp-title{font-size:13px;font-weight:600;}
    #__adp_controls{display:flex;gap:8px;align-items:center;}
    #__adp_tag{
      font-size:10px;padding:2px 8px;border-radius:10px;
      background:rgba(255,255,255,.22);white-space:nowrap;
    }
    .adp-btn{
      cursor:pointer;font-size:17px;line-height:1;
      opacity:.8;background:none;border:none;color:#fff;padding:0;
    }
    .adp-btn:hover{opacity:1;}
    #__adp_body{padding:10px 14px;max-height:560px;overflow-y:auto;}
    #__adp_body::-webkit-scrollbar{width:4px;}
    #__adp_body::-webkit-scrollbar-thumb{background:#dadce0;border-radius:2px;}
    .adp-sec{
      font-size:10px;color:#80868b;text-transform:uppercase;
      letter-spacing:.07em;margin:12px 0 6px;font-weight:600;
    }
    .adp-sec:first-child{margin-top:2px;}
    .adp-sec-hint{
      font-size:9px;text-transform:none;
      letter-spacing:0;color:#b0b4b9;margin-left:4px;
    }

    /* ── 摘要 ── */
    .adp-summary{
      display:flex;background:#f8f9fa;
      border-radius:8px;overflow:hidden;margin-bottom:4px;
    }
    .adp-summary-item{
      flex:1;text-align:center;padding:8px 4px;
      border-right:1px solid #e8eaed;
    }
    .adp-summary-item:last-child{border-right:none;}
    .adp-summary-val{font-size:16px;font-weight:600;display:block;line-height:1.3;}
    .adp-summary-label{color:#80868b;font-size:10px;}

    /* ── 表格 ── */
    .adp-table{
      width:100%;border-collapse:collapse;
      font-size:11px;table-layout:fixed;
    }
    .adp-table thead tr{background:#f8f9fa;}
    .adp-table th{
      padding:5px 4px;text-align:left;
      color:#80868b;font-weight:600;font-size:10px;
      border-bottom:1px solid #e8eaed;
      white-space:nowrap;line-height:1.4;
    }
    .adp-table th small{
      display:block;font-size:9px;font-weight:400;
      color:#b0b4b9;letter-spacing:0;
    }
    /* 列宽分配,总计 600px - 28px padding*2 = ~572px 表格宽 */
    .adp-table th:first-child { width:26px; text-align:center; }
    .adp-table th.col-type    { width:66px; }
    .adp-table th.col-tp      { width:54px; }
    .adp-table th.col-start   { width:76px; }
    .adp-table th.col-end     { width:76px; }
    .adp-table th.col-ttfb    { width:50px; }
    .adp-table th.col-dur     { width:50px; }
    .adp-table th.col-stat    { width:30px; text-align:center; }
    .adp-table th.col-url     { width:auto; }

    .adp-table td{
      padding:5px 4px;vertical-align:middle;
      border-bottom:1px solid #f1f3f4;line-height:1.5;
    }
    .adp-table tbody tr.adp-tr-main{cursor:pointer;}
    .adp-table tbody tr.adp-tr-main:hover td{background:#fafafa;}
    .adp-td-num{text-align:center;color:#80868b;white-space:nowrap;}
    .adp-td-time{font-size:10px;color:#5f6368;font-variant-numeric:tabular-nums;}
    .adp-td-url{
      max-width:0;overflow:hidden;text-overflow:ellipsis;
      white-space:nowrap;color:#80868b;font-size:10px;
    }
    .adp-td-stat{text-align:center;}
    .adp-dur-slow{color:#ea4335;font-weight:600;}
    .adp-dur-ok{color:#34a853;}

    /* ── 展开行 ── */
    .adp-tr-detail{display:none;}
    .adp-tr-detail.open{display:table-row;}
    .adp-tr-detail td{
      padding:6px 8px 10px 32px;
      background:#f5f7ff;
      border-bottom:2px solid #e0e6ff;
    }
    .adp-detail-row{
      display:flex;flex-wrap:wrap;gap:16px;margin-bottom:6px;
    }
    .adp-detail-item{font-size:10px;min-width:80px;}
    .adp-detail-label{color:#80868b;display:block;margin-bottom:2px;}
    .adp-detail-val{font-weight:600;color:#202124;font-size:11px;}
    .adp-detail-url{
      font-size:10px;color:#1a73e8;
      word-break:break-all;line-height:1.5;margin-top:4px;
    }
    .adp-expand-arrow{
      display:inline-block;font-size:9px;color:#b0b4b9;
      margin-right:2px;transition:transform .15s;
      vertical-align:middle;
    }
    .adp-expand-arrow.open{transform:rotate(90deg);}

    /* ── 广告槽 ── */
    .adp-slot{margin:5px 0;padding:7px 10px;background:#f8f9fa;border-radius:7px;line-height:1.9;}
    .adp-path{font-size:10px;color:#5f6368;word-break:break-all;margin-bottom:3px;}
    .adp-meta{display:flex;flex-wrap:wrap;gap:10px;font-size:11px;}
    .adp-row{
      display:flex;justify-content:space-between;align-items:center;
      padding:3px 0;border-bottom:1px solid #f1f3f4;line-height:1.6;
    }
    .adp-row:last-child{border-bottom:none;}

    /* ── 颜色 ── */
    .ok{color:#34a853;} .err{color:#ea4335;} .warn{color:#f9ab00;}
    .muted{color:#80868b;} .blue{color:#1a73e8;font-weight:500;}
    .adp-badge{font-size:10px;padding:1px 5px;border-radius:3px;}
    .adp-badge-slow{background:#fce8e6;color:#ea4335;}
    .adp-badge-ok{background:#e6f4ea;color:#34a853;}
    .adp-badge-mid{background:#fef7e0;color:#b06000;}

    /* ── 底栏 ── */
    #__adp_foot{
      border-top:1px solid #f1f3f4;padding:7px 10px;
      display:flex;justify-content:space-between;align-items:center;gap:5px;
    }
    #__adp_time{font-size:10px;color:#80868b;flex:1;}
    .adp-foot-btn{
      font-size:11px;padding:3px 9px;border-radius:5px;
      border:1px solid #dadce0;color:#5f6368;background:#fff;
      cursor:pointer;white-space:nowrap;transition:all .15s;
    }
    .adp-foot-btn:hover{background:#f8f9fa;border-color:#1a73e8;color:#1a73e8;}
    .adp-foot-btn.primary{border-color:#1a73e8;color:#1a73e8;}
    .adp-foot-btn.primary:hover{background:#e8f0fe;}
    .adp-foot-btn:disabled{opacity:.45;cursor:not-allowed;pointer-events:none;}
    .adp-empty{color:#80868b;font-size:11px;padding:10px 0;}

    /* ── CMP 警告 ── */
    .adp-cmp-alert{
      background:#fce8e6;border:1px solid #f5c6c6;border-radius:7px;
      padding:8px 12px;margin-bottom:8px;color:#c62828;font-size:11px;
      line-height:1.6;
    }
    .adp-cmp-desc{color:#b71c1c;font-size:10px;margin-top:3px;line-height:1.6;}
    .adp-tr-cmp td{background:#fff8f8 !important;}
    .adp-tr-cmp:hover td{background:#fce8e6 !important;}

    /* ── Toast ── */
    #__adp_toast{
      position:absolute;bottom:48px;left:50%;transform:translateX(-50%);
      background:#202124;color:#fff;font-size:11px;
      padding:5px 13px;border-radius:5px;
      opacity:0;transition:opacity .2s;pointer-events:none;
      white-space:nowrap;z-index:1;
    }
    #__adp_toast.show{opacity:1;}
  `);

  // ── 分类 ─────────────────────────────────────────────
  function classify(u) {
    if (u.includes("fundingchoicesmessages.google.com")) return { kind: "CMP同意检查", color: "err", isCmp: true };
    if (u.includes("pagead/ads")) return { kind: "广告请求", color: "blue" };
    if (u.includes("sodar")) return { kind: "流量质检", color: "warn" };
    if (u.includes("pagead/ping") || u.includes("/ping?")) return { kind: "曝光ping", color: "muted" };
    if (u.includes("activeview")) return { kind: "可见度", color: "muted" };
    if (u.includes("show_ads_impl")) return { kind: "AdSense脚本", color: "muted" };
    if (u.includes("gampad")) return { kind: "竞价请求", color: "blue" };
    if (u.includes("safeframe")) return { kind: "SafeFrame", color: "muted" };
    if (u.includes("csi.gstatic.com")) return { kind: "性能上报", color: "muted" };
    if (u.includes("partnerpixels")) return { kind: "伴随像素", color: "muted" };
    if (u.includes("rum.js")) return { kind: "RUM", color: "muted" };
    if (u.includes("securepubads") && u.includes("gpt.js")) return { kind: "GPT库", color: "muted" };
    if (u.includes("securepubads")) return { kind: "GPT请求", color: "blue" };
    if (u.includes("pagead/js")) return { kind: "广告JS", color: "muted" };
    if (u.includes("adsbygoogle")) return { kind: "AdSense", color: "blue" };
    if (u.includes("tpc.googlesyndication")) return { kind: "竞价同步", color: "muted" };
    if (u.includes("googlesyndication")) return { kind: "Syndication", color: "muted" };
    if (u.includes("doubleclick") || u.includes("cm.g.doubleclick")) return { kind: "DFP请求", color: "blue" };
    try {
      const p = new URL(u);
      const seg = p.pathname.split("/").filter(Boolean)[1] || p.hostname.split(".")[0];
      return { kind: seg, color: "muted" };
    } catch {
      return { kind: "other", color: "muted" };
    }
  }

  function isAdUrl(u) {
    return [
      "securepubads",
      "doubleclick",
      "googlesyndication",
      "pagead",
      "adtrafficquality",
      "safeframe",
      "csi.gstatic.com",
      "cm.g.doubleclick",
      "fundingchoicesmessages.google.com",
    ].some((k) => u.includes(k));
  }

  // ── Toast ─────────────────────────────────────────────
  function toast(msg) {
    const el = $("__adp_toast");
    if (!el) return;
    el.textContent = msg;
    el.classList.add("show");
    setTimeout(() => el.classList.remove("show"), 2400);
  }

  // ── 复制 ─────────────────────────────────────────────
  function copyData() {
    const slots = Object.values(state.slots);
    const lines = ["= Google Ad Timing Probe =", `站点: ${siteUrl}`, `时间: ${new Date().toLocaleString()}`, `类型: ${state.type}`, ""];
    if (state.requests.length) {
      lines.push(`── 网络请求 (${state.requests.length}) ──`);
      state.requests.forEach((r, i) => {
        lines.push(`${i + 1}. [${r.kind}]  T+${ms(r.startTime)}  请求:${absTime(r.startTime)}  结束:${absTime(r.startTime + r.duration)}`);
        lines.push(`   TTFB:${ms(r.ttfb)}  耗时:${ms(r.duration)}  DNS:${ms(r.dns)}  TCP:${ms(r.tcp)}  传输:${ms(r.transfer)}`);
        lines.push(`   ${r.url}`);
      });
      lines.push("");
    }
    if (slots.length) {
      lines.push(`── GPT 广告槽 (${slots.length}) ──`);
      slots.forEach((s) => {
        const fill = s.isEmpty == null ? "等待" : s.isEmpty ? "未填充" : "已填充";
        lines.push(`[${fill}] ${s.path}`);
        if (s.size) lines.push(`  尺寸:     ${s.size}`);
        if (s.reqToResp) lines.push(`  响应:     ${ms(s.reqToResp)}`);
        if (s.reqToRender) lines.push(`  渲染:     ${ms(s.reqToRender)}`);
        if (s.lineItemId) lines.push(`  LineItem: ${s.lineItemId}`);
        if (s.creativeId) lines.push(`  Creative: ${s.creativeId}`);
        lines.push("");
      });
    }
    if (state.iframes.length) {
      lines.push(`── 广告 iframe (${state.iframes.length}) ──`);
      state.iframes.forEach((f, i) => {
        lines.push(`iframe ${i + 1}: ${f.w}×${f.h}  ${f.visible ? "视口内" : "视口外"}  ${f.src}`);
      });
    }
    try {
      GM_setClipboard(lines.join("\n"), "text");
      toast("✓ 已复制到剪贴板");
    } catch {
      const ta = document.createElement("textarea");
      ta.value = lines.join("\n");
      ta.style.cssText = "position:fixed;opacity:0;top:0;left:0";
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      ta.remove();
      toast("✓ 已复制到剪贴板");
    }
  }

  // ── 截图 ─────────────────────────────────────────────
  function capturePanel() {
    const shotBtn = $("__adp_shot");
    if (shotBtn) {
      shotBtn.disabled = true;
      shotBtn.textContent = "截图中...";
    }

    loadHtml2Canvas(() => {
      const wrap = $("__adp_wrap");
      if (!wrap) {
        if (shotBtn) {
          shotBtn.disabled = false;
          shotBtn.textContent = "📷 截图";
        }
        return;
      }
      const bodyEl = $("__adp_body");
      const footEl = $("__adp_foot");
      const toastEl = $("__adp_toast");

      const wasHidden = bodyEl && bodyEl.style.display === "none";
      if (wasHidden) {
        bodyEl.style.display = "";
        footEl.style.display = "";
      }
      if (toastEl) toastEl.style.display = "none";

      // 展开所有 detail 行
      const detailRows = wrap.querySelectorAll(".adp-tr-detail");
      const arrows = wrap.querySelectorAll(".adp-expand-arrow");
      detailRows.forEach((r) => r.classList.add("open"));
      arrows.forEach((a) => a.classList.add("open"));

      const prevMaxH = bodyEl ? bodyEl.style.maxHeight : "";
      if (bodyEl) bodyEl.style.maxHeight = "none";

      const restore = () => {
        // 只恢复用户未手动展开的行
        detailRows.forEach((r, i) => {
          if (!state.expanded[i]) r.classList.remove("open");
        });
        arrows.forEach((a, i) => {
          if (!state.expanded[i]) a.classList.remove("open");
        });
        if (bodyEl) bodyEl.style.maxHeight = prevMaxH;
        if (wasHidden) {
          bodyEl.style.display = "none";
          footEl.style.display = "none";
        }
        if (toastEl) toastEl.style.display = "";
        if (shotBtn) {
          shotBtn.disabled = false;
          shotBtn.textContent = "📷 截图";
        }
      };

      unsafeWindow
        .html2canvas(wrap, {
          scale: 2,
          backgroundColor: "#ffffff",
          logging: false,
          useCORS: false,
          allowTaint: false,
        })
        .then((canvas) => {
          restore();
          const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
          const fname = `ad-probe_${siteUrl}_${ts}.png`;
          const a = document.createElement("a");
          a.href = canvas.toDataURL("image/png");
          a.download = fname;
          a.style.display = "none";
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          toast("✓ 已保存 " + fname);
        })
        .catch((e) => {
          restore();
          toast("截图失败: " + e.message);
        });
    });
  }

  // ── 挂载面板 ─────────────────────────────────────────
  function mountPanel() {
    if ($("__adp_wrap")) return;
    if (!document.body) return;
    const wrap = document.createElement("div");
    wrap.id = "__adp_wrap";
    wrap.innerHTML = `
      <div id="__adp_head">
        <span class="adp-title">⏱ Ad Timing Probe</span>
        <div id="__adp_controls">
          <span id="__adp_tag">检测中...</span>
          <button class="adp-btn" id="__adp_min">−</button>
          <button class="adp-btn" id="__adp_cls">×</button>
        </div>
      </div>
      <div id="__adp_body"><p class="adp-empty">等待广告加载...</p></div>
      <div id="__adp_foot">
        <span id="__adp_time">–</span>
        <button class="adp-foot-btn" id="__adp_copy">📋 复制</button>
        <button class="adp-foot-btn primary" id="__adp_shot">📷 截图</button>
        <button class="adp-foot-btn primary" id="__adp_refbtn">刷新</button>
      </div>
      <div id="__adp_toast"></div>
    `;
    document.body.appendChild(wrap);

    let collapsed = false;
    $("__adp_min").onclick = () => {
      collapsed = !collapsed;
      $("__adp_body").style.display = collapsed ? "none" : "";
      $("__adp_foot").style.display = collapsed ? "none" : "";
      $("__adp_min").textContent = collapsed ? "+" : "−";
    };
    $("__adp_cls").onclick = () => wrap.remove();
    $("__adp_refbtn").onclick = () => {
      collectStatic();
      render();
    };
    $("__adp_copy").onclick = copyData;
    $("__adp_shot").onclick = capturePanel;

    loadHtml2Canvas(() => {});

    let ox, oy;
    $("__adp_head").onmousedown = (e) => {
      const r = wrap.getBoundingClientRect();
      ox = e.clientX - r.left;
      oy = e.clientY - r.top;
      const mv = (e2) => {
        wrap.style.left = e2.clientX - ox + "px";
        wrap.style.top = e2.clientY - oy + "px";
        wrap.style.right = "auto";
        wrap.style.bottom = "auto";
      };
      const up = () => {
        document.removeEventListener("mousemove", mv);
        document.removeEventListener("mouseup", up);
      };
      document.addEventListener("mousemove", mv);
      document.addEventListener("mouseup", up);
    };
  }

  // ── 渲染 ─────────────────────────────────────────────
  function render() {
    collectStatic();
    const body = $("__adp_body");
    if (!body) return;
    if ($("__adp_tag")) $("__adp_tag").textContent = state.type;
    if ($("__adp_time")) $("__adp_time").textContent = new Date().toLocaleTimeString();

    let html = "";
    const slots = Object.values(state.slots);
    const filled = slots.filter((s) => s.isEmpty === false).length;
    const unfilled = slots.filter((s) => s.isEmpty === true).length;
    const adReqs = state.requests.filter((r) => ["广告请求", "GPT请求", "竞价请求"].includes(r.kind));
    const avgDur = adReqs.length ? Math.round(adReqs.reduce((s, r) => s + r.duration, 0) / adReqs.length) : null;
    const slowCount = state.requests.filter((r) => r.duration > 2000).length;

    // CMP 警告横幅
    if (state.hasCmp) {
      html += `<div class="adp-cmp-alert">
        ⚠️ 检测到 <b>Funding Choices CMP(用户同意管理)</b>
        <div class="adp-cmp-desc">此请求会阻塞广告竞价,是中国大陆广告加载慢的常见原因。<br>如流量主要来自国内,可在 Google Ad Manager → Privacy &amp; messaging 中关闭 GDPR/CCPA 消息。</div>
      </div>`;
    }

    // 摘要
    if (state.requests.length || slots.length) {
      html += `<div class="adp-summary">
        <div class="adp-summary-item">
          <span class="adp-summary-val">${slots.length}</span>
          <span class="adp-summary-label">广告槽</span>
        </div>
        <div class="adp-summary-item">
          <span class="adp-summary-val ok">${filled}</span>
          <span class="adp-summary-label">已填充</span>
        </div>
        <div class="adp-summary-item">
          <span class="adp-summary-val ${unfilled ? "err" : "muted"}">${unfilled}</span>
          <span class="adp-summary-label">未填充</span>
        </div>
        <div class="adp-summary-item">
          <span class="adp-summary-val ${slowCount ? "warn" : ""}">${avgDur != null ? avgDur + "ms" : "–"}</span>
          <span class="adp-summary-label">平均耗时</span>
        </div>
        <div class="adp-summary-item">
          <span class="adp-summary-val ${slowCount ? "err" : "muted"}">${slowCount}</span>
          <span class="adp-summary-label">慢请求</span>
        </div>
      </div>`;
    }

    // 网络请求表格
    if (state.requests.length) {
      html += `<div class="adp-sec">📡 网络请求 (${state.requests.length})
        <span class="adp-sec-hint">点击行展开 DNS/TCP/传输 详情</span>
      </div>`;
      html += `<table class="adp-table"><thead><tr>
        <th>#</th>
        <th class="col-type">类型</th>
        <th class="col-tp">T+<small>页面起</small></th>
        <th class="col-start">请求时间<small>HH:MM:SS</small></th>
        <th class="col-end">结束时间<small>HH:MM:SS</small></th>
        <th class="col-ttfb">TTFB<small>等待</small></th>
        <th class="col-dur">耗时<small>总计</small></th>
        <th class="col-stat">状态</th>
        <th class="col-url">URL</th>
      </tr></thead><tbody>`;

      state.requests.forEach((r, i) => {
        const slow = r.duration > 2000;
        const mid = r.duration >= 300 && r.duration <= 2000;
        const durCls = slow ? "adp-dur-slow" : r.duration < 300 ? "adp-dur-ok" : "";
        const badge = slow
          ? `<span class="adp-badge adp-badge-slow">慢</span>`
          : mid
            ? `<span class="adp-badge adp-badge-mid">中</span>`
            : `<span class="adp-badge adp-badge-ok">快</span>`;
        const isOpen = !!state.expanded[i];
        const shortUrl = (() => {
          try {
            const u = new URL(r.url);
            return u.pathname.split("/").filter(Boolean).pop() || u.hostname;
          } catch {
            return r.url;
          }
        })();
        const reqTime = absTime(r.startTime);
        const endTime = absTime(r.startTime + r.duration);

        html += `
        <tr class="adp-tr-main${r.isCmp ? " adp-tr-cmp" : ""}" data-idx="${i}">
          <td class="adp-td-num">
            <span class="adp-expand-arrow ${isOpen ? "open" : ""}" id="__adp_arrow_${i}">▶</span>${i + 1}
          </td>
          <td class="${r.color}">${r.kind}</td>
          <td>${ms(r.startTime)}</td>
          <td class="adp-td-time">${reqTime}</td>
          <td class="adp-td-time">${endTime}</td>
          <td>${ms(r.ttfb)}</td>
          <td class="${durCls}">${ms(r.duration)}</td>
          <td class="adp-td-stat">${badge}</td>
          <td class="adp-td-url" title="${r.url}">${shortUrl}</td>
        </tr>
        <tr class="adp-tr-detail ${isOpen ? "open" : ""}" id="__adp_detail_${i}">
          <td colspan="9">
            <div class="adp-detail-row">
              <div class="adp-detail-item">
                <span class="adp-detail-label">DNS 解析</span>
                <span class="adp-detail-val">${ms(r.dns)}</span>
              </div>
              <div class="adp-detail-item">
                <span class="adp-detail-label">TCP 连接</span>
                <span class="adp-detail-val">${ms(r.tcp)}</span>
              </div>
              <div class="adp-detail-item">
                <span class="adp-detail-label">TTFB 等待</span>
                <span class="adp-detail-val">${ms(r.ttfb)}</span>
              </div>
              <div class="adp-detail-item">
                <span class="adp-detail-label">数据传输</span>
                <span class="adp-detail-val">${ms(r.transfer)}</span>
              </div>
              <div class="adp-detail-item">
                <span class="adp-detail-label">总耗时</span>
                <span class="adp-detail-val ${durCls}">${ms(r.duration)}</span>
              </div>
            </div>
            <div class="adp-detail-url">${r.url}</div>
          </td>
        </tr>`;
      });

      html += `</tbody></table>`;
    }

    // GPT 广告槽
    if (slots.length) {
      html += `<div class="adp-sec">📦 GPT 广告槽 (${slots.length})</div>`;
      slots.forEach((s) => {
        const fillHtml =
          s.isEmpty == null
            ? '<span class="muted">等待...</span>'
            : s.isEmpty
              ? '<span class="err">✕ 未填充</span>'
              : '<span class="ok">✓ 已填充</span>';
        html += `<div class="adp-slot">
          <div class="adp-path">${s.path}</div>
          <div class="adp-meta">
            ${fillHtml}
            ${s.size ? `<span>尺寸 ${s.size}</span>` : ""}
            ${s.reqToResp ? `<span>响应 <b>${ms(s.reqToResp)}</b></span>` : ""}
            ${s.reqToRender ? `<span>渲染 <b class="blue">${ms(s.reqToRender)}</b></span>` : ""}
            ${s.lineItemId ? `<span class="muted">LI ${s.lineItemId}</span>` : ""}
            ${s.creativeId ? `<span class="muted">CR ${s.creativeId}</span>` : ""}
          </div>
        </div>`;
      });
    }

    // iframe
    if (state.iframes.length) {
      html += `<div class="adp-sec">🖼 广告 iframe (${state.iframes.length})</div>`;
      state.iframes.forEach((f, i) => {
        const sizeOk = f.w > 0 && f.h > 0;
        html += `<div class="adp-row">
          <span class="muted">iframe ${i + 1} &nbsp;<span class="${sizeOk ? "" : "warn"}">${f.w}×${f.h}</span></span>
          <span style="font-size:10px;color:#80868b;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${f.srcShort}</span>
          <span class="${f.visible ? "ok" : "muted"}">${f.visible ? "视口内" : "视口外"}</span>
        </div>`;
      });
    }

    if (!html) html = '<p class="adp-empty">暂未检测到广告,点击刷新重试</p>';
    body.innerHTML = html;

    // 绑定展开事件
    body.querySelectorAll(".adp-tr-main").forEach((tr) => {
      tr.onclick = () => {
        const idx = +tr.dataset.idx;
        const detail = $(`__adp_detail_${idx}`);
        const arrow = $(`__adp_arrow_${idx}`);
        if (!detail) return;
        const isOpen = detail.classList.toggle("open");
        state.expanded[idx] = isOpen;
        if (arrow) arrow.classList.toggle("open", isOpen);
      };
    });
  }

  // ── GPT 钩子 ─────────────────────────────────────────
  function hookGPT(gt) {
    if (state.gptHooked) return;
    state.gptHooked = true;
    state.type = "GPT / AdX";
    const timing = {};
    gt.cmd.push(() => {
      gt.pubads().addEventListener("slotRequested", (e) => {
        const id = e.slot.getSlotElementId();
        timing[id] = { req: performance.now() };
        if (!state.slots[id]) state.slots[id] = { id, path: e.slot.getAdUnitPath(), isEmpty: null };
        render();
      });
      gt.pubads().addEventListener("slotResponseReceived", (e) => {
        const id = e.slot.getSlotElementId();
        if (timing[id]) timing[id].resp = performance.now();
      });
      gt.pubads().addEventListener("slotRenderEnded", (e) => {
        const id = e.slot.getSlotElementId();
        const rec = timing[id] || {};
        const now = performance.now();
        state.slots[id] = Object.assign(state.slots[id] || { id, path: e.slot.getAdUnitPath() }, {
          isEmpty: e.isEmpty,
          size: e.size ? e.size.join("×") : null,
          lineItemId: e.lineItemId,
          creativeId: e.creativeId,
          reqToResp: rec.req && rec.resp ? rec.resp - rec.req : null,
          reqToRender: rec.req ? now - rec.req : null,
        });
        render();
      });
      try {
        gt.pubads()
          .getSlots()
          .forEach((slot) => {
            const id = slot.getSlotElementId();
            if (!state.slots[id]) state.slots[id] = { id, path: slot.getAdUnitPath(), isEmpty: null };
          });
      } catch (e) {}
    });
  }

  // ── 静态采集 ─────────────────────────────────────────
  function collectStatic() {
    state.requests = performance
      .getEntriesByType("resource")
      .filter((r) => isAdUrl(r.name))
      .map((r) => {
        const { kind, color, isCmp } = classify(r.name);
        return {
          kind,
          color,
          isCmp: !!isCmp,
          url: r.name.split("?")[0],
          startTime: r.startTime,
          duration: r.duration,
          dns: r.domainLookupEnd - r.domainLookupStart,
          tcp: r.connectEnd - r.connectStart,
          ttfb: r.responseStart - r.requestStart,
          transfer: r.responseEnd - r.responseStart,
        };
      })
      .sort((a, b) => a.startTime - b.startTime);

    state.hasCmp = state.requests.some((r) => r.isCmp);

    state.iframes = [...document.querySelectorAll("iframe")]
      .filter(
        (f) =>
          (f.src || "").includes("google") ||
          (f.src || "").includes("doubleclick") ||
          (f.src || "").includes("safeframe") ||
          f.getAttribute("data-google-query-id") ||
          (f.id || "").includes("google"),
      )
      .map((f) => {
        const r = f.getBoundingClientRect();
        const src = f.src || "";
        let srcShort = "(no src)";
        try {
          srcShort = src ? new URL(src).hostname : "(no src)";
        } catch {}
        return { w: Math.round(r.width), h: Math.round(r.height), visible: r.top < window.innerHeight, src, srcShort };
      });

    if (!state.gptHooked) {
      state.type = unsafeWindow.adsbygoogle ? "AdSense" : state.requests.length ? "Google Ads" : "未检测到广告";
    }
  }

  // ── 等待 GPT ─────────────────────────────────────────
  function waitForGPT() {
    let tries = 0;
    const timer = setInterval(() => {
      tries++;
      const gt = unsafeWindow.googletag;
      if (gt) {
        clearInterval(timer);
        hookGPT(gt);
        render();
      } else if (tries > 60) {
        clearInterval(timer);
        collectStatic();
        render();
      }
    }, 150);
  }

  // ── 等待 body ─────────────────────────────────────────
  function waitForBody(cb) {
    if (document.body) {
      cb();
      return;
    }
    const ob = new MutationObserver(() => {
      if (document.body) {
        ob.disconnect();
        cb();
      }
    });
    ob.observe(document.documentElement, { childList: true });
  }

  function main() {
    mountPanel();
    waitForGPT();
    window.addEventListener("load", () => setTimeout(() => render(), 1500));
    if (document.readyState === "complete") setTimeout(() => render(), 500);
  }

  waitForBody(main);
})();