检测任意网站谷歌广告的加载与渲染时间(支持 AdSense / GPT / AdX)
// ==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 & 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} <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);
})();