MailScout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();

})();