MailScout

智能提取网站站长联系方式(邮箱、QQ),识别站点类型,一键复制导出

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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.

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

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

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

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

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

// ==UserScript==
// @name         MailScout
// @namespace    https://xpornkit.com/zh
// @version      1.0.0
// @description  智能提取网站站长联系方式(邮箱、QQ),识别站点类型,一键复制导出
// @author       MailScout
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ============================================================
  // 提取引擎
  // ============================================================
  const Extractor = {
    EMAIL_REGEX: /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g,

    EMAIL_VARIANT_PATTERNS: [
      /[a-zA-Z0-9._%+\-]+#[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g,
      /[a-zA-Z0-9._%+\-]+\s*\[at\]\s*[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/gi,
      /[a-zA-Z0-9._%+\-]+\s*\(at\)\s*[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/gi,
      /[a-zA-Z0-9._%+\-]+\s+AT\s+[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g,
      /[a-zA-Z0-9._%+\-]+\s*[\[\(]?at[\]\)]?\s*[a-zA-Z0-9\-]+\s*[\[\(]?dot[\]\)]?\s*[a-zA-Z]{2,}/gi,
    ],

    INVALID_EMAIL_PREFIXES: [
      'noreply', 'no-reply', 'no_reply',
      'mailer-daemon', 'postmaster',
      'example', 'test', 'demo', 'sample',
      '[email protected]', 'email@example',
      'your@email', 'name@domain', 'user@example',
      '[email protected]',
    ],

    normalizeEmailVariant(raw) {
      let email = raw.trim();
      email = email.replace(/#/, '@');
      email = email.replace(/\s*[\[\(]?at[\]\)]?\s*/gi, '@');
      email = email.replace(/\s*[\[\(]?dot[\]\)]?\s*/gi, '.');
      email = email.replace(/\s+/g, '');
      return email.toLowerCase();
    },

    isValidEmail(email) {
      const lower = email.toLowerCase();
      for (const prefix of this.INVALID_EMAIL_PREFIXES) {
        if (lower.startsWith(prefix) || lower === prefix) return false;
      }
      const parts = lower.split('@');
      if (parts.length !== 2) return false;
      if (parts[0].length < 1 || parts[1].length < 3) return false;
      return true;
    },

    extractEmails() {
      if (!document.body) return [];
      const text = document.body.innerText || '';
      const results = new Set();

      const standardMatches = text.match(this.EMAIL_REGEX) || [];
      standardMatches.forEach(e => results.add(e.toLowerCase()));

      const mailtoLinks = document.querySelectorAll('a[href^="mailto:"]');
      mailtoLinks.forEach(link => {
        const href = link.getAttribute('href');
        const email = href.replace(/^mailto:/i, '').split('?')[0].trim();
        if (email) results.add(email.toLowerCase());
      });

      this.EMAIL_VARIANT_PATTERNS.forEach(pattern => {
        const matches = text.match(pattern) || [];
        matches.forEach(raw => {
          const normalized = this.normalizeEmailVariant(raw);
          if (normalized.includes('@') && normalized.includes('.')) {
            results.add(normalized);
          }
        });
      });

      return [...results].filter(e => this.isValidEmail(e));
    },

    groupEmailsByDomain(emails) {
      const groups = {};
      const domainLabels = {
        'gmail.com': 'Gmail', 'qq.com': 'QQ邮箱', '163.com': '163邮箱',
        '126.com': '126邮箱', 'sina.com': '新浪邮箱', 'outlook.com': 'Outlook',
        'hotmail.com': 'Hotmail', 'yahoo.com': 'Yahoo', 'foxmail.com': 'Foxmail',
      };
      emails.forEach(email => {
        const domain = email.split('@')[1];
        const label = domainLabels[domain] || '企业邮箱 (' + domain + ')';
        if (!groups[label]) groups[label] = [];
        groups[label].push(email);
      });
      return groups;
    },

    QQ_REGEX: /(?<![\d])[1-9]\d{4,10}(?![\d])/g,
    QQ_KEYWORDS: ['qq', 'QQ', '扣扣', '企鹅', 'tencent'],

    isLikelyQQ(numStr, context) {
      const num = parseInt(numStr, 10);
      const len = numStr.length;
      if (len === 11 && numStr.startsWith('1')) return false;
      if (len === 4 && num >= 1990 && num <= 2099) return false;
      if (len === 6 && num >= 100000 && num <= 999999) {
        if (context && /邮编|zip|postal/i.test(context)) return false;
      }
      if (len >= 8 && /^20\d{6,}$/.test(numStr)) return false;
      if (context && /[¥$¥]\s*$/.test(context)) return false;
      return true;
    },

    isInQQContext(numStr) {
      const selectors = [
        '[class*="qq"]', '[id*="qq"]',
        '[class*="QQ"]', '[id*="QQ"]',
        '[class*="Qq"]', '[id*="Qq"]'
      ];
      try {
        const elements = document.querySelectorAll(selectors.join(','));
        for (const el of elements) {
          if (el.textContent && el.textContent.includes(numStr)) return true;
        }
      } catch (e) {}
      return false;
    },

    extractQQs() {
      if (!document.body) return [];
      const text = document.body.innerText || '';
      const results = [];
      const seen = new Set();

      const qqLinks = document.querySelectorAll('a[href*="wpa.qq.com"], a[href*="tencent://message"]');
      qqLinks.forEach(link => {
        const href = link.getAttribute('href');
        const match = href.match(/uin=(\d{5,11})/);
        if (match && !seen.has(match[1])) {
          seen.add(match[1]);
          results.push({ qq: match[1], qqEmail: match[1] + '@qq.com', confidence: 'high', source: 'link' });
        }
      });

      const lines = text.split('\n');
      lines.forEach(line => {
        const matches = line.match(this.QQ_REGEX);
        if (!matches) return;
        matches.forEach(numStr => {
          if (seen.has(numStr)) return;
          const idx = line.indexOf(numStr);
          const before = line.substring(Math.max(0, idx - 20), idx);
          const after = line.substring(idx + numStr.length, idx + numStr.length + 20);
          const context = (before + after).toLowerCase();
          if (!this.isLikelyQQ(numStr, before)) return;
          const hasKeyword = this.QQ_KEYWORDS.some(k => context.includes(k.toLowerCase()));
          const inQQDom = this.isInQQContext(numStr);
          let confidence = 'low';
          if (hasKeyword || inQQDom) confidence = 'high';
          else if (numStr.length >= 5 && numStr.length <= 10) confidence = 'medium';
          if (confidence !== 'low') {
            seen.add(numStr);
            results.push({ qq: numStr, qqEmail: numStr + '@qq.com', confidence, source: hasKeyword ? 'keyword' : 'text' });
          }
        });
      });
      return results;
    },

    IMAGE_KEYWORDS: [
      'email', 'mail', 'qq', 'contact', 'lx', 'yxh',
      'contactus', '邮箱', '联系', '联系方式', '联系我们',
      'wechat', '微信', 'qrcode', '二维码'
    ],

    scanContactImages() {
      const images = document.querySelectorAll('img');
      const suspects = [];
      images.forEach(img => {
        if (suspects.length >= 10) return;
        let rawSrc = img.src || '';
        let dataSrc = img.getAttribute('data-src') || '';
        const safeSrc = rawSrc.startsWith('data:') ? rawSrc.substring(0, 60) : rawSrc.substring(0, 200);
        const safeDataSrc = dataSrc.startsWith('data:') ? dataSrc.substring(0, 60) : dataSrc.substring(0, 200);
        const combinedText = [safeSrc, img.alt || '', img.className || '', img.title || '', safeDataSrc].join(' ').toLowerCase();
        if (this.IMAGE_KEYWORDS.some(k => combinedText.includes(k))) {
          let displaySrc = rawSrc || dataSrc;
          if (displaySrc.startsWith('data:')) displaySrc = '[base64图片]';
          else if (displaySrc.length > 150) displaySrc = displaySrc.substring(0, 150) + '...';
          suspects.push({ src: displaySrc, alt: (img.alt || '').substring(0, 100), hint: '发现疑似联系方式图片,请手动整理' });
        }
      });
      return suspects;
    },

    extractSiteInfo() {
      const title = document.title || '';
      const metaDesc = document.querySelector('meta[name="description"]');
      const description = metaDesc ? metaDesc.getAttribute('content') || '' : '';
      let footerText = '';
      const footer = document.querySelector('footer') || document.getElementById('footer');
      if (footer) {
        footerText = (footer.innerText || '').replace(/\s+/g, ' ').trim();
        if (footerText.length > 200) footerText = footerText.substring(0, 200) + '...';
      }
      return { title: title.trim(), description: description.trim(), footerText: footerText.trim(), url: window.location.href, domain: window.location.hostname };
    },

    scanPage() {
      const emails = this.extractEmails();
      const qqs = this.extractQQs();
      const images = this.scanContactImages();
      const siteInfo = this.extractSiteInfo();
      const qqFromEmails = new Set();
      emails.forEach(e => {
        if (e.endsWith('@qq.com')) {
          const prefix = e.split('@')[0];
          if (/^[1-9]\d{4,10}$/.test(prefix)) qqFromEmails.add(prefix);
        }
      });
      const dedupedQQs = qqs.filter(item => !qqFromEmails.has(item.qq));
      return { emails, emailGroups: this.groupEmailsByDomain(emails), qqs: dedupedQQs, images, siteInfo, scanTime: new Date().toISOString() };
    }
  };

  // ============================================================
  // 分类引擎
  // ============================================================
  const Classifier = {
    URL_PATTERNS: {
      '导航/目录': ['nav', 'dir', 'site', 'links', 'dh', 'daohang', 'hao', 'catalog', 'directory', 'fenlei'],
      '博客': ['blog', 'post', 'article', 'diary', 'journal', 'boke', 'wordpress'],
      '论坛/社区': ['forum', 'bbs', 'community', 'discuss', 'tieba', 'luntan'],
      '关于/联系': ['about', 'contact', 'aboutus', 'contactus', 'lianxi', 'guanyu', 'join'],
      '新闻/资讯': ['news', 'press', 'zixun', 'xinwen'],
      '工具/服务': ['tool', 'service', 'api', 'app'],
    },

    classifyByURL() {
      const combined = (window.location.hostname + window.location.pathname).toLowerCase();
      const matched = [];
      for (const [type, keywords] of Object.entries(this.URL_PATTERNS)) {
        for (const kw of keywords) {
          if (combined.includes(kw)) { matched.push(type); break; }
        }
      }
      return matched;
    },

    classifyByDOM() {
      const tags = [];
      if (document.querySelectorAll('article').length > 0) tags.push('博客');
      const commentSelectors = ['#comments', '.comments', '#comment', '.comment-list', '#disqus_thread', '.ds-thread'];
      for (const sel of commentSelectors) { if (document.querySelector(sel)) { tags.push('博客'); break; } }
      const allLinks = document.querySelectorAll('a[href^="http"]');
      const externalLinks = [...allLinks].filter(a => { try { return new URL(a.href).hostname !== window.location.hostname; } catch { return false; } });
      if (externalLinks.length > 30) tags.push('导航/目录');
      if (document.querySelector('meta[name="generator"][content*="WordPress"]')) tags.push('WordPress');
      return [...new Set(tags)];
    },

    findContactPageLinks() {
      const contactKeywords = ['contact', 'about', 'aboutus', 'contactus', 'lianxi', 'guanyu', 'join', 'cooperation', '联系', '关于', '联系我们', '关于我们', '合作'];
      const links = document.querySelectorAll('a[href]');
      const contactLinks = [];
      links.forEach(link => {
        const href = (link.getAttribute('href') || '').toLowerCase();
        const text = (link.textContent || '').trim().toLowerCase();
        const combined = href + ' ' + text;
        if (!href || href === '#' || href.startsWith('javascript:') || href.startsWith('mailto:')) return;
        for (const kw of contactKeywords) {
          if (combined.includes(kw)) {
            let fullUrl = href;
            try { fullUrl = new URL(href, window.location.origin).href; } catch { continue; }
            if (fullUrl !== window.location.href) {
              contactLinks.push({ url: fullUrl, text: link.textContent.trim() || href, keyword: kw });
            }
            break;
          }
        }
      });
      const seen = new Set();
      return contactLinks.filter(item => { if (seen.has(item.url)) return false; seen.add(item.url); return true; });
    },

    classify() {
      const urlTags = this.classifyByURL();
      const domTags = this.classifyByDOM();
      const contactLinks = this.findContactPageLinks();
      const allTags = [...new Set([...urlTags, ...domTags])];
      return { tags: allTags, contactLinks, isContactPage: urlTags.includes('关于/联系'), isBlog: allTags.includes('博客'), isNavSite: allTags.includes('导航/目录') };
    }
  };

  // ============================================================
  // 过滤引擎
  // ============================================================
  const Filter = {
    WECHAT_KEYWORDS: ['微信', 'wechat', 'wxid', 'weixin', '公众号'],

    detectWeChat() {
      if (!document.body) return { found: false, wechatId: null };
      const text = (document.body.innerText || '').toLowerCase();
      const found = this.WECHAT_KEYWORDS.some(kw => text.includes(kw.toLowerCase()));
      let wechatId = null;
      const wxMatch = text.match(/微信[号:]?\s*[::]?\s*([a-zA-Z0-9_\-]{5,20})/);
      if (wxMatch) wechatId = wxMatch[1];
      return { found, wechatId };
    },

    applyFilters(scanResult) {
      const { emails, qqs, images } = scanResult;
      const wechat = this.detectWeChat();
      const hasEmail = emails && emails.length > 0;
      const hasQQ = qqs && qqs.length > 0;
      const hasImages = images && images.length > 0;
      let status = 'found', message = '';
      if (!hasEmail && !hasQQ) {
        if (wechat.found) { status = 'wechat_only'; message = '仅检测到微信联系方式,未发现邮箱或QQ'; }
        else if (hasImages) { status = 'image_only'; message = '发现疑似联系方式图片,请手动整理'; }
        else { status = 'empty'; message = '未检测到任何联系方式'; }
      } else {
        message = hasImages ? '已提取联系方式,另有疑似图片联系方式需手动查看' : '已成功提取联系方式';
      }
      const copyFormats = this.generateCopyFormats(scanResult);
      return { ...scanResult, wechat, status, message, copyFormats };
    },

    generateCopyFormats(scanResult) {
      const { emails, qqs, siteInfo } = scanResult;
      const siteName = siteInfo.title || siteInfo.domain || '';
      const formats = [];
      if (emails && emails.length > 0) {
        emails.forEach(email => { formats.push({ type: 'email', text: `${siteName} | ${email}`, email }); });
      }
      if (qqs && qqs.length > 0) {
        qqs.forEach(item => { formats.push({ type: 'qq', text: `${siteName} | ${item.qqEmail}`, email: item.qqEmail, qq: item.qq, confidence: item.confidence }); });
      }
      return formats;
    }
  };

  // ============================================================
  // 存储(GM_setValue / GM_getValue)
  // ============================================================
  const Storage = {
    getHistory() {
      return GM_getValue('mailscout_history', {});
    },
    saveRecord(domain, data) {
      const history = this.getHistory();
      history[domain] = { data, lastScan: new Date().toISOString() };
      GM_setValue('mailscout_history', history);
    },
    getRecord(domain) {
      const history = this.getHistory();
      return history[domain] || null;
    },
    clearHistory() {
      GM_setValue('mailscout_history', {});
    }
  };

  // ============================================================
  // 工具函数
  // ============================================================
  function esc(str) {
    if (!str) return '';
    return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  function copyText(text) {
    GM_setClipboard(text, 'text');
  }

  // ============================================================
  // UI 面板
  // ============================================================
  const PANEL_ID = 'mailscout-panel';
  const FAB_ID = 'mailscout-fab';
  let currentResult = null;
  let panelVisible = false;

  GM_addStyle(`
    #${FAB_ID} {
      position: fixed; top: 120px; right: 24px; width: 40px; height: 40px;
      background: #333; border-radius: 50%; display: flex; align-items: center; justify-content: center;
      cursor: pointer; z-index: 2147483646; transition: transform 0.2s, opacity 0.2s;
      opacity: 0.7; user-select: none; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
      font-size: 16px; color: #fff; font-weight: bold; line-height: 1;
    }
    #${FAB_ID}:hover { opacity: 1; transform: scale(1.1); }
    #${FAB_ID} .ms-dot {
      position: absolute; top: 0; right: 0; width: 10px; height: 10px;
      background: #4caf50; border-radius: 50%; border: 2px solid #fff;
    }
    #${PANEL_ID} {
      position: fixed; top: 0; right: -380px; width: 360px; height: 100vh;
      background: #fff; z-index: 2147483647; transition: right 0.3s ease;
      box-shadow: -4px 0 20px rgba(0,0,0,0.12); font-family: -apple-system, "Segoe UI", sans-serif;
      display: flex; flex-direction: column; font-size: 13px; color: #333;
      overflow: hidden;
    }
    #${PANEL_ID}.ms-open { right: 0; }
    #${PANEL_ID} * { box-sizing: border-box; margin: 0; padding: 0; }

    #${PANEL_ID} .ms-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 14px 20px; border-bottom: 1px solid #e0e0e0; flex-shrink: 0;
    }
    #${PANEL_ID} .ms-header h1 { font-size: 16px; font-weight: 700; color: #222; }
    #${PANEL_ID} .ms-header-btns { display: flex; gap: 8px; }
    #${PANEL_ID} .ms-btn {
      padding: 6px 14px; font-size: 12px; font-weight: 600; border: none;
      border-radius: 4px; cursor: pointer; background: #333; color: #fff;
    }
    #${PANEL_ID} .ms-btn:hover { background: #555; }
    #${PANEL_ID} .ms-btn-sm {
      padding: 4px 10px; font-size: 11px; border: 1px solid #ccc;
      border-radius: 3px; background: #fff; color: #555; cursor: pointer;
    }
    #${PANEL_ID} .ms-btn-sm:hover { background: #f5f5f5; }
    #${PANEL_ID} .ms-btn-close {
      width: 28px; height: 28px; border: none; background: transparent;
      font-size: 18px; color: #999; cursor: pointer; border-radius: 4px;
    }
    #${PANEL_ID} .ms-btn-close:hover { background: #f0f0f0; color: #333; }

    #${PANEL_ID} .ms-body { flex: 1; overflow-y: auto; }
    #${PANEL_ID} .ms-body::-webkit-scrollbar { width: 4px; }
    #${PANEL_ID} .ms-body::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }

    #${PANEL_ID} .ms-status {
      padding: 10px 20px; font-size: 12px; border-bottom: 1px solid #e0e0e0;
    }
    #${PANEL_ID} .ms-status.found { background: #f0faf0; color: #2e7d32; }
    #${PANEL_ID} .ms-status.empty { background: #fafafa; color: #999; }
    #${PANEL_ID} .ms-status.wechat { background: #fff8e1; color: #f57f17; }
    #${PANEL_ID} .ms-status.image { background: #fff3e0; color: #e65100; }

    #${PANEL_ID} .ms-history {
      padding: 10px 20px; font-size: 12px; color: #666;
      background: #f9f9f9; border-bottom: 1px solid #e0e0e0;
      display: flex; align-items: center; justify-content: space-between;
    }

    #${PANEL_ID} .ms-section { padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
    #${PANEL_ID} .ms-section-title {
      font-size: 12px; font-weight: 700; color: #999; margin-bottom: 10px; letter-spacing: 0.5px;
    }

    #${PANEL_ID} .ms-info-row { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; }
    #${PANEL_ID} .ms-info-label { color: #999; min-width: 36px; flex-shrink: 0; }
    #${PANEL_ID} .ms-info-value { color: #333; word-break: break-all; }

    #${PANEL_ID} .ms-tag {
      display: inline-block; padding: 2px 8px; margin: 2px 4px 2px 0;
      font-size: 11px; background: #f0f0f0; color: #555; border-radius: 3px;
    }

    #${PANEL_ID} .ms-row {
      display: flex; align-items: center; justify-content: space-between; gap: 8px;
      padding: 6px 0; border-bottom: 1px solid #f5f5f5;
    }
    #${PANEL_ID} .ms-row:last-child { border-bottom: none; }
    #${PANEL_ID} .ms-text { font-size: 13px; color: #333; word-break: break-all; }
    #${PANEL_ID} .ms-sub { font-size: 11px; color: #999; margin-top: 2px; }
    #${PANEL_ID} .ms-badge {
      padding: 1px 6px; font-size: 10px; background: #e8f5e9; color: #4caf50;
      border-radius: 3px; flex-shrink: 0;
    }
    #${PANEL_ID} .ms-copy {
      padding: 3px 8px; font-size: 11px; border: 1px solid #ddd;
      border-radius: 3px; background: #fff; color: #555; cursor: pointer; flex-shrink: 0;
    }
    #${PANEL_ID} .ms-copy:hover { background: #f5f5f5; }
    #${PANEL_ID} .ms-copy.copied { background: #e8f5e9; color: #4caf50; border-color: #c8e6c9; }

    #${PANEL_ID} .ms-warn {
      padding: 8px 10px; font-size: 12px; color: #666; background: #fafafa;
      border-radius: 4px; margin-bottom: 6px;
    }

    #${PANEL_ID} .ms-link-row {
      display: flex; align-items: center; justify-content: space-between; gap: 8px;
      padding: 4px 0; border-bottom: 1px solid #f5f5f5;
    }
    #${PANEL_ID} .ms-link-row a { color: #1a73e8; text-decoration: none; font-size: 12px; word-break: break-all; }
    #${PANEL_ID} .ms-link-row a:hover { text-decoration: underline; }

    #${PANEL_ID} .ms-footer {
      display: flex; align-items: center; justify-content: space-between; gap: 8px;
      padding: 12px 20px; border-top: 1px solid #e0e0e0; flex-shrink: 0;
    }
    #${PANEL_ID} .ms-btn-outline {
      padding: 6px 12px; font-size: 12px; border: 1px solid #ccc;
      border-radius: 4px; background: #fff; color: #555; cursor: pointer;
    }
    #${PANEL_ID} .ms-btn-outline:hover { background: #f5f5f5; }
    #${PANEL_ID} .ms-btn-danger {
      padding: 4px 10px; font-size: 11px; border: 1px solid #e57373;
      border-radius: 3px; background: #fff; color: #e57373; cursor: pointer;
    }
    #${PANEL_ID} .ms-btn-danger:hover { background: #ffebee; }

    #${PANEL_ID} .ms-toast {
      position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
      padding: 8px 20px; background: #333; color: #fff; border-radius: 4px;
      font-size: 12px; z-index: 2147483647; opacity: 0; transition: opacity 0.3s;
      pointer-events: none;
    }
    #${PANEL_ID} .ms-toast.show { opacity: 1; }

    .ms-hidden { display: none !important; }
  `);

  function createPanel() {
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div class="ms-header">
        <h1>MailScout</h1>
        <div class="ms-header-btns">
          <button class="ms-btn" id="ms-btn-scan">扫描</button>
          <button class="ms-btn-close" id="ms-btn-close">&times;</button>
        </div>
      </div>
      <div class="ms-body">
        <div id="ms-status" class="ms-status ms-hidden"></div>
        <div id="ms-history" class="ms-history ms-hidden">
          <span>该站点有历史扫描记录</span>
          <button class="ms-btn-sm" id="ms-btn-history">查看</button>
        </div>
        <div id="ms-section-site" class="ms-section ms-hidden">
          <div class="ms-section-title">网站资料</div>
          <div id="ms-site-info"></div>
        </div>
        <div id="ms-section-tags" class="ms-section ms-hidden">
          <div class="ms-section-title">站点类型</div>
          <div id="ms-site-tags"></div>
        </div>
        <div id="ms-section-emails" class="ms-section ms-hidden">
          <div class="ms-section-title">邮箱</div>
          <div id="ms-email-list"></div>
        </div>
        <div id="ms-section-qqs" class="ms-section ms-hidden">
          <div class="ms-section-title">QQ / QQ邮箱</div>
          <div id="ms-qq-list"></div>
        </div>
        <div id="ms-section-images" class="ms-section ms-hidden">
          <div class="ms-section-title">疑似图片联系方式</div>
          <div id="ms-image-list"></div>
        </div>
        <div id="ms-section-links" class="ms-section ms-hidden">
          <div class="ms-section-title">推荐扫描页面</div>
          <div id="ms-contact-links"></div>
        </div>
        <div id="ms-section-copy" class="ms-section ms-hidden">
          <div class="ms-section-title">一键复制</div>
          <div id="ms-copy-list"></div>
        </div>
      </div>
      <div class="ms-footer">
        <button class="ms-btn-outline" id="ms-btn-export">导出 CSV</button>
        <button class="ms-btn-danger" id="ms-btn-clear">清空历史</button>
      </div>
      <div style="text-align:center;padding:6px 20px 10px;font-size:11px;">
        <a href="https://xpornkit.com/zh" target="_blank" rel="noopener" style="color:#999;text-decoration:none;">成人导航</a>
      </div>
      <div class="ms-toast" id="ms-toast"></div>
    `;
    document.body.appendChild(panel);

    panel.querySelector('#ms-btn-scan').addEventListener('click', doScan);
    panel.querySelector('#ms-btn-close').addEventListener('click', togglePanel);
    panel.querySelector('#ms-btn-history').addEventListener('click', showHistory);
    panel.querySelector('#ms-btn-export').addEventListener('click', doExport);
    panel.querySelector('#ms-btn-clear').addEventListener('click', doClear);

    panel.addEventListener('click', (e) => {
      const copyBtn = e.target.closest('[data-copy]');
      if (copyBtn) {
        copyText(copyBtn.dataset.copy);
        copyBtn.textContent = '已复制';
        copyBtn.classList.add('copied');
        setTimeout(() => { copyBtn.textContent = '复制'; copyBtn.classList.remove('copied'); }, 1200);
        showToast('已复制到剪贴板');
        return;
      }
      const openBtn = e.target.closest('[data-open]');
      if (openBtn) {
        window.open(openBtn.dataset.open, '_blank');
      }
    });

    return panel;
  }

  function togglePanel() {
    const panel = document.getElementById(PANEL_ID) || createPanel();
    panelVisible = !panelVisible;
    if (panelVisible) {
      panel.classList.add('ms-open');
      doScan();
    } else {
      panel.classList.remove('ms-open');
    }
  }

  function showToast(text) {
    const toast = document.getElementById('ms-toast');
    if (!toast) return;
    toast.textContent = text;
    toast.classList.add('show');
    setTimeout(() => toast.classList.remove('show'), 1500);
  }

  function resetUI() {
    const ids = ['ms-status', 'ms-history', 'ms-section-site', 'ms-section-tags', 'ms-section-emails', 'ms-section-qqs', 'ms-section-images', 'ms-section-links', 'ms-section-copy'];
    ids.forEach(id => {
      const el = document.getElementById(id);
      if (el) el.classList.add('ms-hidden');
    });
  }

  function showStatus(cls, text) {
    const el = document.getElementById('ms-status');
    if (!el) return;
    el.className = 'ms-status ' + cls;
    el.textContent = text;
  }

  function doScan() {
    resetUI();
    const domain = window.location.hostname;

    const record = Storage.getRecord(domain);
    if (record) {
      const historyEl = document.getElementById('ms-history');
      if (historyEl) historyEl.classList.remove('ms-hidden');
    }

    try {
      const rawResult = Extractor.scanPage();
      const classification = Classifier.classify();
      const filteredResult = Filter.applyFilters(rawResult);
      const finalResult = { ...filteredResult, classification };
      currentResult = finalResult;
      renderResult(finalResult);
      Storage.saveRecord(domain, finalResult);
      updateFabDot();
    } catch (err) {
      showStatus('empty', '扫描出错: ' + err.message);
    }
  }

  function showHistory() {
    const domain = window.location.hostname;
    const record = Storage.getRecord(domain);
    if (record && record.data) {
      currentResult = record.data;
      renderResult(record.data);
      showToast('已加载历史记录 (' + (record.lastScan || '').split('T')[0] + ')');
    }
  }

  function renderResult(data) {
    const statusMap = {
      'found': 'found', 'wechat_only': 'wechat', 'image_only': 'image', 'empty': 'empty'
    };
    showStatus(statusMap[data.status] || 'empty', data.message);

    if (data.siteInfo) {
      const el = document.getElementById('ms-section-site');
      el.classList.remove('ms-hidden');
      let html = '';
      if (data.siteInfo.title) html += `<div class="ms-info-row"><span class="ms-info-label">标题</span><span class="ms-info-value">${esc(data.siteInfo.title)}</span></div>`;
      if (data.siteInfo.domain) html += `<div class="ms-info-row"><span class="ms-info-label">域名</span><span class="ms-info-value">${esc(data.siteInfo.domain)}</span></div>`;
      if (data.siteInfo.description) html += `<div class="ms-info-row"><span class="ms-info-label">描述</span><span class="ms-info-value">${esc(data.siteInfo.description)}</span></div>`;
      if (data.siteInfo.footerText) html += `<div class="ms-info-row"><span class="ms-info-label">底部</span><span class="ms-info-value">${esc(data.siteInfo.footerText)}</span></div>`;
      document.getElementById('ms-site-info').innerHTML = html;
    }

    if (data.classification && data.classification.tags.length > 0) {
      document.getElementById('ms-section-tags').classList.remove('ms-hidden');
      document.getElementById('ms-site-tags').innerHTML = data.classification.tags.map(t => `<span class="ms-tag">${esc(t)}</span>`).join('');
    }

    if (data.emails && data.emails.length > 0) {
      document.getElementById('ms-section-emails').classList.remove('ms-hidden');
      let html = '';
      if (data.emailGroups && Object.keys(data.emailGroups).length > 0) {
        for (const [label, list] of Object.entries(data.emailGroups)) {
          list.forEach(email => {
            html += `<div class="ms-row"><div><div class="ms-text">${esc(email)}</div><div class="ms-sub">${esc(label)}</div></div><button class="ms-copy" data-copy="${esc(email)}">复制</button></div>`;
          });
        }
      } else {
        data.emails.forEach(email => {
          html += `<div class="ms-row"><div class="ms-text">${esc(email)}</div><button class="ms-copy" data-copy="${esc(email)}">复制</button></div>`;
        });
      }
      document.getElementById('ms-email-list').innerHTML = html;
    }

    if (data.qqs && data.qqs.length > 0) {
      document.getElementById('ms-section-qqs').classList.remove('ms-hidden');
      document.getElementById('ms-qq-list').innerHTML = data.qqs.map(item => {
        const badgeText = item.confidence === 'high' ? '高' : '中';
        return `<div class="ms-row"><div><div class="ms-text">${esc(item.qq)}</div><div class="ms-sub">${esc(item.qqEmail)}</div></div><span class="ms-badge">${badgeText}</span><button class="ms-copy" data-copy="${esc(item.qqEmail)}">复制邮箱</button></div>`;
      }).join('');
    }

    if (data.images && data.images.length > 0) {
      document.getElementById('ms-section-images').classList.remove('ms-hidden');
      document.getElementById('ms-image-list').innerHTML = data.images.map(img => `<div class="ms-warn"><div>${esc(img.hint)}</div><div style="font-size:11px;color:#999;margin-top:4px">${esc(img.alt || img.src)}</div></div>`).join('');
    }

    if (data.classification && data.classification.contactLinks.length > 0) {
      document.getElementById('ms-section-links').classList.remove('ms-hidden');
      document.getElementById('ms-contact-links').innerHTML = data.classification.contactLinks.map(link => `<div class="ms-link-row"><a href="${esc(link.url)}" target="_blank">${esc(link.text)}</a><button class="ms-btn-sm" data-open="${esc(link.url)}">打开</button></div>`).join('');
    }

    if (data.copyFormats && data.copyFormats.length > 0) {
      document.getElementById('ms-section-copy').classList.remove('ms-hidden');
      document.getElementById('ms-copy-list').innerHTML = data.copyFormats.map(item => {
        const typeLabel = item.type === 'email' ? '邮箱' : 'QQ邮箱';
        return `<div class="ms-row"><span class="ms-tag">${typeLabel}</span><div class="ms-text" style="flex:1">${esc(item.text)}</div><button class="ms-copy" data-copy="${esc(item.text)}">复制</button></div>`;
      }).join('');
    }
  }

  function doExport() {
    if (!currentResult) { showToast('暂无数据可导出'); return; }
    const lines = [];
    const siteName = currentResult.siteInfo ? currentResult.siteInfo.title : '';
    const domain = currentResult.siteInfo ? currentResult.siteInfo.domain : '';
    lines.push('网站名称,域名,联系方式类型,联系方式,QQ邮箱');
    if (currentResult.emails) currentResult.emails.forEach(email => { lines.push(`"${siteName}","${domain}","邮箱","${email}",""`); });
    if (currentResult.qqs) currentResult.qqs.forEach(item => { lines.push(`"${siteName}","${domain}","QQ","${item.qq}","${item.qqEmail}"`); });
    const csvContent = '\uFEFF' + lines.join('\n');
    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `MailScout_${domain}_${new Date().toISOString().split('T')[0]}.csv`;
    a.click(); URL.revokeObjectURL(url);
    showToast('已导出 CSV 文件');
  }

  function doClear() {
    if (confirm('确定要清空所有扫描历史记录吗?')) {
      Storage.clearHistory();
      showToast('历史记录已清空');
      const historyEl = document.getElementById('ms-history');
      if (historyEl) historyEl.classList.add('ms-hidden');
      updateFabDot();
      markSearchResults();
    }
  }

  // ============================================================
  // 悬浮按钮
  // ============================================================
  function createFab() {
    if (document.getElementById(FAB_ID)) return;
    const fab = document.createElement('div');
    fab.id = FAB_ID;
    fab.title = 'MailScout';
    fab.textContent = 'M';

    let isDragging = false;
    let offsetX, offsetY;

    fab.addEventListener('mousedown', (e) => {
      isDragging = false;
      offsetX = e.clientX - fab.getBoundingClientRect().left;
      offsetY = e.clientY - fab.getBoundingClientRect().top;
      const onMove = (e) => {
        isDragging = true;
        fab.style.right = 'auto';
        fab.style.left = (e.clientX - offsetX) + 'px';
        fab.style.top = (e.clientY - offsetY) + 'px';
      };
      const onUp = () => {
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });

    fab.addEventListener('click', () => {
      if (isDragging) return;
      togglePanel();
    });

    document.body.appendChild(fab);
    updateFabDot();
  }

  function updateFabDot() {
    const fab = document.getElementById(FAB_ID);
    if (!fab) return;
    const existing = fab.querySelector('.ms-dot');
    if (existing) existing.remove();

    const domain = window.location.hostname;
    const record = Storage.getRecord(domain);
    if (record) {
      const dot = document.createElement('div');
      dot.className = 'ms-dot';
      dot.title = '该站点已扫描过 (' + (record.lastScan || '').split('T')[0] + ')';
      fab.appendChild(dot);
    }
  }

  // ============================================================
  // 搜索结果标记(Google / Baidu / Bing)
  // ============================================================
  function markSearchResults() {
    const host = window.location.hostname;
    const path = window.location.pathname;
    let engine = null;
    if (host.includes('google.') && path.startsWith('/search')) engine = 'google';
    else if (host.includes('baidu.com') && path.startsWith('/s')) engine = 'baidu';
    else if (host.includes('bing.com') && path.startsWith('/search')) engine = 'bing';
    if (!engine) return;

    const skipHosts = { google: 'google.', baidu: 'baidu.com', bing: 'bing.com' };
    const skipHost = skipHosts[engine];
    const history = Storage.getHistory();
    const scannedDomains = new Set(Object.keys(history));
    if (scannedDomains.size === 0) return;

    const badgeStyle = 'display:inline-block;margin-left:8px;padding:1px 6px;font-size:11px;font-weight:500;color:#4caf50;background:#e8f5e9;border:1px solid #c8e6c9;border-radius:3px;vertical-align:middle;line-height:18px;';

    function getDomain(link, parentEl) {
      try {
        let href = link.href || '';
        if (engine === 'baidu') {
          const realUrl = link.getAttribute('data-url') || link.getAttribute('mu') || (parentEl && parentEl.getAttribute('mu'));
          if (realUrl && realUrl.startsWith('http')) href = realUrl;
        }
        const url = new URL(href);
        if (url.hostname.includes(skipHost)) return null;
        return url.hostname.replace(/^www\./, '');
      } catch { return null; }
    }

    function addBadge(titleEl) {
      const badge = document.createElement('span');
      badge.textContent = '已扫描';
      badge.style.cssText = badgeStyle;
      titleEl.appendChild(badge);
    }

    function doMark() {
      if (engine === 'google') {
        document.querySelectorAll('h3').forEach(h3 => {
          if (h3.dataset.mailscoutMarked) return;
          const link = h3.closest('a[href]');
          if (!link) return;
          const domain = getDomain(link, null);
          if (!domain) return;
          if (!scannedDomains.has(domain) && !scannedDomains.has('www.' + domain)) return;
          h3.dataset.mailscoutMarked = '1';
          addBadge(h3);
        });
      } else if (engine === 'baidu') {
        document.querySelectorAll('div.result, div.c-container').forEach(result => {
          if (result.dataset.mailscoutMarked) return;
          const link = result.querySelector('a[href]');
          if (!link) return;
          const domain = getDomain(link, result);
          if (!domain) return;
          if (!scannedDomains.has(domain) && !scannedDomains.has('www.' + domain)) return;
          result.dataset.mailscoutMarked = '1';
          const titleEl = result.querySelector('h3, .t');
          if (titleEl) addBadge(titleEl);
        });
      } else if (engine === 'bing') {
        document.querySelectorAll('li.b_algo').forEach(result => {
          if (result.dataset.mailscoutMarked) return;
          const link = result.querySelector('a[href]');
          if (!link) return;
          const domain = getDomain(link, null);
          if (!domain) return;
          if (!scannedDomains.has(domain) && !scannedDomains.has('www.' + domain)) return;
          result.dataset.mailscoutMarked = '1';
          const titleEl = result.querySelector('h2');
          if (titleEl) addBadge(titleEl);
        });
      }
    }

    doMark();
    const observer = new MutationObserver(() => doMark());
    const container = document.getElementById('search') || document.getElementById('rso')
      || document.getElementById('content_left') || document.getElementById('b_results') || document.body;
    observer.observe(container, { childList: true, subtree: true });
  }

  // ============================================================
  // 初始化
  // ============================================================
  GM_registerMenuCommand('MailScout - 打开面板', togglePanel);
  createFab();
  markSearchResults();

})();