素问搜索助手

在知乎 / 爱发电 / sooon.ai 之间一键跳转并自动搜索

// ==UserScript==
// @name         素问搜索助手
// @namespace    https://gf.zukizuki.org/zh-CN/scripts/532096
// @version      1.1.2
// @description  在知乎 / 爱发电 / sooon.ai 之间一键跳转并自动搜索
// @author       You
// @match        https://www.zhihu.com/question/*/answer/*
// @match        https://afdian.com/p/*
// @match        https://sooon.ai/home/read/**
// @grant        GM_log
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @icon       https://sooon.ai/assets/favicon-BRntVMog.ico
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';
  const DEBUG = true;
  const SCRIPT_NAME = '素问搜索助手';
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const logPrefix = 'injected:';
  let searching = false;
  let finished = false;
  let searchStartTime = 0;

  function dbg(...msg) {
    if (!DEBUG) return;
    const t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
    // Sanitize DOM elements for logging to prevent circular JSON errors
    const sanitizedMsg = msg.map(item => {
        if (item instanceof Element) {
            return `[DOM Element: ${item.tagName}${item.id ? '#' + item.id : ''}${item.className ? '.' + item.className.trim().replace(/\s+/g, '.') : ''}]`;
        }
        return item;
    });
    (GM_log || console.log)(logPrefix, `[SuWen ${t}]`, ...sanitizedMsg);
  }

  dbg(`${SCRIPT_NAME} loading. Version 1.3.5`);

  async function queryEle(selector, { all = false, maxWait = 10_000, context = document } = {}) {
    dbg(`queryEle: Searching for "${selector}". All: ${all}, MaxWait: ${maxWait}ms`);
    const base = 100, maxA = 10;
    let waited = 0;
    for (let i = 1; i <= maxA; i++) {
      const ele = all ? context.querySelectorAll(selector) : context.querySelector(selector);
      if (ele && (!all || ele.length)) {
        dbg(`queryEle: Found "${selector}".`);
        return ele;
      }
      const d = Math.min(base * 2 ** (i - 1) + Math.random() * 100, 2_000);
      waited += d;
      if (i > 1 && i % 3 === 0) dbg(`queryEle: Still waiting for "${selector}", attempt ${i}, waited ${waited.toFixed(0)}ms...`);
      await sleep(d);
      if (waited >= maxWait) break;
    }
    dbg(`queryEle: Timeout for selector "${selector}" after ${waited.toFixed(0)}ms.`);
    return null;
  }

  async function getCurrentTag() {
    // ... (getCurrentTag implementation - no changes from 1.3.4) ...
    dbg('getCurrentTag: Attempting extraction.');
    if (location.hostname.includes('zhihu.com')) {
      dbg('getCurrentTag: On Zhihu.');
      const root = await queryEle('.RichContent--unescapable');
      if (!root) { dbg('getCurrentTag: Zhihu root not found.'); return null; }
      const first = root.querySelector('p[data-first-child], p:first-child');
      const txt = (first ? first.textContent : root?.textContent || '').trim();
      const m = txt.match(/#([^#]+)#/);
      const tag = m ? m[1].trim() : null;
      dbg('getCurrentTag: Zhihu tag:', tag);
      return tag;
    }

    if (location.hostname.includes('afdian.com')) {
      dbg('getCurrentTag: On Afdian.');
      let txt = (await queryEle('div.feed-content.mt16.post-page.unlock > pre', {maxWait: 500}))?.textContent;
      if (!txt) {
        dbg('getCurrentTag: Afdian <pre> not found, trying <article> <p>.');
        txt = (await queryEle('article.afd-post__article > p, article > p', {maxWait: 1000}))?.textContent;
      }
      const m = txt?.trim().match(/#([^#]+)#/);
      const tag = m ? m[1].trim() : null;
      dbg('getCurrentTag: Afdian tag:', tag);
      return tag;
    }
    dbg('getCurrentTag: No matching site for tag extraction.');
    return null;
  }

  async function jumpToSuwen() {
    // ... (jumpToSuwen implementation - no changes from 1.3.4) ...
    dbg('jumpToSuwen: Menu command triggered.');
    const tag = await getCurrentTag();
    if (!tag) {
      GM_notification({ text: '未检测到 #标签#,请确认首段包含标签', timeout: 3000 });
      dbg('jumpToSuwen: No tag found, notification sent.');
      return;
    }
    const url = `https://sooon.ai/home/read/feed?search=${encodeURIComponent(tag)}`;
    dbg('jumpToSuwen: Opening URL for tag:', tag, url);
    window.open(url, '_blank');
  }

  const needMenu =
    /zhihu\.com\/question\/\d+\/answer\/\d+/.test(location.href) ||
    /afdian\.com\/p\//.test(location.href);

  if (needMenu) {
    dbg('Registering menu command "🔗 跳转素问".');
    GM_registerMenuCommand('🔗 跳转素问', jumpToSuwen);
  }

  function handleZhihu() {
    // ... (handleZhihu implementation - no changes from 1.3.4) ...
    dbg('handleZhihu: Initializing.');
    const rootSel = '#root main .AnswerCard .RichContent--unescapable';

    const addBtn = async () => {
      // dbg('handleZhihu - addBtn: Attempting to add button.');
      const answerCards = document.querySelectorAll(rootSel);
      if (!answerCards.length) {
          return;
      }

      for (const root of answerCards) {
          if (root.querySelector('.go-sooon-link')) {
            continue;
          }
          // dbg('handleZhihu - addBtn: Processing card without button:', root);
          const tag = await getCurrentTag();
          if (!tag) {
            // dbg('handleZhihu - addBtn: No tag found for this card/page.');
            continue;
          }

          const first = root.querySelector('p[data-first-child], p:first-child');
          if (!first) {
            // dbg('handleZhihu - addBtn: First paragraph not found in this card.');
            continue;
          }

          const btn = document.createElement('button');
          btn.className = 'go-sooon-link';
          btn.textContent = '跳转素问';
          btn.onclick = e => {
            const url = `https://sooon.ai/home/read/feed?search=${encodeURIComponent(tag)}`;
            dbg('handleZhihu - addBtn: Button clicked. Ctrl:', e.ctrlKey, 'URL:', url);
            e.ctrlKey ? window.open(url, '_blank') : (location.href = url);
          };
          first.after(btn);
          dbg('handleZhihu - addBtn: Button added to card.');
      }
    };

    // dbg('handleZhihu: Setting up MutationObserver.');
    new MutationObserver((mutations, observer) => {
        addBtn();
    }).observe(document.body, { childList: true, subtree: true });
    addBtn();
  }

function handleAfdian() {
  dbg('handleAfdian: Initializing.');

  const addBtn = async () => {
    const tag = await getCurrentTag();
    if (!tag) {
      dbg('handleAfdian - addBtn: No tag found.');
      return;
    }

    const titleBox = await queryEle('.title-box h1, .main-post-title, h1.common-title.no-divider.mt0', { maxWait: 1500 });
    if (titleBox && !titleBox.parentElement.querySelector('.go-sooon-link')) {
      const btn = document.createElement('button');
      btn.className = 'go-sooon-link';
      btn.textContent = '跳转素问';
      btn.style.cssText = 'font-size:.75em;color:#946CE6;border:1px solid #946CE6;padding:2px 6px;margin-left:8px;border-radius:3px;vertical-align:middle;';
      btn.onclick = e => {
        const url = `https://sooon.ai/home/read/feed?search=${encodeURIComponent(tag)}`;
        dbg('handleAfdian - addBtn: Button clicked. Ctrl:', e.ctrlKey, 'URL:', url);
        e.ctrlKey ? window.open(url, '_blank') : (location.href = url);
      };
      titleBox.style.display = 'inline-block';
      titleBox.after(btn);
      dbg('handleAfdian - addBtn: Button added after title.');
      return;
    }

    const contentArea = await queryEle('div.feed-content.mt16.post-page.unlock, article.afd-post__article');
    if (contentArea && !contentArea.querySelector('.go-sooon-link')) {
      const btn = document.createElement('button');
      btn.className = 'go-sooon-link';
      btn.textContent = '跳转素问';
      btn.style.cssText = 'font-size:.75em;color:#946CE6;border:1px solid #946CE6;padding:2px 6px;margin-left:8px;border-radius:3px;vertical-align:middle;';
      btn.onclick = e => {
        const url = `https://sooon.ai/home/read/feed?search=${encodeURIComponent(tag)}`;
        dbg('handleAfdian - addBtn: Button clicked. Ctrl:', e.ctrlKey, 'URL:', url);
        e.ctrlKey ? window.open(url, '_blank') : (location.href = url);
      };
      contentArea.prepend(btn);
      dbg('handleAfdian - addBtn: Button prepended to content area.');
    } else {
      dbg('handleAfdian - addBtn: No suitable location found or button already exists.');
    }
  };

  const observer = new MutationObserver(() => {
    addBtn();
  });
  observer.observe(document.body, { childList: true, subtree: true });

  addBtn();
}

 function handleSooon() {
  const searchQuery = new URLSearchParams(location.search).get('search');
  if (!searchQuery) {
    dbg('[Sooon] No "search" query parameter. Exiting Sooon handler.');
    return;
  }

  dbg(`[Sooon] Initializing. Search Query: "${searchQuery}"`);
  let searching = false;
  let finished = false;

  const events = ['focus', 'visibilitychange', 'mousemove', 'keydown', 'scroll', 'touchstart'];

  async function doSearch(isInitialAttempt = false) {
    dbg(`[Sooon] doSearch: Entering. Query: "${searchQuery}", Initial: ${isInitialAttempt}, Searching: ${searching}, Finished: ${finished}`);
    if (searching || finished) {
      dbg('[Sooon] doSearch: Already searching or finished. Aborting.');
      return;
    }
    searching = true;
    dbg('[Sooon] doSearch: Set searching = true.');

    try { // Wrap core logic in try...finally to ensure `searching` is reset
        if (isInitialAttempt) {
            const initialDelay = 1500;
            dbg(`[Sooon] doSearch: Initial attempt, sleeping ${initialDelay}ms for page stabilization.`);
            await sleep(initialDelay);
        }

        const fastBtn = await queryEle('button[aria-label="快速搜索"]');
        if (!fastBtn) {
            dbg('[Sooon] doSearch: "快速搜索" button not found.');
            return; // searching will be set to false in finally
        }
        dbg('[Sooon] doSearch: "快速搜索" button found:', fastBtn);

        let searchModalInput = document.querySelector('form input[name="input"]');
        let isModalAlreadyOpen = searchModalInput && searchModalInput.closest('form') && window.getComputedStyle(searchModalInput.closest('form')).display !== 'none';

        if (!isModalAlreadyOpen) {
            dbg('[Sooon] doSearch: Search modal not open. Clicking "快速搜索".');
            fastBtn.focus(); await sleep(50);
            fastBtn.click(); // Click to open modal
            await sleep(300); // Wait for modal animation / appearance
            if (!await queryEle('form input[name="input"]', {maxWait: 3000})) {
                dbg('[Sooon] doSearch: Search modal input did not appear after clicking "快速搜索".');
                return; // searching will be set to false in finally
            }
            dbg('[Sooon] doSearch: Search modal input appeared.');
        } else {
            dbg('[Sooon] doSearch: Search modal seems already open.');
        }

        const formCtx = await queryEle('form:has(input[name="input"])');
        if (!formCtx) {
            dbg('[Sooon] doSearch: Search form (form:has(input[name="input"])) not found.');
            return;
        }
        dbg('[Sooon] doSearch: Search form context found:', formCtx);

        const input = await queryEle('input[name="input"]', {context: formCtx});
        const submit = await queryEle('button[type="submit"]', {context: formCtx});

        if (!input || !submit) {
            dbg('[Sooon] doSearch: Missing input or submit button in form.');
            return;
        }
        dbg('[Sooon] doSearch: Input and submit button found:', input, submit);

        // --- Enhanced Input Simulation ---
        dbg('[Sooon] doSearch: Simulating input interaction.');
        input.click(); // Click the input field
        await sleep(100);
        input.focus();
        await sleep(100);

        dbg('[Sooon] doSearch: Clearing input field.');
        input.value = '';
        input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); // composed: true for shadow DOM if any
        input.dispatchEvent(new Event('change', { bubbles: true })); // also dispatch change
        await sleep(150); // Longer pause after clearing

        dbg(`[Sooon] doSearch: Setting input value to "${searchQuery}".`);
        input.value = searchQuery;
        input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
        input.dispatchEvent(new Event('change', { bubbles: true }));
        await sleep(500); // Longer pause after setting value

        dbg('[Sooon] doSearch: Input value set. Input field:', input);
        // Optional: blur input before clicking submit, or directly focus submit
        // input.blur();
        // await sleep(50);

        dbg('[Sooon] doSearch: Focusing and clicking submit button.');
        submit.focus();
        await sleep(100); // Ensure focus is registered
        submit.click();
        dbg('[Sooon] doSearch: Submit button clicked.', submit);

        await sleep(800); // Increased wait for results after submit, give server/UI time

        const resultSelector = 'div.mantine-focus-never button span.inline-block';
        const resultSpans = await queryEle(resultSelector, { all: true, context: formCtx, maxWait: 8000 });
        let firstClickableResult = null;

        if (resultSpans && resultSpans.length > 0) {
            dbg(`[Sooon] doSearch: Found ${resultSpans.length} potential result spans.`);
            for (const span of resultSpans) {
                if (span.textContent.trim() === '') continue;
                const parentButton = span.closest('button');
                if (parentButton && parentButton.offsetParent !== null) {
                    firstClickableResult = parentButton;
                    dbg('[Sooon] doSearch: Found clickable result:', parentButton.textContent.trim(), parentButton);
                    break;
                }
            }
        } else {
            dbg(`[Sooon] doSearch: No result spans found with selector: "${resultSelector}".`);
        }

        if (firstClickableResult) {
          firstClickableResult.focus(); await sleep(50);
          firstClickableResult.click();
          dbg('[Sooon] doSearch: Clicked first result. Text:', `"${firstClickableResult.textContent.trim()}"`);
          finished = true;
          dbg('[Sooon] doSearch: Set finished = true. Removing event listeners.');
          events.forEach(ev => window.removeEventListener(ev, namedMaybeSearchWrapper));
        } else {
          const allBtnsInForm = formCtx.querySelectorAll('div.mantine-focus-never button, div[role="listbox"] button');
          dbg(`[Sooon] doSearch: No clickable result found. Query: "${searchQuery}". Candidate buttons in form: ${allBtnsInForm.length}`);
        }
    } catch (error) {
        dbg('[Sooon] doSearch: Error during execution:', error);
        // Potentially GM_notification or more robust error handling here
    } finally {
        searching = false;
        dbg('[Sooon] doSearch: Exiting. Searching set to false.');
    }
  }

  const namedMaybeSearchWrapper = async (eventOrTrigger) => {
    const trigger = typeof eventOrTrigger === 'string' ? eventOrTrigger : (eventOrTrigger.type || 'unknown_event_type');
    // Reduce log spam for frequent events like mousemove unless debugging them specifically
    if (trigger !== 'mousemove' || DEBUG) {
        dbg(`[Sooon] namedMaybeSearchWrapper: Triggered by "${trigger}". Query: "${searchQuery}", Searching: ${searching}, Finished: ${finished}, Visible: ${document.visibilityState}, HasFocus: ${document.hasFocus()}`);
    }


    if (trigger === 'init') {
        await sleep(1000);
        if (DEBUG) dbg(`[Sooon] namedMaybeSearchWrapper: Post 'init' delay. Visible: ${document.visibilityState}, HasFocus: ${document.hasFocus()}`);
    }
// 强制检测是否卡住(搜索框已打开但没有点击结果)
const stuckInModal = document.querySelector('form input[name="input"]') &&
                     !finished &&
                     document.visibilityState === 'visible' &&
                     document.hasFocus();

if ((!searching && !finished && document.visibilityState === 'visible' && document.hasFocus()) || stuckInModal) {
  dbg(`[Sooon] namedMaybeSearchWrapper: Trigger "${trigger}" - attempting doSearch. Stuck: ${stuckInModal}`);
  await doSearch(trigger === 'init' || stuckInModal);
} else {
      // if (DEBUG || trigger !== 'mousemove') dbg('[Sooon] namedMaybeSearchWrapper: Conditions not met for doSearch. Skipping.');
    }
  };

  namedMaybeSearchWrapper('init');
  events.forEach(ev => window.addEventListener(ev, namedMaybeSearchWrapper));
}

  (function addGlobalStyle() {
    // ... (addGlobalStyle implementation - no changes from 1.3.4) ...
    dbg('addGlobalStyle: Adding CSS.');
    const css = `
      .go-sooon-link{
        background:none;font-size:1em;font-weight:500;color:#0066FF;
        border:1px solid #228BE6;padding:2px 8px;margin-left:10px;border-radius:3px;
        cursor:pointer;display:inline-block;vertical-align:middle
      }
      .go-sooon-link:hover{opacity:.8;color:gray;border-color:gray}
    `;
    const s = document.createElement('style');
    s.textContent = css;
    document.head.appendChild(s);
  })();

  function init() {
    // ... (init implementation - no changes from 1.3.4) ...
    dbg(`init: Script initializing. Host: ${location.hostname}, Path: ${location.pathname}, ReadyState: ${document.readyState}`);
    if (location.hostname.includes('zhihu.com')) handleZhihu();
    else if (location.hostname.includes('afdian.com')) handleAfdian();
    else if (location.hostname.includes('sooon.ai') && location.pathname.startsWith('/home/read/')) handleSooon();
    else dbg('init: No matching site/path for handlers.');
  }

  if (document.readyState === 'loading') {
    dbg('Document loading. Adding DOMContentLoaded listener.');
    window.addEventListener('DOMContentLoaded', init);
  } else {
    dbg('Document already loaded. Calling init.');
    init();
  }
  dbg(`${SCRIPT_NAME} execution finished (initial setup phase).`);
})();