SearXNG Gemini Summary

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

// ==UserScript==
// @name         SearXNG Gemini Summary
// @namespace    https://github.com/Sanka1610/SearXNG-Gemini-Summary
// @version      1.0.0
// @description  SearXNGの検索結果にGeminiによる概要を表示
// @author       Sanka1610
// @match        *://*/searx/search*
// @match        *://*/searxng/search*
// @match        *://searx.*/*
// @match        *://*.searx.*/*
// @match        https://opnxng.com/*
// @match        https://search.charleseroop.com/*
// @match        http://127.0.0.1:8888/search*
// @match        http://localhost:8888/search*
// @grant        none
// @license      MIT
// @homepageURL  https://github.com/Sanka1610/SearXNG-Gemini-Summary
// @supportURL   https://github.com/Sanka1610/SearXNG-Gemini-Summary/issues
// @icon         https://docs.searxng.org/_static/searxng-wordmark.svg
// ==/UserScript==

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

// ユーザー変更可能設定
const CONFIG={
    MAX_RESULTS:20,
    MODEL_NAME:'gemini-2.0-flash',
    SNIPPET_CHAR_LIMIT:5000,
    CACHE_KEY:'GEMINI_SUMMARY_CACHE',
    CACHE_LIMIT:30,
    CACHE_EXPIRE:7*24*60*60*1000
};

// ダークモード判定
const isDark=window.matchMedia('(prefers-color-scheme: dark)').matches;

// API暗号化

  // 暗号化キー
    // 変更を推奨します!
    // 32文字の半角英数字で構成された任意の文字列に変更してください
const FIXED_KEY = '1234567890abcdef1234567890abcdef';

  // 暗号化処理
async function encrypt(text){
    const enc = new TextEncoder();
    const key = await crypto.subtle.importKey('raw', enc.encode(FIXED_KEY), 'AES-GCM', false, ['encrypt']);
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const ct = await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, enc.encode(text));
    return btoa(String.fromCharCode(...iv)) + ':' + btoa(String.fromCharCode(...new Uint8Array(ct)));
}

  // 復元処理
async function decrypt(cipher){
    const [ivB64, ctB64] = cipher.split(':');
    const iv = Uint8Array.from(atob(ivB64), c=>c.charCodeAt(0));
    const ct = Uint8Array.from(atob(ctB64), c=>c.charCodeAt(0));
    const enc = new TextEncoder();
    const key = await crypto.subtle.importKey('raw', enc.encode(FIXED_KEY), 'AES-GCM', false, ['decrypt']);
    const decrypted = await crypto.subtle.decrypt({name:'AES-GCM', iv}, key, ct);
    return new TextDecoder().decode(decrypted);
}

// ログ
const log={
    debug:(...args)=>console.debug('[Gemini][DEBUG]',new Date().toLocaleTimeString(),...args),
    info:(...args)=>console.info('[Gemini][INFO]',new Date().toLocaleTimeString(),...args),
    warn:(...args)=>console.warn('[Gemini][WARN]',new Date().toLocaleTimeString(),...args),
    error:(...args)=>console.error('[Gemini][ERROR]',new Date().toLocaleTimeString(),...args)
};

// 検索クエリ正規化
function normalizeQuery(q) {
    return q
        .trim()
        .toLowerCase()
        .replace(/[ ]/g, ' ')
        .replace(/\s+/g, ' ');
}

// キャッシュ
const getCache=()=>{
    try{
        const c=JSON.parse(sessionStorage.getItem(CONFIG.CACHE_KEY));
        return c&&typeof c==='object'?c:{keys:[],data:{}};
    }catch{return {keys:[],data:{}};}
};
const setCache=cache=>{
    const now=Date.now();
    cache.keys=cache.keys.filter(k=>cache.data[k]?.ts&&now-cache.data[k].ts<=CONFIG.CACHE_EXPIRE);
    while(cache.keys.length>CONFIG.CACHE_LIMIT) delete cache.data[cache.keys.shift()];
    sessionStorage.setItem(CONFIG.CACHE_KEY,JSON.stringify(cache));
};

// テキスト整形
const formatResponse=text=>text.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');

