根据 https://forum.smart-teach.cn/d/2764-yi-si-zhao-dao-liao-lun-tan-sou-suo-de-ke-yong-fang-fa/16 发现的bug
作用:移除url末尾空格;拦截论坛的 pushState / replaceState 路由跳转;屏蔽“已解决主题帖”;修复下拉菜单中的搜索结果预览
// ==UserScript==
// @name 智教联盟论坛搜索修复
// @namespace https://forum.smart-teach.cn/
// @version 2.4.0
// @description 修复实时主题搜索预览、搜索链接并提供已解决主题显示开关
// @match https://forum.smart-teach.cn/*
// @match http://forum.smart-teach.cn/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// ==/UserScript==
(() => {
'use strict';
const CONFIG = Object.freeze({
searchParam: 'q',
apiLimit: 8,
hideSolvedKey: 'hideSolvedPreview',
});
const state = {
previewRequest: 0,
hideSolved: GM_getValue(CONFIG.hideSolvedKey, false),
};
const searchSelector = '[role="search"] input[type="search"]';
// Flarum 用空格分隔搜索语法,只清理首尾空白,不改动内部空格。
const trimQuery = value => typeof value === 'string' ? value.trim() : value;
const fixUrl = url => {
try {
const target = new URL(url, location.href);
const query = target.searchParams.get(CONFIG.searchParam);
const fixed = trimQuery(query);
if (query !== null && fixed !== query) {
target.searchParams.set(CONFIG.searchParam, fixed);
return target.href;
}
} catch {}
return url;
};
// Flarum 用 pushState/replaceState 完成搜索跳转。
// 在路由写入地址前修正 q,避免地址栏重新变成 ?q=关键词%20。
for (const method of ['pushState', 'replaceState']) {
const original = history[method];
history[method] = function (state, title, url, ...args) {
return original.call(this, state, title, typeof url === 'string' ? fixUrl(url) : url, ...args);
};
}
const isSolvedQuery = query =>
/(?:^|\s)is:solved(?:\s|$)/i.test(query || '');
const getSearchMenu = () => document.querySelector('.Search-results');
const registerSettingsMenu = () => {
GM_registerMenuCommand(
`${state.hideSolved ? '显示' : '隐藏'}已解决主题帖预览`,
() => {
GM_setValue(CONFIG.hideSolvedKey, !state.hideSolved);
location.reload();
},
);
};
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}[character]));
const findSectionHeader = (menu, title) =>
[...menu.querySelectorAll(':scope > li.Dropdown-header')]
.find(item => item.textContent.trim() === title);
const renderDiscussionPreview = results => {
const menu = getSearchMenu();
if (!menu) return;
menu.querySelectorAll('[data-stcn-preview]').forEach(item => item.remove());
if (!results.length) return;
const themeHeader = findSectionHeader(menu, '主题');
if (!themeHeader) return;
const nextHeader = [...menu.children]
.find(child =>
child.classList.contains('Dropdown-header') &&
themeHeader.compareDocumentPosition(child) &
Node.DOCUMENT_POSITION_FOLLOWING
);
const fragment = document.createDocumentFragment();
for (const discussion of results) {
const item = document.createElement('li');
item.dataset.stcnPreview = 'true';
const link = document.createElement('a');
link.className = 'hasIcon';
link.href = fixUrl(discussion.url);
link.innerHTML =
`<i aria-hidden="true" class="icon fas fa-search Button-icon"></i>` +
`<span class="Button-label">${escapeHtml(discussion.title)}</span>`;
item.append(link);
fragment.append(item);
}
(nextHeader || null)?.before(fragment);
if (!nextHeader) menu.append(fragment);
};
const loadDiscussionPreview = async query => {
const request = ++state.previewRequest;
const normalized = trimQuery(query);
if (!normalized) {
renderDiscussionPreview([]);
return;
}
try {
const params = new URLSearchParams({
'filter[q]': normalized,
'page[limit]': String(CONFIG.apiLimit),
});
const response = await fetch(`/api/discussions?${params}`);
if (!response.ok) return;
const payload = await response.json();
if (request !== state.previewRequest) return;
const results = (payload.data || [])
.map(item => {
const attributes = item.attributes || {};
const slug = attributes.slug || item.id;
return {
title: attributes.title,
url: `/d/${item.id}-${slug}`,
};
})
.filter(item => item.title && item.url)
renderDiscussionPreview(results);
} catch {
// API 不可用时保留 Flarum 自己的搜索入口。
}
};
// 搜索预览按钮是动态生成的,提交前直接修正其 href。
const fixSearchLink = event => {
const link = event.target.closest?.('.Search-results a[href]');
if (link) link.href = fixUrl(link.href);
};
document.addEventListener('pointerdown', fixSearchLink, true);
document.addEventListener('click', fixSearchLink, true);
// 在页面上下文拦截论坛自己的搜索预览请求。
const patch = document.createElement('script');
patch.textContent = `(${(param => {
const trim = value => typeof value === 'string' ? value.replace(/\s+$/, '') : value;
const url = value => {
try {
const target = new URL(value, location.href);
const query = target.searchParams.get(param);
const fixed = trim(query);
if (query !== null && fixed !== query) {
target.searchParams.set(param, fixed);
return target.href;
}
} catch {}
return value;
};
const body = value => {
if (value instanceof URLSearchParams) {
const query = value.get(param);
const fixed = trim(query);
if (query !== null && fixed !== query) {
const copy = new URLSearchParams(value);
copy.set(param, fixed);
return copy;
}
return value;
}
if (typeof value !== 'string') return value;
try {
const params = new URLSearchParams(value);
if (params.has(param)) {
const query = params.get(param);
const fixed = trim(query);
if (fixed !== query) {
params.set(param, fixed);
return params.toString();
}
}
} catch {}
return value;
};
const fetch = window.fetch;
window.fetch = (input, init) => {
if (input instanceof Request) {
input = new Request(url(input.url), input);
if (input.method !== 'GET' && input.method !== 'HEAD') {
input = new Request(input, { body: body(input.body) });
}
}
else {
input = url(input);
if (init) init = { ...init, body: body(init.body) };
}
return fetch.call(this, input, init);
};
const open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, target, ...args) {
return open.call(this, method, url(target), ...args);
};
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (value) {
return send.call(this, body(value));
};
for (const method of ['pushState', 'replaceState']) {
const original = history[method];
history[method] = function (state, title, target, ...args) {
return original.call(
this,
state,
title,
typeof target === 'string' ? url(target) : target,
...args
);
};
}
}).toString()})(${JSON.stringify(CONFIG.searchParam)});`;
const injectPatch = () => {
const root = document.documentElement || document.head;
if (!root) {
requestAnimationFrame(injectPatch);
return;
}
root.append(patch);
patch.remove();
};
injectPatch();
// 修复当前地址、链接和表单提交。
const fixCurrentUrl = () => {
const fixed = fixUrl(location.href);
if (fixed !== location.href) history.replaceState(history.state, '', fixed);
};
fixCurrentUrl();
addEventListener('popstate', fixCurrentUrl);
addEventListener('pageshow', fixCurrentUrl);
document.addEventListener('submit', event => {
const input = event.target.querySelector?.(
`input[name="${CONFIG.searchParam}"]`,
);
if (input) input.value = trimQuery(input.value);
}, true);
// Flarum 的搜索框没有 name="q",必须在其原生 oninput 前修正值。
document.addEventListener('input', event => {
const input = event.target;
if (input.matches?.(searchSelector)) {
const fixed = trimQuery(input.value);
if (fixed !== input.value) input.value = fixed;
loadDiscussionPreview(input.value);
}
}, true);
document.addEventListener('keydown', event => {
const input = event.target;
if (event.key === 'Enter' && input.matches?.(searchSelector)) {
const fixed = trimQuery(input.value);
if (fixed !== input.value) input.value = fixed;
if (state.hideSolved) {
filterSolvedPreview();
event.preventDefault();
event.stopImmediatePropagation();
const target = new URL(location.href);
target.search = '';
target.searchParams.set(CONFIG.searchParam, fixed);
location.assign(target.href);
}
}
}, true);
const filterSolvedPreview = () => {
const menu = getSearchMenu();
if (!menu) return;
const solvedHeader = findSectionHeader(menu, '已解决');
if (!solvedHeader) return;
solvedHeader.hidden = state.hideSolved;
let sibling = solvedHeader.nextElementSibling;
while (sibling && !sibling.classList.contains('Dropdown-header')) {
sibling.hidden = state.hideSolved;
sibling = sibling.nextElementSibling;
}
};
const refreshPreview = () => {
filterSolvedPreview();
};
const previewObserver = new MutationObserver(refreshPreview);
previewObserver.observe(document, { childList: true, subtree: true });
document.addEventListener('input', event => {
if (event.target.matches?.(searchSelector)) {
queueMicrotask(refreshPreview);
}
}, true);
registerSettingsMenu();
})();