SearXNG Gemini Overview

SearXNGの検索結果にGeminiによる概要を表示します

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         SearXNG Gemini Overview
// @namespace    https://github.com/Sanka1610/SearXNG-Gemini-Overview
// @version      1.5.0
// @description  SearXNGの検索結果にGeminiによる概要を表示します
// @author       Sanka1610
// @match        *://searx.*/*
// @match        *://searxng.*/*
// @match        *://search.*/*
// @match        *://priv.au/*
// @match        *://im-in.space/*
// @match        *://ooglester.com/*
// @match        *://fairsuch.net/*
// @match        *://copp.gg/*
// @match        *://darmarit.org/searx/*
// @match        *://etsi.me/*
// @match        *://gruble.de/*
// @match        *://seek.fyi/*
// @match        *://baresearch.org/*
// @match        *://search.zina.dev/*
// @match        *://opnxng.com/*
// @match        *://search.bladerunn.in/*
// @match        *://127.0.0.1:8888/search*
// @match        *://localhost:8888/search*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// @homepageURL  https://github.com/Sanka1610/SearXNG-Gemini-Overview
// @supportURL   https://github.com/Sanka1610/SearXNG-Gemini-Overview/issues
// @icon         https://docs.searxng.org/_static/searxng-wordmark.svg
// ==/UserScript==