// APIキー取得
async function getApiKey(force = false) {
    if (force) localStorage.removeItem('GEMINI_API_KEY');

  // 暗号化されたキーを取得し、復元
    let encrypted = localStorage.getItem('GEMINI_API_KEY');
    let key = null;

    if (encrypted) {
        try {
            key = await decrypt(encrypted);
        } catch (e) {
            console.error('APIキー復号失敗', e);
        }
    }

    if (key) return key;

    // 入力UI表示
    const overlay = document.createElement('div');
    overlay.style.position = 'fixed';
    overlay.style.top = '0';
    overlay.style.left = '0';
    overlay.style.width = '100%';
    overlay.style.height = '100%';
    overlay.style.background = 'rgba(0,0,0,0.5)';
    overlay.style.display = 'flex';
    overlay.style.justifyContent = 'center';
    overlay.style.alignItems = 'center';
    overlay.style.zIndex = '9999';
    overlay.style.pointerEvents = 'auto';

    const modalDiv = document.createElement('div');
    modalDiv.style.background = isDark ? '#1e1e1e' : '#fff';
    modalDiv.style.color = isDark ? '#fff' : '#000';
    modalDiv.style.padding = '1.5em 2em';
    modalDiv.style.borderRadius = '12px';
    modalDiv.style.textAlign = 'center';
    modalDiv.style.maxWidth = '480px';
    modalDiv.style.boxShadow = '0 0 10px rgba(0,0,0,0.3)';
    modalDiv.style.fontFamily = 'sans-serif';
    modalDiv.style.pointerEvents = 'auto';
    modalDiv.innerHTML = `
<h2 style="margin-bottom:0.5em;">Gemini APIキー設定</h2>
<p style="font-size:0.9em;margin-bottom:1em;">
  以下のリンクからGoogle AI StudioにアクセスしてAPIキーを発行してください。<br>
  <a href="https://aistudio.google.com/app/apikey?hl=ja" target="_blank" style="color:#0078d4;text-decoration:underline;">Google AI Studio でAPIキーを発行</a>
</p>
<input type="text" id="gemini-api-input" placeholder="APIキーを入力" style="width:90%;padding:0.5em;margin-bottom:1em;border:1px solid ${isDark ? '#555' : '#ccc'};border-radius:6px;background:${isDark ? '#333' : '#fafafa'};color:inherit;"/>
<br>
<div style="display:flex;justify-content:center;gap:1em;">
<button id="gemini-save-btn" style="background:#0078d4;color:#fff;border:none;padding:0.5em 1.2em;border-radius:8px;cursor:pointer;font-weight:bold;">保存</button>
<button id="gemini-cancel-btn" style="background:${isDark ? '#555' : '#ccc'};color:${isDark ? '#fff' : '#000'};border:none;padding:0.5em 1.2em;border-radius:8px;cursor:pointer;">キャンセル</button>
</div>
`;

    overlay.appendChild(modalDiv);
    document.body.appendChild(overlay);

    // 保存ボタン
return new Promise(resolve => {
    overlay.querySelector('#gemini-save-btn').onclick = async () => {    
        const val = overlay.querySelector('#gemini-api-input').value.trim();
        if (!val) return alert('APIキーが入力されていません。');    

        try {    
            const btn = overlay.querySelector('#gemini-save-btn');    
            btn.disabled = true;    
            btn.textContent = '保存中…';    

            const enc = await encrypt(val);    
            localStorage.setItem('GEMINI_API_KEY', enc);    

            overlay.remove();    
            resolve(val);    

            setTimeout(() => location.reload(), 500);    
        } catch (e) {    
            alert('暗号化に失敗しました');    
            console.error(e);    
            const btn = overlay.querySelector('#gemini-save-btn');    
            btn.disabled = false;    
            btn.textContent = '保存';    
        }
    };

    overlay.querySelector('#gemini-cancel-btn').onclick = () => {
        overlay.remove();
        resolve(null);
        };
    });
}

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

    async function fetchNextPage(){
        if(currentResults>=maxResults) return [];
        pageNo++;
        const formData=new FormData(form);
        formData.set('pageno',pageNo);
        try{
            const resp=await fetch(form.action,{method:'POST',body:formData});
            const doc=new DOMParser().parseFromString(await resp.text(),'text/html');
            const newResults=Array.from(doc.querySelectorAll('#main_results .result')).slice(0,maxResults-currentResults);
            currentResults+=newResults.length;
            if(currentResults<maxResults&&newResults.length>0){
                const nextResults=await fetchNextPage();
                return newResults.concat(nextResults);
            }
            return newResults;
        }catch(e){
            log.error('検索結果取得エラー:',e);
            return [];
        }
    }

    const additionalResults=await fetchNextPage();
    results.push(...additionalResults);
    return results.slice(0,maxResults);
}

  // 概要描画
function renderSummary(jsonData, contentEl, timeEl, query, cacheKey) {
    if (!jsonData) { 
        contentEl.textContent = '無効な応答'; 
        return; 
    }

    let html = '';

    // 導入文
    if (jsonData.intro) html += `<section><p>${formatResponse(jsonData.intro)}</p></section>`;

    // セクション
    if (Array.isArray(jsonData.sections)) {
        jsonData.sections.forEach(sec => {
            if (sec.title && Array.isArray(sec.content)) {
                html += `<section><h4>${sec.title}</h4><ul>`;
                sec.content.forEach(item => html += `<li>${formatResponse(item)}</li>`);
                html += '</ul></section>';
            }
        });
    }

    // 出典
    if (Array.isArray(jsonData.urls) && jsonData.urls.length > 0) {
        html += '<section><h4>出典</h4><ul>';
        jsonData.urls.slice(0, 3).forEach(url => {
            try {
                const u = new URL(url);
                const domain = u.hostname.replace(/^www\./, '');
                html += `<li><a href="${url}" target="_blank">${domain}</a></li>`;
            } catch {
                html += `<li>${url}</li>`;
            }
        });
        html += '</ul></section>';
    }

    contentEl.innerHTML = html;

    // 更新日時表示
    const now = new Date();
    timeEl.textContent = now.toLocaleString('ja-JP', { hour12: false });

    // キャッシュ更新
    const cacheData = getCache();
    if (!cacheData.keys.includes(cacheKey)) cacheData.keys.push(cacheKey);
    cacheData.data[cacheKey] = { html, ts: Date.now(), time: timeEl.textContent };
    setCache(cacheData);
}

