Files
Interview-Notebook/website/templates/search.js
T
CyC2018 0d2cacc5e8 Update search.js
搜索排序:讲解正文排在学习路线和附录之前,英文按完整单词优先;中英文连写时拆开,常见说法互通,整句问法去掉疑问词,没有结果时找相近的小节。
2026-11-01 20:08:24 +08:00

425 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
function normalize(value) {
return String(value).normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
}
var preparedIndexes = new WeakMap();
function prepareIndex(index) {
var prepared = preparedIndexes.get(index);
if (prepared) return prepared;
prepared = [];
index.documents.forEach(function (doc) {
var page = normalize(doc.title);
doc.sections.forEach(function (section, i) {
// 文章开头没有正文的部分与一级标题重复,不单独作为结果
if (i === 0 && !section.text && doc.sections.length > 1 && normalize(doc.sections[1].title) === page) return;
var heading = normalize(section.title);
var body = normalize(section.text);
// 学习路线只是阅读导读,附录是实验过程,排在讲解正文之后
var penalty = (/^学习路线/.test(doc.title) ? 45 : 0) + (/^a\d+ /.test(heading) ? 40 : 0);
prepared.push({ page: page, heading: heading, body: body, text: heading + ' ' + page + ' ' + body,
lead: body.slice(0, 120), flatHeading: heading.replace(/ /g, ''), flatBody: body.replace(/ /g, ''), penalty: penalty, pageTitle: doc.title, path: doc.path, section: section });
});
});
preparedIndexes.set(index, prepared);
return prepared;
}
function occurrences(text, term) {
var count = 0, offset = 0, found;
while (count < 6 && (found = text.indexOf(term, offset)) !== -1) {
count++;
offset = found + term.length;
}
return count;
}
// 英文词在标题中单独出现时才算完整命中,避免 RAG 命中 Storage
function wholeWord(text, term) {
if (!/^[a-z0-9]+$/.test(term)) return true;
return new RegExp('(^|[^a-z0-9])' + term + '($|[^a-z0-9])').test(text);
}
// 中英文连写时在两者之间切开,例如“hashmap扩容”按“hashmap”和“扩容”搜索
var CJK = /[㐀-鿿豈-﫿]/;
function splitTerms(query) {
var terms = [];
joinMixed(query).split(' ').forEach(function (word) {
(word.match(/[㐀-鿿豈-﫿]+|[^㐀-鿿豈-﫿]+/g) || []).forEach(function (part) {
if (part && terms.indexOf(part) === -1) terms.push(part);
});
});
return terms;
}
// 笔记中统一了术语(垃圾收集、I/O、哈希、新生代……),读者常用的另一种说法也要能搜到
var SYNONYMS = [
['垃圾收集', '垃圾回收'], ['i/o', 'io'], ['哈希', '散列', 'hash'], ['新生代', '年轻代'], ['重排序', '指令重排'],
['首部', '头部'], ['幻读', '幻影读'], ['脏读', '读脏数据'], ['b+tree', 'b+树', 'b+ 树'], ['多路复用', 'i/o 复用', '复用'],
['多版本并发控制', 'mvcc'], ['java 虚拟机', 'jvm']
];
// 中英文混写的术语(“b+ 树”“i/o 复用”)在切词前换成一个整体,否则会被切成“b+”和“树”两个词分别匹配
var MIXED = [];
SYNONYMS.forEach(function (group) {
var whole = group.filter(function (word) { return !/ /.test(word) && !(CJK.test(word) && /[a-z]/.test(word)); })[0];
if (!whole) return;
group.forEach(function (word) {
if (word === whole || !(CJK.test(word) && /[a-z]/.test(word))) return;
var pattern = word.replace(/ /g, '').split('').map(function (c) { return c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join(' ?');
MIXED.push([new RegExp(pattern, 'g'), whole]);
});
});
function joinMixed(query) {
MIXED.forEach(function (item) { query = query.replace(item[0], item[1]); });
return query;
}
// 词中含有另一种说法时也替换,例如“散列冲突”同时按“哈希冲突”搜索
function alternatives(term) {
var result = [term];
SYNONYMS.forEach(function (group) {
group.forEach(function (word) {
if (term.indexOf(word) === -1) return;
group.forEach(function (other) {
var variant = term.split(word).join(other);
if (result.indexOf(variant) === -1) result.push(variant);
});
});
});
return result;
}
function firstHit(text, choices) {
for (var i = 0; i < choices.length; i++) if (text.includes(choices[i])) return choices[i];
return null;
}
function score(entry, groups, normalizedQuery) {
var total = 0;
for (var i = 0; i < groups.length; i++) {
var term = firstHit(entry.text, groups[i]);
if (!term) return null;
// 小节标题正好是搜索词时最相关,例如“死锁的必要条件”中的“必要条件”;标题用的是另一种说法时也算(搜 MVCC,标题是“多版本并发控制”)
var head = firstHit(entry.heading, groups[i]) || term;
total += (entry.heading === head ? 30 : 0) + (entry.heading.includes(head) ? (wholeWord(entry.heading, head) ? 40 : 25) : 0) +
(entry.page.includes(term) ? 15 : 0) + (entry.lead.includes(term) ? 10 : 0) + occurrences(entry.body, term);
}
var phrase = groups.length > 1 && normalizedQuery ? normalizedQuery.replace(/ /g, '') : '';
if (phrase) total += (entry.flatHeading.includes(phrase) ? 25 : 0) + (entry.flatBody.includes(phrase) ? 10 : 0);
return total - entry.penalty + (entry.heading === normalizedQuery ? 100 : 0);
}
// 整句的问法:先去掉“什么是”“为什么”这类词再搜(“什么是幂等”按“幂等”搜)
var QUESTION = /什么是|是什么|为什么要|为什么|为啥|怎么样|怎么|如何|有哪些|哪些|有什么|区别是|作用是|原理是|的区别|区别|对比|比较|异同|和|与|跟|及|吗|呢|么|的|了|?|\?/g;
function stripQuestion(query) {
// 去掉后剩下的单个汉字或单个字母没有意义(“和为 S”去掉“和”只剩“为”和“s”),这时不用去掉后的版本
var terms = splitTerms(normalize(query.replace(QUESTION, ' '))).filter(function (term) { return term.length > 1; });
return terms.length ? terms.join(' ') : '';
}
// 仍然没有结果时按相邻两个字的词组匹配,至少命中七成;原文中连续出现的长片段(“四个条件”)额外加分
function bigrams(terms) {
var pairs = [];
terms.forEach(function (term) {
if (!CJK.test(term)) return;
for (var i = 0; i + 1 < term.length; i++) {
var pair = term.slice(i, i + 2);
if (pairs.indexOf(pair) === -1) pairs.push(pair);
}
});
return pairs;
}
function fuzzyScore(entry, pairs, segments) {
var hit = 0, total = 0;
pairs.forEach(function (pair) {
if (!entry.text.includes(pair)) return;
hit++;
total += (entry.heading.includes(pair) ? 12 : 0) + (entry.page.includes(pair) ? 4 : 0) + Math.min(occurrences(entry.body, pair), 3);
});
if (hit < Math.max(2, Math.ceil(pairs.length * .7))) return null;
segments.forEach(function (segment) {
if (segment.length > 2 && entry.text.includes(segment)) total += segment.length * 6;
});
return total + hit * 5 - entry.penalty;
}
function search(index, query, limit) {
var normalizedQuery = normalize(query).slice(0, 120);
if (!normalizedQuery) return [];
var entries = prepareIndex(index), results = [], fuzzy = false, used = [];
var seen = {};
function collect(scoreOf) {
entries.forEach(function (entry) {
var value = scoreOf(entry);
if (value === null) return;
var section = entry.section, key = entry.path + '?' + section.id;
if (seen[key]) { seen[key].score = Math.max(seen[key].score, value); return; }
seen[key] = { page: entry.pageTitle, path: entry.path, title: section.title || entry.pageTitle,
id: section.id, text: section.text, score: value, penalty: entry.penalty };
results.push(seen[key]);
});
}
function exact(text) {
var terms = splitTerms(text);
if (!terms.length) return;
var groups = terms.map(alternatives);
used = used.concat([].concat.apply([], groups));
collect(function (entry) { return score(entry, groups, text); });
}
exact(normalizedQuery);
// 去掉问法后的结果合并进来:“死锁的必要条件”原文只出现在学习路线中,正文的小节标题是“必要条件”
var stripped = stripQuestion(normalizedQuery);
if (stripped && stripped !== normalizedQuery) exact(stripped);
if (!results.length) {
var terms = splitTerms(stripped || normalizedQuery);
var pairs = bigrams(terms.map(function (term) { return alternatives(term)[0]; }));
var ascii = terms.filter(function (term) { return !CJK.test(term); }).map(alternatives);
if (pairs.length >= 2) {
fuzzy = true;
used = pairs.concat([].concat.apply([], ascii));
collect(function (entry) {
if (ascii.some(function (group) { return !firstHit(entry.text, group); })) return null;
return fuzzyScore(entry, pairs, terms);
});
}
// 只剩英文词时(“HashMap和Hashtable的区别”中的“区别”找不到同一节),只按英文词搜
if (!results.length && ascii.length && ascii.length < terms.length) {
fuzzy = true;
used = [].concat.apply([], ascii);
collect(function (entry) { return score(entry, ascii, ''); });
}
}
results.sort(function (a, b) { return b.score - a.score || a.penalty - b.penalty; });
var shown = results.slice(0, limit || 20);
shown.total = results.length;
shown.fuzzy = fuzzy;
shown.terms = used;
return shown;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { search: search, normalize: normalize, highlight: highlight, snippet: snippet };
return;
}
var indexPromise;
function loadIndex() {
if (!indexPromise) {
indexPromise = fetch(window.CS_NOTES_SEARCH_INDEX, { credentials: 'omit' }).then(function (response) {
if (!response.ok) throw new Error('Search index unavailable');
return response.json();
}).then(function (index) {
if (index.version !== 1 || !Array.isArray(index.documents)) throw new Error('Invalid search index');
prepareIndex(index);
return index;
}).catch(function (error) { indexPromise = null; throw error; });
}
return indexPromise;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character];
});
}
function snippet(text, terms) {
var lower = normalize(text), position = -1;
// 先找最长的词:“死锁的四个条件”的摘要应定位到“四个条件”,而不是第一次出现“死锁”的地方
(Array.isArray(terms) ? terms.slice() : normalize(terms).split(' ')).sort(function (a, b) { return b.length - a.length; })
.some(function (term) { position = term ? lower.indexOf(term) : -1; return position !== -1; });
var start = Math.max(0, position - 45);
return (start ? '…' : '') + text.slice(start, start + 170) + (text.length > start + 170 ? '…' : '');
}
// 摘要和标题中标出搜索词:先按搜索词切分原文再逐段转义,不会匹配到转义后的实体
function highlight(text, query) {
var terms = (Array.isArray(query) ? query : normalize(query).split(' ')).filter(Boolean).sort(function (a, b) { return b.length - a.length; }).map(function (term) {
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
});
if (!terms.length) return escapeHtml(text);
var pattern = new RegExp('(' + terms.join('|') + ')', 'gi');
return String(text).split(pattern).map(function (part, index) {
return index % 2 ? '<mark>' + escapeHtml(part) + '</mark>' : escapeHtml(part);
}).join('');
}
function mountSearch() {
var sidebar = document.querySelector('.sidebar');
if (!sidebar || sidebar.querySelector('.site-search')) return;
var wrapper = document.createElement('div');
wrapper.className = 'search site-search';
var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
wrapper.innerHTML = '<input type="search" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-label="搜索全部笔记" aria-controls="site-search-results" placeholder="搜索全部笔记" autocomplete="off" enterkeyhint="search">' +
'<kbd class="search-key" aria-hidden="true">' + (mac ? '⌘K' : 'Ctrl K') + '</kbd>' +
'<p class="search-status" role="status" aria-live="polite"></p>' +
'<div id="site-search-results" class="results-panel" role="listbox" aria-label="搜索结果" hidden></div>';
sidebar.insertBefore(wrapper, sidebar.firstChild);
var input = wrapper.querySelector('input');
var status = wrapper.querySelector('.search-status');
var panel = wrapper.querySelector('.results-panel');
var timer;
var sequence = 0;
function update() {
var query = input.value.trim();
var request = ++sequence;
panel.hidden = !query;
panel.innerHTML = '';
input.setAttribute('aria-expanded', String(!!query));
setActive(-1);
if (!query) { status.textContent = ''; return; }
status.textContent = '正在搜索…';
loadIndex().then(function (index) {
if (request !== sequence) return;
var results = search(index, query);
status.textContent = !results.length ? '没有找到相关内容,可以换个说法或减少关键词' :
(results.fuzzy ? '没有完全匹配的内容,以下是相近的章节' :
results.total > results.length ? '找到 ' + results.total + ' 个相关章节,显示前 ' + results.length + ' 个' : '找到 ' + results.length + ' 个相关章节');
lastTerms = results.terms;
panel.innerHTML = results.map(function (result, i) {
var url = '/#' + result.path.split('/').map(encodeURIComponent).join('/') +
(result.id ? '?id=' + encodeURIComponent(result.id) : '');
return '<a class="matching-post" id="search-result-' + i + '" role="option" aria-selected="false" tabindex="-1" href="' + escapeHtml(url) + '"><strong>' + highlight(result.title, results.terms) +
'</strong><small>' + escapeHtml(result.page) + '</small><p>' + highlight(snippet(result.text, results.terms), results.terms) + '</p></a>';
}).join('');
markCurrent();
}).catch(function () {
if (request === sequence) status.textContent = '搜索暂时不可用,请稍后重试。';
});
}
input.addEventListener('focus', function () { loadIndex().catch(function () {}); });
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(update, 140); });
// 当前选中的结果:焦点一直在输入框里,可以接着修改搜索词
var active = -1, lastTerms = [];
function options() { return Array.from(panel.querySelectorAll('.matching-post')); }
function setActive(i) {
var list = options();
active = list.length ? Math.max(-1, Math.min(i, list.length - 1)) : -1;
list.forEach(function (link, j) { link.setAttribute('aria-selected', String(j === active)); });
if (active >= 0) {
input.setAttribute('aria-activedescendant', list[active].id);
list[active].scrollIntoView({ block: 'nearest' });
} else {
input.removeAttribute('aria-activedescendant');
}
}
input.addEventListener('keydown', function (event) {
if (event.isComposing || event.keyCode === 229) return;
var list = options();
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (!list.length) return;
event.preventDefault();
var step = event.key === 'ArrowDown' ? 1 : -1;
setActive(active === -1 && step < 0 ? list.length - 1 : active + step >= list.length ? 0 : active + step);
} else if (event.key === 'Enter') {
// 结果还没出来时(输入后 140ms 内)先搜一次,再打开第一条
if (!list.length && input.value.trim()) { clearTimeout(timer); update(); return; }
if (!list.length) return;
event.preventDefault();
(list[active] || list[0]).click();
} else if (event.key === 'Escape') {
event.preventDefault();
if (active >= 0) setActive(-1);
else if (input.value) { input.value = ''; update(); }
else input.blur();
}
});
panel.addEventListener('mousemove', function (event) {
var link = event.target.closest('.matching-post');
if (link) { var i = options().indexOf(link); if (i !== active) setActive(i); }
});
// ⌘K / Ctrl+K 在任何地方聚焦搜索框(手机上同时打开侧边栏)
document.addEventListener('keydown', function (event) {
if (event.key !== 'k' && event.key !== 'K' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
event.preventDefault();
document.body.classList.toggle('close', window.matchMedia('(max-width: 768px)').matches);
input.focus();
input.select();
});
// 搜索结果在跳转后保留,方便接着看下一条;当前所在的小节标为 aria-current
function markCurrent() {
var here = decodeURIComponent(location.hash);
panel.querySelectorAll('.matching-post').forEach(function (link) {
var same = decodeURIComponent(link.getAttribute('href').slice(1)) === here;
if (same) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current');
});
}
window.addEventListener('hashchange', markCurrent);
panel.addEventListener('click', function (event) {
var link = event.target.closest('a');
if (link) {
try { sessionStorage.setItem('cs-notes-search-hit', JSON.stringify({ href: link.getAttribute('href'), terms: lastTerms, time: Date.now() })); } catch (error) {}
setTimeout(markHits, 60);
}
if (link && window.matchMedia('(max-width: 768px)').matches) {
document.body.classList.remove('close');
}
});
}
var HIT_SKIP = /^(PRE|CODE|SCRIPT|STYLE|MARK|BUTTON|SVG)$/;
var shownPath = null;
function clearHits() {
document.querySelectorAll('mark.search-hit').forEach(function (mark) {
mark.replaceWith(document.createTextNode(mark.textContent));
});
}
function markHits() {
var saved;
try { saved = JSON.parse(sessionStorage.getItem('cs-notes-search-hit')); } catch (error) { return; }
if (!saved || Date.now() - saved.time > 30000) return;
var hash = decodeURIComponent(location.hash), href = decodeURIComponent(saved.href.replace(/^\//, ''));
// 换了文章时,新文章渲染出来之前页面上还是旧文章,等 doneEach 再标
if (hash !== href || hash.split('?')[0] !== shownPath) return;
sessionStorage.removeItem('cs-notes-search-hit');
clearHits();
var id = new URLSearchParams(href.split('?').slice(1).join('?')).get('id');
var heading = id && document.getElementById(id), section = document.querySelector('.markdown-section');
if (!section || !saved.terms || !saved.terms.length) return;
var level = heading && /^H([1-6])$/.test(heading.tagName) ? Number(heading.tagName[1]) : 0;
var nodes = [], node = heading || section.firstElementChild;
for (; node; node = node.nextElementSibling) {
if (node !== heading && /^H[1-6]$/.test(node.tagName) && Number(node.tagName[1]) <= level) break;
nodes.push(node);
}
var terms = saved.terms.filter(Boolean).sort(function (a, b) { return b.length - a.length; });
var pattern = new RegExp('(' + terms.map(function (term) { return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')', 'gi');
var count = 0;
nodes.forEach(function (block) {
var walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, {
acceptNode: function (text) {
for (var p = text.parentNode; p && p !== block.parentNode; p = p.parentNode) if (HIT_SKIP.test(p.nodeName.toUpperCase())) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
var texts = [];
while (walker.nextNode()) texts.push(walker.currentNode);
texts.forEach(function (text) {
var parts = text.nodeValue.split(pattern);
if (parts.length < 2 || count > 60) return;
var fragment = document.createDocumentFragment();
parts.forEach(function (part, i) {
if (!part) return;
if (i % 2) { var mark = document.createElement('mark'); mark.className = 'search-hit'; mark.textContent = part; fragment.appendChild(mark); count++; }
else fragment.appendChild(document.createTextNode(part));
});
text.replaceWith(fragment);
});
});
if (heading) {
heading.classList.remove('jump-target');
void heading.offsetWidth;
heading.classList.add('jump-target');
}
}
window.$docsify.plugins.push(function (hook) {
hook.mounted(mountSearch);
hook.doneEach(mountSearch);
hook.doneEach(function () {
shownPath = decodeURIComponent(location.hash.split('?')[0]);
setTimeout(markHits, 0);
});
});
})();