为X.com(Twitter)添加高级搜索浮动模态框,支持与搜索框双向同步并记住位置与显示状态。右上角的搜索图标可拖动,并会记住位置。
// ==UserScript==
// @name X.com (Twitter) Advanced Search Modal 🔍
// @name:ja X.com(Twitter)高度検索モーダル 🔍
// @name:en X.com (Twitter) Advanced Search Modal 🔍
// @name:zh-CN X.com(Twitter)高级搜索模态框 🔍
// @name:zh-TW X.com(Twitter)高級搜尋模態框 🔍
// @name:ko X.com (Twitter) 고급 검색 モ달 🔍
// @name:fr X.com (Twitter) : Modal de recherche avancée 🔍
// @name:es Modal de búsqueda avanzada para X.com (Twitter) 🔍
// @name:de Erweiterte Suchmodal für X.com (Twitter) 🔍
// @name:pt-BR Modal de busca avançada no X.com (Twitter) 🔍
// @name:ru Расширенный поиск для X.com (Twitter) 🔍
// @version 4.7.5
// @description Adds a floating modal for advanced search on X.com (Twitter). Syncs with search box and remembers position/display state. The top-right search icon is now draggable and its position persists.
// @description:ja X.com(Twitter)に高度な検索機能を呼び出せるフローティング・モーダルを追加します。検索ボックスと双方向で同期し、位置や表示状態も記憶します。右上の検索アイコンはドラッグで移動でき、位置は保存されます。
// @description:en Adds a floating modal for advanced search on X.com (formerly Twitter). Syncs with search box and remembers position/display state. The top-right search icon is draggable with persistent position.
// @description:zh-CN 为X.com(Twitter)添加高级搜索浮动模态框,支持与搜索框双向同步并记住位置与显示状态。右上角的搜索图标可拖动,并会记住位置。
// @description:zh-TW 為 X.com(Twitter)增加高級搜尋模態框,支援與搜尋框雙向同步並記住位置與顯示狀態。右上角搜尋圖示可拖曳,位置會被保存。
// @description:ko X.com(Twitter)에 고급 검색 모달을 추가합니다. 검색창과 양방향 동기화하며 위치와 표시 상태를 기억합니다. 우상단 검색 아이콘은 드래그 이동 및 위치 저장이 가능합니다。
// @description:fr Ajoute une fenêtre modale de recherche avancée à X.com (Twitter), synchronisée avec la barre de recherche et mémoire de l’état d’affichage. L’icône de recherche en haut à droite est déplaçable.
// @description:es Agrega un modal flotante de búsqueda avanzada en X.com (Twitter), sincronizado con la caja de búsqueda y con estado persistente.
// @description:de Fügt X.com (Twitter) ein modales Fenster für erweiterte Suche hinzu, synchronisiert mit der Suchleiste und speichert Position/Zustand. Das Suchsymbol oben rechts ist per Drag & Drop verschiebbar und bleibt gespeichert.
// @description:pt-BR Adiciona um modal de busca avançada flutuante no X.com (Twitter), sincronizado com a caixa de busca e com estado salvo. O ícone de busca no canto superior direito é arrastável com posição persistente.
// @description:ru Добавляет модальное окно расширенного поиска на X.com (Twitter). Синхронизируется с поисковой строкой и запоминает состояние. Кнопку поиска в правом верхнем углу можно перетаскивать; её положение сохраняется.
// @namespace https://github.com/koyasi777/x-advanced-search-userscript
// @author koyasi777
// @match https://x.com/*
// @match https://twitter.com/*
// @exclude https://x.com/i/tweetdeck*
// @exclude https://twitter.com/i/tweetdeck*
// @icon https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @run-at document-idle
// @license MIT
// @homepageURL https://github.com/koyasi777/x-advanced-search-userscript
// @supportURL https://github.com/koyasi777/x-advanced-search-userscript/issues
// ==/UserScript==
(function() {
'use strict';
if (window.__X_ADV_SEARCH_INITED__) return;
window.__X_ADV_SEARCH_INITED__ = true;
const i18n = {
translations: {
'en': {
modalTitle: "Advanced Search",
tooltipClose: "Close",
labelAllWords: "All of these words",
placeholderAllWords: "e.g., AI news",
labelExactPhrase: "This exact phrase",
placeholderExactPhrase: 'e.g., "ChatGPT 4o"',
labelAnyWords: "Any of these words (OR)",
placeholderAnyWords: "e.g., iPhone Android",
labelNotWords: "None of these words (-)",
placeholderNotWords: "e.g., -sale -ads",
labelHashtag: "Hashtags (#)",
placeholderHashtag: "e.g., #TechEvent",
labelLang: "Language (lang:)",
optLangDefault: "Any language",
optLangJa: "Japanese (ja)",
optLangEn: "English (en)",
optLangId: "Indonesian (id)",
optLangHi: "Hindi (hi)",
optLangDe: "German (de)",
optLangTr: "Turkish (tr)",
optLangEs: "Spanish (es)",
optLangPt: "Portuguese (pt)",
optLangAr: "Arabic (ar)",
optLangFr: "French (fr)",
optLangKo: "Korean (ko)",
optLangRu: "Russian (ru)",
optLangZhHans: "Chinese Simplified (zh-cn)",
optLangZhHant: "Chinese Traditional (zh-tw)",
hrSeparator: " ",
labelFilters: "Filters",
labelVerified: "Verified accounts",
labelLinks: "Links",
labelImages: "Images",
labelVideos: "Videos",
checkInclude: "Include",
checkExclude: "Exclude",
labelReplies: "Replies",
optRepliesDefault: "Default (Show all)",
optRepliesInclude: "Include replies",
optRepliesOnly: "Replies only",
optRepliesExclude: "Exclude replies",
labelEngagement: "Engagement",
placeholderMinReplies: "Min replies",
placeholderMinLikes: "Min likes",
placeholderMinRetweets: "Min retweets",
labelDateRange: "Date range",
tooltipSince: "From this date",
tooltipUntil: "Until this date",
labelFromUser: "From these accounts (from:)",
placeholderFromUser: "e.g., @X",
labelToUser: "To these accounts (to:)",
placeholderToUser: "e.g., @google",
labelMentioning: "Mentioning these accounts (@)",
placeholderMentioning: "e.g., @OpenAI",
buttonClear: "Clear",
buttonApply: "Search",
tooltipTrigger: "Open Advanced Search",
tabSearch: "Search",
tabHistory: "History",
tabSaved: "Saved",
buttonSave: "Save",
buttonSaved: "Saved",
secretMode: "Secret",
secretOn: "Secret mode ON (No history)",
secretOff: "Secret mode OFF",
toastSaved: "Saved.",
toastDeleted: "Deleted.",
toastReordered: "Order updated.",
emptyHistory: "No history yet.",
emptySaved: "No saved searches.",
run: "Run",
delete: "Delete",
updated: "Updated.",
tooltipSecret: "Toggle Secret Mode (no history will be recorded)",
historyClearAll: "Clear All",
confirmClearHistory: "Clear all history?",
labelAccountScope: "Accounts",
optAccountAll: "All accounts",
optAccountFollowing: "Accounts you follow",
labelLocationScope: "Location",
optLocationAll: "All locations",
optLocationNearby: "Near you",
chipFollowing: "Following",
chipNearby: "Nearby",
labelSearchTarget: "Search target",
labelHitName: "Exclude hits in display name",
labelHitHandle: "Exclude hits in username (@handle)",
hintSearchTarget: "Hide posts that only match in name or handle (not in body).",
hintName: "If a keyword appears only in the display name, hide it.",
hintHandle: "If a keyword appears only in @username, hide it. Exception: when the query explicitly uses from:/to:/@ with the same word.",
tabMute: "Mute",
labelMuteWord: "Add mute word",
placeholderMuteWord: "e.g., spoiler",
labelCaseSensitive: "Case sensitive",
labelEnabled: "Enabled",
labelEnableAll: "Enable all",
buttonAdd: "Add",
emptyMuted: "No muted words.",
mutedListTitle: "Muted words",
mutedListHeading: "Muted items",
muteHit: "Mute hits in body",
buttonImport: "Import",
buttonExport: "Export",
/* Accounts tab */
tabAccounts: "Accounts",
emptyAccounts: "No accounts yet. Open a profile and click the Add button to save it.",
buttonAddAccount: "Add account",
toastAccountAdded: "Account added.",
toastAccountExists: "Already added.",
buttonConfirm: "Confirm",
/* Lists tab */
tabLists: "Lists",
emptyLists: "No lists yet. Open a List and click the + button in the top-right to add it.",
buttonAddList: "Add list",
toastListAdded: "List added.",
toastListExists: "Already added.",
},
'ja': {
modalTitle: "高度な検索",
tooltipClose: "閉じる",
labelAllWords: "すべての語句を含む",
placeholderAllWords: "例: AI ニュース",
labelExactPhrase: "この語句を完全に含む",
placeholderExactPhrase: '例: "ChatGPT 4o"',
labelAnyWords: "いずれかの語句を含む (OR)",
placeholderAnyWords: "例: iPhone Android",
labelNotWords: "含まない語句 (-)",
placeholderNotWords: "例: -セール -広告",
labelHashtag: "ハッシュタグ (#)",
placeholderHashtag: "例: #技術書典",
labelLang: "言語 (lang:)",
optLangDefault: "指定しない",
optLangJa: "日本語 (ja)",
optLangEn: "英語 (en)",
optLangId: "インドネシア語 (id)",
optLangHi: "ヒンディー語 (hi)",
optLangDe: "ドイツ語 (de)",
optLangTr: "トルコ語 (tr)",
optLangEs: "スペイン語 (es)",
optLangPt: "ポルトガル語 (pt)",
optLangAr: "アラビア語 (ar)",
optLangFr: "フランス語 (fr)",
optLangKo: "韓国語 (ko)",
optLangRu: "ロシア語 (ru)",
optLangZhHans: "中国語(簡体字)(zh-cn)",
optLangZhHant: "中国語(繁体字)(zh-tw)",
hrSeparator: " ",
labelFilters: "フィルター",
labelVerified: "認証済みアカウント",
labelLinks: "リンク",
labelImages: "画像",
labelVideos: "動画",
checkInclude: "含む",
checkExclude: "含まない",
labelReplies: "返信",
optRepliesDefault: "指定しない",
optRepliesInclude: "返信を含める",
optRepliesOnly: "返信のみ",
optRepliesExclude: "返信を除外",
labelEngagement: "エンゲージメント",
placeholderMinReplies: "最小返信数",
placeholderMinLikes: "最小いいね数",
placeholderMinRetweets: "最小リポスト数",
labelDateRange: "期間指定",
tooltipSince: "この日以降",
tooltipUntil: "この日以前",
labelFromUser: "このアカウントから (from:)",
placeholderFromUser: "例: @X",
labelToUser: "このアカウントへ (to:)",
placeholderToUser: "例: @google",
labelMentioning: "このアカウントへのメンション (@)",
placeholderMentioning: "例: @OpenAI",
buttonClear: "クリア",
buttonApply: "検索実行",
tooltipTrigger: "高度な検索を開く",
tabSearch: "検索",
tabHistory: "履歴",
tabSaved: "保存",
buttonSave: "保存",
buttonSaved: "保存済み",
secretMode: "シークレット",
secretOn: "シークレットモード ON(履歴は記録しません)",
secretOff: "シークレットモード OFF",
toastSaved: "保存しました。",
toastDeleted: "削除しました。",
toastReordered: "並び順を更新しました。",
emptyHistory: "履歴はまだありません。",
emptySaved: "保存済みの検索はありません。",
run: "実行",
delete: "削除",
updated: "更新しました。",
tooltipSecret: "シークレットモードを切り替え(履歴を記録しません)",
historyClearAll: "すべて削除",
confirmClearHistory: "履歴をすべて削除しますか?",
labelAccountScope: "アカウント",
optAccountAll: "すべてのアカウント",
optAccountFollowing: "フォローしているアカウント",
labelLocationScope: "場所",
optLocationAll: "すべての場所",
optLocationNearby: "近くの場所",
chipFollowing: "フォロー中",
chipNearby: "近く",
labelSearchTarget: "検索対象",
labelHitName: "表示名(名前)でのヒットは除外",
labelHitHandle: "ユーザー名(@)でのヒットは除外",
hintSearchTarget: "本文ではなく、名前/ユーザー名のみに一致した投稿を非表示にします。",
hintName: "キーワードが表示名のみに含まれる場合は非表示にします。",
hintHandle: "キーワードが @ユーザー名のみに含まれる場合は非表示にします。例外: 同じ語を from:/to:/@ で明示しているときは表示します。",
tabMute: "ミュート",
labelMuteWord: "ミュート語句の追加",
placeholderMuteWord: "例: ネタバレ",
labelCaseSensitive: "大文字小文字を区別",
labelEnabled: "有効",
labelEnableAll: "すべて有効",
buttonAdd: "追加",
emptyMuted: "ミュート語句はまだありません。",
mutedListTitle: "ミュート語句",
mutedListHeading: "ミュート一覧",
muteHit: "本文でのヒットをミュート",
buttonImport: "インポート",
buttonExport: "エクスポート",
/* Accounts tab */
tabAccounts: "アカウント",
emptyAccounts: "アカウントはまだありません。アカウントページの追加ボタンから追加してください。",
buttonAddAccount: "アカウントを追加",
toastAccountAdded: "アカウントを追加しました。",
toastAccountExists: "すでに追加済みです。",
buttonConfirm: "確認",
/* Lists tab */
tabLists: "リスト",
emptyLists: "リストはまだありません。リストを開き右上の+ボタンから追加してください。",
buttonAddList: "リストを追加",
toastListAdded: "リストを追加しました。",
toastListExists: "すでに追加済みです。",
},
'zh-CN': {},
'ko': {},
'fr': {},
'es': {},
'de': {},
'pt-BR': {},
'ru': {}
},
lang: 'en',
init: function() {
const supportedLangs = Object.keys(this.translations);
let detectedLang = document.documentElement.lang || navigator.language || 'en';
if (supportedLangs.includes(detectedLang)) { this.lang = detectedLang; return; }
const baseLang = detectedLang.split('-')[0];
if (supportedLangs.includes(baseLang)) { this.lang = baseLang; return; }
this.lang = 'en';
},
t: function(key) { return this.translations[this.lang]?.[key] || this.translations['en'][key] || `[${key}]`; },
apply: function(container) {
container.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = this.t(el.dataset.i18n); });
container.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = this.t(el.dataset.i18nPlaceholder); });
container.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = this.t(el.dataset.i18nTitle); });
}
};
const SEARCH_SVG = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" fill="none"></circle>
<line x1="16.65" y1="16.65" x2="22" y2="22"
stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
</svg>`;
const themeManager = {
colors: {
light: {
'--modal-bg': '#ffffff', '--modal-text-primary': '#0f1419', '--modal-text-secondary': '#536471', '--modal-border': '#d9e1e8',
'--modal-input-bg': '#eff3f4', '--modal-input-border': '#cfd9de', '--modal-button-hover-bg': 'rgba(15, 20, 25, 0.1)',
'--modal-scrollbar-thumb': '#aab8c2', '--modal-scrollbar-track': '#eff3f4', '--modal-close-color': '#0f1419',
'--modal-close-hover-bg': 'rgba(15, 20, 25, 0.1)', '--hr-color': '#eff3f4',
},
dim: {
'--modal-bg': '#15202b', '--modal-text-primary': '#f7f9f9', '--modal-text-secondary': '#8899a6', '--modal-border': '#38444d',
'--modal-input-bg': '#192734', '--modal-input-border': '#38444d', '--modal-button-hover-bg': 'rgba(247, 249, 249, 0.1)',
'--modal-scrollbar-thumb': '#536471', '--modal-scrollbar-track': '#192734', '--modal-close-color': '#f7f9f9',
'--modal-close-hover-bg': 'rgba(247, 249, 249, 0.1)', '--hr-color': '#38444d',
},
dark: {
'--modal-bg': '#000000', '--modal-text-primary': '#e7e9ea', '--modal-text-secondary': '#71767b', '--modal-border': '#2f3336',
'--modal-input-bg': '#16181c', '--modal-input-border': '#54595d', '--modal-button-hover-bg': 'rgba(231, 233, 234, 0.1)',
'--modal-scrollbar-thumb': '#536471', '--modal-scrollbar-track': '#16181c', '--modal-close-color': '#e7e9ea',
'--modal-close-hover-bg': 'rgba(231, 233, 234, 0.1)', '--hr-color': '#2f3336',
}
},
applyTheme: function(modalElement, triggerEl) {
if (!modalElement) return;
const bodyBg = getComputedStyle(document.body).backgroundColor;
let theme = 'dark';
if (bodyBg === 'rgb(21, 32, 43)') theme = 'dim';
else if (bodyBg === 'rgb(255, 255, 255)') theme = 'light';
const themeColors = this.colors[theme] || this.colors.dark;
const targets = [modalElement, document.documentElement];
if (triggerEl) targets.push(triggerEl);
for (const t of targets) {
for (const [key, value] of Object.entries(themeColors)) {
t.style.setProperty(key, value);
}
}
},
observeChanges: function(modalElement, triggerEl) {
const observer = new MutationObserver(() => this.applyTheme(modalElement, triggerEl));
observer.observe(document.body, { attributes: true, attributeFilter: ['style'] });
this.applyTheme(modalElement, triggerEl);
}
};
function decodeURIComponentSafe(s) {
try { return decodeURIComponent(s); } catch { return s; }
}
// “ ” 『』などのスマート引用を ASCII の " に寄せる
function normalizeQuotes(s) {
return String(s).replace(/[\u201C\u201D\u300C\u300D\uFF02]/g, '"');
}
// 解析前に軽く正規化(URL から来る %22..., 連続空白など)
function normalizeForParse(s) {
if (!s) return '';
let out = String(s);
// URL っぽいエンコードだけ軽く剥がす(%22 等)
if (/%[0-9A-Fa-f]{2}/.test(out)) out = decodeURIComponentSafe(out);
out = normalizeQuotes(out);
// 制御文字を潰し、空白を整形
out = out.replace(/\s+/g, ' ').trim();
return out;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// ── OR/引用のための簡易トークナイザ
function tokenizeQuotedWords(s) {
const out = [];
let cur = '';
let inQ = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"') { inQ = !inQ; cur += c; continue; }
if (!inQ && /\s/.test(c)) { if (cur) { out.push(cur); cur=''; } }
else { cur += c; }
}
if (cur) out.push(cur);
return out.filter(Boolean);
}
// トップレベルの OR で文字列を分割(引用/括弧を考慮)
function splitTopLevelOR(str) {
const parts = [];
let cur = '';
let inQ = false, depth = 0;
for (let i = 0; i < str.length; ) {
const c = str[i];
if (c === '"') { inQ = !inQ; cur += c; i++; continue; }
if (!inQ && (c === '(' || c === ')')) { depth += (c === '(' ? 1 : -1); cur += c; i++; continue; }
if (!inQ && depth === 0) {
// 単語境界の "or" / "OR"
if ((str.slice(i, i+2).toLowerCase() === 'or') &&
(i === 0 || /\s|\(/.test(str[i-1] || '')) &&
(i+2 >= str.length || /\s|\)/.test(str[i+2] || ''))) {
parts.push(cur.trim());
cur = '';
i += 2;
continue;
}
}
cur += c; i++;
}
if (cur.trim()) parts.push(cur.trim());
return parts.length > 1 ? parts : null;
}
// OR 専用判定(演算子/否定/括弧が無い素の OR 群なら true)
function isPureORQuery(q) {
const hasOps = /(?:^|\s)(?:from:|to:|lang:|filter:|is:|min_replies:|min_faves:|min_retweets:|since:|until:)\b/i.test(q);
const hasNeg = /(^|\s)-\S/.test(q);
const hasPar = /[()]/.test(q);
return !hasOps && !hasNeg && !hasPar;
}
function waitForElement(selector, timeout = 10000, checkProperty = null) {
return new Promise((resolve) => {
const checkInterval = 100;
let elapsedTime = 0;
const intervalId = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
if (checkProperty) {
if (element[checkProperty]) {
clearInterval(intervalId);
resolve(element);
return;
}
} else {
clearInterval(intervalId);
resolve(element);
return;
}
}
elapsedTime += checkInterval;
if (elapsedTime >= timeout) {
clearInterval(intervalId);
resolve(null);
}
}, checkInterval);
});
}
function hideUIImmediately(modal, trigger) {
if (modal) modal.style.display = 'none';
if (trigger) trigger.style.display = 'none';
}
function waitForUrlChange(oldURL, timeout = 1500) {
return new Promise((resolve) => {
let done = false;
const finish = (ok) => { if (!done) { done = true; cleanup(); resolve(ok); } };
const check = () => { if (location.href !== oldURL) finish(true); };
const origPush = history.pushState, origReplace = history.replaceState;
history.pushState = function(...a){ const r = origPush.apply(this, a); queueMicrotask(check); return r; };
history.replaceState = function(...a){ const r = origReplace.apply(this, a); queueMicrotask(check); return r; };
const onPop = () => queueMicrotask(check);
window.addEventListener('popstate', onPop);
const mo = new MutationObserver(check);
mo.observe(document.body, { childList: true, subtree: true });
const to = setTimeout(() => finish(false), timeout);
function cleanup() {
history.pushState = origPush;
history.replaceState = origReplace;
window.removeEventListener('popstate', onPop);
mo.disconnect();
clearTimeout(to);
}
check();
});
}
// ▼ ルート適用を軽く検証(URL一致 + プロフィール系DOMが現れたか)
function waitForRouteApply(path, timeoutMs = 2000) {
const goal = new URL(path, location.origin).pathname;
// ルート毎の判定を用意(必要に応じて拡張)
const perRouteProbes = [
// 検索ページ:検索結果タイムライン or 検索ボックス or 何かしらのツイート
{ test: p => p.startsWith('/search'),
sels: [
'[aria-label*="Search results"], [aria-label*="検索結果"]',
'div[data-testid="primaryColumn"] input[data-testid="SearchBox_Search_Input"]',
'div[data-testid="primaryColumn"] article[data-testid="tweet"]'
] },
// プロフィール
{ test: p => /^\/[A-Za-z0-9_]{1,50}\/?$/.test(p),
sels: [
'[data-testid="UserName"]',
'div[data-testid="UserProfileHeader_Items"]',
'div[data-testid="UserDescription"]'
] },
// デフォルト(保険):主要カラムに何かレンダされたらOK
{ test: _ => true,
sels: [
'div[data-testid="primaryColumn"]',
'main[role="main"]'
] }
];
const probes = (perRouteProbes.find(x => x.test(goal)) || perRouteProbes.at(-1)).sels;
return new Promise(resolve => {
const t0 = performance.now();
(function tick() {
const elapsed = performance.now() - t0;
const urlOk = location.pathname === goal;
const domOk = probes.some(sel => document.querySelector(sel));
if (urlOk && domOk) return resolve(true);
if (elapsed >= timeoutMs) return resolve(false);
// 立ち上がりは速く、以後はやや疎にポーリング
setTimeout(tick, elapsed < 300 ? 60 : elapsed < 700 ? 120 : 180);
})();
});
}
// ▼ SPA 遷移の核。pushState → 合成 popstate → DOM適用待ち → 失敗ならフォールバック
async function spaNavigate(path, { ctrlMeta = false, timeoutMs = 1200 } = {}) {
try {
const to = new URL(path, location.origin);
if (to.origin !== location.origin) throw new Error('cross-origin');
history.pushState(history.state, '', to.pathname + to.search + to.hash);
// X のルーターは popstate を購読している想定
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
const ok = await waitForRouteApply(to.pathname, timeoutMs);
if (ok) return; // 成功
} catch (e) {
// fall through to fallback
}
// フォールバック:修飾キーありなら新規タブ、なければ通常遷移
if (ctrlMeta) window.open(path, '_blank', 'noopener');
else location.assign(path);
}
const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
let isUpdating = false;
let manualOverrideOpen = false;
const lastHistory = { q: null, pf: null, lf: null, ts: 0 };
// ▼ 入力中ガード(IME合成を含めてカバー)
let __typingGuardUntil = 0;
const TYPING_GRACE_MS = 600; // 入力終了からこのmsはスキャン停止
const markTyping = () => { __typingGuardUntil = Date.now() + TYPING_GRACE_MS; };
const isTyping = () => Date.now() < __typingGuardUntil;
const isMediaViewPath = (pathname) => /\/status\/\d+\/(?:photo|video|media|analytics)(?:\/\d+)?\/?$/.test(pathname);
const isComposePath = (pathname) => /^\/compose\/post(?:\/|$)/.test(pathname);
const isProfileMediaPath = (pathname) => /^\/[A-Za-z0-9_]{1,50}\/(?:photo|header_photo)\/?$/.test(pathname);
const isBlockedPath = (pathname) => isMediaViewPath(pathname) || isComposePath(pathname) || isProfileMediaPath(pathname);
GM_addStyle(`
:root { --modal-primary-color:#1d9bf0; --modal-primary-color-hover:#1a8cd8; --modal-primary-text-color:#fff; }
#advanced-search-trigger { position:fixed; top:18px; right:20px; z-index:9999; background-color:var(--modal-primary-color); color:var(--modal-primary-text-color); border:none; border-radius:50%; width:50px; height:50px; font-size:24px; cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,0.15); display:flex; align-items:center; justify-content:center; transition:transform .2s, background-color .2s; }
#advanced-search-trigger:hover { transform:scale(1.1); background-color:var(--modal-primary-color-hover); }
#advanced-search-modal { position:fixed; z-index:10000; width:420px; display:none; flex-direction:column; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background-color:var(--modal-bg, #000); color:var(--modal-text-primary, #e7e9ea); border:1px solid var(--modal-border, #333); border-radius:16px; box-shadow:0 8px 24px rgba(29,155,240,.2); transition:background-color .2s,color .2s,border-color .2s; }
.adv-modal-header{padding:12px 16px;border-bottom:1px solid var(--modal-border,#333);cursor:move;display:flex;justify-content:space-between;align-items:center}
.adv-modal-header h2{margin:0;font-size:18px;font-weight:700}
.adv-modal-close{background:0 0;border:none;color:var(--modal-close-color,#e7e9ea);font-size:24px;cursor:pointer;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color .2s}
.adv-modal-close:hover{background-color:var(--modal-close-hover-bg,rgba(231,233,234,.1))}
.adv-modal-body{flex:1;overflow-y:auto;padding:0}
.adv-form-group{margin-bottom:16px}
.adv-form-group label{display:block;margin-bottom:6px;font-size:14px;font-weight:700;color:var(--modal-text-secondary,#8b98a5)}
.adv-form-group input[type=text],.adv-form-group input[type=number],.adv-form-group input[type=date],.adv-form-group select{width:100%;background-color:var(--modal-input-bg,#202327);border:1px solid var(--modal-input-border,#38444d);border-radius:4px;padding:8px 12px;color:var(--modal-text-primary,#e7e9ea);font-size:15px;box-sizing:border-box}
.adv-form-group input:focus,.adv-form-group select:focus{outline:0;border-color:var(--modal-primary-color)}
.adv-form-group input::placeholder{color:var(--modal-text-secondary,#536471)}
.adv-form-group-date-container{display:flex;gap:10px}
.adv-filter-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.adv-checkbox-group{background-color:var(--modal-input-bg,#202327);border:1px solid var(--modal-input-border,#38444d);border-radius:8px;padding:10px;display:flex;flex-direction:column;gap:8px}
.adv-checkbox-group span{font-weight:700;font-size:14px;color:var(--modal-text-primary,#e7e9ea)}
.adv-checkbox-item{display:flex;align-items:center}
.adv-checkbox-item input{margin-right:8px; accent-color:var(--modal-primary-color);}
.adv-checkbox-item label{color:var(--modal-text-secondary,#8b98a5);margin-bottom:0}
.adv-modal-footer{padding:12px 16px;border-top:1px solid var(--modal-border,#333);display:flex;justify-content:flex-end;gap:12px}
.adv-modal-button{padding:8px 16px;border-radius:9999px;border:1px solid var(--modal-text-secondary,#536471);background-color:transparent;color:var(--modal-text-primary,#e7e9ea);font-weight:700;cursor:pointer;transition:background-color .2s}
.adv-modal-button:hover{background-color:var(--modal-button-hover-bg,rgba(231,233,234,.1))}
.adv-modal-button.primary{background-color:var(--modal-primary-color);border-color:var(--modal-primary-color);color:var(--modal-primary-text-color)}
.adv-modal-button.primary:hover{background-color:var(--modal-primary-color-hover)}
.adv-modal-button[disabled]{opacity:.5; cursor:not-allowed;}
.adv-modal-body::-webkit-scrollbar{width:8px}
.adv-modal-body::-webkit-scrollbar-track{background:var(--modal-scrollbar-track,#202327)}
.adv-modal-body::-webkit-scrollbar-thumb{background:var(--modal-scrollbar-thumb,#536471);border-radius:4px}
body.adv-dragging{user-select:none}
.adv-account-label-group{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.adv-exclude-toggle{display:flex;align-items:center}
.adv-exclude-toggle input{margin-right:4px}
.adv-exclude-toggle label{font-size:13px;font-weight:normal;color:var(--modal-text-secondary,#8b98a5);cursor:pointer}
hr.adv-separator{border:none;height:1px;background-color:var(--hr-color,#333);margin:20px 0;transition:background-color .2s}
#adv-zoom-root{ transform-origin: top left; will-change: transform; padding:16px; }
.adv-modal-body{ overflow:auto; }
.adv-form-row.two-cols { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
@media (max-width: 480px) { .adv-form-row.two-cols { grid-template-columns:1fr; } }
.adv-tabs { display:flex; border-bottom:1px solid var(--modal-border,#333); padding:0 8px; gap:6px; align-items:stretch; }
.adv-tab-btn { appearance:none; border:none; background:transparent; color:var(--modal-text-secondary,#8b98a5); padding:10px 12px; cursor:pointer; font-weight:700; border-radius:8px 8px 0 0; font-size:12.5px; }
.adv-tab-btn.active { color:var(--modal-text-primary,#e7e9ea); background-color:var(--modal-input-bg,#202327); border:1px solid var(--modal-input-border,#38444d); border-bottom:none; }
.adv-tab-content { display:none; }
.adv-tab-content.active { display:block; }
#adv-tab-history, #adv-tab-saved { padding:12px 16px; }
.adv-secret-wrap { display:flex; align-items:center; gap:8px; }
.adv-secret-btn { cursor:pointer; border:1px solid var(--modal-input-border,#38444d); background:var(--modal-input-bg,#202327); color:var(--modal-text-primary,#e7e9ea); padding:4px 8px; border-radius:9999px; font-weight:700; user-select:none; display:flex; align-items:center; gap:6px; font-size:12px; }
.adv-secret-btn .dot { width:7px; height:7px; border-radius:50%; background:#777; box-shadow:0 0 0px #0000; transition:all .2s; }
.adv-secret-btn.off { opacity:0.9; }
.adv-secret-btn.on { background-color:var(--modal-primary-color); border-color:var(--modal-primary-color); color:var(--modal-primary-text-color); }
.adv-secret-btn.on .dot { background:#fff; box-shadow:0 0 8px rgba(255,255,255,.9); }
.adv-list { display:flex; flex-direction:column; gap:8px; }
.adv-item { border:1px solid var(--modal-input-border,#38444d); background:var(--modal-input-bg,#202327); border-radius:8px; padding:8px; display:flex; gap:8px; align-items:flex-start; }
.adv-item.dragging { opacity:.6; }
.adv-item-handle { cursor:grab; user-select:none; padding:4px 6px; border-radius:6px; border:1px dashed var(--modal-border,#333); }
.adv-item-avatar { width:36px; height:36px; border-radius:9999px; object-fit:cover; flex:0 0 auto; background:var(--modal-border,#333); }
a.adv-link { color: inherit; text-decoration: none; }
a.adv-link:hover { text-decoration: underline; cursor: pointer; }
.adv-item-avatar-link { display:inline-block; border-radius:9999px; }
.adv-item-main { flex:1; min-width:0; }
.adv-item-title { font-size:14px; font-weight:700; color:var(--modal-text-primary,#e7e9ea); word-break:break-word; }
.adv-item-sub { font-size:12px; color:var(--modal-text-secondary,#8b98a5); margin-top:2px; display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
.adv-item-actions { display:flex; gap:6px; align-items:center; align-self:center; }
.adv-chip { border:1px solid var(--modal-input-border,#38444d); background:transparent; color:var(--modal-text-primary,#e7e9ea); padding:4px 8px; border-radius:9999px; font-size:12px; cursor:pointer; }
.adv-chip.danger { border-color:#8b0000; color:#ffb3b3; }
.adv-chip.primary { background-color:var(--modal-primary-color); border-color:var(--modal-primary-color); color:var(--modal-primary-text-color); }
.adv-chip.scope { padding:2px 6px; font-size:11px; line-height:1.2; opacity:0.95; }
.adv-toast { position:fixed; z-index:10001; left:50%; transform:translateX(-50%); bottom:24px; background:#111a; color:#fff; backdrop-filter: blur(6px); border:1px solid #fff3; padding:8px 12px; border-radius:8px; font-weight:700; opacity:0; pointer-events:none; transition:opacity .2s, transform .2s; }
.adv-toast.show { opacity:1; transform:translateX(-50%) translateY(-6px); }
.adv-modal-footer { justify-content:flex-end; }
.adv-modal-footer .adv-modal-button#adv-save-button { margin-right:auto; }
.adv-tab-toolbar { display:flex; justify-content:flex-end; margin-bottom:8px; }
[data-testid="cellInnerDiv"][data-adv-hidden],
article[data-adv-hidden] { display:none !important; }
#advanced-search-modal { max-height:none; }
.adv-resizer { position:absolute; z-index:10002; background:transparent; }
.adv-resizer.e, .adv-resizer.w { top:-3px; bottom:-3px; width:8px; }
.adv-resizer.e { right:-3px; cursor: ew-resize; }
.adv-resizer.w { left:-3px; cursor: ew-resize; }
.adv-resizer.n, .adv-resizer.s { left:-3px; right:-3px; height:8px; }
.adv-resizer.n { top:-3px; cursor: ns-resize; }
.adv-resizer.s { bottom:-3px; cursor: ns-resize; }
.adv-resizer.se, .adv-resizer.ne, .adv-resizer.sw, .adv-resizer.nw { width:12px; height:12px; }
.adv-resizer.se { right:-4px; bottom:-4px; cursor:nwse-resize; }
.adv-resizer.ne { right:-4px; top:-4px; cursor:nesw-resize; }
.adv-resizer.sw { left:-4px; bottom:-4px; cursor:nesw-resize; }
.adv-resizer.nw { left:-4px; top:-4px; cursor:nwse-resize; }
/* ▶ Mute タブ */
.adv-mute-add { display:flex; gap:8px; align-items:center; margin-bottom:10px; }
.adv-mute-add input[type=text]{ flex:1; }
.adv-mute-list { display:flex; flex-direction:column; gap:8px; }
/* ▼ グローバル無効(マスターOFF)のとき:リスト全体を淡く */
.adv-mute-list.disabled {
opacity: .6;
filter: grayscale(35%);
}
/* ▼ 個別無効(enabled=false)の行だけ淡く+打ち消し等の視覚 */
.adv-mute-item {
border:1px solid var(--modal-input-border,#38444d);
background:var(--modal-input-bg,#202327);
border-radius:8px;
padding:8px;
display:flex;
flex-wrap: wrap;
gap:8px;
align-items:flex-start;
transition: opacity .15s ease, filter .15s ease, border-color .15s ease;
}
.adv-mute-item.disabled {
opacity: .55;
filter: grayscale(25%);
border-color: color-mix(in oklab, var(--modal-input-border,#38444d), transparent 20%);
}
.adv-mute-item.disabled .adv-mute-word {
color: var(--modal-text-secondary,#8b98a5);
text-decoration: line-through;
}
.adv-mute-word {
font-weight:700;
color:var(--modal-text-primary,#e7e9ea);
word-break:break-word;
}
.adv-mute-actions {
display:flex;
gap:6px;
align-items:center;
flex: 0 0 auto;
white-space: nowrap;
margin-left: auto;
}
@media (max-width: 480px) {
.adv-mute-actions { margin-top: 4px; }
}
.adv-toggle {
display: inline-flex;
gap: 6px;
align-items: center;
color: var(--modal-text-secondary,#8b98a5);
line-height: 1;
margin-bottom:0!important;
}
.adv-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
flex: 0 0 auto;
vertical-align: middle;
}
.adv-toggle span {
font-size: 11px;
line-height: 1;
}
.adv-mute-header { display:flex; justify-content:space-between; align-items:center; margin:12px 0 6px; }
.adv-mute-title { font-weight:700; color: var(--modal-text-primary,#e7e9ea); }
/* マスター切替の一瞬だけ付けるガードクラス */
.adv-no-anim, .adv-no-anim * {
transition: none !important;
}
#adv-accounts-empty:not(:empty),
#adv-lists-empty:not(:empty) {
padding: 12px;
}
/* ▼ マスターOFF中は、個別無効の“さらに薄く”を抑制(親の薄さのみ適用) */
.adv-mute-list.disabled .adv-mute-item.disabled {
opacity: 1; /* 子の追加の薄さを無効化(親のopacityのみが効く) */
filter: none; /* 子の追加グレースケールも無効化(親側のfilterのみ適用) */
/* 任意:ボーダーだけ通常色に戻したい場合 */
/* border-color: var(--modal-input-border,#38444d); */
}
/* === Trigger: モーダルと同質の見た目に合わせる === */
#advanced-search-trigger.adv-trigger-search {
width: 49px; height: 49px;
border-radius: 9999px;
background-color: var(--modal-bg, #000);
color: var(--modal-text-primary, #e7e9ea);
border: 2px solid var(--modal-border, #2f3336); /* ← モーダルと同じ枠色 */
box-shadow: 0 8px 24px rgba(29,155,240,.2); /* ← モーダルと同じshadow */
display:flex; align-items:center; justify-content:center;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
#advanced-search-trigger.adv-trigger-search:hover {
/* 背景は変えず、浮かせる表現だけ強化 */
transform: translateZ(0) scale(1.04);
box-shadow: 0 12px 36px rgba(29,155,240,.28);
border-color: var(--modal-border, #2f3336);
}
#advanced-search-trigger.adv-trigger-search:active {
transform: translateZ(0) scale(0.98);
box-shadow: 0 6px 18px rgba(29,155,240,.22);
}
#advanced-search-trigger.adv-trigger-search:focus-visible {
outline: none;
box-shadow:
0 8px 24px rgba(29,155,240,.2),
0 0 0 3px color-mix(in oklab, var(--modal-primary-color, #1d9bf0) 45%, transparent);
}
#advanced-search-trigger.adv-trigger-search svg {
width: 22px; height: 22px;
display:block;
/* 検索アイコンは stroke="currentColor" を使っているので配色は自動追従 */
}
`);
const modalHTML = `
<div id="advanced-search-modal">
<div class="adv-modal-header">
<h2 data-i18n="modalTitle"></h2>
<div class="adv-secret-wrap">
<button id="adv-secret-btn" class="adv-secret-btn off" data-i18n-title="tooltipSecret" title="">
<span class="dot" aria-hidden="true"></span>
<span id="adv-secret-label" data-i18n="secretMode"></span>
<span id="adv-secret-state" style="font-weight:700;"></span>
</button>
<button class="adv-modal-close" data-i18n-title="tooltipClose">×</button>
</div>
</div>
<div class="adv-modal-body">
<div class="adv-tabs">
<button class="adv-tab-btn active" data-tab="search" data-i18n="tabSearch"></button>
<button class="adv-tab-btn" data-tab="history" data-i18n="tabHistory"></button>
<button class="adv-tab-btn" data-tab="saved" data-i18n="tabSaved"></button>
<button class="adv-tab-btn" data-tab="mute" data-i18n="tabMute"></button>
<button class="adv-tab-btn" data-tab="lists" data-i18n="tabLists"></button>
<button class="adv-tab-btn" data-tab="accounts" data-i18n="tabAccounts"></button>
</div>
<div class="adv-tab-content active" id="adv-tab-search">
<div id="adv-zoom-root">
<form id="advanced-search-form">
<div class="adv-form-group"><label for="adv-all-words" data-i18n="labelAllWords"></label><input type="text" id="adv-all-words" data-i18n-placeholder="placeholderAllWords"></div>
<div class="adv-form-group"><label for="adv-exact-phrase" data-i18n="labelExactPhrase"></label><input type="text" id="adv-exact-phrase" data-i18n-placeholder="placeholderExactPhrase"></div>
<div class="adv-form-group"><label for="adv-any-words" data-i18n="labelAnyWords"></label><input type="text" id="adv-any-words" data-i18n-placeholder="placeholderAnyWords"></div>
<div class="adv-form-group"><label for="adv-not-words" data-i18n="labelNotWords"></label><input type="text" id="adv-not-words" data-i18n-placeholder="placeholderNotWords"></div>
<div class="adv-form-group"><label for="adv-hashtag" data-i18n="labelHashtag"></label><input type="text" id="adv-hashtag" data-i18n-placeholder="placeholderHashtag"></div>
<div class="adv-form-group">
<label for="adv-lang" data-i18n="labelLang"></label>
<select id="adv-lang">
<option value="" data-i18n="optLangDefault"></option>
<option value="ja" data-i18n="optLangJa"></option>
<option value="en" data-i18n="optLangEn"></option>
<option value="id" data-i18n="optLangId"></option> <!-- インドネシア -->
<option value="hi" data-i18n="optLangHi"></option> <!-- ヒンディー(インド) -->
<option value="de" data-i18n="optLangDe"></option> <!-- ドイツ -->
<option value="tr" data-i18n="optLangTr"></option> <!-- トルコ -->
<option value="es" data-i18n="optLangEs"></option> <!-- スペイン語(メキシコ含む) -->
<option value="pt" data-i18n="optLangPt"></option> <!-- ポルトガル語(ブラジル)-->
<option value="ar" data-i18n="optLangAr"></option> <!-- アラビア語(サウジ等) -->
<option value="fr" data-i18n="optLangFr"></option>
<option value="ko" data-i18n="optLangKo"></option>
<option value="ru" data-i18n="optLangRu"></option>
<option value="zh-cn" data-i18n="optLangZhHans"></option> <!-- 簡体中文 -->
<option value="zh-tw" data-i18n="optLangZhHant"></option> <!-- 繁體中文 -->
</select>
</div>
<hr class="adv-separator">
<div class="adv-form-group">
<label data-i18n="labelFilters"></label>
<div class="adv-filter-grid">
<div class="adv-checkbox-group"><span data-i18n="labelVerified"></span><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-verified-include"><label for="adv-filter-verified-include" data-i18n="checkInclude"></label></div><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-verified-exclude"><label for="adv-filter-verified-exclude" data-i18n="checkExclude"></label></div></div>
<div class="adv-checkbox-group"><span data-i18n="labelLinks"></span><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-links-include"><label for="adv-filter-links-include" data-i18n="checkInclude"></label></div><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-links-exclude"><label for="adv-filter-links-exclude" data-i18n="checkExclude"></label></div></div>
<div class="adv-checkbox-group"><span data-i18n="labelImages"></span><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-images-include"><label for="adv-filter-images-include" data-i18n="checkInclude"></label></div><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-images-exclude"><label for="adv-filter-images-exclude" data-i18n="checkExclude"></label></div></div>
<div class="adv-checkbox-group"><span data-i18n="labelVideos"></span><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-videos-include"><label for="adv-filter-videos-include" data-i18n="checkInclude"></label></div><div class="adv-checkbox-item"><input type="checkbox" id="adv-filter-videos-exclude"><label for="adv-filter-videos-exclude" data-i18n="checkExclude"></label></div></div>
</div>
</div>
<div class="adv-form-group" title="" data-i18n-title="hintSearchTarget">
<label data-i18n="labelSearchTarget"></label>
<div class="adv-checkbox-group">
<div class="adv-checkbox-item">
<input type="checkbox" id="adv-exclude-hit-name" checked>
<label for="adv-exclude-hit-name" data-i18n="labelHitName" title="" data-i18n-title="hintName"></label>
</div>
<div class="adv-checkbox-item">
<input type="checkbox" id="adv-exclude-hit-handle" checked>
<label for="adv-exclude-hit-handle" data-i18n="labelHitHandle" title="" data-i18n-title="hintHandle"></label>
</div>
</div>
</div>
<div class="adv-form-row two-cols">
<div class="adv-form-group">
<label for="adv-account-scope" data-i18n="labelAccountScope"></label>
<select id="adv-account-scope">
<option value="" data-i18n="optAccountAll"></option>
<option value="following" data-i18n="optAccountFollowing"></option>
</select>
</div>
<div class="adv-form-group">
<label for="adv-location-scope" data-i18n="labelLocationScope"></label>
<select id="adv-location-scope">
<option value="" data-i18n="optLocationAll"></option>
<option value="nearby" data-i18n="optLocationNearby"></option>
</select>
</div>
</div>
<div class="adv-form-group"><label data-i18n="labelReplies"></label><select id="adv-replies"><option value="" data-i18n="optRepliesDefault"></option><option value="include" data-i18n="optRepliesInclude"></option><option value="only" data-i18n="optRepliesOnly"></option><option value="exclude" data-i18n="optRepliesExclude"></option></select></div>
<hr class="adv-separator">
<div class="adv-form-group">
<label data-i18n="labelEngagement"></label>
<div class="adv-filter-grid">
<input type="number" id="adv-min-replies" data-i18n-placeholder="placeholderMinReplies" min="0">
<input type="number" id="adv-min-faves" data-i18n-placeholder="placeholderMinLikes" min="0">
<input type="number" id="adv-min-retweets" data-i18n-placeholder="placeholderMinRetweets" min="0">
</div>
</div>
<div class="adv-form-group">
<label data-i18n="labelDateRange"></label>
<div class="adv-form-group-date-container">
<input type="date" id="adv-since" data-i18n-title="tooltipSince">
<input type="date" id="adv-until" data-i18n-title="tooltipUntil">
</div>
</div>
<hr class="adv-separator">
<div class="adv-form-group">
<div class="adv-account-label-group">
<label for="adv-from-user" data-i18n="labelFromUser"></label>
<div class="adv-exclude-toggle"><input type="checkbox" id="adv-from-user-exclude"><label for="adv-from-user-exclude" data-i18n="checkExclude"></label></div>
</div>
<input type="text" id="adv-from-user" data-i18n-placeholder="placeholderFromUser">
</div>
<div class="adv-form-group">
<div class="adv-account-label-group">
<label for="adv-to-user" data-i18n="labelToUser"></label>
<div class="adv-exclude-toggle"><input type="checkbox" id="adv-to-user-exclude"><label for="adv-to-user-exclude" data-i18n="checkExclude"></label></div>
</div>
<input type="text" id="adv-to-user" data-i18n-placeholder="placeholderToUser">
</div>
<div class="adv-form-group">
<div class="adv-account-label-group">
<label for="adv-mentioning" data-i18n="labelMentioning"></label>
<div class="adv-exclude-toggle"><input type="checkbox" id="adv-mentioning-exclude"><label for="adv-mentioning-exclude" data-i18n="checkExclude"></label></div>
</div>
<input type="text" id="adv-mentioning" data-i18n-placeholder="placeholderMentioning">
</div>
</form>
</div>
</div>
<div class="adv-tab-content" id="adv-tab-history">
<div class="adv-tab-toolbar">
<button id="adv-history-clear-all" class="adv-chip danger"></button>
</div>
<div id="adv-history-empty" class="adv-item-sub"></div>
<div id="adv-history-list" class="adv-list"></div>
</div>
<div class="adv-tab-content" id="adv-tab-saved">
<div id="adv-saved-empty" class="adv-item-sub"></div>
<div id="adv-saved-list" class="adv-list"></div>
</div>
<div class="adv-tab-content" id="adv-tab-lists">
<div id="adv-lists-empty" class="adv-item-sub"></div>
<div id="adv-lists-list" class="adv-list"></div>
</div>
<div class="adv-tab-content" id="adv-tab-accounts">
<div id="adv-accounts-empty" class="adv-item-sub"></div>
<div id="adv-accounts-list" class="adv-list"></div>
</div>
<!-- ▶ Mute タブ -->
<div class="adv-tab-content" id="adv-tab-mute">
<div style="padding:12px 16px;">
<div class="adv-form-group">
<!-- 追加する並び:まず“追加”UI -->
<div class="adv-mute-add">
<input type="text" id="adv-mute-input" data-i18n-placeholder="placeholderMuteWord">
<button id="adv-mute-add" class="adv-modal-button" data-i18n="buttonAdd"></button>
<label class="adv-toggle" title="">
<input type="checkbox" id="adv-mute-cs">
<span data-i18n="labelCaseSensitive"></span>
</label>
</div>
<!-- ▼ 新しい見出しブロック(ミュート一覧 + すべて有効/無効) -->
<div class="adv-mute-header" style="margin-top:12px;">
<div class="adv-mute-title" data-i18n="mutedListHeading"></div>
<label class="adv-toggle">
<input type="checkbox" id="adv-mute-enable-all" checked>
<span data-i18n="labelEnableAll"></span>
</label>
</div>
<div id="adv-mute-empty" class="adv-item-sub"></div>
<div id="adv-mute-list" class="adv-mute-list"></div>
</div>
</div>
</div>
</div>
<div class="adv-modal-footer">
<button id="adv-save-button" class="adv-modal-button" data-i18n="buttonSave"></button>
<button id="adv-clear-button" class="adv-modal-button" data-i18n="buttonClear"></button>
<button id="adv-apply-button" class="adv-modal-button primary" data-i18n="buttonApply"></button>
</div>
</div>
<div id="adv-toast" class="adv-toast" role="status" aria-live="polite"></div>
`;
const initialize = async () => {
i18n.init();
const kv = {
get(key, def) { try { return GM_getValue(key, def); } catch (_) { return def; } },
set(key, val) { try { GM_setValue(key, val); } catch (_) {} },
del(key) { try { GM_deleteValue(key); } catch (_) {} },
};
const loadJSON = (key, def) => {
try {
const raw = kv.get(key, JSON.stringify(def));
return JSON.parse(raw);
} catch(_) { return def; }
};
const saveJSON = (key, value) => {
try { kv.set(key, JSON.stringify(value)); } catch(_) {}
};
const trigger = document.createElement('button');
trigger.id = 'advanced-search-trigger';
trigger.type = 'button';
trigger.innerHTML = SEARCH_SVG;
trigger.classList.add('adv-trigger-search');
trigger.setAttribute('aria-label', i18n.t('tooltipTrigger'));
trigger.setAttribute('aria-haspopup', 'dialog');
trigger.setAttribute('aria-expanded', 'false');
document.body.appendChild(trigger);
const modalContainer = document.createElement('div');
modalContainer.innerHTML = modalHTML;
document.body.appendChild(modalContainer);
i18n.apply(modalContainer);
const modal = document.getElementById('advanced-search-modal');
const form = document.getElementById('advanced-search-form');
const closeButton = modal.querySelector('.adv-modal-close');
const clearButton = document.getElementById('adv-clear-button');
const applyButton = document.getElementById('adv-apply-button');
const saveButton = document.getElementById('adv-save-button');
const footerEl = modal.querySelector('.adv-modal-footer');
const toastEl = document.getElementById('adv-toast');
const secretBtn = document.getElementById('adv-secret-btn');
const secretStateEl = document.getElementById('adv-secret-state');
const historyClearAllBtn = document.getElementById('adv-history-clear-all');
historyClearAllBtn.textContent = i18n.t('historyClearAll');
const accountScopeSel = document.getElementById('adv-account-scope');
const locationScopeSel = document.getElementById('adv-location-scope');
['n','e','s','w','ne','nw','se','sw'].forEach(dir => {
const h = document.createElement('div');
h.className = `adv-resizer ${dir}`;
h.dataset.dir = dir;
modal.appendChild(h);
});
const EXC_NAME_KEY = 'advExcludeHitName_v1';
const EXC_HANDLE_KEY = 'advExcludeHitHandle_v1';
const excNameEl = document.getElementById('adv-exclude-hit-name');
const excHandleEl = document.getElementById('adv-exclude-hit-handle');
const loadExcludeFlags = () => ({
name: kv.get(EXC_NAME_KEY, '1') === '1',
handle: kv.get(EXC_HANDLE_KEY, '1') === '1',
});
const saveExcludeFlags = (v) => {
kv.set(EXC_NAME_KEY, v.name ? '1':'0');
kv.set(EXC_HANDLE_KEY, v.handle ? '1':'0');
};
{
const st = loadExcludeFlags();
if (excNameEl) excNameEl.checked = st.name;
if (excHandleEl) excHandleEl.checked = st.handle;
}
[excNameEl, excHandleEl].forEach(el=>{
if (!el) return;
el.addEventListener('change', ()=>{
saveExcludeFlags({ name: excNameEl.checked, handle: excHandleEl.checked });
scanAndFilterTweets();
});
});
themeManager.observeChanges(modal, trigger);
const ZOOM_STATE_KEY = 'advSearchZoom_v1';
let zoom = 1.0;
const ZOOM_MIN = 0.5, ZOOM_MAX = 2.0, ZOOM_STEP = 0.1;
const zoomRoot = () => document.getElementById('adv-zoom-root');
const loadZoom = () => {
try {
const z = parseFloat(kv.get(ZOOM_STATE_KEY, '1'));
if (!Number.isNaN(z)) zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
} catch(_) {}
};
const saveZoom = () => { try { kv.set(ZOOM_STATE_KEY, String(zoom)); } catch(_) {} };
const applyZoom = () => {
const el = zoomRoot();
if (!el) return;
el.style.zoom = '';
el.style.transform = '';
el.style.width = '';
if ('zoom' in el.style) {
el.style.zoom = zoom;
} else {
el.style.transform = `scale(${zoom})`;
el.style.width = `${(100/zoom).toFixed(3)}%`;
}
};
const setZoom = (z) => { zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(z*100)/100)); applyZoom(); saveZoom(); };
const onWheelZoom = (e) => {
const isAccel = e.ctrlKey || e.metaKey;
if (!isAccel) return;
if (!modal.contains(e.target)) return;
e.preventDefault();
const factor = e.deltaY > 0 ? (1 - ZOOM_STEP) : (1 + ZOOM_STEP);
setZoom(zoom * factor);
};
const onKeyZoom = (e) => {
const accel = (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey;
if (!accel) return;
if (!modal.contains(e.target)) return;
const k = e.key;
if (k === '+' || k === '=') { e.preventDefault(); setZoom(zoom + ZOOM_STEP); }
else if (k === '-' || k === '_') { e.preventDefault(); setZoom(zoom - ZOOM_STEP); }
else if (k === '0') { e.preventDefault(); setZoom(1.0); }
};
loadZoom();
requestAnimationFrame(applyZoom);
modal.addEventListener('wheel', onWheelZoom, { passive:false });
modal.addEventListener('keydown', onKeyZoom);
const modalDisplayObserver = new MutationObserver(() => { if (modal.style.display === 'flex') applyZoom(); });
modalDisplayObserver.observe(modal, { attributes:true, attributeFilter:['style'] });
const searchInputSelectors = [
'div[data-testid="primaryColumn"] input[data-testid="SearchBox_Search_Input"]',
'div[data-testid="sidebarColumn"] input[data-testid="SearchBox_Search_Input"]'
];
const getActiveSearchInput = () => {
for (const selector of searchInputSelectors) {
const input = document.querySelector(selector);
if (input && input.offsetParent !== null) return input;
}
const fallback = document.querySelector('input[data-testid="SearchBox_Search_Input"]');
return (fallback && fallback.offsetParent !== null) ? fallback : null;
};
// React controlled input を確実に同期させる共通関数
const syncControlledInput = (el, nextVal) => {
try {
const proto = Object.getPrototypeOf(el) || HTMLInputElement.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && desc.set) {
desc.set.call(el, nextVal); // React の setter を叩く
} else {
el.value = nextVal;
}
el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true }));
} catch {
try { el.value = nextVal; el.dispatchEvent(new Event('input', { bubbles: true })); } catch {}
}
};
const MODAL_STATE_KEY = 'advSearchModalState_v3.2';
const TRIGGER_STATE_KEY = 'advSearchTriggerState_v1.0';
const HISTORY_KEY = 'advSearchHistory_v2';
const SAVED_KEY = 'advSearchSaved_v2';
const SECRET_KEY = 'advSearchSecretMode_v1';
const MUTE_KEY = 'advMutedWords_v1';
const migrateMuted = (list) =>
Array.isArray(list)
? list
.map(it => ({
id: it.id || uid(),
word: (it.word||'').trim(),
cs: !!it.cs,
enabled: it.enabled !== false,
ts: it.ts || Date.now()
}))
.filter(it => it.word)
: [];
const loadMuted = () => migrateMuted(loadJSON(MUTE_KEY, []));
const saveMuted = (arr) => saveJSON(MUTE_KEY, migrateMuted(arr));
const addMuted = (word, cs=false) => {
const w = (word||'').trim();
if (!w) return;
const list = loadMuted();
if (list.some(it => it.word === w && !!it.cs === !!cs)) return;
list.unshift({ id: uid(), word: w, cs: !!cs, enabled: true, ts: Date.now() });
saveMuted(list);
renderMuted();
scanAndFilterTweets();
};
const deleteMuted = (id) => {
const list = loadMuted().filter(it => it.id !== id);
saveMuted(list);
renderMuted();
scanAndFilterTweets();
};
const toggleMutedCS = (id) => {
const list = loadMuted().map(it => it.id === id ? { ...it, cs: !it.cs, ts: Date.now() } : it);
saveMuted(list);
renderMuted();
scanAndFilterTweets();
};
const toggleMutedEnabled = (id) => {
const list = loadMuted().map(it => it.id === id ? { ...it, enabled: !it.enabled, ts: Date.now() } : it);
saveMuted(list);
renderMuted();
scanAndFilterTweets();
};
// マスターON/OFF(全体の適用を止めるだけ。各エントリの enabled は保持)
const MUTE_MASTER_KEY = 'advMuteMasterEnabled_v1';
const loadMuteMaster = () => { try { return kv.get(MUTE_MASTER_KEY, '1') === '1'; } catch(_) { return true; } };
const saveMuteMaster = (on) => { try { kv.set(MUTE_MASTER_KEY, on ? '1' : '0'); } catch(_) {} };
const tabButtons = Array.from(document.querySelectorAll('.adv-tab-btn'));
const tabSearch = document.getElementById('adv-tab-search');
const tabHistory = document.getElementById('adv-tab-history');
const tabSaved = document.getElementById('adv-tab-saved');
const tabLists = document.getElementById('adv-tab-lists');
const tabAccounts = document.getElementById('adv-tab-accounts');
const tabMute = document.getElementById('adv-tab-mute');
const saveModalRelativeState = () => {
if (modal.style.display === 'none') {
try {
const current = (()=>{
try { return JSON.parse(kv.get(MODAL_STATE_KEY, '{}')); } catch(_) { return {}; }
})();
current.visible = false;
kv.set(MODAL_STATE_KEY, JSON.stringify(current));
} catch(_) {}
return;
}
const rect = modal.getBoundingClientRect();
const winW = window.innerWidth, winH = window.innerHeight;
const fromRight = winW - rect.right, fromBottom = winH - rect.bottom;
const h_anchor = rect.left < fromRight ? 'left' : 'right';
const h_value = h_anchor === 'left' ? rect.left : fromRight;
const v_anchor = rect.top < fromBottom ? 'top' : 'bottom';
const v_value = v_anchor === 'top' ? rect.top : fromBottom;
const state = { h_anchor, h_value, v_anchor, v_value, visible: true,
w: Math.round(rect.width), h: Math.round(rect.height) };
kv.set(MODAL_STATE_KEY, JSON.stringify(state));
};
const applyModalStoredPosition = () => {
try {
const s = JSON.parse(kv.get(MODAL_STATE_KEY, '{}'));
const h_anchor = s.h_anchor || 'right';
const h_value = s.h_value ?? 20;
const v_anchor = s.v_anchor || 'top';
const v_value = s.v_value ?? 80;
modal.style.left = modal.style.right = modal.style.top = modal.style.bottom = 'auto';
if (h_anchor === 'right') modal.style.right = `${h_value}px`; else modal.style.left = `${h_value}px`;
if (v_anchor === 'bottom') modal.style.bottom = `${v_value}px`; else modal.style.top = `${v_value}px`;
const minW = 300, minH = 240;
if (s.w) modal.style.width = `${Math.max(minW, Math.min(s.w, window.innerWidth - 20))}px`;
else modal.style.width = '420px';
if (s.h) modal.style.height = `${Math.max(minH, Math.min(s.h, window.innerHeight - 20))}px`;
else modal.style.height = '';
} catch(e) { console.error('Failed to apply modal position:', e); }
};
const keepModalInViewport = () => {
if (modal.style.display === 'none') return;
const rect = modal.getBoundingClientRect();
const winW = window.innerWidth, winH = window.innerHeight, m = 10;
const minW = 300, minH = 240;
const maxW = Math.max(minW, winW - 2*m);
const maxH = Math.max(minH, winH - 2*m);
const w = Math.min(Math.max(rect.width, minW), maxW);
const h = Math.min(Math.max(rect.height, minH), maxH);
if (Math.round(w) !== Math.round(rect.width)) modal.style.width = `${w}px`;
if (Math.round(h) !== Math.round(rect.height)) modal.style.height = `${h}px`;
let x = rect.left, y = rect.top;
if (x < m) x = m; if (y < m) y = m;
if (x + w > winW - m) x = winW - w - m;
if (y + h > winH - m) y = winH - h - m;
if (Math.round(x) !== Math.round(rect.left) || Math.round(y) !== Math.round(rect.top)) {
modal.style.left = `${x}px`; modal.style.top = `${y}px`;
modal.style.right = 'auto'; modal.style.bottom = 'auto';
}
};
const loadModalState = () => {
try { applyModalStoredPosition(); } catch(e) {
console.error('Failed to load modal state:', e);
kv.del(MODAL_STATE_KEY);
}
};
const saveTriggerRelativeState = () => {
const rect = trigger.getBoundingClientRect();
const winW = window.innerWidth, winH = window.innerHeight;
const fromRight = winW - rect.right, fromBottom = winH - rect.bottom;
const h_anchor = rect.left < fromRight ? 'left' : 'right';
const h_value = h_anchor === 'left' ? rect.left : fromRight;
const v_anchor = rect.top < fromBottom ? 'top' : 'bottom';
const v_value = v_anchor === 'top' ? rect.top : fromBottom;
const state = { h_anchor, h_value, v_anchor, v_value };
kv.set(TRIGGER_STATE_KEY, JSON.stringify(state));
};
const applyTriggerStoredPosition = () => {
try {
const s = JSON.parse(kv.get(TRIGGER_STATE_KEY, '{}'));
const h_anchor = s.h_anchor || 'right';
const h_value = s.h_value ?? 20;
const v_anchor = s.v_anchor || 'top';
const v_value = s.v_value ?? 18;
trigger.style.left = trigger.style.right = trigger.style.top = trigger.style.bottom = 'auto';
if (h_anchor === 'right') trigger.style.right = `${h_value}px`; else trigger.style.left = `${h_value}px`;
if (v_anchor === 'bottom') trigger.style.bottom = `${v_value}px`; else trigger.style.top = `${v_value}px`;
} catch(e) { console.error('Failed to apply trigger position:', e); }
};
const keepTriggerInViewport = () => {
const rect = trigger.getBoundingClientRect();
const winW = window.innerWidth, winH = window.innerHeight, m = 6;
let x = rect.left, y = rect.top;
if (x < m) x = m; if (y < m) y = m;
if (x + rect.width > winW - m) x = winW - rect.width - m;
if (y + rect.height > winH - m) y = winH - rect.height - m;
if (Math.round(x) !== Math.round(rect.left) || Math.round(y) !== Math.round(rect.top)) {
trigger.style.left = `${x}px`; trigger.style.top = `${y}px`;
trigger.style.right = 'auto'; trigger.style.bottom = 'auto';
saveTriggerRelativeState();
}
};
const setupTriggerDrag = () => {
const DRAG_THRESHOLD = 4;
let isPointerDown = false, isDragging = false, start = {x:0,y:0,left:0,top:0}, suppressClick=false;
const onPointerDown = (e) => {
if (e.button !== 0) return;
isPointerDown = true; isDragging = false; suppressClick=false;
const rect = trigger.getBoundingClientRect();
start = { x:e.clientX, y:e.clientY, left:rect.left, top:rect.top };
try{ trigger.setPointerCapture(e.pointerId);}catch(_){}
};
const onPointerMove = (e) => {
if (!isPointerDown) return;
const dx = e.clientX - start.x, dy = e.clientY - start.y;
if (!isDragging) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
isDragging = true;
trigger.style.right = 'auto'; trigger.style.bottom = 'auto';
trigger.style.left = `${start.left}px`; trigger.style.top = `${start.top}px`;
document.body.classList.add('adv-dragging');
}
const winW = window.innerWidth, winH = window.innerHeight;
const w = trigger.offsetWidth, h = trigger.offsetHeight;
let nx = start.left + dx, ny = start.top + dy;
nx = Math.max(0, Math.min(nx, winW - w)); ny = Math.max(0, Math.min(ny, winH - h));
trigger.style.left = `${nx}px`; trigger.style.top = `${ny}px`;
};
const onPointerUp = (e) => {
if (!isPointerDown) return; isPointerDown = false;
try{ trigger.releasePointerCapture(e.pointerId);}catch(_){}
if (isDragging) {
isDragging = false; document.body.classList.remove('adv-dragging');
suppressClick = true; setTimeout(()=>{suppressClick=false;},150);
saveTriggerRelativeState();
}
};
trigger.addEventListener('click', (e)=> {
if (suppressClick) {
e.preventDefault();
e.stopPropagation();
suppressClick = false;
return;
}
}, true);
trigger.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
applyTriggerStoredPosition();
requestAnimationFrame(keepTriggerInViewport);
setupTriggerDrag();
const readScopesFromControls = () => ({ pf: accountScopeSel.value === 'following', lf: locationScopeSel.value === 'nearby' });
const applyScopesToControls = ({pf=false, lf=false}) => {
accountScopeSel.value = pf ? 'following' : '';
locationScopeSel.value = lf ? 'nearby' : '';
};
const readScopesFromURL = (urlStr) => {
try {
const u = new URL(urlStr || location.href, location.origin);
const pf = (u.searchParams.get('pf') || '') === 'on';
const lf = (u.searchParams.get('lf') || '') === 'on';
return { pf, lf };
} catch { return { pf:false, lf:false }; }
};
const STATE_SYNC = {
parseFromSearchToModal: () => {
if (isUpdating || modal.style.display === 'none') return;
const si = getActiveSearchInput();
parseQueryAndApplyToModal(si ? si.value : '');
applyScopesToControls(readScopesFromURL());
updateSaveButtonState();
},
applyFromModalToSearch: () => {
if (isUpdating) return;
isUpdating = true;
const finalQuery = buildQueryStringFromModal();
const si = getActiveSearchInput();
if (si) { syncControlledInput(si, finalQuery); }
isUpdating = false;
updateSaveButtonState();
}
};
const buildQueryStringFromModal = () => {
const q = [];
const fields = {
all: document.getElementById('adv-all-words').value.trim(),
exact: document.getElementById('adv-exact-phrase').value.trim(),
any: document.getElementById('adv-any-words').value.trim(),
not: document.getElementById('adv-not-words').value.trim(),
hash: document.getElementById('adv-hashtag').value.trim(),
lang: document.getElementById('adv-lang').value,
replies: document.getElementById('adv-replies').value,
min_replies: document.getElementById('adv-min-replies').value,
min_faves: document.getElementById('adv-min-faves').value,
min_retweets: document.getElementById('adv-min-retweets').value,
since: document.getElementById('adv-since').value,
until: document.getElementById('adv-until').value,
};
if (fields.all) q.push(fields.all);
if (fields.exact) q.push(`"${fields.exact.replace(/"/g,'')}"`);
// 引用で 1 語として扱い、OR 連結を生成
if (fields.any) {
const tokens = tokenizeQuotedWords(fields.any).map(t => {
// 既に "…": そのまま。未引用で空白を含む → 引用を付ける
if (/^".*"$/.test(t)) return t;
if (/\s/.test(t)) return `"${t.replace(/"/g,'')}"`;
return t;
});
if (tokens.length) q.push(`(${tokens.join(' OR ')})`);
}
if (fields.not) q.push(...fields.not.split(/\s+/).filter(Boolean).map(w=>`-${w}`));
if (fields.hash) q.push(...fields.hash.split(/\s+/).filter(Boolean).map(h=>`#${h.replace(/^#/,'')}`));
if (fields.lang) q.push(`lang:${fields.lang}`);
const createAccountQuery = (inputId, operator) => {
const value = document.getElementById(inputId).value.trim();
if (!value) return null;
const isExclude = document.getElementById(`${inputId}-exclude`).checked;
const terms = value.split(/\s+/).filter(Boolean);
if (isExclude) return terms.map(t=>`-${operator}${t.replace(/^@/,'')}`).join(' ');
const processed = terms.map(t=>`${operator}${t.replace(/^@/,'')}`);
return processed.length>1 ? `(${processed.join(' OR ')})` : processed[0];
};
const fromQ = createAccountQuery('adv-from-user','from:'); if (fromQ) q.push(fromQ);
const toQ = createAccountQuery('adv-to-user','to:'); if (toQ) q.push(toQ);
const mentionQ = createAccountQuery('adv-mentioning','@'); if (mentionQ) q.push(mentionQ);
if (fields.min_replies) q.push(`min_replies:${fields.min_replies}`);
if (fields.min_faves) q.push(`min_faves:${fields.min_faves}`);
if (fields.min_retweets) q.push(`min_retweets:${fields.min_retweets}`);
if (fields.since) q.push(`since:${fields.since}`);
if (fields.until) q.push(`until:${fields.until}`);
const addFilter = (type, mapping) => {
const include = document.getElementById(`adv-filter-${type}-include`).checked;
const exclude = document.getElementById(`adv-filter-${type}-exclude`).checked;
if (include) q.push(mapping);
if (exclude) q.push(`-${mapping}`);
};
addFilter('verified','is:verified');
addFilter('links','filter:links');
addFilter('images','filter:images');
addFilter('videos','filter:videos');
if (fields.replies) {
const replyMap = { include:'include:replies', only:'filter:replies', exclude:'-filter:replies' };
if (replyMap[fields.replies]) q.push(replyMap[fields.replies]);
}
return q.join(' ');
};
const parseQueryAndApplyToModal = (query) => {
if (isUpdating) return; isUpdating = true;
const formEl = document.getElementById('advanced-search-form');
formEl.reset();
try {
const st = loadExcludeFlags();
const nameEl = document.getElementById('adv-exclude-hit-name');
const handleEl = document.getElementById('adv-exclude-hit-handle');
if (nameEl) { nameEl.checked = nameEl.defaultChecked = !!st.name; }
if (handleEl) { handleEl.checked = handleEl.defaultChecked = !!st.handle; }
} catch (_) {}
// クエリを正規化(スマート引用・%xx・空白)
const rawNorm = normalizeForParse(query || '');
// トップレベル OR を先に見る(純粋 OR / ハイブリッド OR の切り分け)
const orParts = splitTopLevelOR(rawNorm);
if (orParts && isPureORQuery(rawNorm)) {
// 引用を 1 語として数えるトークナイザ
const tokenize = (s) => tokenizeQuotedWords(s).filter(Boolean);
const tokenized = orParts.map(p => tokenize(p));
const allAreSingle = tokenized.every(ts => ts.length === 1);
if (allAreSingle) {
// ① 純粋 OR:全部 any に入れる(exact/all は空)→ 早期 return
document.getElementById('adv-any-words').value = orParts.join(' ');
isUpdating = false;
return;
}
const head = tokenized[0];
const rest = tokenized.slice(1);
const restAllSingle = rest.every(ts => ts.length === 1);
if (head.length >= 2 && restAllSingle) {
// ② ハイブリッド OR:
// - 先頭片の「最後のトークン」→ OR 集合
// - 先頭片の「それ以外」 → all(必須語)
// - 後続片(単一トークン) → OR 集合
const required = head.slice(0, -1);
const orTokens = [head[head.length - 1], ...rest.map(ts => ts[0])];
document.getElementById('adv-all-words').value = required.join(' ');
document.getElementById('adv-any-words').value = orTokens.join(' ');
// exact は空のまま(引用は any 側へ)
isUpdating = false;
return;
}
// それ以外(レア)は通常パースにフォールバック
}
// ここから通常パース(rawNorm をベース)
let q = ` ${rawNorm} `;
// 言語や演算子は先に抜く(引用の前後どちらでもOKだが、先にやると視覚的に期待通り)
const extract = (regex, cb) => {
let m;
while ((m = regex.exec(q)) !== null) {
cb(m[1].trim());
q = q.replace(m[0], ' ');
regex.lastIndex = 0;
}
};
// 言語
extract(/\blang:([^\s()"]+)/gi, v => { document.getElementById('adv-lang').value = v.toLowerCase(); });
// ハッシュタグ
extract(/\s#([^\s)"]+)/g, v => {
const el = document.getElementById('adv-hashtag');
el.value = (el.value + ' ' + v).trim();
});
// 最小エンゲージメント・期間
extract(/\bmin_replies:(\d+)\b/gi, v => document.getElementById('adv-min-replies').value = v);
extract(/\bmin_faves:(\d+)\b/gi, v => document.getElementById('adv-min-faves').value = v);
extract(/\bmin_retweets:(\d+)\b/gi,v => document.getElementById('adv-min-retweets').value= v);
extract(/\bsince:(\d{4}-\d{2}-\d{2})\b/gi, v => document.getElementById('adv-since').value = v);
extract(/\buntil:(\d{4}-\d{2}-\d{2})\b/gi, v => document.getElementById('adv-until').value = v);
// フィルタ
const filterMap = { 'is:verified':'verified', 'filter:links':'links', 'filter:images':'images', 'filter:videos':'videos' };
Object.entries(filterMap).forEach(([op,id])=>{
const r = new RegExp(`\\s(-?)${op.replace(':','\\:')}\\b`, 'gi');
q = q.replace(r, (m, neg) => {
document.getElementById(`adv-filter-${id}-${neg ? 'exclude' : 'include'}`).checked = true;
return ' ';
});
});
// 返信
if (/\binclude:replies\b/i.test(q)) { document.getElementById('adv-replies').value='include'; q=q.replace(/\binclude:replies\b/ig,' '); }
else if (/\bfilter:replies\b/i.test(q)) { document.getElementById('adv-replies').value='only'; q=q.replace(/\bfilter:replies\b/ig,' '); }
else if (/\b-filter:replies\b/i.test(q)) { document.getElementById('adv-replies').value='exclude'; q=q.replace(/\b-filter:replies\b/ig,' '); }
// アカウント演算子
const parseAccountField = (inputId, operator) => {
const exclOp = `-${operator}`;
const values = [];
// 除外
const reEx = new RegExp(`\\s${exclOp.replace(/[-:]/g,'\\$&')}([^\\s()"]+)`, 'gi');
q = q.replace(reEx, (m, u) => { values.push(u); document.getElementById(`${inputId}-exclude`).checked = true; return ' '; });
// 包含(括弧 OR グループ)
const reGroup = new RegExp(`\\((?:${operator.replace(':','\\:')}([^\\s()"]+))(?:\\s+OR\\s+${operator.replace(':','\\:')}([^\\s()"]+))*\\)`, 'gi');
q = q.replace(reGroup, (m) => {
m.replace(new RegExp(`${operator.replace(':','\\:')}([^\\s()"]+)`, 'gi'), (_m, u) => { values.push(u); return _m; });
return ' ';
});
// 単体
const reIn = new RegExp(`\\s(?!-)${operator.replace(':','\\:')}([^\\s()"]+)`, 'gi');
q = q.replace(reIn, (m, u) => { values.push(u); return ' '; });
if (values.length) document.getElementById(inputId).value = [...new Set(values)].join(' ');
};
parseAccountField('adv-from-user','from:');
parseAccountField('adv-to-user','to:');
parseAccountField('adv-mentioning','@');
// ▼ 括弧内 OR は any へ(**先にやる**。引用は壊さない、グループ丸ごと除去)
{
const groups = q.match(/\((?:[^()"]+|"[^"]*")+\)/g); // 引用対応の簡易版
if (groups) {
const tokens = groups
.map(g => g.slice(1, -1)) // (...) → 中身
.flatMap(s => s.split(/\s+OR\s+/i))
.map(s => s.trim())
.filter(Boolean);
if (tokens.length) {
const el = document.getElementById('adv-any-words');
el.value = (el.value ? el.value + ' ' : '') + tokens.join(' ');
}
// グループは丸ごと削る:以後の引用抽出に巻き込ませない
q = q.replace(/\((?:[^()"]+|"[^"]*")+\)/g, ' ');
}
}
// ▼ 引用フレーズ(括弧の外だけが残っている)。exact は最初の1件のみ
{
let exactSet = false;
q = q.replace(/"([^"]+)"/g, (_m, p1) => {
if (!exactSet) {
document.getElementById('adv-exact-phrase').value = p1.trim();
exactSet = true;
}
return ' ';
});
}
// 除外語
const nots = (q.match(/\s-\S+/g) || []).map(w => w.trim().slice(1));
if (nots.length) document.getElementById('adv-not-words').value = nots.join(' ');
q = q.replace(/\s-\S+/g,' ');
document.getElementById('adv-all-words').value =
q.trim().split(/\s+/).filter(Boolean).join(' ');
isUpdating = false;
};
const syncFromModalToSearchBox = () => {
if (isUpdating) return; isUpdating=true;
const finalQuery = buildQueryStringFromModal();
const si = getActiveSearchInput();
if (si){ syncControlledInput(si, finalQuery); }
isUpdating=false;
updateSaveButtonState();
};
const syncFromSearchBoxToModal = STATE_SYNC.parseFromSearchToModal;
const showToast = (msg) => {
toastEl.textContent = msg;
toastEl.classList.add('show');
setTimeout(()=> toastEl.classList.remove('show'), 1500);
};
const loadSecret = () => { try { return kv.get(SECRET_KEY, '0') === '1'; } catch(_) { return false; } };
const saveSecret = (on) => { try { kv.set(SECRET_KEY, on ? '1' : '0'); } catch(_) {} };
const applySecretBtn = () => {
const on = loadSecret();
secretBtn.classList.toggle('on', on);
secretBtn.classList.toggle('off', !on);
secretBtn.title = i18n.t(on ? 'secretOn' : 'secretOff');
secretStateEl.textContent = on ? 'ON' : 'OFF';
};
secretBtn.addEventListener('click', (e)=>{
e.stopPropagation();
const on = !loadSecret();
saveSecret(on);
applySecretBtn();
showToast(i18n.t(on ? 'secretOn' : 'secretOff'));
});
applySecretBtn();
const migrateList = (list) => Array.isArray(list) ? list.map(it => ({ id:it.id||uid(), q:it.q||'', ts:it.ts||Date.now(), pf:!!it.pf, lf:!!it.lf })) : [];
const recordHistory = (q, pf, lf) => {
if (!q || loadSecret()) return;
const now = Date.now();
if (lastHistory.q === q && lastHistory.pf === pf && lastHistory.lf === lf && (now - lastHistory.ts) < 3000) return;
lastHistory.q = q; lastHistory.pf = pf; lastHistory.lf = lf; lastHistory.ts = now;
const listRaw = loadJSON(HISTORY_KEY, []);
const list = migrateList(listRaw);
const idx = list.findIndex(it => it.q === q && !!it.pf === !!pf && !!it.lf === !!lf);
if (idx === 0) {
list[0].ts = now;
} else if (idx > 0) {
const [item] = list.splice(idx, 1);
item.ts = now;
list.unshift(item);
} else {
list.unshift({ id: uid(), q, pf: !!pf, lf: !!lf, ts: now });
if (list.length > 50) list.length = 50;
}
saveJSON(HISTORY_KEY, list);
renderHistory();
};
const deleteHistory = (id) => {
const listRaw = loadJSON(HISTORY_KEY, []);
const list = migrateList(listRaw);
const next = list.filter(it => it.id !== id);
saveJSON(HISTORY_KEY, next);
renderHistory();
showToast(i18n.t('toastDeleted'));
};
const clearAllHistory = () => {
if (!confirm(i18n.t('confirmClearHistory'))) return;
saveJSON(HISTORY_KEY, []);
renderHistory();
showToast(i18n.t('toastDeleted'));
};
const addSaved = (q, pf, lf) => {
const listRaw = loadJSON(SAVED_KEY, []);
const list = migrateList(listRaw);
if (list.some(it => it.q === q && !!it.pf === !!pf && !!it.lf === !!lf)) {
updateSaveButtonState();
return;
}
const item = { id: uid(), q, pf: !!pf, lf: !!lf, ts: Date.now() };
list.push(item);
saveJSON(SAVED_KEY, list);
renderSaved();
showToast(i18n.t('toastSaved'));
updateSaveButtonState();
};
const deleteSaved = (id) => {
const listRaw = loadJSON(SAVED_KEY, []);
const list = migrateList(listRaw);
const next = list.filter(it => it.id !== id);
saveJSON(SAVED_KEY, next);
renderSaved();
showToast(i18n.t('toastDeleted'));
updateSaveButtonState();
};
const fmtTime = (ts) => { try { return new Date(ts).toLocaleString(); } catch { return ''; } };
const updateSaveButtonState = () => {
const q = buildQueryStringFromModal().trim();
const {pf, lf} = readScopesFromControls();
const saved = migrateList(loadJSON(SAVED_KEY, []));
const exists = !!q && saved.some(it => it.q === q && !!it.pf === !!pf && !!it.lf === !!lf);
saveButton.disabled = !q || exists;
saveButton.textContent = i18n.t(exists ? 'buttonSaved' : 'buttonSave');
saveButton.setAttribute('aria-disabled', saveButton.disabled ? 'true' : 'false');
};
const activateTab = (name) => {
tabButtons.forEach(b => b.classList.toggle('active', b.dataset.tab === name));
[tabSearch, tabHistory, tabSaved, tabLists, tabAccounts, tabMute]
.forEach((el) => el.classList.toggle('active', el.id === `adv-tab-${name}`));
footerEl.style.display = (name === 'search') ? '' : 'none';
if (name === 'history') renderHistory();
if (name === 'saved') renderSaved();
if (name === 'lists') renderLists();
if (name === 'accounts') renderAccounts();
if (name === 'mute') renderMuted();
if (name === 'search') updateSaveButtonState();
};
tabButtons.forEach(btn => btn.addEventListener('click', (e)=> { e.preventDefault(); activateTab(btn.dataset.tab); }));
const scopeChipsHTML = (pf, lf) => {
const chips = [];
if (pf) chips.push(`<span class="adv-chip scope" role="note">${i18n.t('chipFollowing')}</span>`);
if (lf) chips.push(`<span class="adv-chip scope" role="note">${i18n.t('chipNearby')}</span>`);
return chips.join('');
};
const historyEmptyEl = document.getElementById('adv-history-empty');
const historyListEl = document.getElementById('adv-history-list');
const renderHistory = () => {
const list = migrateList(loadJSON(HISTORY_KEY, []));
historyListEl.innerHTML = '';
historyEmptyEl.textContent = list.length ? '' : i18n.t('emptyHistory');
list.forEach(item => {
const row = document.createElement('div');
row.className = 'adv-item';
row.dataset.id = item.id;
row.innerHTML = `
<div class="adv-item-main">
<div class="adv-item-title">${escapeHTML(item.q)}</div>
<div class="adv-item-sub">
<span>${fmtTime(item.ts)}</span>
${scopeChipsHTML(!!item.pf, !!item.lf)}
</div>
</div>
<div class="adv-item-actions">
<button class="adv-chip primary" data-action="run">${i18n.t('run')}</button>
<button class="adv-chip danger" data-action="delete">${i18n.t('delete')}</button>
</div>
`;
row.querySelector('[data-action="run"]').addEventListener('click', () => {
parseQueryAndApplyToModal(item.q);
applyScopesToControls({ pf: !!item.pf, lf: !!item.lf });
activateTab('search');
executeSearch({ pf: item.pf, lf: item.lf });
});
row.querySelector('[data-action="delete"]').addEventListener('click', () => {
deleteHistory(item.id);
});
historyListEl.appendChild(row);
});
};
historyClearAllBtn.addEventListener('click', clearAllHistory);
const savedEmptyEl = document.getElementById('adv-saved-empty');
const savedListEl = document.getElementById('adv-saved-list');
const renderSaved = () => {
const list = migrateList(loadJSON(SAVED_KEY, []));
savedListEl.innerHTML = '';
savedEmptyEl.textContent = list.length ? '' : i18n.t('emptySaved');
list.forEach((item) => {
const row = document.createElement('div');
row.className = 'adv-item';
row.draggable = true;
row.dataset.id = item.id;
row.innerHTML = `
<div class="adv-item-handle" title="Drag">≡</div>
<div class="adv-item-main">
<div class="adv-item-title">${escapeHTML(item.q)}</div>
<div class="adv-item-sub">
<span>${fmtTime(item.ts)}</span>
${scopeChipsHTML(!!item.pf, !!item.lf)}
</div>
</div>
<div class="adv-item-actions">
<button class="adv-chip primary" data-action="run">${i18n.t('run')}</button>
<button class="adv-chip danger" data-action="delete">${i18n.t('delete')}</button>
</div>
`;
row.querySelector('[data-action="run"]').addEventListener('click', ()=>{
parseQueryAndApplyToModal(item.q);
applyScopesToControls({pf:!!item.pf, lf:!!item.lf});
activateTab('search');
executeSearch({pf:item.pf, lf:item.lf});
});
row.querySelector('[data-action="delete"]').addEventListener('click', ()=> deleteSaved(item.id));
row.addEventListener('dragstart', (ev) => {
row.classList.add('dragging');
ev.dataTransfer.setData('text/plain', item.id);
ev.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => row.classList.remove('dragging'));
row.addEventListener('dragover', (ev) => {
ev.preventDefault();
const dragging = savedListEl.querySelector('.dragging');
if (!dragging) return;
const after = getDragAfterElement(savedListEl, ev.clientY);
if (after == null) savedListEl.appendChild(dragging);
else savedListEl.insertBefore(dragging, after);
});
savedListEl.appendChild(row);
});
updateSaveButtonState();
};
const getDragAfterElement = (container, y) => {
const els = [...container.querySelectorAll('.adv-item:not(.dragging)')];
let closest = { offset: Number.NEGATIVE_INFINITY, element: null };
for (const el of els) {
const box = el.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
closest = { offset, element: el };
}
}
return closest.element;
};
savedListEl.addEventListener('drop', () => {
const orderIds = [...savedListEl.querySelectorAll('.adv-item')].map(el=>el.dataset.id);
const list = migrateList(loadJSON(SAVED_KEY, []));
const idToItem = Object.fromEntries(list.map(x=>[x.id, x]));
const reordered = orderIds.map(id => idToItem[id]).filter(Boolean);
saveJSON(SAVED_KEY, reordered);
showToast(i18n.t('toastReordered'));
updateSaveButtonState();
});
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function escapeAttr(s) {
return String(s).replace(/[&<>"']/g, c => (
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]
));
}
function parseSearchTokens(queryOrURL) {
// クエリ文字列を決定
let q = '';
try {
if (queryOrURL) {
q = String(queryOrURL);
} else {
const u = new URL(location.href);
q = u.searchParams.get('q') || '';
}
} catch (_) {}
// UI 側の入力やモーダルからもフォールバック取得
if (!q) {
const si = typeof getActiveSearchInput === 'function' ? getActiveSearchInput() : null;
if (si?.value) q = si.value;
}
if (!q && typeof buildQueryStringFromModal === 'function') {
q = buildQueryStringFromModal() || '';
}
// 前後にスペース追加(演算子抽出や -word 検出を安定させる)
q = ' ' + q + ' ';
// 1) 除外語(-xxx)を拾って控えておく(のちに include から引く)
const NEG = [];
(q.match(/\s-\S+/g) || []).forEach(w => NEG.push(w.trim().slice(1)));
// 2) 引用フレーズを抽出(抽出したものは q から除去)
const phrases = [];
q = q.replace(/"([^"]+)"/g, (_m, p1) => {
phrases.push((p1 || '').trim());
return ' ';
});
// 3) ハッシュタグを抽出(抽出したものは q から除去)
const hashtags = [];
q = q.replace(/\s#([^\s)"]+)/g, (_m, p1) => {
hashtags.push('#' + p1);
return ' ';
});
// 4) from:/to:/@ に明示されたユーザー(除外ではないもの)を収集
// (後の「ハンドル名のみヒット除外」の“例外”判定に使う)
const opUsers = new Set();
q.replace(/\s(?:from:|to:|@)([^\s()]+)/g, (m, user) => {
// 先頭が " -" なら除外指定なのでスキップ
if (!m.startsWith(' -')) opUsers.add(String(user || '').toLowerCase());
return m;
});
// 5) 言語/各種フィルタ/日付・数値演算子・アカウント演算子はクエリから除去
q = q
.replace(/\s(?:lang|min_replies|min_faves|min_retweets|since|until):[^\s]+/g, ' ')
.replace(/\s(?:is:verified|filter:(?:links|images|videos|replies)|include:replies|-filter:replies)\b/gi, ' ')
.replace(/\s(?:from:|to:|@)[^\s()]+/g, ' ');
// 6) 丸括弧(半角/全角)と OR は “語” になる前に取り除く
q = q.replace(/[()()]/g, ' ').replace(/\bOR\b/gi, ' ');
// 7) 残ったものを単語に分割
let words = q
.split(/\s+/)
.map(s => s.trim())
.filter(Boolean);
// 8) 語の前後の記号を剥がす(# はハッシュタグで別管理なので先頭 # は温存)
// ※ Unicode プロパティエスケープ使用(要 "u" フラグ対応ブラウザ。ダメなら簡易版に差し替え可)
const trimPunctKeepHash = (s) => {
if (!s) return '';
if (s.startsWith('#')) return s; // # は温存
return s
.replace(/^[\p{P}\p{S}]+/gu, '') // 先頭の句読点/記号
.replace(/[\p{P}\p{S}]+$/gu, ''); // 末尾の句読点/記号
};
words = words.map(trimPunctKeepHash).filter(Boolean);
// 9) 正規化(小文字化)と除外語の差し引き
const normalize = (s) => String(s || '').toLowerCase();
const NEGnorm = NEG.map(normalize);
const includeTerms = new Set(
[...phrases, ...hashtags, ...words]
.map(trimPunctKeepHash) // 念のため二度がけ(phrases/hashtags も整形)
.map(normalize)
.filter(s => s && !NEGnorm.includes(s))
);
// 10) ハッシュタグ集合("#xxx" の小文字化)
const hashtagSet = new Set(
hashtags
.map(h => h.startsWith('#') ? h : ('#' + h))
.map(normalize)
);
return { includeTerms, opUsers, hashtagSet };
}
function pickTweetFields(article) {
const body = article.querySelector('[data-testid="tweetText"]')?.innerText || '';
let disp = '';
try {
const nameBlock = article.querySelector('[data-testid="User-Name"] a[href^="/"] div[dir="ltr"]');
disp = nameBlock?.innerText || '';
} catch(_) {}
let handle = '';
try {
const handleEl = article.querySelector('[data-testid="User-Name"] a[href^="/"] div[dir="ltr"] span')
|| article.querySelector('[data-testid="User-Name"] a[href^="/"] .r-1qd0xha');
handle = handleEl?.innerText || '';
} catch(_) {}
const replyHandles = Array.from(
article.querySelectorAll('a[href^="/"]')
)
.filter(a => {
const txt = (a.textContent || '').trim();
if (!txt.startsWith('@')) return false;
return !a.closest('[data-testid="tweetText"]');
})
.map(a => (a.textContent || '').trim())
.filter(Boolean);
return { body, disp, handle, replyHandles };
}
function getTweetCell(article) {
return article.closest('[data-testid="cellInnerDiv"]') || article;
}
function shouldHideTweetByNameHandle(article, flags, tokens) {
const { includeTerms, opUsers, hashtagSet } = tokens || {};
if (includeTerms.size === 0) return false;
const { body, disp, handle, replyHandles } = pickTweetFields(article);
// 正規化
const normSpace = (s) => String(s || '')
.toLowerCase()
.replace(/[_.\-]+/g, ' ') // 区切りはスペース化
.replace(/\s+/g, ' ')
.trim();
const normId = (s) => String(s || '').replace(/^@/, '').toLowerCase();
const stripNonAlnum = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9\u00c0-\u024f]+/gi, '');
const textBody = normSpace(body);
const textName = normSpace(disp);
// ハンドル集合
const handlesRaw = [handle, ...replyHandles].map(normId).filter(Boolean); // ex: ["elon_musk", "x_ai_lab"]
const handlesSpace = handlesRaw.map(normSpace); // ex: ["elon musk", "x ai lab"]
const handlesTok = handlesSpace.map(h => h.split(' ').filter(Boolean)); // ex: [["elon","musk"], ["x","ai","lab"]]
const handlesTight = handlesRaw.map(stripNonAlnum); // ex: ["elonmusk","xailab"]
// 本文に出ていれば表示確定(“名前/ハンドルのみヒット”ではない)
for (const term of includeTerms) {
const t = normSpace(term);
if (t && textBody.includes(t)) return false;
}
// --- 表示名ヒットの除外(短語ガードつき) ---
if (flags.name) {
for (const term of includeTerms) {
const t = normSpace(term);
if (!t) continue;
// 2文字以下の英字のみはスキップ(過剰除外防止)
if (/^[a-z]{1,2}$/.test(t)) continue;
if (textName.includes(t)) return true;
}
}
// --- @ユーザー名ヒットの除外(演算子例外 + 短語ガード + 境界意識) ---
if (flags.handle) {
for (const term of includeTerms) {
const raw = String(term || '');
// ハッシュタグは("#"付き or 事前解析でタグ認定)なら常に除外
const rawLC = raw.trim().toLowerCase();
if (rawLC.startsWith('#') || (hashtagSet && hashtagSet.has(rawLC.startsWith('#') ? rawLC : '#' + rawLC))) {
continue;
}
const bare = raw.replace(/^@/, '').toLowerCase();
// 明示(from:/to:/@)している語は例外
if (opUsers.has(bare)) continue;
const tSpace = normSpace(raw); // "john doe" / "musk"
const tTight = stripNonAlnum(raw); // "johndoe" / "musk"
// ── 短語ガード:英字だけで長さ < 3 の単語は無視(例: ai, it, go)
if (/^[a-z0-9]+$/.test(tTight) && tTight.length < 3) continue;
// 1) トークン(_ 分割)一致 or 連続トークン一致
if (tSpace) {
const tTokens = tSpace.split(' ').filter(Boolean); // ["john","doe"] or ["musk"]
for (const hTokens of handlesTok) {
if (tTokens.length === 1) {
// 単語一致:完全一致のみ(部分一致は暴発するためNG)
if (hTokens.some(tok => tok === tTokens[0])) return true;
} else {
// 連続トークン一致:"john doe" → ["john","doe"] が連続で出るか
for (let i = 0; i + tTokens.length <= hTokens.length; i++) {
let ok = true;
for (let j = 0; j < tTokens.length; j++) {
if (hTokens[i + j] !== tTokens[j]) { ok = false; break; }
}
if (ok) return true;
}
}
}
}
// 2) 非英数字除去の“完全一致”のみ(部分一致NG)
if (tTight && handlesTight.some(h => h === tTight)) return true;
}
}
return false;
}
function scanAndFilterTweets() {
try {
const flags = {
name: document.getElementById('adv-exclude-hit-name')?.checked ?? true,
handle: document.getElementById('adv-exclude-hit-handle')?.checked ?? true,
};
const masterOn = loadMuteMaster();
const muted = loadMuted();
const hasMute = masterOn && muted.length > 0; // ← masterOn を噛ませる
const enabledMuted = hasMute ? muted.filter(m => m.enabled !== false) : [];
const muteCI = enabledMuted.length ? new Set(enabledMuted.filter(m => !m.cs).map(m => m.word.toLowerCase())) : new Set();
const muteCS = enabledMuted.length ? enabledMuted.filter(m => m.cs).map(m => m.word) : [];
if (!flags.name && !flags.handle && !hasMute) return;
const tokens = parseSearchTokens();
const list = document.querySelectorAll('article[data-testid="tweet"]');
for (const art of list) {
const cell = getTweetCell(art);
const hideByNameHandle = shouldHideTweetByNameHandle(art, flags, tokens);
let hideByMute = false;
if (hasMute) {
const body = (art.querySelector('[data-testid="tweetText"]')?.innerText || '');
const bodyCI = body.toLowerCase();
for (const w of muteCI) { if (w && bodyCI.includes(w)) { hideByMute = true; break; } }
if (!hideByMute) {
for (const w of muteCS) { if (w && body.includes(w)) { hideByMute = true; break; } }
}
}
const reasons = [];
if (hideByMute) reasons.push('muted_word');
if (hideByNameHandle) reasons.push('name_handle_only');
if (reasons.length > 0) {
cell.setAttribute('data-adv-hidden', reasons.join(' '));
} else {
cell.removeAttribute('data-adv-hidden');
}
}
cleanupAdjacentSeparators();
} catch (e) {
console.error('scanAndFilterTweets failed', e);
}
}
function cleanupAdjacentSeparators() {
// (既存のまま:必要ならここに区切り線セルの非表示処理)
}
const executeSearch = async (scopesOverride) => {
const finalQuery = buildQueryStringFromModal().trim();
if (!finalQuery) return;
const scopes = scopesOverride || readScopesFromControls();
const params = new URLSearchParams({ q: finalQuery, src: 'typed_query' });
if (scopes.pf) params.set('pf', 'on');
if (scopes.lf) params.set('lf', 'on');
const targetPath = `/search?${params.toString()}`;
// 1) まず検索ボックスが見つかれば React state を更新して見た目と中身を同調
const si = getActiveSearchInput?.();
if (si) {
syncControlledInput(si, finalQuery);
}
// 2) ルートに関わらず常に SPA 遷移で検索を確定
recordHistory(finalQuery, scopes.pf, scopes.lf);
const before = location.href;
try {
await spaNavigate(targetPath);
} catch {
// SPA 失敗時のフォールバック
location.assign(`https://x.com${targetPath}`);
return;
}
// 3) 遷移が成功したら余計な replaceState はしない(URL とルーター state の乖離を避ける)
// もしフォーカスが残っていたら外す
try { si && si.blur(); } catch {}
// 4) 念のため遷移確認
try { await waitForUrlChange(before, 1500); } catch {}
};
const onScopeChange = async () => {
const si = getActiveSearchInput();
const q = (() => {
if (si && si.value && si.value.trim()) return si.value.trim();
return buildQueryStringFromModal().trim();
})();
const { pf, lf } = readScopesFromControls();
const params = new URLSearchParams({ src: 'typed_query' });
if (q) params.set('q', q);
if (pf) params.set('pf', 'on');
if (lf) params.set('lf', 'on');
// 入力側を先に最新化
if (si) syncControlledInput(si, q);
recordHistory(q, pf, lf);
const path = `/search?${params.toString()}`;
try {
await spaNavigate(path);
} catch {
location.assign(`https://x.com${path}`);
}
};
accountScopeSel.addEventListener('change', onScopeChange);
locationScopeSel.addEventListener('change', onScopeChange);
const setupModalDrag = () => {
const header = modal.querySelector('.adv-modal-header');
let dragging=false, offset={x:0,y:0};
header.addEventListener('mousedown', e=>{
if (e.target.matches('button,a') && !e.target.classList.contains('adv-secret-btn')) return;
dragging=true;
const rect = modal.getBoundingClientRect();
modal.style.right=modal.style.bottom='auto';
modal.style.left=`${rect.left}px`; modal.style.top=`${rect.top}px`;
offset = { x:e.clientX-rect.left, y:e.clientY-rect.top };
document.body.classList.add('adv-dragging');
});
document.addEventListener('mousemove', e=>{
if(!dragging) return;
let nx = e.clientX - offset.x, ny = e.clientY - offset.y;
nx=Math.max(0,Math.min(nx,window.innerWidth - modal.offsetWidth));
ny=Math.max(0,Math.min(ny,window.innerHeight - modal.offsetHeight));
modal.style.left=`${nx}px`; modal.style.top=`${ny}px`;
});
document.addEventListener('mouseup', ()=>{
if(dragging){ dragging=false; document.body.classList.remove('adv-dragging'); saveModalRelativeState(); }
});
};
const setupModalResize = () => {
const MIN_W = 300, MIN_H = 240;
const MARGIN = 10;
let resizing = null;
const onPointerDown = (e) => {
const h = e.target.closest('.adv-resizer');
if (!h) return;
e.preventDefault();
const dir = h.dataset.dir;
const r = modal.getBoundingClientRect();
modal.style.right = 'auto';
modal.style.bottom= 'auto';
modal.style.left = `${r.left}px`;
modal.style.top = `${r.top}px`;
resizing = {
dir,
startX: e.clientX,
startY: e.clientY,
startLeft: r.left,
startTop: r.top,
startW: r.width,
startH: r.height
};
try { h.setPointerCapture(e.pointerId); } catch(_) {}
document.body.classList.add('adv-dragging');
};
const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
const onPointerMove = (e) => {
if (!resizing) return;
const dx = e.clientX - resizing.startX;
const dy = e.clientY - resizing.startY;
let newLeft = resizing.startLeft;
let newTop = resizing.startTop;
let newW = resizing.startW;
let newH = resizing.startH;
const dir = resizing.dir;
if (dir.includes('e')) newW = resizing.startW + dx;
if (dir.includes('w')) { newW = resizing.startW - dx; newLeft = resizing.startLeft + dx; }
if (dir.includes('s')) newH = resizing.startH + dy;
if (dir.includes('n')) { newH = resizing.startH - dy; newTop = resizing.startTop + dy; }
const maxW = window.innerWidth - 2*MARGIN;
const maxH = window.innerHeight - 2*MARGIN;
newW = clamp(newW, MIN_W, maxW);
newH = clamp(newH, MIN_H, maxH);
newLeft = clamp(newLeft, MARGIN, Math.max(MARGIN, window.innerWidth - newW - MARGIN));
newTop = clamp(newTop, MARGIN, Math.max(MARGIN, window.innerHeight - newH - MARGIN));
modal.style.left = `${Math.round(newLeft)}px`;
modal.style.top = `${Math.round(newTop)}px`;
modal.style.width = `${Math.round(newW)}px`;
modal.style.height = `${Math.round(newH)}px`;
};
const onPointerUp = (e) => {
if (!resizing) return;
document.body.classList.remove('adv-dragging');
try { e.target.releasePointerCapture?.(e.pointerId); } catch(_) {}
resizing = null;
saveModalRelativeState();
};
modal.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
/* ========= Accounts storage & UI ========= */
const ACCOUNTS_KEY = 'advAccounts_v1';
const migrateAccounts = (list) =>
Array.isArray(list)
? list
.map(it => ({
id: it.id || uid(),
handle: (it.handle || '').replace(/^@/, '').trim(),
name: (it.name || '').trim(),
avatar: it.avatar || '',
ts: it.ts || Date.now(),
}))
.filter(it => it.handle)
: [];
const loadAccounts = () => migrateAccounts(loadJSON(ACCOUNTS_KEY, []));
const saveAccounts = (arr) => saveJSON(ACCOUNTS_KEY, migrateAccounts(arr));
// 追加 or 更新(既存があれば name / avatar 差分のみ更新)
const addAccount = ({ handle, name='', avatar='' }) => {
const h = (handle || '').replace(/^@/, '').trim();
if (!h) return 'empty';
const list = loadAccounts();
const ix = list.findIndex(x => x.handle.toLowerCase() === h.toLowerCase());
if (ix >= 0) {
let changed = false;
if (name && name !== list[ix].name) { list[ix].name = name; changed = true; }
if (avatar && avatar !== list[ix].avatar) { list[ix].avatar = avatar; changed = true; }
if (changed) {
list[ix].ts = Date.now();
saveAccounts(list);
renderAccounts();
return 'updated';
}
return 'exists';
}
list.unshift({ id: uid(), handle: h, name, avatar, ts: Date.now() });
saveAccounts(list);
renderAccounts();
return 'ok';
};
// 既存アカウントがある場合だけ name / avatar を更新(未登録なら何もしない)
const updateAccountIfExists = ({ handle, name='', avatar='' }) => {
const h = (handle || '').replace(/^@/, '').trim();
if (!h) return 'empty';
const list = loadAccounts();
const ix = list.findIndex(x => x.handle.toLowerCase() === h.toLowerCase());
if (ix < 0) return 'not_found';
let changed = false;
if (name && name !== list[ix].name) { list[ix].name = name; changed = true; }
if (avatar && avatar !== list[ix].avatar) { list[ix].avatar = avatar; changed = true; }
if (changed) {
list[ix].ts = Date.now();
saveAccounts(list);
renderAccounts();
return 'updated';
}
return 'unchanged';
};
const deleteAccount = (id) => {
const next = loadAccounts().filter(x => x.id !== id);
saveAccounts(next);
renderAccounts();
showToast(i18n.t('toastDeleted'));
};
const accountsEmptyEl = document.getElementById('adv-accounts-empty');
const accountsListEl = document.getElementById('adv-accounts-list');
function renderAccounts() {
const list = loadAccounts();
accountsListEl.innerHTML = '';
accountsEmptyEl.textContent = list.length ? '' : i18n.t('emptyAccounts');
list.forEach(item => {
const row = document.createElement('div');
row.className = 'adv-item';
row.draggable = true;
row.dataset.id = item.id;
const title = escapeHTML(item.name || `@${item.handle}`);
const sub = escapeHTML(`@${item.handle}`);
row.innerHTML = `
<div class="adv-item-handle" title="Drag">≡</div>
${
item.avatar
? `<a class="adv-item-avatar-link adv-link" href="/${escapeAttr(item.handle)}" title="@${escapeAttr(item.handle)}">
<img class="adv-item-avatar" src="${escapeAttr(item.avatar)}" alt="@${escapeAttr(item.handle)}">
</a>`
: `<a class="adv-item-avatar-link adv-link" href="/${escapeAttr(item.handle)}" title="@${escapeAttr(item.handle)}">
<div class="adv-item-avatar" aria-hidden="true"></div>
</a>`
}
<div class="adv-item-main">
<div class="adv-item-title">
<a class="adv-link" href="/${escapeAttr(item.handle)}" title="@${escapeAttr(item.handle)}">${title}</a>
</div>
<div class="adv-item-sub">
<a class="adv-link" href="/${escapeAttr(item.handle)}">@${escapeHTML(item.handle)}</a>
<span>${fmtTime(item.ts)}</span>
</div>
</div>
<div class="adv-item-actions">
<button class="adv-chip primary" data-action="confirm">${i18n.t('buttonConfirm')}</button>
<button class="adv-chip danger" data-action="delete">${i18n.t('delete')}</button>
</div>
`;
row.querySelector('[data-action="confirm"]').addEventListener('click', (e) => {
// 相対パスで SPA 遷移(Ctrl/⌘ なら新規タブを尊重)
spaNavigate(`/${item.handle}`, { ctrlMeta: e.ctrlKey || e.metaKey });
});
/* アイコン / 表示名 / @handle の <a> を SPA 対応 */
row.querySelectorAll('a.adv-link').forEach(a => {
a.addEventListener('click', (ev) => {
// 修飾あり or 中クリック はデフォルト動作(新規タブ等)
if (ev.defaultPrevented || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button !== 0) return;
ev.preventDefault();
const href = a.getAttribute('href') || `/${item.handle}`;
spaNavigate(href, { ctrlMeta: false });
});
});
row.querySelector('[data-action="delete"]').addEventListener('click', () => deleteAccount(item.id));
row.addEventListener('dragstart', (ev) => {
row.classList.add('dragging');
ev.dataTransfer.setData('text/plain', item.id);
ev.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => row.classList.remove('dragging'));
row.addEventListener('dragover', (ev) => {
ev.preventDefault();
const dragging = accountsListEl.querySelector('.dragging');
if (!dragging) return;
const after = getDragAfterElement(accountsListEl, ev.clientY);
if (after == null) accountsListEl.appendChild(dragging);
else accountsListEl.insertBefore(dragging, after);
});
accountsListEl.appendChild(row);
});
}
accountsListEl?.addEventListener('drop', () => {
const orderIds = [...accountsListEl.querySelectorAll('.adv-item')].map(el => el.dataset.id);
const list = loadAccounts();
const map = Object.fromEntries(list.map(x => [x.id, x]));
const reordered = orderIds.map(id => map[id]).filter(Boolean);
saveAccounts(reordered);
showToast(i18n.t('toastReordered'));
});
function getProfileHandleFromURL(href = location.href) {
try {
const u = new URL(href, location.origin);
const m = u.pathname.match(/^\/([A-Za-z0-9_]{1,50})\/?$/);
return m ? m[1] : '';
} catch { return ''; }
}
// 指定ハンドルのプロフィール領域だけをスコープにして name / avatar を取得
function collectProfileMeta(handle) {
let name = '';
let avatar = '';
try {
const h = String(handle || '').replace(/^@/, '').trim();
// 1) プロフィール領域(表示名)
// ※ グローバルヘッダの自分の名前を拾わないように、最初に [data-testid="UserName"] を基準に限定
const profileRoot =
document.querySelector('[data-testid="UserName"]') ||
document.querySelector('[data-testid="User-Name"]');
if (profileRoot) {
const texts = Array.from(profileRoot.querySelectorAll('span, div[dir="auto"]'))
.map(el => (el.textContent || '').trim())
.filter(Boolean);
// 例: ["みみる@米国株投資", "@mimiru_usstock", ...]
name = texts.find(t => !t.startsWith('@')) || '';
}
// 2) アバター領域をハンドルで限定
// DOM例: <div data-testid="UserAvatar-Container-mimiru_usstock"> ... </div>
let avatarScope = null;
if (h) {
avatarScope = document.querySelector(`[data-testid="UserAvatar-Container-${CSS.escape(h)}"]`);
}
// フォールバック(ハンドル付き data-testid が無い古い/差分レイアウト)
if (!avatarScope) {
// プロフィールのヘッダ右側の塊に限定
avatarScope = profileRoot?.closest('[data-testid="UserProfileHeader_Items"]')?.parentElement
|| profileRoot?.parentElement
|| document;
}
// 2-1) まず <img> 優先
const img = avatarScope.querySelector('img[src*="profile_images"]');
if (img?.src) {
avatar = img.src;
} else {
// 2-2) 背景画像 style="background-image:url(...)" から抽出
// 提示DOMの:
// <div class="... r-1wyyakw ..." style="background-image:url('...')"></div>
const bg = avatarScope.querySelector('[style*="background-image"]');
if (bg) {
const m = String(bg.getAttribute('style') || '').match(/background-image:\s*url\((["']?)(.*?)\1\)/i);
if (m && m[2]) avatar = m[2];
}
}
// バナー(header_photo) を誤検出しないように、ヘッダバナー領域を除外
// (banner は /header_photo へのリンク配下; avatarScope 内に入らない設計だが保険)
if (avatar && /profile_banners\//.test(avatar)) {
avatar = '';
}
} catch {}
return { name, avatar };
}
let profileButtonInstalledFor = '';
function ensureProfileAddButton(force = false) {
const handle = getProfileHandleFromURL();
if (!handle) return;
if (!force && profileButtonInstalledFor === handle) return;
const moreBtn = document.querySelector('button[data-testid="userActions"]');
if (!moreBtn) return;
if (moreBtn.parentElement?.querySelector?.('#adv-add-account-btn')) {
profileButtonInstalledFor = handle;
return;
}
const btn = document.createElement('button');
btn.id = 'adv-add-account-btn';
btn.type = 'button';
// 見た目を完全同期(class も style もコピー)
const syncVisual = (dst, src) => {
dst.className = src.className;
const st = src.getAttribute('style');
if (st !== null) dst.setAttribute('style', st);
// 念のため currentColor 継承
dst.style.color ||= 'inherit';
};
syncVisual(btn, moreBtn);
// 将来のテーマ切替/hover などで X が style/class を書き換えたら追従
const visMo = new MutationObserver(() => syncVisual(btn, moreBtn));
visMo.observe(moreBtn, { attributes: true, attributeFilter: ['class', 'style'] });
btn.setAttribute('aria-label', i18n.t('buttonAddAccount'));
btn.title = i18n.t('buttonAddAccount');
// ▼ 内側の div / svg / span から「class と inline style」を抽出
const innerDiv = moreBtn.querySelector('div[dir="ltr"]') || moreBtn.querySelector('div');
const innerCls = innerDiv?.getAttribute('class') || innerDiv?.classList?.value || '';
const innerStyle = innerDiv?.getAttribute('style') || '';
const svgEl = innerDiv?.querySelector('svg') || moreBtn.querySelector('svg');
const svgCls = svgEl?.getAttribute('class') || svgEl?.classList?.value || '';
const spanEl = innerDiv?.querySelector('span') || moreBtn.querySelector('span');
const spanCls = spanEl?.getAttribute('class') || spanEl?.classList?.value || '';
btn.innerHTML = `
<div dir="ltr" class="${innerCls}" style="${innerStyle}">
<svg viewBox="0 0 24 24" aria-hidden="true" class="${svgCls}" fill="currentColor">
<g><path d="M12 2a5 5 0 110 10 5 5 0 010-10zm-7 18a7 7 0 0114 0v2H5v-2zm14-8h2v2h-2v2h-2v-2h-2v-2h2V8h2v2z"></path></g>
</svg>
<span class="${spanCls}"></span>
</div>
`;
btn.addEventListener('click', () => {
const { name, avatar } = collectProfileMeta(handle);
const ret = addAccount({ handle, name, avatar });
if (ret === 'ok') showToast(i18n.t('toastAccountAdded'));
else if (ret === 'updated') showToast(i18n.t('updated'));
else if (ret === 'exists') showToast(i18n.t('toastAccountExists'));
});
moreBtn.parentElement?.insertBefore(btn, moreBtn);
profileButtonInstalledFor = handle;
// プロフィールに来たタイミングで自動同期
// 未登録は追加しない。既存時のみ差分更新。
try {
const { name, avatar } = collectProfileMeta(handle);
const status = updateAccountIfExists({ handle, name, avatar });
if (status === 'updated') showToast(i18n.t('updated'));
// 'not_found' / 'unchanged' は無通知でOK
} catch {}
}
/* ========= Lists storage & UI ========= */
const LISTS_KEY = 'advLists_v1';
const migrateLists = (list) =>
Array.isArray(list)
? list
.map(it => ({
id: it.id || uid(),
name: (it.name || '').trim(),
url: (it.url || '').trim(),
ts: it.ts || Date.now(),
}))
.filter(it => it.name && it.url)
: [];
const loadLists = () => migrateLists(loadJSON(LISTS_KEY, []));
const saveLists = (arr) => saveJSON(LISTS_KEY, migrateLists(arr));
const addList = ({ name, url }) => {
const nm = (name || '').trim();
let u = (url || '').trim();
if (!nm || !u) return 'empty';
try {
const parsed = new URL(u, location.origin);
if (parsed.origin === location.origin) u = parsed.pathname + parsed.search + parsed.hash;
} catch {}
const list = loadLists();
if (list.some(x => x.url === u)) return 'exists';
list.unshift({ id: uid(), name: nm, url: u, ts: Date.now() });
saveLists(list);
renderLists();
return 'ok';
};
const deleteList = (id) => {
const next = loadLists().filter(x => x.id !== id);
saveLists(next);
renderLists();
showToast(i18n.t('toastDeleted'));
};
const advListsEmptyEl = document.getElementById('adv-lists-empty');
const advListsListEl = document.getElementById('adv-lists-list');
function renderLists() {
const list = loadLists();
advListsListEl.innerHTML = '';
advListsEmptyEl.textContent = list.length ? '' : i18n.t('emptyLists');
list.forEach(item => {
const row = document.createElement('div');
row.className = 'adv-item';
row.draggable = true;
row.dataset.id = item.id;
const title = escapeHTML(item.name);
const sub = escapeHTML(item.url);
row.innerHTML = `
<div class="adv-item-handle" title="Drag">≡</div>
<div class="adv-item-main">
<div class="adv-item-title">
<a class="adv-link" href="${escapeAttr(item.url)}">${title}</a>
</div>
<div class="adv-item-sub">
<a class="adv-link" href="${escapeAttr(item.url)}">${sub}</a>
<span>${fmtTime(item.ts)}</span>
</div>
</div>
<div class="adv-item-actions">
<button class="adv-chip primary" data-action="confirm">${i18n.t('buttonConfirm')}</button>
<button class="adv-chip danger" data-action="delete">${i18n.t('delete')}</button>
</div>
`;
row.querySelector('[data-action="confirm"]').addEventListener('click', (e) => {
spaNavigate(item.url, { ctrlMeta: e.ctrlKey || e.metaKey });
});
row.querySelectorAll('a.adv-link').forEach(a => {
a.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button !== 0) return;
ev.preventDefault();
const href = a.getAttribute('href') || item.url;
spaNavigate(href, { ctrlMeta: false });
});
});
row.querySelector('[data-action="delete"]').addEventListener('click', () => deleteList(item.id));
row.addEventListener('dragstart', (ev) => {
row.classList.add('dragging');
ev.dataTransfer.setData('text/plain', item.id);
ev.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => row.classList.remove('dragging'));
row.addEventListener('dragover', (ev) => {
ev.preventDefault();
const dragging = advListsListEl.querySelector('.dragging');
if (!dragging) return;
const after = getDragAfterElement(advListsListEl, ev.clientY);
if (after == null) advListsListEl.appendChild(dragging);
else advListsListEl.insertBefore(dragging, after);
});
advListsListEl.appendChild(row);
});
}
advListsListEl?.addEventListener('drop', () => {
const orderIds = [...advListsListEl.querySelectorAll('.adv-item')].map(el => el.dataset.id);
const list = loadLists();
const map = Object.fromEntries(list.map(x => [x.id, x]));
const reordered = orderIds.map(id => map[id]).filter(Boolean);
saveLists(reordered);
showToast(i18n.t('toastReordered'));
});
const isListPath = (pathname = location.pathname) => /^\/i\/lists\/\d+\/?$/.test(pathname);
function getListMeta() {
// 1) <title> から取り出し(最優先)
let rawTitle = '';
try { rawTitle = (document.title || '').trim(); } catch (_) {}
// 末尾の " / X" または " / Twitter" を削る
let baseTitle = rawTitle.replace(/\s*\/\s*(X|Twitter)\s*$/i, '').trim();
let name = '';
let m;
// パターンA: "@owner/リスト名"
m = baseTitle.match(/^\s*@([A-Za-z0-9_]{1,50})\/\s*(.+)\s*$/);
if (m) {
name = (m[2] || '').trim();
}
// パターンB: "リスト名 (@owner)"
if (!name) {
m = baseTitle.match(/^\s*(.+?)\s*\(@[A-Za-z0-9_]{1,50}\)\s*$/);
if (m) {
name = (m[1] || '').trim();
}
}
// 余分な引用符 “ ” " ' に対応
if (name) {
name = name.replace(/^[“"'](.+)[”"']$/, '$1').trim();
}
// 2) タイトルで取れない/怪しい時は見出しから拾う(@を含む/長文/ヘルプ文は除外)
if (!name) {
try {
const headingRoot =
document.querySelector('[data-testid="ScrollSnap-ListHeader"]') ||
document.querySelector('[data-testid="primaryColumn"]') ||
document;
const candidates = Array.from(
headingRoot.querySelectorAll('h1[role="heading"], h2[role="heading"], h3[role="heading"]')
)
.flatMap(h => Array.from(h.querySelectorAll('span, div[dir="ltr"], div[dir="auto"]'))
.map(el => (el.textContent || '').trim()))
.filter(Boolean)
// 「@…」はオーナー表記なので除外
.filter(txt => !/^@/.test(txt))
// 長文やヘルプ文(キーボードショートカット系)を弾く
.filter(txt => {
const t = txt.replace(/\s+/g, ' ');
if (t.length > 80) return false;
const NG = ['キーボードショートカット', 'keyboard', 'help', 'ショートカット', 'press', '?'];
return !NG.some(ng => t.toLowerCase().includes(ng.toLowerCase()));
});
if (candidates.length) {
// 一番短い候補(=装飾の少ないタイトルの可能性が高い)
name = candidates.sort((a, b) => a.length - b.length)[0].trim();
}
} catch (_) {}
}
// 3) 最終フォールバック
if (!name) name = '';
// URL は現ページ(SPA対応でクエリ/ハッシュも保持)
const url = location.pathname + location.search + location.hash;
return { name, url };
}
let listButtonInstalledAt = '';
function ensureListAddButton(force = false) {
if (!isListPath()) return;
if (!force && listButtonInstalledAt === location.pathname) return;
const shareBtn = document.querySelector('button[data-testid="share-button"]');
if (!shareBtn) return;
if (shareBtn.parentElement?.querySelector?.('#adv-add-list-btn')) {
listButtonInstalledAt = location.pathname;
return;
}
const btn = document.createElement('button');
btn.id = 'adv-add-list-btn';
btn.type = 'button';
const syncVisual = (dst, src) => {
dst.className = src.className;
const st = src.getAttribute('style');
if (st !== null) dst.setAttribute('style', st);
dst.style.color ||= 'inherit';
};
syncVisual(btn, shareBtn);
const visMo = new MutationObserver(() => syncVisual(btn, shareBtn));
visMo.observe(shareBtn, { attributes: true, attributeFilter: ['class', 'style'] });
btn.setAttribute('aria-label', i18n.t('buttonAddList'));
btn.title = i18n.t('buttonAddList');
const innerDiv = shareBtn.querySelector('div[dir="ltr"]') || shareBtn.querySelector('div');
const innerCls = innerDiv?.getAttribute('class') || innerDiv?.classList?.value || '';
const innerStyle = innerDiv?.getAttribute('style') || '';
const svgEl = innerDiv?.querySelector('svg') || shareBtn.querySelector('svg');
const svgCls = svgEl?.getAttribute('class') || svgEl?.classList?.value || '';
const spanEl = innerDiv?.querySelector('span') || shareBtn.querySelector('span');
const spanCls = spanEl?.getAttribute('class') || spanEl?.classList?.value || '';
btn.innerHTML = `
<div dir="ltr" class="${innerCls}" style="${innerStyle}">
<svg viewBox="0 0 24 24" aria-hidden="true" class="${svgCls}" fill="currentColor">
<g><path d="M12 5c.55 0 1 .45 1 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H6a1 1 0 110-2h5V6c0-.55.45-1 1-1z"></path></g>
</svg>
<span class="${spanCls}"></span>
</div>
`;
btn.addEventListener('click', () => {
const { name, url } = getListMeta();
const ret = addList({ name, url });
if (ret === 'ok') showToast(i18n.t('toastListAdded'));
else if (ret === 'exists') showToast(i18n.t('toastListExists'));
});
// 左隣に設置
shareBtn.parentElement?.insertBefore(btn, shareBtn);
listButtonInstalledAt = location.pathname;
}
const reconcileUI = () => {
const stored = (()=>{ try { return JSON.parse(kv.get(MODAL_STATE_KEY,'{}')); } catch{ return {}; } })();
const desiredVisible = !!stored.visible;
const blocked = isBlockedPath(location.pathname);
if (blocked) {
trigger.style.display = 'none';
} else {
trigger.style.display = '';
applyTriggerStoredPosition();
requestAnimationFrame(keepTriggerInViewport);
}
const shouldShow = (!blocked) && (desiredVisible || manualOverrideOpen);
const wasShown = (modal.style.display === 'flex');
modal.style.display = shouldShow ? 'flex' : 'none';
if (shouldShow) {
applyModalStoredPosition();
requestAnimationFrame(keepModalInViewport);
if (!wasShown) {
syncFromSearchBoxToModal();
applyScopesToControls(readScopesFromURL());
updateSaveButtonState();
}
}
};
trigger.addEventListener('click', () => {
if (trigger.style.display === 'none') return;
const isVisibleNow = modal.style.display === 'flex';
if (isVisibleNow) {
manualOverrideOpen = false;
modal.style.display = 'none';
saveModalRelativeState();
} else {
manualOverrideOpen = true;
modal.style.display = 'flex';
syncFromSearchBoxToModal();
applyScopesToControls(readScopesFromURL());
applyModalStoredPosition();
requestAnimationFrame(keepModalInViewport);
applyZoom();
saveModalRelativeState();
updateSaveButtonState();
}
});
closeButton.addEventListener('click', () => {
manualOverrideOpen = false;
modal.style.display = 'none';
saveModalRelativeState();
});
clearButton.addEventListener('click', () => { form.reset(); syncFromModalToSearchBox(); });
applyButton.addEventListener('click', () => executeSearch());
applyButton.addEventListener('click', () => { setTimeout(scanAndFilterTweets, 800); });
saveButton.addEventListener('click', () => {
const q = buildQueryStringFromModal().trim();
if (!q) return;
const {pf, lf} = readScopesFromControls();
addSaved(q, pf, lf);
activateTab('saved');
});
form.addEventListener('input', syncFromModalToSearchBox);
form.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.target.matches('input[type="text"], input[type="number"]'))) {
e.preventDefault();
// 検索確定 → ルーティング反映待ち → スキャン
Promise.resolve(executeSearch())
.finally(() => setTimeout(scanAndFilterTweets, 800));
}
});
const muteEmptyEl = document.getElementById('adv-mute-empty');
const muteListEl = document.getElementById('adv-mute-list');
const muteInputEl = document.getElementById('adv-mute-input');
const muteCsEl = document.getElementById('adv-mute-cs');
const muteAddBtn = document.getElementById('adv-mute-add');
const renderMuted = () => {
const list = loadMuted();
muteListEl.innerHTML = '';
muteEmptyEl.textContent = list.length ? '' : i18n.t('emptyMuted');
list.forEach(item => {
const row = document.createElement('div');
row.className = 'adv-mute-item';
if (!item.enabled) row.classList.add('disabled');
row.innerHTML = `
<div class="adv-mute-word">${escapeHTML(item.word)}</div>
<div class="adv-mute-actions">
<label class="adv-toggle">
<input type="checkbox" ${item.enabled ? 'checked' : ''} data-action="toggle-enabled">
<span data-i18n="labelEnabled">${i18n.t('labelEnabled')}</span>
</label>
<label class="adv-toggle">
<input type="checkbox" ${item.cs ? 'checked' : ''} data-action="toggle-cs">
<span data-i18n="labelCaseSensitive">${i18n.t('labelCaseSensitive')}</span>
</label>
<button class="adv-chip danger" data-action="delete">${i18n.t('delete')}</button>
</div>
`;
row.querySelector('[data-action="toggle-enabled"]').addEventListener('change', () => toggleMutedEnabled(item.id));
row.querySelector('[data-action="toggle-cs"]').addEventListener('change', () => toggleMutedCS(item.id));
row.querySelector('[data-action="delete"]').addEventListener('click', () => deleteMuted(item.id));
muteListEl.appendChild(row);
});
};
function applyMuteVisualState() {
const listEl = document.getElementById('adv-mute-list');
if (!listEl) return;
const masterOn = loadMuteMaster();
// ▼ 切替の瞬間だけトランジション全停止
listEl.classList.add('adv-no-anim');
// 強制リフローでスタイル確定
void listEl.offsetHeight;
listEl.classList.toggle('disabled', !masterOn);
// 次フレームで解除(描画を跨がせるのがポイント)
requestAnimationFrame(() => {
listEl.classList.remove('adv-no-anim');
});
}
muteAddBtn.addEventListener('click', () => {
addMuted(muteInputEl.value, !!muteCsEl.checked);
muteInputEl.value = '';
muteCsEl.checked = false;
});
muteInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); muteAddBtn.click(); }
});
const muteEnableAllEl = document.getElementById('adv-mute-enable-all');
if (muteEnableAllEl && !muteEnableAllEl._advBound) {
muteEnableAllEl._advBound = true;
// 初期状態はマスター値をそのまま反映
try {
muteEnableAllEl.checked = loadMuteMaster();
} catch {}
applyMuteVisualState(); // 初期描画でリスト外観を整える
muteEnableAllEl.addEventListener('change', () => {
saveMuteMaster(!!muteEnableAllEl.checked);
applyMuteVisualState(); // 視覚の即時反映(リスト半透明/通常)
scanAndFilterTweets(); // 機能面の反映(既存)
});
}
const installNavigationHooks = (onRouteChange) => {
let lastHref = location.href;
const _debounce = (fn, wait=60) => { let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), wait); }; };
const fireIfChanged = _debounce(() => {
const now = location.href;
if (now !== lastHref) {
lastHref = now;
try {
const u = new URL(now, location.origin);
if (u.pathname.startsWith('/search')) {
const q = u.searchParams.get('q') || '';
const pf = (u.searchParams.get('pf') || '') === 'on';
const lf = (u.searchParams.get('lf') || '') === 'on';
if (q) recordHistory(decodeURIComponent(q), pf, lf);
}
} catch(_) {}
onRouteChange();
}
}, 60);
const wrapHistory = (m) => {
const orig = history[m];
history[m] = function(...args){
try {
const href = args && args[2];
if (href) {
const u = new URL(href, location.href);
if (u.origin === location.origin && isBlockedPath(u.pathname)) {
hideUIImmediately(
document.getElementById('advanced-search-modal'),
document.getElementById('advanced-search-trigger')
);
}
}
} catch(_) {}
const ret = orig.apply(this, args);
queueMicrotask(fireIfChanged);
return ret;
};
};
wrapHistory('pushState'); wrapHistory('replaceState');
window.addEventListener('popstate', fireIfChanged);
document.addEventListener('click', (e) => {
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
try {
const u = new URL(a.href, location.href);
if (u.origin === location.origin) {
const sameTab = !(e.metaKey || e.ctrlKey || e.shiftKey || a.target === '_blank' || e.button === 1);
if (sameTab && isBlockedPath(u.pathname)) {
hideUIImmediately(
document.getElementById('advanced-search-modal'),
document.getElementById('advanced-search-trigger')
);
}
setTimeout(fireIfChanged, 0);
}
} catch(_) {}
}, true);
const mo = new MutationObserver(fireIfChanged);
mo.observe(document.documentElement, { childList:true, subtree:true });
const pollId = setInterval(fireIfChanged, 300);
return () => { mo.disconnect(); clearInterval(pollId); };
};
const setupObservers = () => {
const observer = new MutationObserver((mutations) => {
let searchBoxChanged=false;
for (const m of mutations) {
if (m.addedNodes.length>0) {
for (const node of m.addedNodes) {
if (node.nodeType!==Node.ELEMENT_NODE) continue;
if (node.matches?.('input[data-testid="SearchBox_Search_Input"]') || node.querySelector?.('input[data-testid="SearchBox_Search_Input"]')) { searchBoxChanged=true; break; }
}
}
if (searchBoxChanged) break;
}
if (searchBoxChanged) { syncFromSearchBoxToModal(); }
document.querySelectorAll('input[data-testid="SearchBox_Search_Input"]').forEach(input=>{
if (!input.dataset.advSearchAttached) {
input.dataset.advSearchAttached='true';
// ▼ 入力系イベントはすべて「入力中」と見なしてガード更新(IME対応)
const typingEvents = ['input','keydown','keyup','compositionstart','compositionupdate','compositionend'];
typingEvents.forEach(ev => input.addEventListener(ev, markTyping, { passive: true }));
input.addEventListener('input', () => {
if (input === getActiveSearchInput()) {
syncFromSearchBoxToModal();
}
});
const f = input.closest('form');
if (f && !f.dataset.advSearchSubmitAttached) {
f.dataset.advSearchSubmitAttached = 'true';
f.addEventListener('submit', () => {
const val = (input.value || '').trim();
const {pf, lf} = readScopesFromControls();
if (val) recordHistory(val, pf, lf);
}, true);
}
}
});
// ▼ ツイート要素が増減したかを検出(無関係なUI変化では走らせない)
const hasTweetMut = mutations.some(m => {
const added = Array.from(m.addedNodes || []);
const removed = Array.from(m.removedNodes || []);
const hit = (n) => n.nodeType === Node.ELEMENT_NODE && (
n.matches?.('article[data-testid="tweet"], [data-testid="cellInnerDiv"]') ||
n.querySelector?.('article[data-testid="tweet"], [data-testid="cellInnerDiv"]')
);
return added.some(hit) || removed.some(hit);
});
// ▼ 入力中は絶対に走らせない。かつ、検索ボックス由来の変化では走らせない。
// さらに、ツイート変化があった時だけ実行。
if (!isTyping() && !searchBoxChanged && hasTweetMut) {
scanAndFilterTweets();
}
ensureProfileAddButton();
ensureListAddButton();
});
observer.observe(document.body, { childList:true, subtree:true });
installNavigationHooks(() => {
manualOverrideOpen = false;
reconcileUI();
syncFromSearchBoxToModal();
applyScopesToControls(readScopesFromURL());
updateSaveButtonState();
scanAndFilterTweets();
ensureProfileAddButton(true);
ensureListAddButton(true);
});
};
window.addEventListener('resize', debounce(()=>{
if (modal.style.display === 'flex') { applyModalStoredPosition(); requestAnimationFrame(keepModalInViewport); }
if (trigger.style.display !== 'none') { applyTriggerStoredPosition(); requestAnimationFrame(keepTriggerInViewport); }
}, 100));
loadModalState();
reconcileUI();
setupModalDrag();
setupModalResize();
setupObservers();
renderHistory();
renderSaved();
renderAccounts();
renderMuted();
activateTab('search');
(async () => {
const input = await waitForElement(searchInputSelectors.join(','), 7000);
if (input) {
syncFromSearchBoxToModal();
applyScopesToControls(readScopesFromURL());
updateSaveButtonState();
scanAndFilterTweets();
ensureProfileAddButton(true);
ensureListAddButton(true);
}
})();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();