SEO

展示SEO信息

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         SEO
// @namespace    [email protected]
// @description  展示SEO信息
// @version      0.5
// @match        *://*.3d66.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @license MIT
// ==/UserScript==

(() => {
  "use strict";

  const STORE_KEY = `__page_hud_clean__:${location.host}`;
  const HUD_STATE_KEY = "__seo_hud_state__";

  const getHudState = () => {
    try {
      if (typeof GM_getValue === "function") return GM_getValue(HUD_STATE_KEY, "");
    } catch {}
    try {
      return localStorage.getItem(HUD_STATE_KEY) || "";
    } catch {
      return "";
    }
  };

  const setHudState = (value) => {
    try {
      if (typeof GM_setValue === "function") {
        GM_setValue(HUD_STATE_KEY, value);
        return;
      }
    } catch {}
    try {
      localStorage.setItem(HUD_STATE_KEY, value);
    } catch {}
  };

  const DEFAULT = {
    refreshMs: 1000,
    fields: [
      {
        label: "title",
        type: "headTagText",
        tagName: "title",
      },
      {
        label: "description",
        type: "meta",
        metaKey: "description",
      },
      {
        label: "keywords",
        type: "meta",
        metaKey: "keywords",
      },
      {
        label: "pubDate",
        type: "meta",
        metaKey: "pubDate",
      },
      {
        label: "upDate",
        type: "meta",
        metaKey: "upDate",
      },
      {
        label: "mobile-agent",
        type: "meta",
        metaKey: "mobile-agent",
      },
      {
        label: "canonical",
        type: "headLinkRelAttr",
        relKey: "canonical",
        attr: "href",
      },
      {
        label: 'link[rel="alternate"] attrs',
        type: "headLinkRelAttrs",
        relKey: "alternate",
      },
      {
        label: 'script[type="application/ld+json"]',
        type: "headScriptTypeTextList",
        scriptType: "application/ld+json",
      },
      {
        label: "H1 count",
        type: "js",
        expr: "(function(){ var hs = Array.prototype.slice.call(document.querySelectorAll('h1')); var texts = []; for (var i=0;i<hs.length;i++){ var t = (hs[i].textContent || '').trim(); if (t) texts.push(t); } return hs.length + '\\n' + texts.join('\\n'); })()",
      },
    ],
  };

  const load = () => {
    try {
      const raw = localStorage.getItem(STORE_KEY);
      if (!raw) return structuredClone(DEFAULT);
      const cfg = JSON.parse(raw);
      const ms = Number(cfg.refreshMs);
      return {
        refreshMs: Number.isFinite(ms) ? ms : DEFAULT.refreshMs,
        fields: Array.isArray(cfg.fields) ? cfg.fields : DEFAULT.fields,
        collapsed: !!cfg.collapsed,
      };
    } catch {
      return structuredClone(DEFAULT);
    }
  };

  const save = (cfg) => localStorage.setItem(STORE_KEY, JSON.stringify(cfg));

  const cfg = load();

  const host = document.createElement("div");
  host.style.cssText = "position:fixed;right:12px;bottom:12px;z-index:2147483647;";
  const shadow = host.attachShadow({ mode: "open" });

  const style = document.createElement("style");
  style.textContent = `
    *{box-sizing:border-box}
    .hud{
      width:500px;max-height:85vh;overflow:auto;border-radius:12px;
      background:rgba(20,20,20,.8);
      color:#fff;border:1px solid rgba(255,255,255,.14);
      font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
      box-shadow:0 12px 40px rgba(0,0,0,.35);
      scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.25) rgba(20,20,20,.92);
    }
    .hud::-webkit-scrollbar{
      width:10px;height:10px;
    }
    .hud::-webkit-scrollbar-track{
      background:transparent;
    }
    .hud::-webkit-scrollbar-thumb{
      background:rgba(255,255,255,.25);border-radius:8px;border:2px solid rgba(20,20,20,.92);
    }
    .hud::-webkit-scrollbar-thumb:hover{
      background:rgba(255,255,255,.35);
    }
    .hud::-webkit-scrollbar-corner{
      background:transparent;
    }
    .hdr{
      position:sticky;top:0;
      background:rgba(20,20,20,.75);
      z-index:1;
      padding:10px;border-bottom:1px solid rgba(255,255,255,.12);
      display:flex;gap:8px;align-items:center;justify-content:space-between;
      border-top-left-radius:12px;border-top-right-radius:12px;
    }
    .title{font-weight:700}
    .btns{display:flex;gap:6px;justify-content:flex-end}
    button{
      cursor:pointer;border-radius:10px;padding:5px 10px;
      border:1px solid rgba(255,255,255,.2);
      background:rgba(255,255,255,.10);color:#fff;
    }
    button:hover{background:rgba(255,255,255,.16)}
    .body{padding:10px}
    .row{padding:10px 0;border-bottom:1px solid rgba(255,255,255,.10)}
    .row:last-child{border-bottom:none}
    .topline{display:flex;gap:8px;align-items:center;justify-content:space-between}
    .lblwrap{display:flex;gap:8px;align-items:baseline;min-width:0}
    .lbl{
      font-weight:700;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
    }
    .expr{
      opacity:.55;
      max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
    }
    .val{margin-top:6px;opacity:.9;word-break:break-all;white-space:pre-wrap}
    .val.clamp{
      display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;
    }
    .val a{color:#9cd2ff;text-decoration:underline;word-break:break-all}
    .toggle{
      margin-top:6px;display:flex;justify-content:flex-end;gap:6px;
    }
    .toggle button{
      padding:3px 8px;border-radius:8px;font-size:11px;
    }
    .persistSel{
      background:rgba(255,229,100,.35);border-radius:3px;padding:0 1px;
    }
    .mini{
      display:flex;align-items:center;gap:8px;
      background:#141414;color:#fff;border:1px solid rgba(255,255,255,.14);
      border-radius:999px;padding:8px 10px;
      box-shadow:0 12px 40px rgba(0,0,0,.35);
    }
    .mini .pill{font-weight:700}
    .hint{opacity:.65}
  `;
  shadow.appendChild(style);

  const root = document.createElement("div");
  shadow.appendChild(root);

  const collapsedStateRaw = getHudState();
  const collapsedInitial = collapsedStateRaw ? collapsedStateRaw === "1" : !!cfg.collapsed;
  let expanded = !collapsedInitial;
  const expandedRows = new Set();
  let wheelBound = false;
  let refreshPaused = false;

  const esc = (s) =>
    (s ?? "").toString().replace(/[&<>"]/g, (c) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      '"': "&quot;",
    }[c]));

  const linkify = (s) => {
    const text = (s ?? "").toString();
    if (!text) return "";
    const re = /\bhttps?:\/\/[^\s"'<>]+/gi;
    let last = 0;
    let out = "";
    let match;
    while ((match = re.exec(text))) {
      const url = match[0];
      out += esc(text.slice(last, match.index));
      out += `<a href="${esc(url)}" target="_blank" rel="noreferrer">${esc(url)}</a>`;
      last = match.index + url.length;
    }
    out += esc(text.slice(last));
    return out;
  };

  const getHtmlValue = (f) => {
    try {
      const joinValues = (items) =>
        items
          .map((v) => (v == null ? "" : String(v)))
          .filter(Boolean)
          .join("\n");

      if (f.type === "js") {
        const expr = (f.expr || "").trim();
        if (!expr) return "";
        try {
          const result = Function(`"use strict"; return (${expr});`)();
          return result == null ? "" : String(result);
        } catch (err) {
          return `[JS error] ${err && err.message ? err.message : String(err)}`;
        }
      }

      if (f.type === "cssAttr") {
        const els = Array.from(document.querySelectorAll(f.selector || ""));
        const attr = (f.attr || "").trim();
        if (!attr) return "";
        return joinValues(els.map((el) => el.getAttribute(attr) || ""));
      }

      if (f.type === "cssText") {
        const els = Array.from(document.querySelectorAll(f.selector || ""));
        return joinValues(els.map((el) => el.outerHTML));
      }

      if (f.type === "meta") {
        const key = (f.metaKey || "").trim();
        if (!key) return "";
        const nameEls = Array.from(
          document.querySelectorAll(`meta[name="${CSS.escape(key)}"]`)
        );
        const propEls = Array.from(
          document.querySelectorAll(`meta[property="${CSS.escape(key)}"]`)
        );
        return joinValues(nameEls.concat(propEls).map((el) => el.outerHTML));
      }

      if (f.type === "headTagText") {
        const tagName = (f.tagName || "").trim();
        if (!tagName) return "";
        const target = tagName.toLowerCase();
        const els = Array.from(document.head?.children || []).filter(
          (node) => node.tagName?.toLowerCase() === target
        );
        return joinValues(els.map((el) => el.outerHTML));
      }

      if (f.type === "headLinkRelAttr") {
        const relKey = (f.relKey || "").trim();
        const attr = (f.attr || "").trim();
        if (!relKey || !attr) return "";
        const relNeedle = relKey.toLowerCase();

        return joinValues(
          Array.from(document.head?.querySelectorAll("link[rel]") || [])
            .filter((node) => {
              const rel = node.getAttribute("rel") || "";
              return rel
                .toLowerCase()
                .split(/\s+/)
                .filter(Boolean)
                .includes(relNeedle);
            })
            .map((el) => el.getAttribute(attr) || "")
        );
      }

      if (f.type === "headLinkRelAttrs") {
        const relKey = (f.relKey || "").trim();
        if (!relKey) return "";
        const relNeedle = relKey.toLowerCase();

        return joinValues(
          Array.from(document.head?.querySelectorAll("link[rel]") || [])
            .filter((node) => {
              const rel = node.getAttribute("rel") || "";
              return rel
                .toLowerCase()
                .split(/\s+/)
                .filter(Boolean)
                .includes(relNeedle);
            })
            .map((el) => el.outerHTML)
        );
      }

      if (f.type === "headScriptTypeTextList") {
        const scriptType = (f.scriptType || "").trim();
        if (!scriptType) return "";
        const typeNeedle = scriptType.toLowerCase();
        return joinValues(
          Array.from(document.head?.querySelectorAll("script[type]") || [])
            .filter((el) => (el.getAttribute("type") || "").toLowerCase() === typeNeedle)
            .map((el) => el.outerHTML)
        );
      }

      return "";
    } catch {
      return "";
    }
  };

  const NOFOLLOW_STYLE_ID = "__hud_nofollow_style__";
  const NOFOLLOW_MARK_CLASS = "__hud-nofollow";
  const NOFOLLOW_BADGE_CLASS = "__hud-nofollow-badge";

  const ensureNofollowStyle = () => {
    if (document.getElementById(NOFOLLOW_STYLE_ID)) return;
    const styleEl = document.createElement("style");
    styleEl.id = NOFOLLOW_STYLE_ID;
    styleEl.textContent = `
      a.${NOFOLLOW_MARK_CLASS} .${NOFOLLOW_BADGE_CLASS}{
        position:absolute !important;top:-6px !important;right:-6px !important;z-index:99999 !important;
        pointer-events:none !important;
        display:inline-flex !important;align-items:center !important;
        padding:2px 5px !important;
        font-size:9px !important;line-height:1 !important;border-radius:6px !important;
        border:1px solid rgba(255,255,255,.35) !important;
        color:rgba(255,255,255,.95) !important;
        background:rgba(200,50,50,.85) !important;
        white-space:nowrap !important;
        transform:scale(0.9) !important;
      }
    `;
    document.head.appendChild(styleEl);
  };

  const applyNofollowBadges = () => {
    ensureNofollowStyle();
    document.querySelectorAll("a[rel]").forEach((a) => {
      const rel = (a.getAttribute("rel") || "")
        .toLowerCase()
        .split(/\s+/)
        .filter(Boolean);
      const hasNofollow = rel.includes("nofollow");
      const badge = a.querySelector(`.${NOFOLLOW_BADGE_CLASS}`);

      if (!hasNofollow) {
        a.classList.remove(NOFOLLOW_MARK_CLASS);
        badge?.remove();
        return;
      }

      a.classList.add(NOFOLLOW_MARK_CLASS);

      const computed = getComputedStyle(a);
      if (computed.position === "static") {
        a.style.position = "relative";
      }

      if (!badge) {
        const nextBadge = document.createElement("span");
        nextBadge.className = NOFOLLOW_BADGE_CLASS;
        nextBadge.textContent = "✖";
        nextBadge.style.cssText =
          "position:absolute;top:-6px;right:-6px;z-index:99999;pointer-events:none;display:inline-flex;align-items:center;padding:2px 5px;font-size:9px;line-height:1;border-radius:6px;border:1px solid rgba(255,255,255,.35);color:rgba(255,255,255,.95);background:rgba(200,50,50,.85);white-space:nowrap;transform:scale(0.9);";
        a.appendChild(nextBadge);
      }
    });
  };

  const toggle = () => {
    expanded = !expanded;
    cfg.collapsed = !expanded;
    save(cfg);
    setHudState(expanded ? "0" : "1");
    render();
  };

  const bindWheel = () => {
    if (wheelBound) return;
    host.addEventListener(
      "wheel",
      (e) => {
        if (!expanded) return;
        const hudEl = root.querySelector(".hud");
        if (!hudEl) return;

        const rect = hudEl.getBoundingClientRect();
        const inHud =
          e.clientX >= rect.left &&
          e.clientX <= rect.right &&
          e.clientY >= rect.top &&
          e.clientY <= rect.bottom;

        if (inHud) {
          hudEl.scrollTop += e.deltaY;
          e.preventDefault();
        }
      },
      { passive: false }
    );
    wheelBound = true;
  };

  const hasActiveSelectionInHud = () => {
    if (shadow.getSelection) {
      const s = shadow.getSelection();
      if (s && s.rangeCount > 0 && !s.isCollapsed) return true;
    }

    const sel = document.getSelection();
    if (!sel || sel.isCollapsed) return false;

    if (sel.anchorNode === host || sel.focusNode === host) return true;

    const a = sel.anchorNode;
    const f = sel.focusNode;
    return (a && root.contains(a)) || (f && root.contains(f));
  };

  const render = () => {
    if (hasActiveSelectionInHud() && !window.__HUD_MANUAL_REFRESH__) {
      refreshPaused = true;
      return;
    }

    if (refreshPaused && !window.__HUD_MANUAL_REFRESH__) return;

    if (!expanded) {
      root.innerHTML = `
        <div class="mini">
          <div class="pill">SEO</div>
          <button id="__show">Show</button>
        </div>
      `;
      root.querySelector("#__show")?.addEventListener("click", () => toggle());
      applyNofollowBadges();
      return;
    }

    const prevScrollTop = root.querySelector(".hud")?.scrollTop ?? 0;

    const rows = cfg.fields
      .map((f) => {
        const rawHtml = getHtmlValue(f) ?? "";
        const lineCount = rawHtml ? rawHtml.split(/\r?\n/).length : 0;
        const isLongText = rawHtml.length > 200 || lineCount > 3;
        const rowKey = `${f.type}::${f.label || ""}`;
        const isExpanded = !isLongText || expandedRows.has(rowKey);
        const valHTML = linkify(rawHtml);
        const valTitle = isLongText ? esc(rawHtml) : "";
        const valClass = isLongText && !isExpanded ? "val clamp" : "val";
        const expr = f.type === "js" ? (f.expr || "") : "";

        return `
          <div class="row" data-row-key="${esc(rowKey)}">
            <div class="topline">
              <div class="lblwrap" title="${esc(f.label || "")}">
                <div class="lbl">${esc(f.label || "Field")}</div>
                ${expr ? `<div class="expr" title="${esc(expr)}">${esc(expr)}</div>` : ""}
              </div>
            </div>
            <div class="${valClass}"${valTitle ? ` title="${valTitle}"` : ""}>${
              valHTML || '<span style="opacity:.55">—</span>'
            }</div>
            ${
              isLongText
                ? `<div class="toggle"><button data-toggle="${esc(rowKey)}">${
                    isExpanded ? "收起" : "展开"
                  }</button></div>`
                : ""
            }
          </div>
        `;
      })
      .join("");

    root.innerHTML = `
      <div class="hud">
        <div class="hdr">
          <div>
            <span class="title">查看SEO数据</span>
            <span style="opacity:.65;margin-left:8px">${esc(location.host)}</span>
          </div>
          <div class="btns">
            <button id="__hide">Hide</button>
          </div>
        </div>

        <div class="body">
          ${rows || `<div style="opacity:.75">No fields. Edit DEFAULT.fields in the script.</div>`}
        </div>
      </div>
    `;

    const hudEl = root.querySelector(".hud");
    if (hudEl) hudEl.scrollTop = prevScrollTop;

    root.querySelector("#__hide")?.addEventListener("click", () => toggle());

    root.querySelectorAll("[data-toggle]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const key = btn.getAttribute("data-toggle") || "";
        if (!key) return;
        if (expandedRows.has(key)) expandedRows.delete(key);
        else expandedRows.add(key);
        render();
      });
    });

    applyNofollowBadges();
  };

  window.addEventListener("keydown", (e) => {
    if (e.ctrlKey && e.shiftKey && (e.key === "H" || e.key === "h")) toggle();
  });

  document.documentElement.appendChild(host);
  bindWheel();

  root.addEventListener("mousedown", () => {
    refreshPaused = true;
  });

  document.addEventListener("selectionchange", () => {
    refreshPaused = hasActiveSelectionInHud();
  });

  document.addEventListener("mouseup", () => {
    setTimeout(() => {
      refreshPaused = hasActiveSelectionInHud();
      if (!refreshPaused) render();
    }, 10);
  });

  render();

  let timer = null;
  const startTimer = () => {
    if (timer) clearInterval(timer);
    if (Number(cfg.refreshMs) > 0) {
      timer = setInterval(() => {
        if (!refreshPaused) render();
      }, Number(cfg.refreshMs));
    } else {
      timer = null;
    }
  };

  startTimer();

  window.__HUD_CFG__ = cfg;
  window.__HUD_SAVE__ = () => {
    save(cfg);
    startTimer();
    render();
  };
})();