(async () => {
    'use strict';

// 設定定数
    const CONFIG = {
        MODEL_NAME: 'gemini-2.5-flash-lite',   // GeminiAPIモデル
        MAX_RESULTS: 20,                       // 解析対象の検索結果の上限
        SNIPPET_CHAR_LIMIT: 5000,              // 送信テキストの制限
        MAX_RETRY: 3,                          // 失敗時の再試行回数
        RETRY_DELAY: 1500,                     // 再試行の間隔(ms)
        CACHE_TTL_MS: 7 * 24 * 60 * 60 * 1000, // キャッシュ有効期限 (7日間)
        CACHE_MATCH_THRESHOLD: 0.25            // 一致率によるキャッシュ判定
    };

// ユーティリティ関数

  // ログ出力
    const log = {
        info: (...args) => console.info('[GeminiOverview]', ...args),
        error: (...args) => console.error('[GeminiOverview]', ...args)
    };

  // URL整形
    const urlUtils = {

    // 正規化
        normalize: (url) => {
            if (!url) return '';
            try {
                let u = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
                return u.endsWith('/') ? u.slice(0, -1) : u;
            } catch (e) { return url; }
        },

    // サイト名の抽出
        getSiteName: (url) => {
            if (!url) return '';
            try {
                let domain = new URL(url).hostname.replace(/^www\./, '');
                let siteName = domain.split('.')[0];
                return siteName.charAt(0).toUpperCase() + siteName.slice(1);
            } catch (e) { return url; }
        },

    // XSS対策:安全なプロトコルかチェック
        isValid: (url) => {
            try {
                const u = new URL(url);
                return u.protocol === 'http:' || u.protocol === 'https:';
            } catch (e) { return false; }
        }
    };

  // キャッシュ管理

    // キャッシュのクリーンアップ
    function cleanupOldCaches() {
        const now = Date.now();
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            if (key && key.startsWith('GEMINI_CACHE_')) {
                try {
                    const item = JSON.parse(localStorage.getItem(key));
                    if (now - item.timestamp > CONFIG.CACHE_TTL_MS) {
                        localStorage.removeItem(key);
                    }
                } catch (e) {
                    localStorage.removeItem(key);
                }
            }
        }
    }

  // APIキー管理
    async function getApiKey(force = false) {
        let key = force ? null : GM_getValue('GEMINI_API_KEY');
        if (key) return key;

    // ダークモード判定
        return new Promise((resolve) => {
            const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            
    // 取得UI

      // 背景
            const overlay = document.createElement('div');
            Object.assign(overlay.style, {
                position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
                background: 'rgba(0,0,0,0.6)', display: 'flex', justifyContent: 'center',
                alignItems: 'center', zIndex: '9999', backdropFilter: 'blur(4px)'
            });

      // 本体
            const modal = document.createElement('div');
            Object.assign(modal.style, {
                background: isDark ? '#252525' : '#fff', color: isDark ? '#eee' : '#333',
                padding: '2em', borderRadius: '16px', textAlign: 'center', maxWidth: '400px', width: '90%'
            });

            modal.innerHTML = `
                <h2 style="margin-top:0;">Gemini API設定</h2>
                <p>概要の生成にはAPIキーが必要です。</p>
                <input id="gemini-input" type="password" placeholder="APIキーを入力" 
                    style="width:100%; padding:10px; margin:20px 0; border-radius:8px; border:1px solid #555; box-sizing:border-box;">
                <div style="display:flex; justify-content:center; gap:10px;">
                    <button id="gemini-save" style="background:#3399FF; color:white; border:none; padding:10px 20px; border-radius:8px; cursor:pointer; font-weight:bold;">保存</button>
                    <button id="gemini-cancel" style="background:#666; color:white; border:none; padding:10px 20px; border-radius:8px; cursor:pointer; font-weight:bold;">キャンセル</button>
                </div>
            `;
      // 表示
            overlay.appendChild(modal);
            document.body.appendChild(overlay);

      // 保存処理
            overlay.querySelector('#gemini-save').onclick = () => {
                const val = overlay.querySelector('#gemini-input').value.trim();
                if (val) {
                    GM_setValue('GEMINI_API_KEY', val);
                    document.body.removeChild(overlay);
                    resolve(val);
                    location.reload();
                }
            };

      // キャンセル処理
            overlay.querySelector('#gemini-cancel').onclick = () => {
                document.body.removeChild(overlay);
                resolve(null);
            };
        });
    }

    // テキスト整形
    function formatTextNodes(text, urlList) {
        const fragment = document.createDocumentFragment();
        
    // 改行ロジック
        let formatted = text.replace(/。/g, '。\n\n');
        formatted = formatted.replace(/\.(?=[A-Z])/g, '.\n\n');
        formatted = formatted.replace(/\n{3,}/g, '\n\n').trim();
        const parts = formatted.split(/(\*\*.*?\*\*|\[\d+\]|\n)/g);
        parts.forEach(part => {
            if (!part) return;
            if (part === '\n') {
                fragment.appendChild(document.createElement('br'));
            } else if (part.startsWith('**') && part.endsWith('**')) {

     // 強調表示
                const strong = document.createElement('strong');
                strong.textContent = part.slice(2, -2);
                fragment.appendChild(strong);
            } else if (/^\[\d+\]$/.test(part)) {

     // 出典リンク
                const num = parseInt(part.match(/\d+/)[0]);
                const url = urlList[num - 1];
                const sup = document.createElement('sup');
                sup.style.whiteSpace = 'nowrap';

                if (url && urlUtils.isValid(url)) {
                    const a = document.createElement('a');
                    a.href = url;
                    a.target = '_blank';
                    a.textContent = `[${num}]`;
                    Object.assign(a.style, { textDecoration: 'none', color: '#3399FF', fontWeight: 'bold' });
                    sup.appendChild(a);
                } else {
                    sup.textContent = `[${num}]`;
                }
                fragment.appendChild(sup);
            } else {

        // 通常のテキスト
                fragment.appendChild(document.createTextNode(part));
            }
        });
        return fragment;
    }


  // UI描画処理
    function renderOverview(data, contentEl, timeEl, urlList) {
        if (!data) return;
        contentEl.textContent = '';
        
    // 出典統計
        const counts = {};
        const fullText = (data.body || '') + (data.sections || []).map(s => (s.content || []).join(' ')).join(' ');
        (fullText.match(/\[(\d+)\]/g) || []).forEach(m => {
            const n = parseInt(m.replace(/\D/g, ''));
            counts[n] = (counts[n] || 0) + 1;
        });

    // Bodyの描画
        if (data.body) {
            const section = document.createElement('section');
            section.style.marginBottom = '1.5em';
            const bodyDiv = document.createElement('div');
            Object.assign(bodyDiv.style, { lineHeight: '1.8', whiteSpace: 'pre-wrap' });
            bodyDiv.appendChild(formatTextNodes(data.body, urlList));
            section.appendChild(bodyDiv);
            contentEl.appendChild(section);
        }

        // 各sectionの描画
        if (data.sections) {
            data.sections.forEach(sec => {
                const section = document.createElement('section');
                section.style.marginBottom = '1.2em';
                
                const title = document.createElement('strong');
                title.textContent = sec.title;
                
                const ul = document.createElement('ul');
                ul.style.marginTop = '0.5em';
                
                sec.content.forEach(item => {
                    const li = document.createElement('li');
                    Object.assign(li.style, { marginBottom: '0.6em', lineHeight: '1.6', whiteSpace: 'pre-wrap' });
                    li.appendChild(formatTextNodes(item, urlList));
                    ul.appendChild(li);
                });

                section.append(title, ul);
                contentEl.appendChild(section);
            });
        }

    // 上位3つを主な出典
        const top3 = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3).map(e => parseInt(e[0]));
        if (top3.length > 0) {
            const footer = document.createElement('div');
            Object.assign(footer.style, { borderTop: '1px solid rgba(128,128,128,0.3)', paddingTop: '0.8em', fontSize: '0.95em' });
            
            const label = document.createElement('strong');
            label.textContent = '主な出典:';
            
            const ul = document.createElement('ul');
            Object.assign(ul.style, { listStyle: 'none', padding: '0', marginTop: '5px' });
            
            top3.sort((a, b) => a - b).forEach(idx => {
                const url = urlList[idx - 1];
                if (url && urlUtils.isValid(url)) {
                    const li = document.createElement('li');
                    li.style.marginBottom = '3px';
                    li.textContent = `[${idx}] `;
                    const a = document.createElement('a');
                    a.href = url;
                    a.target = '_blank';
                    a.textContent = urlUtils.getSiteName(url);
                    Object.assign(a.style, { color: '#3399FF', textDecoration: 'none' });
                    li.appendChild(a);
                    ul.appendChild(li);
                }
            });
            footer.append(label, ul);
            contentEl.appendChild(footer);
        }

    // 時間表示
        timeEl.textContent = new Date().toLocaleTimeString('ja-JP');
    }

  // 検索結果取得 簡易クローラー
    async function fetchSearchResults(form, mainResults, maxResults) {
        let results = Array.from(mainResults.querySelectorAll('.result'));
        let currentCount = results.length;
        let pageNo = parseInt(new FormData(form).get('pageno') || 1);

        async function fetchNext() {
            if (currentCount >= maxResults) return [];
            pageNo++;
            const fd = new FormData(form);
            fd.set('pageno', pageNo);
            try {
                const resp = await fetch(form.action, { method: 'POST', body: fd });
                const doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
                const newItems = Array.from(doc.querySelectorAll('#main_results .result'))
                                      .slice(0, maxResults - currentCount);
                currentCount += newItems.length;
                if (currentCount < maxResults && newItems.length > 0) {
                    return newItems.concat(await fetchNext()); // 再帰呼び出し
                }
                return newItems;
            } catch (e) { return []; }
        }
        results.push(...(await fetchNext()));
        return results.slice(0, maxResults);
    }

