// ==UserScript==
// @name SearXNG検索オプション強化UI 🔍️
// @name:ja SearXNG検索オプション強化UI 🔍️
// @name:en Enhanced Search Options UI for SearXNG 🔍️
// @name:zh-CN SearXNG搜索选项增强界面 🔍️
// @name:zh-TW SearXNG搜尋選項增強介面 🔍️
// @name:ko SearXNG 검색 옵션 강화 UI 🔍️
// @name:fr Interface améliorée pour les options de recherche SearXNG 🔍️
// @name:es Interfaz mejorada de opciones de búsqueda para SearXNG 🔍️
// @name:de Verbesserte Suchoptionen-Oberfläche für SearXNG 🔍️
// @name:pt-BR Interface aprimorada de opções de pesquisa para SearXNG 🔍️
// @name:ru Улучшенный интерфейс опций поиска SearXNG 🔍️
// @version 3.9.1
// @description SearXNG検索エンジンに詳細検索オプションサイドバーを追加(言語選択も自動検出と英語と日本語のみにしてすっきり)。サイドバーはドラッグで移動でき、位置も保存されます。
// @description:en Adds a detailed search options sidebar to SearXNG. Simplifies language selection to English and Japanese with auto-detection. The sidebar is draggable and its position is persisted.
// @description:zh-CN 为SearXNG添加详细搜索选项侧边栏,仅保留英文与日文并启用自动检测。侧边栏支持拖拽移动并可保存位置。
// @description:zh-TW 為 SearXNG 新增詳細搜尋選項側邊欄,僅保留英文與日文並啟用自動偵測。側邊欄可拖曳移動並保存位置。
// @description:ko SearXNG에 상세 검색 옵션 사이드바를 추가합니다. 언어 선택은 영어/일본어와 자동 감지로 간소화됩니다. 사이드바는 드래그 이동 및 위치 저장을 지원합니다.
// @description:fr Ajoute une barre latérale d’options de recherche à SearXNG. Langues réduites à anglais/japonais avec détection automatique. La barre est déplaçable et sa position est conservée.
// @description:es Añade una barra lateral con opciones avanzadas a SearXNG. Selección de idioma simplificada a inglés y japonés con autodetección. La barra se puede arrastrar y guarda su posición.
// @description:de Fügt SearXNG eine Seitenleiste mit erweiterten Suchoptionen hinzu. Sprachwahl auf Englisch/Japanisch mit Auto-Erkennung. Die Leiste ist verschiebbar und speichert ihre Position.
// @description:pt-BR Adiciona uma barra lateral com opções detalhadas ao SearXNG, com idioma reduzido a inglês/japonês e detecção automática. A barra é arrastável e tem posição persistente.
// @description:ru Добавляет в SearXNG боковую панель расширенных параметров поиска. Языки: английский/японский с автоопределением. Панель можно перетаскивать; позиция сохраняется.
// @namespace https://github.com/koyasi777/searxng-search-options-enhancer
// @author koyasi777
// @match *://*/searx/search*
// @match *://*/searxng/search*
// @match *://searx.*/*
// @match *://*.searx.*/*
// @match https://search.localhost/*
// @grant GM_addStyle
// @license MIT
// @icon https://docs.searxng.org/_static/searxng-wordmark.svg
// ==/UserScript==
(function () {
'use strict';
/*** 🌐 言語フィルタ処理を先に定義しておく ***/
function filterLanguageDropdown() {
const allowedLanguages = [
"all", "auto", // デフォルト・自動検出
"ja", "ja-JP", // 日本語
"en"
];
const select = document.getElementById("language");
if (!select) return;
for (let i = select.options.length - 1; i >= 0; i--) {
const opt = select.options[i];
if (!allowedLanguages.includes(opt.value)) {
select.remove(i);
}
}
}
/*** 🧩 以下、検索オプションサイドバー ***/
const SIDEBAR_ID = 'gso-advanced-sidebar';
const COLLAPSE_KEY = 'gso_sidebar_collapsed';
const POS_KEY = 'gso_sidebar_pos'; // ⬅ 追加: 位置保存用
const STYLE = `
#${SIDEBAR_ID} {
position: fixed;
top: 100px;
right: 20px;
width: 260px;
max-height: 90vh;
overflow-y: auto;
background: #ffffff;
border: 1px solid #dadce0;
border-radius: 12px;
padding: 16px;
font-family: Roboto, Arial, sans-serif;
font-size: 13px;
z-index: 99999;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
color: #202124;
}
/* ⬇ 右寄せデフォルトをユーザ移動後は無効化 */
#${SIDEBAR_ID}[data-user-pos="1"] { right: auto !important; }
#${SIDEBAR_ID}.collapsed {
width: 180px;
max-height: 21px;
overflow: hidden;
padding: 6px 12px;
padding-top:10px;
}
#${SIDEBAR_ID}.collapsed label,
#${SIDEBAR_ID}.collapsed input,
#${SIDEBAR_ID}.collapsed select,
#${SIDEBAR_ID}.collapsed .gso-body {
display: none;
}
#${SIDEBAR_ID} .gso-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
cursor: grab; /* ⬅ ドラッグハンドル */
user-select: none;
touch-action: none; /* ⬅ モバイルでのスクロール抑止(ヘッダ上) */
}
#${SIDEBAR_ID}.dragging {
cursor: grabbing;
box-shadow: 0 6px 18px rgba(0,0,0,0.30);
}
#${SIDEBAR_ID} .gso-header h3 {
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 0;
}
#${SIDEBAR_ID} .gso-toggle {
font-size: 12px;
cursor: pointer;
color: #3367d6;
margin: 0;
user-select: none;
background: none;
border: none;
padding: 0;
}
#${SIDEBAR_ID} label {
display: block;
margin-top: 10px;
font-weight: 500;
}
#${SIDEBAR_ID} input,
#${SIDEBAR_ID} select {
width: 100%;
margin-top: 4px;
padding: 6px;
border: 1px solid #ccc;
border-radius: 6px;
background-color: #fff;
color: #202124;
box-sizing: border-box;
}
@media (prefers-color-scheme: dark) {
#${SIDEBAR_ID} {
background: #202124;
color: #e8eaed;
border: 1px solid #5f6368;
}
#${SIDEBAR_ID} input,
#${SIDEBAR_ID} select {
background-color: #303134;
color: #e8eaed;
border: 1px solid #5f6368;
}
}
#${SIDEBAR_ID} .gso-buttons {
display: flex;
gap: 10px;
margin-top: 16px;
}
#${SIDEBAR_ID} .gso-buttons button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
#${SIDEBAR_ID} .gso-buttons button:focus {
outline: 2px solid #4285f4;
outline-offset: 2px;
}
#${SIDEBAR_ID} .gso-buttons button:hover {
filter: brightness(1.03);
}
#${SIDEBAR_ID} .gso-buttons button:active {
transform: scale(0.97);
}
#${SIDEBAR_ID} .gso-clear {
background: #f1f3f4;
color: #202124;
}
#${SIDEBAR_ID} .gso-search {
background: #1a73e8;
color: white;
}
@media (prefers-color-scheme: dark) {
#${SIDEBAR_ID} .gso-clear {
background: #3c4043;
color: #e8eaed;
}
#${SIDEBAR_ID} .gso-search {
background: #8ab4f8;
color: #202124;
}
}
`;
GM_addStyle(STYLE);
const selects = {
filetype: [['', 'すべて'], ['filetype:pdf', 'PDF'], ['filetype:doc', 'DOC'], ['filetype:xls', 'XLS'], ['filetype:ppt', 'PPT'], ['filetype:txt', 'TXT']],
region: [['', 'すべて'], ['region:jp', '日本'], ['region:us', 'アメリカ'], ['region:cn', '中国']],
occt: [['', '全体'], ['intitle:', 'タイトル'], ['inurl:', 'URL'], ['inanchor:', 'リンク先']],
rights: [['', '制限なし'], ['cc_publicdomain', 'パブリックドメイン'], ['cc_attribute', '帰属'], ['cc_sharealike', '継承'], ['cc_noncommercial', '非営利']],
date: [['', '指定なし'], ['date:h', '1時間以内'], ['date:d', '1日以内'], ['date:w', '1週間以内'], ['date:m', '1か月以内'], ['date:y', '1年以内']]
};
const timeRangeMap = {
'hour': 'date:h',
'day': 'date:d',
'week': 'date:w',
'month': 'date:m',
'year': 'date:y'
};
const reverseTimeMap = Object.fromEntries(Object.entries(timeRangeMap).map(([k, v]) => [v, k]));
// ==== 双方向同期(Sidebar <-> #q)====
let syncingFromSidebar = false;
let syncingFromQ = false;
let syncTimer = 0;
function debounce(fn, wait = 120) {
return (...args) => {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => fn(...args), wait);
};
}
function syncQFromSidebarImmediate() {
const qInput = document.querySelector('#q');
if (!qInput) return;
if (syncingFromQ) return;
syncingFromSidebar = true;
qInput.value = buildQueryFromUI();
// 時間範囲のリアルタイム同期
const dateValue = document.getElementById('gso-date')?.value || '';
const timeRange = reverseTimeMap[dateValue] || '';
const timeRangeSelect = document.getElementById('time_range');
if (timeRangeSelect) timeRangeSelect.value = timeRange;
syncingFromSidebar = false;
}
const syncQFromSidebar = debounce(syncQFromSidebarImmediate, 120);
const fields = [
['all', 'すべてのキーワード'],
['exact', '完全一致キーワード'],
['any', 'いずれかのキーワード'],
['none', '含めないキーワード'],
['site', 'サイト・ドメイン'],
['filetype', 'ファイル形式'],
['region', '地域'],
['occt', '検索対象の範囲'],
['rights', 'ライセンス'],
['date', '最終更新']
];
function parseQuery(query) {
const result = Object.fromEntries(fields.map(([id]) => [id, '']));
const tokens = query.match(/"[^"]+"|\S+/g) || [];
const skipIndexes = new Set();
const orWords = [];
for (let i = 0; i < tokens.length; i++) {
if (tokens[i + 1] === 'OR') {
orWords.push(tokens[i]);
skipIndexes.add(i);
skipIndexes.add(i + 1);
i += 1;
} else if (tokens[i - 1] === 'OR') {
orWords.push(tokens[i]);
skipIndexes.add(i);
}
}
result.any = [...new Set(orWords)].join(' ');
for (let i = 0; i < tokens.length; i++) {
if (skipIndexes.has(i)) continue;
const token = tokens[i];
if (token.startsWith('site:')) result.site = token.slice(5);
else if (token.startsWith('filetype:')) result.filetype = token;
else if (token.startsWith('region:')) result.region = token;
else if (token.startsWith('date:')) result.date = token;
else if (token.startsWith('cc_')) result.rights = token;
else if (/^(intitle|inurl|inanchor):/.test(token)) result.occt = token.split(':')[0] + ':';
else if (token.startsWith('"') && token.endsWith('"')) result.exact += token.slice(1, -1) + ' ';
else if (token.startsWith('-')) result.none += token.slice(1) + ' ';
else result.all += token + ' ';
}
return Object.fromEntries(Object.entries(result).map(([k, v]) => [k, v.trim()]));
}
function buildQueryFromUI(base = '') {
const get = id => document.getElementById(`gso-${id}`)?.value.trim() || '';
const parts = [];
const exact = get('exact');
const any = get('any');
const none = get('none');
const site = get('site');
const filetype = get('filetype');
const region = get('region');
const rights = get('rights');
const occt = get('occt');
const all = get('all');
const allWords = all.split(/\s+/).filter(Boolean);
const anyWords = any.split(/\s+/).filter(Boolean);
const noneWords = none.split(/\s+/).filter(Boolean);
const exclusionWords = new Set([...anyWords, ...noneWords]);
const filteredAll = allWords.filter(w => !exclusionWords.has(w));
if (filteredAll.length > 0) {
parts.push(occt ? `${occt}${filteredAll.join(' ')}` : filteredAll.join(' '));
} else if (occt && allWords.length > 0) {
parts.push(`${occt}${allWords.join(' ')}`);
}
if (exact) parts.push(`"${exact}"`);
if (anyWords.length > 1) parts.push(anyWords.join(' OR '));
else if (anyWords.length === 1) parts.push(anyWords[0]);
noneWords.forEach(w => parts.push(`-${w}`));
if (site) parts.push(`site:${site}`);
if (filetype) parts.push(filetype);
if (region) parts.push(region);
if (rights) parts.push(rights);
return parts.join(' ').trim();
}
function stripOrClauses(query) {
const tokens = query.match(/"[^"]+"|\S+/g) || [];
const result = [];
let i = 0;
while (i < tokens.length) {
if (tokens[i + 1] === 'OR') {
while (tokens[i + 1] === 'OR') {
i += 2;
}
i += 1;
} else {
result.push(tokens[i]);
i += 1;
}
}
return result.join(' ');
}
function submitQuery() {
const form = document.querySelector('form[action="/search"]');
const input = form?.querySelector('input[name="q"]');
if (!input) return;
input.value = buildQueryFromUI();
const dateValue = document.getElementById('gso-date')?.value || '';
const timeRange = reverseTimeMap[dateValue] || '';
const timeRangeSelect = document.getElementById('time_range');
if (timeRangeSelect) {
const trVal = timeRangeSelect.value;
if (trVal && timeRangeMap[trVal]) {
const dateSel = document.getElementById('gso-date');
if (dateSel) dateSel.value = timeRangeMap[trVal];
}
// ネイティブ time_range 変更→サイドバー&#q を即時同期
timeRangeSelect.addEventListener('change', () => {
const dateSel = document.getElementById('gso-date');
if (dateSel && timeRangeMap[timeRangeSelect.value]) {
dateSel.value = timeRangeMap[timeRangeSelect.value];
}
syncQFromSidebarImmediate();
});
}
form.submit();
}
// ===== 🧲 ここからドラッグ&位置保存ロジック =====
function clampToViewport(left, top, el) {
const pad = 8; // 画面端の余白
const w = el.offsetWidth || 260; // 未描画時の保険
const h = el.offsetHeight || 200;
const maxLeft = Math.max(0, window.innerWidth - w - pad);
const maxTop = Math.max(0, window.innerHeight - h - pad);
return {
left: Math.min(Math.max(left, pad), maxLeft),
top: Math.min(Math.max(top, pad), maxTop)
};
}
function applyPos(sidebar, left, top) {
const L = Math.round(left);
const T = Math.round(top);
sidebar.style.left = `${L}px`;
sidebar.style.top = `${T}px`;
sidebar.style.right = 'auto';
sidebar.dataset.userPos = '1';
}
function savePos(sidebar) {
const r = sidebar.getBoundingClientRect();
localStorage.setItem(POS_KEY, JSON.stringify({ left: r.left, top: r.top }));
}
function loadPos(sidebar) {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return;
try {
const { left, top } = JSON.parse(raw);
const { left: L, top: T } = clampToViewport(left, top, sidebar);
applyPos(sidebar, L, T);
} catch { /* noop */ }
}
function addDragBehavior(sidebar, handle) {
let pointerId = null;
let start = null;
handle.addEventListener('pointerdown', (e) => {
pointerId = e.pointerId;
handle.setPointerCapture(pointerId);
const r = sidebar.getBoundingClientRect();
start = { x: e.clientX, y: e.clientY, left: r.left, top: r.top };
sidebar.classList.add('dragging');
document.body.style.userSelect = 'none';
});
handle.addEventListener('pointermove', (e) => {
if (pointerId == null || !handle.hasPointerCapture(pointerId)) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
const { left, top } = clampToViewport(start.left + dx, start.top + dy, sidebar);
applyPos(sidebar, left, top);
});
const endDrag = () => {
if (pointerId == null) return;
handle.releasePointerCapture(pointerId);
pointerId = null;
sidebar.classList.remove('dragging');
document.body.style.userSelect = '';
savePos(sidebar);
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
// ウィンドウリサイズで画面外に行かないように補正
window.addEventListener('resize', () => {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return;
try {
const { left, top } = JSON.parse(raw);
const { left: L, top: T } = clampToViewport(left, top, sidebar);
applyPos(sidebar, L, T);
savePos(sidebar);
} catch { /* noop */ }
});
// ダブルクリックで位置リセット(デフォルト: 右 20px / 上 100px)
handle.addEventListener('dblclick', () => {
localStorage.removeItem(POS_KEY);
sidebar.dataset.userPos = '';
sidebar.style.left = '';
sidebar.style.top = '';
sidebar.style.right = '';
});
}
// ===== ここまでドラッグ&位置保存ロジック =====
// createInput に autoSubmit フラグ追加(Enterのみ有効)
function createInput(labelText, id) {
const label = document.createElement('label');
label.textContent = labelText;
const input = document.createElement('input');
input.id = `gso-${id}`;
input.name = id;
label.appendChild(input);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
submitQuery();
}
});
// 入力のたびに #q をリアルタイム更新(デバウンス)
input.addEventListener('input', () => {
syncQFromSidebar();
});
return label;
}
// 修正: createSelect も同様に、Enterキー以外でsubmitしない
function createSelect(labelText, id, options) {
const label = document.createElement('label');
label.textContent = labelText;
const select = document.createElement('select');
select.id = `gso-${id}`;
select.name = id;
options.forEach(([val, text]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = text;
select.appendChild(opt);
});
label.appendChild(select);
// セレクト変更時は即時反映(待ち無し)
select.addEventListener('change', () => {
syncQFromSidebarImmediate();
});
return label;
}
// 🆕 ✅ Clearボタン用
function clearSidebarInputs() {
fields.forEach(([id]) => {
const el = document.getElementById(`gso-${id}`);
if (!el) return;
if (el.tagName === 'INPUT') {
el.value = '';
} else if (el.tagName === 'SELECT') {
el.selectedIndex = 0;
}
});
['uilang', 'safesearch'].forEach(id => {
const el = document.getElementById(`gso-${id}`);
if (el && el.tagName === 'SELECT') {
el.selectedIndex = 0;
}
});
}
function createSelectFromNative(labelText, id, nativeSelector) {
const native = document.querySelector(nativeSelector);
if (!native) return null;
const label = document.createElement('label');
label.textContent = labelText;
const select = document.createElement('select');
select.id = `gso-${id}`;
select.name = id;
Array.from(native.options).forEach(opt => {
const clone = opt.cloneNode(true);
select.appendChild(clone);
});
select.value = native.value;
select.addEventListener('change', () => {
native.value = select.value;
native.dispatchEvent(new Event('change'));
});
label.appendChild(select);
return label;
}
// Sidebar生成関数(ドラッグ&位置保存に対応)
function insertSidebar() {
if (document.getElementById(SIDEBAR_ID)) return;
filterLanguageDropdown();
const sidebar = document.createElement('div');
sidebar.id = SIDEBAR_ID;
const header = document.createElement('div');
header.className = 'gso-header';
const title = document.createElement('h3');
title.textContent = '詳細検索オプション';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'gso-toggle';
toggle.textContent = '▲ 閉じる';
toggle.setAttribute('aria-expanded', 'true');
toggle.addEventListener('pointerdown', (e) => { e.stopPropagation(); });
toggle.addEventListener('mousedown', (e) => { e.stopPropagation(); });
const onToggle = () => {
// 折りたたみ時も「右上起点」に見えるよう、トグル前の右端を保持
const pre = sidebar.getBoundingClientRect();
const preLeft = pre.left;
const preTop = pre.top;
const preWidth = pre.width;
const collapsed = sidebar.classList.toggle('collapsed');
toggle.textContent = collapsed ? '▼ 開く' : '▲ 閉じる';
toggle.setAttribute('aria-expanded', String(!collapsed));
localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0');
if (sidebar.dataset.userPos === '1') {
requestAnimationFrame(() => {
const post = sidebar.getBoundingClientRect();
// 右端(preLeft + preWidth)を不変にして、新しい幅に合わせて left を再計算
const desiredLeft = preLeft + preWidth - post.width;
const { left, top } = clampToViewport(desiredLeft, preTop, sidebar);
applyPos(sidebar, left, top);
savePos(sidebar);
});
}
};
toggle.addEventListener('click', (e) => { e.stopPropagation(); onToggle(); });
toggle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } });
header.appendChild(title);
header.appendChild(toggle);
sidebar.appendChild(header);
const body = document.createElement('div');
body.className = 'gso-body';
body.id = 'gso-body';
fields.forEach(([id, label]) => {
body.appendChild(selects[id] ? createSelect(label, id, selects[id]) : createInput(label, id));
});
const languageSyncUI = createSelectFromNative('言語設定', 'uilang', '#language');
if (languageSyncUI) body.appendChild(languageSyncUI);
const safeSearchUI = createSelectFromNative('セーフサーチ', 'safesearch', '#safesearch');
if (safeSearchUI) body.appendChild(safeSearchUI);
// 🆕 ✅ Clear/Searchボタン
const buttonContainer = document.createElement('div');
buttonContainer.className = 'gso-buttons';
const clearButton = document.createElement('button');
clearButton.textContent = '🧹 Clear';
clearButton.className = 'gso-clear';
clearButton.onclick = () => clearSidebarInputs();
const searchButton = document.createElement('button');
searchButton.textContent = '🔍 Search';
searchButton.className = 'gso-search';
searchButton.onclick = () => { setTimeout(() => submitQuery(), 0); };
buttonContainer.appendChild(clearButton);
buttonContainer.appendChild(searchButton);
body.appendChild(buttonContainer);
sidebar.appendChild(body);
document.body.appendChild(sidebar);
// ⬇ ここでドラッグ&位置保存ハンドラを有効化
addDragBehavior(sidebar, header);
const qInput = document.querySelector('#q');
if (qInput) {
const parsed = parseQuery(qInput.value);
fields.forEach(([id]) => {
const el = document.getElementById(`gso-${id}`);
if (el && parsed[id]) el.value = parsed[id];
});
const syncSidebarFromQ = () => {
if (syncingFromSidebar) return; // 片方向同期の循環防止
syncingFromQ = true;
const updated = parseQuery(qInput.value);
fields.forEach(([id]) => {
const el = document.getElementById(`gso-${id}`);
if (el) el.value = updated[id] || '';
});
// time_range -> gso-date は SearXNG 側が変わる場合もあるため再同期
const timeRangeSelect = document.getElementById('time_range');
const dateSel = document.getElementById('gso-date');
if (timeRangeSelect && dateSel && timeRangeMap[timeRangeSelect.value]) {
dateSel.value = timeRangeMap[timeRangeSelect.value];
}
syncingFromQ = false;
};
qInput.addEventListener('input', syncSidebarFromQ);
qInput.addEventListener('change', syncSidebarFromQ);
const timeRangeSelect = document.getElementById('time_range');
if (timeRangeSelect) {
const trVal = timeRangeSelect.value;
if (trVal && timeRangeMap[trVal]) {
const dateSel = document.getElementById('gso-date');
if (dateSel) dateSel.value = timeRangeMap[trVal];
}
}
const form = document.querySelector('form[action="/search"]');
if (form) {
qInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitQuery();
}
});
}
}
const saved = localStorage.getItem(COLLAPSE_KEY);
if (saved === '1') {
sidebar.classList.add('collapsed');
// toggle.textContent は上の onclick ロジックに合わせる
const toggleEl = sidebar.querySelector('.gso-toggle');
if (toggleEl) toggleEl.textContent = '▼ 開く';
}
// ⬇ 最後に保存済み位置を反映(collapsed 状態も考慮して補正)
loadPos(sidebar);
}
window.addEventListener('load', insertSidebar);
})();