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);
}
})();