// メイン処理
    const form = document.querySelector('#search_form, form[action="/search"]');
    const mainResults = document.getElementById('main_results');
    const sidebar = document.querySelector('#sidebar');
    if (!form || !mainResults || !sidebar) return;

  // 初期化
    cleanupOldCaches();
    const API_KEY = await getApiKey();
    if (!API_KEY) return;

  // UIコンテナ構築
    const aiBox = document.createElement('div');
    aiBox.style.cssText = `margin: 1em 0; padding: 1.2em; border-radius: 12px; border: 1px solid rgba(128,128,128,0.2);`;
    
    const header = document.createElement('div');
    Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1em' });
    
    const titleGroup = document.createElement('div');
    const boxTitle = document.createElement('span');
    boxTitle.style.fontWeight = 'bold';
    boxTitle.textContent = 'Gemini Overview';
    const cacheStatusEl = document.createElement('span');
    Object.assign(cacheStatusEl.style, { fontSize: '0.8em', color: 'gray', marginLeft: '8px' });
    titleGroup.append(boxTitle, cacheStatusEl);

    const timeEl = document.createElement('span');
    Object.assign(timeEl.style, { fontSize: '0.8em', opacity: '0.6' });
    header.append(titleGroup, timeEl);

    const contentEl = document.createElement('div');
    contentEl.style.fontSize = '0.95em';
    contentEl.textContent = '情報を収集中...';

    aiBox.append(header, contentEl);
    sidebar.insertBefore(aiBox, sidebar.firstChild);

    // ダークモードに追従
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    function updateTheme(e) {
        const isDark = e.matches;
        aiBox.style.background = isDark ? "rgba(255,255,255,0.05)" : "#f9f9f9";
        aiBox.style.color = isDark ? "#eee" : "#333";
    }
    mediaQuery.addEventListener('change', updateTheme);
    updateTheme(mediaQuery);

  // 検索クエリと結果の収集
    const query = document.querySelector('input[name="q"]').value;
    const results = await fetchSearchResults(form, mainResults, CONFIG.MAX_RESULTS);

  // リスト化
    const resultBlocks = [];
    const urlList = [];
    const normalizedList = [];

  // 抽出・整形
    results.forEach((r) => {
        const titleEl = r.querySelector('h3, .result__title, .title, article h2');
        const anchor = r.querySelector('h3 a, article a, .result__title a') || r.querySelector('a');
        if (!anchor) return;

        const title = (titleEl ? titleEl.innerText : anchor.innerText).replace(/\s+/g, ' ').trim();
        const snippetEl = r.querySelector('.result__snippet, .content, .description');
        const snippet = (snippetEl ? snippetEl.innerText : r.innerText).trim();
        const cleaned = snippet.replace(title, '').replace(/\s+/g, ' ').trim();

        if (cleaned && title) {

    // URL保存
            urlList.push(anchor.href);
            normalizedList.push(urlUtils.normalize(anchor.href));

    // リンク付与
            resultBlocks.push(`{[${urlList.length}] Title: ${title}, Content: ${cleaned}}`);
        }
    });

    // キャッシュチェック (Phase 3.1)
    const CACHE_KEY = `GEMINI_CACHE_${encodeURIComponent(query)}`;
    const cachedRaw = localStorage.getItem(CACHE_KEY);
    if (cachedRaw) {
        try {
            const cached = JSON.parse(cachedRaw);
            if (Date.now() - cached.timestamp < CONFIG.CACHE_TTL_MS) {
                const matchRate = normalizedList.filter(u => cached.urls.includes(u)).length / CONFIG.MAX_RESULTS;
                if (matchRate >= CONFIG.CACHE_MATCH_THRESHOLD) {
                    cacheStatusEl.textContent = 'キャッシュを再利用';
                    renderOverview(cached.overview, contentEl, timeEl, urlList);
                    return;
                }
            }
        } catch (e) {}
    }

  // プロンプト作成
    const userLang = navigator.language || 'ja';
    const promptText = `
        Data:${resultBlocks.join('\n')} 

        # 指示
1. 提供データのみを根拠に、クエリ(${query})への簡潔かつ具体的な概要を作成しなさい。
2. 情報源が多数あるため、複数のソースで共通する重要な情報を優先し、網羅的にまとめなさい。
3. 情報が不足する場合は、過度な推測を避けつつ自然な補完に留めなさい。
4. 内容が複数の観点に分かれる場合、複数のsectionsを使用しなさい。
5. 出力はユーザー言語(${userLang})で記述しなさい。
6. 各文末または箇条書き末に、必ず参照番号を[n]形式で付けなさい(例: ~。[1][2]~)。
7. 必ず以下のJSON形式のみで出力しなさい。
    `;

  // APIリクエスト
    let finalData = null;
    let lastError = null;

    // 再試行
    for (let attempt = 0; attempt < CONFIG.MAX_RETRY; attempt++) {
        try {
            if (attempt > 0) {
                contentEl.textContent = `再試行中... (${attempt + 1}/${CONFIG.MAX_RETRY})`;
                await new Promise(r => setTimeout(r, CONFIG.RETRY_DELAY * Math.pow(2, attempt - 1)));
            }

    // JSONモード : v1betaを使用中
            const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${CONFIG.MODEL_NAME}:generateContent?key=${API_KEY}`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    contents: [{ parts: [{ text: promptText }] }],
                    generationConfig: {
                        responseMimeType: "application/json",
                        responseSchema: {
                            type: "OBJECT",
                            properties: {
                                body: { type: "STRING" },
                                sections: {
                                    type: "ARRAY",
                                    items: {
                                        type: "OBJECT",
                                        properties: {
                                            title: { type: "STRING" },
                                            content: { type: "ARRAY", items: { type: "STRING" } }
                                        },
                                        required: ["title", "content"]
                                    }
                                }
                            },
                            required: ["body", "sections"]
                        }
                    }
                })
            });

    // エラーハンドリング
            if (!response.ok) {
                const errorData = await response.json().catch(() => ({}));
                const errorMessage = errorData.error?.message || `HTTP Error ${response.status}`;
                throw new Error(errorMessage);
            }
            
            const resData = await response.json();
            
    // 出力の抽出
            finalData = JSON.parse(resData.candidates[0].content.parts[0].text);
            break;

    // エラー処理
        } catch (err) {
            lastError = err;
            log.error(`Attempt ${attempt + 1} failed:`, err);
        }
    }


  // 最終描画

    // 成功
    if (finalData) {

      // キャッシュ
        localStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: Date.now(), urls: normalizedList, overview: finalData }));

      // 描画処理
        renderOverview(finalData, contentEl, timeEl, urlList);

    // 失敗→エラー処理
    } else {
        contentEl.textContent = `エラー: ${lastError?.message || '取得失敗'}`;
    }

})();