// 実行処理

  // SearXNGサイト判定
if(!document.querySelector('#search_form, form[action="/search"]')||!document.querySelector('#results, .results, #sidebar')){
    log.info('非対応サイトです'); return;
}

  // エラー
const GEMINI_API_KEY=await getApiKey();
if(!GEMINI_API_KEY){ log('APIキー未設定'); return; }

const queryInput=document.querySelector('input[name="q"]');
if(!queryInput?.value?.trim()){ log('検索クエリ取得失敗'); return; }
const query=queryInput.value.trim();

const sidebar=document.querySelector('#sidebar');
if(!sidebar){ log('Sidebarが見つかりません'); return; }

  // UI構築
const aiBox=document.createElement('div');
aiBox.innerHTML=`
<div style="margin-top:1em;margin-bottom:0.5em;padding:0.5em;background:transparent;color:inherit;font-family:inherit;">
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5em;">
    <div style="font-weight:600;font-size:1em;">Geminiによる概要</div>
    <span id="gemini-summary-time" style="font-size:0.8em;opacity:0.7;"></span>
  </div>
  <div id="gemini-summary-content" style="margin-top:1.0em;margin-bottom:1.0em;line-height:1.5;">取得中...</div>
</div>`;
sidebar.insertBefore(aiBox,sidebar.firstChild);

const contentEl=aiBox.querySelector('#gemini-summary-content');
const timeEl=aiBox.querySelector('#gemini-summary-time');

  // キャッシュ利用判定
const cache = getCache();
const cacheKey = normalizeQuery(query);
if (cache.data[cacheKey]) {
    const cachedData = cache.data[cacheKey];
    contentEl.innerHTML = cachedData.html;
    timeEl.textContent = cachedData.time;
    log.info('キャッシュ使用:', query);
    return;
}

const form=document.querySelector('#search_form, form[action="/search"]');
const mainResults=document.getElementById('main_results');
if(!mainResults){ log('main_results見つからず'); return; }

  // 検索結果取得
const results=await fetchSearchResults(form,mainResults,CONFIG.MAX_RESULTS);

  // スニペット収集
const excludePatterns=[
    /google キャッシュ$/i,
];
const snippetsArr=[];
let totalChars=0;
for(const r of results){
    const snippetEl = r.querySelector('.result__snippet') || r;
    let text = snippetEl.innerText.trim();

    // 除外パターン削除
    excludePatterns.forEach(p => {
        text = text.replace(p,'').trim();
    });

    // 空になったらスキップ
    if(!text) continue;
    if(totalChars + text.length > CONFIG.SNIPPET_CHAR_LIMIT) break;
    snippetsArr.push(text);
    totalChars += text.length;
}
const snippets = snippetsArr.map((t,i)=>`${i+1}. ${t}`).join('\n\n');

  // プロンプト作成
const prompt=`

検索クエリ : ${query},
検索スニペット : ${snippets},

指示 :
1. 上記の検索クエリとスニペットから、簡潔かつ具体的な概要を作成してください。
2. 情報が不足する場合は「情報が限られています」と明示し、推測を交えても構いません。。
3. メタ情報や装飾は含まないでください。
4. 概要には必ず1つ以上のセクションを含めてください。
5. セクションは複数あってもよく、内容は箇条書きでも文章でも構いません。
6. 概要は600字以内に収めてください。
7. 出力は必ずJSON形式で、以下のテンプレートを使用してください。 :

{
  "intro": "概要の導入文",
  "sections": [
    {
      "title": "セクションタイトル",
      "content": ["内容1", "内容2", "..."]
    }
  ],
  "urls": ["URL1", "URL2", "URL3"]
}

`;

  // Gemini API呼び出し
try{
    const resp = await fetch(`https://generativelanguage.googleapis.com/v1/models/${CONFIG.MODEL_NAME}:generateContent?key=${GEMINI_API_KEY}`, {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({contents:[{parts:[{text:prompt}]}]})
});
    if(!resp.ok) return contentEl.textContent=`APIエラー: ${resp.status}`;
    const data=await resp.json();
    let parsed={};
    try{
        const raw=data.candidates?.[0]?.content?.parts?.[0]?.text||'';
        const match=raw.match(/\{[\s\S]*\}/);
        parsed=match?JSON.parse(match[0]):{};
    }catch{
        contentEl.textContent='JSON解析失敗';
        return;
    }
    renderSummary(parsed, contentEl, timeEl, query, cacheKey);
}catch(err){
    contentEl.textContent='通信に失敗しました';
    log.error(err);
}

})();