diff --git a/.github/workflows/daily-question-sync.yml b/.github/workflows/daily-question-sync.yml
index 7b2cc39..e594d59 100644
--- a/.github/workflows/daily-question-sync.yml
+++ b/.github/workflows/daily-question-sync.yml
@@ -74,6 +74,10 @@ jobs:
if: steps.import.outputs.updated == 'true'
run: hugo --minify
+ - name: 校验搜索索引收录
+ if: steps.import.outputs.updated == 'true'
+ run: python3 scripts/check_search_index.py --site public
+
- name: 校验待提交文件
if: steps.import.outputs.updated == 'true'
shell: bash
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 342ac11..f51b32e 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -42,3 +42,6 @@ jobs:
- name: Build
run: hugo --minify
+
+ - name: Check search index coverage
+ run: python3 scripts/check_search_index.py --site public
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
index 0df5665..ce90f36 100644
--- a/.github/workflows/static.yml
+++ b/.github/workflows/static.yml
@@ -48,6 +48,9 @@ jobs:
- name: Build
run: hugo --minify
+ - name: Check search index coverage
+ run: python3 scripts/check_search_index.py --site public
+
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
diff --git a/assets/_custom.scss b/assets/_custom.scss
index 6256090..e88a550 100644
--- a/assets/_custom.scss
+++ b/assets/_custom.scss
@@ -1085,6 +1085,21 @@ select {
transform: translateY(-1px);
}
+.sidebar-resizer {
+ display: none;
+}
+
+body.sidebar-resizing,
+body.sidebar-resizing * {
+ cursor: col-resize !important;
+ user-select: none !important;
+}
+
+body.sidebar-resizing .book-menu,
+body.sidebar-resizing .book-menu .book-menu-content {
+ transition: none;
+}
+
.sidebar-toggle-icon {
display: inline-flex;
align-items: center;
@@ -1161,14 +1176,55 @@ select {
margin: 0 0 1rem;
}
+.book-search-topbar {
+ display: none;
+}
+
+.book-search-field {
+ position: relative;
+}
+
+.book-search-filters {
+ display: none;
+}
+
+.book-search-clear {
+ position: absolute;
+ top: 50%;
+ right: 0.55rem;
+ padding: 0.3rem 0.48rem;
+ border: 0;
+ border-radius: 0.55rem;
+ background: rgba(var(--goclub-primary-rgb), 0.08);
+ color: var(--goclub-primary);
+ font-size: 0.68rem;
+ font-weight: 750;
+ cursor: pointer;
+ transform: translateY(-50%);
+}
+
+.book-search-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.book-search.is-results-collapsed .book-search-summary,
+.book-search.is-results-collapsed #book-search-results {
+ display: none;
+}
+
.book-menu .book-search input {
width: 100%;
- padding: 0.88rem 1rem;
+ padding: 0.78rem 1rem 0.98rem;
border-radius: 1rem;
border: 1px solid rgba(112, 121, 120, 0.18);
background: rgba(255, 255, 255, 0.92);
color: var(--body-font-color);
+ line-height: 1.5;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+ padding-right: 3.75rem;
}
.book-menu .book-search input::placeholder {
@@ -1204,6 +1260,335 @@ select {
color: rgba(27, 28, 26, 0.54);
}
+.book-menu .book-search-status {
+ margin-top: 0.65rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: 0.8rem;
+ background: rgba(var(--goclub-primary-rgb), 0.07);
+ color: var(--goclub-muted-text);
+ font-size: 0.74rem;
+ line-height: 1.45;
+}
+
+.book-menu .book-search-status.is-error {
+ background: rgba(176, 57, 57, 0.08);
+ color: #8f3131;
+}
+
+.book-menu #book-search-results:empty {
+ display: none;
+}
+
+.book-menu #book-search-results .book-search-result + .book-search-result {
+ margin-top: 0.24rem;
+}
+
+.book-menu #book-search-results .book-search-result a {
+ display: block;
+ padding: 0.72rem;
+ border-radius: 0.78rem;
+ text-decoration: none;
+}
+
+.book-menu #book-search-results .book-search-result a:hover,
+.book-menu #book-search-results .book-search-result a:focus-visible {
+ background: rgba(var(--goclub-primary-rgb), 0.08);
+}
+
+.book-search-result-heading {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.45rem;
+}
+
+.book-search-result-badge {
+ flex: 0 0 auto;
+ margin-top: 0.06rem;
+ padding: 0.14rem 0.38rem;
+ border-radius: 999px;
+ background: rgba(var(--goclub-primary-rgb), 0.11);
+ color: var(--goclub-primary);
+ font-size: 0.62rem;
+ font-weight: 800;
+ line-height: 1.35;
+}
+
+.book-search-result-badge.is-book {
+ background: rgba(var(--goclub-tertiary-rgb), 0.13);
+ color: var(--goclub-tertiary);
+}
+
+.book-search-result-title {
+ min-width: 0;
+ color: var(--body-font-color);
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+.book-menu #book-search-results .book-search-result-breadcrumb {
+ display: block;
+ padding: 0;
+}
+
+.book-search-result-breadcrumb {
+ margin-top: 0.32rem;
+ overflow: hidden;
+ color: var(--goclub-muted-text);
+ font-size: 0.67rem;
+ line-height: 1.35;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.book-menu #book-search-results .book-search-result-snippet {
+ display: -webkit-box;
+ margin-top: 0.38rem;
+ overflow: hidden;
+ color: var(--goclub-muted-text);
+ font-size: 0.7rem;
+ line-height: 1.5;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+}
+
+.book-search-result mark {
+ padding: 0 0.08em;
+ border-radius: 0.2em;
+ background: rgba(224, 183, 72, 0.34);
+ color: inherit;
+}
+
+.book-search-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 79;
+ border: 0;
+ background: rgba(var(--goclub-ink-rgb), 0.34);
+ backdrop-filter: blur(3px);
+ cursor: default;
+}
+
+.book-search.is-expanded {
+ position: fixed;
+ top: 1rem;
+ left: calc(max(1rem, calc((100vw - 102rem) / 2 + 1rem)));
+ z-index: 80;
+ display: flex;
+ flex-direction: column;
+ width: calc(min(46rem, calc(100vw - 2rem)));
+ height: calc(min(48rem, calc(100vh - 2rem)));
+ margin: 0;
+ padding: 1rem;
+ overflow: hidden;
+ border: 1px solid rgba(var(--goclub-primary-rgb), 0.2);
+ border-radius: 1.35rem;
+ background:
+ radial-gradient(circle at top right, rgba(255, 223, 150, 0.32), transparent 16rem),
+ rgba(246, 245, 241, 0.98);
+ box-shadow: 0 30px 80px rgba(var(--goclub-ink-rgb), 0.24);
+}
+
+.book-search.is-expanded .book-search-topbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.85rem;
+}
+
+.book-search.is-expanded .book-search-topbar strong,
+.book-search.is-expanded .book-search-topbar small {
+ display: block;
+}
+
+.book-search.is-expanded .book-search-topbar strong {
+ color: var(--goclub-primary);
+ font-family: var(--goclub-font-headline);
+ font-size: 1.25rem;
+}
+
+.book-search.is-expanded .book-search-topbar small {
+ margin-top: 0.2rem;
+ color: var(--goclub-muted-text);
+ font-size: 0.76rem;
+}
+
+.book-search-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: 1px solid rgba(var(--goclub-primary-rgb), 0.14);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.82);
+ color: var(--goclub-primary);
+ font-size: 1.25rem;
+ cursor: pointer;
+}
+
+.book-search.is-expanded input {
+ width: 100%;
+ padding: 0.9rem 4.5rem 1.1rem 1.05rem;
+ border: 1px solid rgba(var(--goclub-primary-rgb), 0.22);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.96);
+ color: var(--body-font-color);
+ font-size: 1rem;
+ line-height: 1.5;
+ box-shadow: 0 8px 24px rgba(var(--goclub-ink-rgb), 0.07);
+}
+
+.book-search.is-expanded input:focus {
+ outline: none;
+ border-color: rgba(var(--goclub-primary-rgb), 0.5);
+ box-shadow: 0 0 0 0.25rem rgba(var(--goclub-primary-rgb), 0.12);
+}
+
+.book-search.is-expanded .book-search-clear {
+ right: 0.7rem;
+ padding: 0.42rem 0.62rem;
+ font-size: 0.74rem;
+}
+
+.book-search.is-expanded .book-search-filters {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 0.45rem;
+ margin-top: 0.7rem;
+ padding: 0.05rem 0.1rem 0.2rem;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ scrollbar-width: thin;
+}
+
+.book-search-filter {
+ flex: 0 0 auto;
+ padding: 0.38rem 0.68rem;
+ border: 1px solid rgba(var(--goclub-primary-rgb), 0.14);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.78);
+ color: var(--goclub-muted-text);
+ font-size: 0.72rem;
+ font-weight: 750;
+ line-height: 1.35;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.book-search-filter:hover,
+.book-search-filter:focus-visible {
+ border-color: rgba(var(--goclub-primary-rgb), 0.34);
+ background: rgba(var(--goclub-primary-rgb), 0.08);
+ color: var(--goclub-primary);
+}
+
+.book-search-filter:focus-visible {
+ outline: 2px solid rgba(var(--goclub-primary-rgb), 0.28);
+ outline-offset: 2px;
+}
+
+.book-search-filter[aria-pressed="true"] {
+ border-color: var(--goclub-primary);
+ background: var(--goclub-primary);
+ color: var(--goclub-on-primary-action);
+}
+
+.book-search.is-expanded .book-search-status {
+ margin: 0.6rem 0;
+ padding: 0;
+ background: transparent;
+ font-size: 0.78rem;
+}
+
+.book-search.is-expanded #book-search-results {
+ display: grid;
+ flex: 1 1 auto;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-content: start;
+ gap: 0.65rem;
+ min-height: 0;
+ margin: 0;
+ padding: 0.15rem 0.25rem 0.4rem 0;
+ overflow-y: auto;
+ border: 0;
+ background: transparent;
+ list-style: none;
+}
+
+.book-search.is-expanded #book-search-results:empty {
+ display: none;
+}
+
+.book-search.is-expanded .book-search-result {
+ min-width: 0;
+ margin: 0;
+ border: 1px solid rgba(var(--goclub-primary-rgb), 0.12);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.84);
+}
+
+.book-search.is-expanded .book-search-result a {
+ display: block;
+ min-height: 100%;
+ padding: 0.85rem;
+ border-radius: inherit;
+ color: var(--body-font-color);
+ text-decoration: none;
+}
+
+.book-search.is-expanded .book-search-result a:hover,
+.book-search.is-expanded .book-search-result a:focus-visible {
+ background: rgba(var(--goclub-primary-rgb), 0.07);
+ outline: none;
+}
+
+.book-search.is-expanded .book-search-result-title {
+ font-size: 0.88rem;
+}
+
+.book-search.is-expanded .book-search-result-breadcrumb,
+.book-search.is-expanded .book-search-result-snippet {
+ display: block;
+}
+
+.book-search.is-expanded .book-search-result-snippet {
+ display: -webkit-box;
+ margin-top: 0.45rem;
+ overflow: hidden;
+ color: var(--goclub-muted-text);
+ font-size: 0.75rem;
+ line-height: 1.55;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+}
+
+body.search-overlay-open {
+ overflow: hidden;
+}
+
+body.search-overlay-open .goclub-chatbot {
+ visibility: hidden;
+ pointer-events: none;
+}
+
+mark.goclub-search-hit {
+ padding: 0.05em 0.14em;
+ border-radius: 0.24em;
+ background: rgba(255, 217, 102, 0.72);
+ color: inherit;
+ scroll-margin-block: 35vh;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+mark.goclub-search-hit.is-current {
+ background: #ffd166;
+ box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.2);
+ outline: 1px solid rgba(139, 94, 0, 0.24);
+}
+
.book-menu nav ul ul {
margin-top: 0.35rem;
padding-inline-start: 1rem;
@@ -1268,20 +1653,24 @@ body.sidebar-collapsed .sidebar-toggle:hover {
transform: translateX(50%) translateY(-1px);
}
+body.sidebar-collapsed .goclub-umami-stats--sidebar,
body.sidebar-collapsed .book-brand-copy,
+body.sidebar-collapsed .book-brand-container .theme-toggle,
body.sidebar-collapsed .book-search,
body.sidebar-collapsed .book-menu nav > ul {
- opacity: 0;
- pointer-events: none;
- visibility: hidden;
+ display: none;
}
body.sidebar-collapsed .book-brand a {
+ width: 100%;
justify-content: center;
- padding-inline: 0.6rem;
+ gap: 0;
+ padding: 0.3rem;
}
+body.sidebar-collapsed .book-brand-container,
body.sidebar-collapsed .book-brand {
+ width: 100%;
margin-bottom: 0;
}
@@ -2303,6 +2692,19 @@ body.sidebar-collapsed .book-brand-mark {
display: none;
}
+ .book-search.is-expanded {
+ inset: 0.75rem;
+ width: auto;
+ height: auto;
+ max-height: calc(100vh - 1.5rem);
+ padding: 0.85rem;
+ border-radius: 1.1rem;
+ }
+
+ .book-search.is-expanded #book-search-results {
+ grid-template-columns: 1fr;
+ }
+
.book-page > article.book-article {
padding: 1.35rem;
border-radius: 1.35rem;
@@ -2618,7 +3020,6 @@ body {
}
.book-brand a,
-.book-menu .book-search input,
.book-menu #book-search-results,
.book-header,
.book-toc .book-toc-content,
@@ -3753,6 +4154,49 @@ body {
transform: none;
}
+ .sidebar-resizer {
+ position: fixed;
+ top: 1.5rem;
+ bottom: 1.5rem;
+ left: calc(max(1rem, calc((100vw - 102rem) / 2 + 1rem)) + var(--goclub-sidebar-width) - 0.3rem);
+ z-index: 8;
+ display: block;
+ width: 0.6rem;
+ border-radius: 999px;
+ cursor: col-resize;
+ touch-action: none;
+ }
+
+ .sidebar-resizer::before {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0.22rem;
+ height: 4.5rem;
+ border-radius: 999px;
+ background: rgba(var(--goclub-primary-rgb), 0.16);
+ content: "";
+ opacity: 0.38;
+ transform: translate(-50%, -50%);
+ transition: opacity 0.18s ease, background 0.18s ease;
+ }
+
+ .sidebar-resizer:hover::before,
+ .sidebar-resizer:focus-visible::before,
+ body.sidebar-resizing .sidebar-resizer::before {
+ background: rgba(var(--goclub-primary-rgb), 0.42);
+ opacity: 1;
+ }
+
+ .sidebar-resizer:focus-visible {
+ outline: 2px solid rgba(var(--goclub-primary-rgb), 0.3);
+ outline-offset: -2px;
+ }
+
+ body.sidebar-collapsed .sidebar-resizer {
+ display: none;
+ }
+
.sidebar-toggle:hover {
transform: translateY(-1px);
}
diff --git a/assets/search-data.json b/assets/search-data.json
new file mode 100644
index 0000000..f53b328
--- /dev/null
+++ b/assets/search-data.json
@@ -0,0 +1,140 @@
+[
+{{- $pages := where .Site.Pages "Kind" "in" (slice "page" "section") -}}
+{{- $pages = where $pages "Params.bookSearchExclude" "!=" true -}}
+{{- $pages = where $pages "Content" "!=" "" -}}
+{{- $records := slice -}}
+
+{{- range $page := $pages -}}
+ {{- $title := partial "docs/title" $page -}}
+ {{- $plain := $page.Plain -}}
+ {{- $description := $page.Description | default (truncate 180 $plain) -}}
+ {{- $headings := $page.TableOfContents | plainify | htmlUnescape -}}
+ {{- $aliases := slice $title (replaceRE `\s+` "" $title) -}}
+ {{- with $page.Params.aliases -}}
+ {{- $aliases = $aliases | append . -}}
+ {{- end -}}
+ {{- $breadcrumb := slice -}}
+ {{- range $page.Ancestors.Reverse -}}
+ {{- if and (ne .Kind "home") (ne .RelPermalink "/docs/") -}}
+ {{- $breadcrumb = $breadcrumb | append (partial "docs/title" .) -}}
+ {{- end -}}
+ {{- end -}}
+ {{- $href := $page.RelPermalink -}}
+ {{- $section := (partial "docs/title" $page.Parent) | default $.Site.Title -}}
+ {{- $category := "其他" -}}
+ {{- $type := "article" -}}
+ {{- if hasPrefix $href "/docs/interview/" -}}
+ {{- $category = "面试真题" -}}
+ {{- else if hasPrefix $href "/docs/baguwen/" -}}
+ {{- $category = "八股总结" -}}
+ {{- else if hasPrefix $href "/docs/blog/" -}}
+ {{- $category = "技术博客" -}}
+ {{- else if hasPrefix $href "/docs/companion/" -}}
+ {{- $category = "配套文章" -}}
+ {{- else if hasPrefix $href "/docs/project-study/" -}}
+ {{- $category = "项目学习" -}}
+ {{- else if hasPrefix $href "/docs/resources/" -}}
+ {{- $category = "资源荟萃" -}}
+ {{- end -}}
+ {{- if eq $page.Kind "section" -}}
+ {{- $type = "section" -}}
+ {{- else if hasPrefix $href "/docs/interview/" -}}
+ {{- $type = "interview" -}}
+ {{- else if or (hasPrefix $href "/docs/resources/epub-books/") (hasPrefix $href "/docs/resources/pdf-books/") (hasPrefix $href "/docs/resources/cloud-native-web3-fulltext/") (hasPrefix $href "/docs/resources/web3-books/") -}}
+ {{- $type = "book" -}}
+ {{- end -}}
+
+ {{- $record := dict
+ "href" $href
+ "title" $title
+ "aliases" $aliases
+ "section" $section
+ "category" $category
+ "type" $type
+ "breadcrumb" $breadcrumb
+ "description" $description
+ "headings" $headings
+ "content" $plain
+ -}}
+
+ {{- if and (eq $title "每日一问") (hasPrefix $href "/docs/baguwen/") -}}
+ {{- $records = $records | append (dict
+ "href" $href
+ "title" $title
+ "aliases" $aliases
+ "section" $section
+ "category" $category
+ "type" "article"
+ "breadcrumb" $breadcrumb
+ "description" $description
+ "headings" ""
+ "content" $description
+ ) -}}
+ {{- $renderedContent := printf "%s" $page.Content -}}
+ {{- $dateHeadings := slice -}}
+ {{- range $heading := $page.Fragments.Headings -}}
+ {{- if eq $heading.Level 2 -}}
+ {{- $dateHeadings = $dateHeadings | append $heading -}}
+ {{- else if eq $heading.Level 1 -}}
+ {{- range $childHeading := $heading.Headings -}}
+ {{- if eq $childHeading.Level 2 -}}
+ {{- $dateHeadings = $dateHeadings | append $childHeading -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- range $dateHeading := $dateHeadings -}}
+ {{- if eq $dateHeading.Level 2 -}}
+ {{- range $questionHeading := $dateHeading.Headings -}}
+ {{- if eq $questionHeading.Level 3 -}}
+ {{- $questionContent := "" -}}
+ {{- $idParts := split $renderedContent (printf `id="%s"` $questionHeading.ID) -}}
+ {{- if gt (len $idParts) 1 -}}
+ {{- $headingParts := split (index $idParts 1) "" -}}
+ {{- if gt (len $headingParts) 1 -}}
+ {{- $sectionHTML := index $headingParts 1 -}}
+ {{- $sectionHTML = index (split $sectionHTML "
button.addEventListener('click', selectCategory));
+ document.addEventListener('keypress', focusSearchFieldOnKeyPress);
+ document.addEventListener('keydown', closeSearchPanelOnEscape);
+ window.addEventListener('pagehide', persistSearchState);
+ window.addEventListener('pageshow', restoreSearchPanelFromPageCache);
+
+ pendingRestoreState = readSearchState();
+ if (pendingRestoreState) {
+ setActiveCategory(pendingRestoreState.category);
+ input.value = pendingRestoreState.query;
+ clearButton.classList.remove('hidden');
+ if (pendingRestoreState.panelOpen) {
+ openSearchPanel();
+ }
+ init();
+ }
+
+ function focusSearchFieldOnKeyPress(event) {
+ if (event.target.value !== undefined || input === document.activeElement) {
+ return;
+ }
+
+ const characterPressed = String.fromCharCode(event.charCode);
+ const dataHotkeys = input.getAttribute('data-hotkeys') || '';
+ if (dataHotkeys.indexOf(characterPressed) < 0) {
+ return;
+ }
+
+ input.focus();
+ event.preventDefault();
+ }
+
+ function init() {
+ if (searchIndex || searchPromise) {
+ return searchPromise;
+ }
+
+ input.required = true;
+ setStatus('正在加载搜索索引…', 'loading');
+
+ searchPromise = fetch(searchDataURL)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`Search index request failed: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then(pages => {
+ searchIndex = new Fuse(pages, indexConfig);
+ input.required = false;
+ searchPromise = null;
+ const restoreState = pendingRestoreState;
+ pendingRestoreState = null;
+ if (restoreState && restoreState.query === input.value.trim()) {
+ search(restoreState);
+ } else if (input.value.trim()) {
+ search();
+ } else {
+ clearStatus();
+ }
+ })
+ .catch(error => {
+ console.error(error);
+ input.required = false;
+ searchPromise = null;
+ setStatus('搜索索引加载失败,请重新聚焦搜索框重试。', 'error');
+ });
+
+ return searchPromise;
+ }
+
+ function scheduleSearch() {
+ suppressLastSearchRestore = false;
+ clearButton.classList.toggle('hidden', !input.value);
+ window.clearTimeout(debounceTimer);
+ debounceTimer = window.setTimeout(function () {
+ search();
+ }, 250);
+ }
+
+ function selectCategory(event) {
+ const category = event.currentTarget.getAttribute('data-search-category') || '';
+ if (category === activeCategory) {
+ return;
+ }
+
+ setActiveCategory(category);
+ results.scrollTop = 0;
+ if (input.value.trim()) {
+ search();
+ } else if (activeCategory) {
+ setStatus(`已选择“${activeCategory}”,请输入关键词。`, 'ready');
+ } else {
+ clearStatus();
+ }
+ }
+
+ function setActiveCategory(category) {
+ const nextCategory = filterButtons.some(button => {
+ return button.getAttribute('data-search-category') === category;
+ }) ? category : '';
+ activeCategory = nextCategory;
+ filterButtons.forEach(button => {
+ const isActive = (button.getAttribute('data-search-category') || '') === activeCategory;
+ button.setAttribute('aria-pressed', String(isActive));
+ });
+ }
+
+ function openSearchPanel() {
+ searchPanel.classList.remove('is-results-collapsed');
+ if (searchPanel.parentNode !== document.body) {
+ document.body.appendChild(backdrop);
+ document.body.appendChild(searchPanel);
+ }
+ searchPanel.classList.add('is-expanded');
+ backdrop.classList.remove('hidden');
+ document.body.classList.add('search-overlay-open');
+ }
+
+ function closeSearchPanel() {
+ searchPanel.classList.remove('is-expanded');
+ searchPanel.classList.toggle('is-results-collapsed', Boolean(input.value.trim()));
+ backdrop.classList.add('hidden');
+ document.body.classList.remove('search-overlay-open');
+ if (panelPlaceholder.parentNode) {
+ panelPlaceholder.parentNode.insertBefore(searchPanel, panelPlaceholder.nextSibling);
+ }
+ if (backdropPlaceholder.parentNode) {
+ backdropPlaceholder.parentNode.insertBefore(backdrop, backdropPlaceholder.nextSibling);
+ }
+ persistSearchState();
+ }
+
+ function closeSearchPanelOnEscape(event) {
+ if (event.key !== 'Escape' || !searchPanel.classList.contains('is-expanded')) {
+ return;
+ }
+
+ closeSearchPanel();
+ input.blur();
+ }
+
+ function clearSearch() {
+ suppressLastSearchRestore = true;
+ input.value = '';
+ searchPanel.classList.remove('is-results-collapsed');
+ clearButton.classList.add('hidden');
+ search();
+ clearSearchState();
+ input.focus();
+ }
+
+ function search(restoreState) {
+ const query = input.value.trim();
+ clearResults();
+ currentSearchHits = [];
+ visibleResultCount = 0;
+
+ if (!query) {
+ clearStatus();
+ clearSearchState();
+ return;
+ }
+
+ if (!searchIndex) {
+ setStatus('正在加载搜索索引…', 'loading');
+ init();
+ return;
+ }
+
+ const searchHits = filterCategoryHits(filterExactDateHits(searchIndex.search(query), query));
+ currentSearchHits = rankSearchHits(searchHits);
+ if (!currentSearchHits.length) {
+ const categoryText = activeCategory ? `“${activeCategory}”栏目中` : '';
+ setStatus(`没有在${categoryText || '知识库中'}找到与“${query}”相关的内容。`, 'empty');
+ persistSearchState();
+ return;
+ }
+
+ const restoredCount = restoreState && Number(restoreState.visibleResultCount);
+ showMoreResults(Number.isFinite(restoredCount) && restoredCount > 0
+ ? restoredCount
+ : resultPageSize);
+ if (restoreState) {
+ window.requestAnimationFrame(function () {
+ results.scrollTop = Math.max(0, Number(restoreState.resultsScrollTop) || 0);
+ persistSearchState();
+ });
+ }
+ }
+
+ function rankSearchHits(searchHits) {
+ return searchHits
+ .map((hit, originalIndex) => {
+ const priorityFactor = contentPriorityFactor(hit.item);
+ return {
+ hit,
+ originalIndex,
+ priorityFactor,
+ adjustedScore: (hit.score === undefined ? 1 : hit.score) * priorityFactor
+ };
+ })
+ .sort((left, right) => {
+ return left.adjustedScore - right.adjustedScore
+ || left.priorityFactor - right.priorityFactor
+ || (left.hit.score || 0) - (right.hit.score || 0)
+ || left.originalIndex - right.originalIndex;
+ })
+ .map(entry => entry.hit);
+ }
+
+ function filterExactDateHits(searchHits, query) {
+ if (!/^\d{4}[.-]\d{2}[.-]\d{2}$/.test(query)) {
+ return searchHits;
+ }
+
+ const normalizedDate = query.replace(/-/g, '.');
+ return searchHits.filter(hit => {
+ const breadcrumb = Array.isArray(hit.item.breadcrumb) ? hit.item.breadcrumb : [];
+ return breadcrumb.some(part => part === normalizedDate);
+ });
+ }
+
+ function filterCategoryHits(searchHits) {
+ if (!activeCategory) {
+ return searchHits;
+ }
+ return searchHits.filter(hit => hit.item.category === activeCategory);
+ }
+
+ function contentPriorityFactor(page) {
+ const href = page.href || '';
+ if (page.category === '八股总结' || href.indexOf('/docs/baguwen/') === 0) {
+ return 0.72;
+ }
+ if (page.type === 'interview' || href.indexOf('/docs/interview/') === 0) {
+ return 0.78;
+ }
+ if (page.type === 'article') {
+ return 0.9;
+ }
+ if (page.type === 'book') {
+ return 1.15;
+ }
+ return 1;
+ }
+
+ function showMoreResults(targetVisibleCount) {
+ const query = input.value.trim();
+ const requestedCount = Number.isFinite(targetVisibleCount)
+ ? Math.max(visibleResultCount, targetVisibleCount)
+ : visibleResultCount + resultPageSize;
+ const nextVisibleCount = Math.min(requestedCount, currentSearchHits.length);
+ currentSearchHits
+ .slice(visibleResultCount, nextVisibleCount)
+ .forEach(hit => results.appendChild(renderResult(hit.item, query)));
+ visibleResultCount = nextVisibleCount;
+ const categoryText = activeCategory ? `在“${activeCategory}”栏目中` : '';
+ setStatus(`${categoryText}找到 ${currentSearchHits.length} 条结果,当前显示 ${visibleResultCount} 条。`, 'ready');
+ persistSearchState();
+ }
+
+ function handleResultsScroll() {
+ schedulePersistSearchState();
+ if (autoLoadFrame || visibleResultCount >= currentSearchHits.length) {
+ return;
+ }
+
+ const distanceFromBottom = results.scrollHeight - results.scrollTop - results.clientHeight;
+ if (distanceFromBottom > autoLoadThreshold) {
+ return;
+ }
+
+ autoLoadFrame = window.requestAnimationFrame(function () {
+ autoLoadFrame = null;
+ showMoreResults();
+ });
+ }
+
+ function schedulePersistSearchState() {
+ if (persistFrame) {
+ return;
+ }
+ persistFrame = window.requestAnimationFrame(function () {
+ persistFrame = null;
+ persistSearchState();
+ });
+ }
+
+ function persistSearchState() {
+ const query = input.value.trim();
+ if (!query) {
+ return;
+ }
+
+ const searchState = {
+ path: window.location.pathname,
+ query: query,
+ visibleResultCount: visibleResultCount,
+ resultsScrollTop: results.scrollTop,
+ panelOpen: searchPanel.classList.contains('is-expanded'),
+ category: activeCategory
+ };
+ const currentState = window.history.state && typeof window.history.state === 'object'
+ ? window.history.state
+ : {};
+ window.history.replaceState(Object.assign({}, currentState, {
+ [searchHistoryKey]: searchState
+ }), '');
+ try {
+ window.sessionStorage.setItem(searchSessionKey, JSON.stringify(searchState));
+ } catch (error) {
+ // Search history restoration remains available when session storage is blocked.
+ }
+ }
+
+ function readSearchState() {
+ const state = window.history.state && window.history.state[searchHistoryKey];
+ if (!state || state.path !== window.location.pathname || typeof state.query !== 'string'
+ || !state.query.trim()) {
+ return null;
+ }
+ return state;
+ }
+
+ function readLastSearchState() {
+ try {
+ const state = JSON.parse(window.sessionStorage.getItem(searchSessionKey));
+ if (state && typeof state.query === 'string' && state.query.trim()) {
+ return state;
+ }
+ } catch (error) {
+ // Fall back to the query carried by search-result links.
+ }
+
+ const query = new URLSearchParams(window.location.search).get('search');
+ return query && query.trim()
+ ? { query: query, visibleResultCount: resultPageSize, resultsScrollTop: 0 }
+ : null;
+ }
+
+ function restoreLastSearchState() {
+ if (suppressLastSearchRestore || input.value.trim()) {
+ return;
+ }
+ const state = readLastSearchState();
+ if (!state) {
+ return;
+ }
+
+ input.value = state.query;
+ setActiveCategory(state.category);
+ clearButton.classList.remove('hidden');
+ pendingRestoreState = state;
+ if (searchIndex) {
+ pendingRestoreState = null;
+ search(state);
+ }
+ }
+
+ function clearSearchState() {
+ if (window.history.state && window.history.state[searchHistoryKey]) {
+ const nextState = Object.assign({}, window.history.state);
+ delete nextState[searchHistoryKey];
+ window.history.replaceState(nextState, '');
+ }
+ try {
+ window.sessionStorage.removeItem(searchSessionKey);
+ } catch (error) {
+ // Ignore storage restrictions; the visible search state has still been cleared.
+ }
+ }
+
+ function restoreSearchPanelFromPageCache(event) {
+ if (!event.persisted) {
+ return;
+ }
+ const state = readSearchState();
+ if (!state) {
+ return;
+ }
+ if (state.panelOpen) {
+ openSearchPanel();
+ }
+ window.requestAnimationFrame(function () {
+ results.scrollTop = Math.max(0, Number(state.resultsScrollTop) || 0);
+ });
+ }
+
+ function renderResult(page, query) {
+ const item = document.createElement('li');
+ item.className = 'book-search-result';
+
+ const link = document.createElement('a');
+ link.href = addSearchContext(page.href, query);
+ link.addEventListener('click', persistSearchState);
+
+ const heading = document.createElement('span');
+ heading.className = 'book-search-result-heading';
+
+ const badge = document.createElement('span');
+ badge.className = `book-search-result-badge is-${page.type || 'article'}`;
+ badge.textContent = resultTypeLabel(page.type);
+
+ const title = document.createElement('strong');
+ title.className = 'book-search-result-title';
+ appendHighlightedText(title, page.title, query);
+
+ heading.appendChild(badge);
+ heading.appendChild(title);
+ link.appendChild(heading);
+
+ const breadcrumb = document.createElement('span');
+ breadcrumb.className = 'book-search-result-breadcrumb';
+ const breadcrumbParts = Array.isArray(page.breadcrumb) ? page.breadcrumb : [];
+ breadcrumb.textContent = breadcrumbParts.length ? breadcrumbParts.join(' › ') : page.category || page.section;
+ link.appendChild(breadcrumb);
+
+ const snippet = document.createElement('small');
+ snippet.className = 'book-search-result-snippet';
+ appendHighlightedText(snippet, makeSnippet(page, query), query);
+ link.appendChild(snippet);
+
+ item.appendChild(link);
+ return item;
+ }
+
+ function resultTypeLabel(type) {
+ return {
+ book: '书籍章节',
+ interview: '面试',
+ section: '栏目',
+ article: '文章'
+ }[type] || '文章';
+ }
+
+ function makeSnippet(page, query) {
+ const preferred = (page.content || page.description || '').replace(/\s+/g, ' ').trim();
+ if (!preferred) {
+ return '';
+ }
+
+ const terms = queryTerms(query);
+ const lower = preferred.toLocaleLowerCase();
+ const positions = terms
+ .map(term => lower.indexOf(term.toLocaleLowerCase()))
+ .filter(position => position >= 0);
+ const matchAt = positions.length ? Math.min.apply(null, positions) : 0;
+ const start = Math.max(0, matchAt - 42);
+ const end = Math.min(preferred.length, matchAt + 118);
+ return `${start > 0 ? '…' : ''}${preferred.slice(start, end)}${end < preferred.length ? '…' : ''}`;
+ }
+
+ function addSearchContext(href, query) {
+ const url = new URL(href, window.location.origin);
+ url.searchParams.set('search', query);
+ return `${url.pathname}${url.search}${url.hash}`;
+ }
+
+ function appendHighlightedText(container, text, query) {
+ const terms = queryTerms(query).sort((left, right) => right.length - left.length);
+ if (!terms.length) {
+ container.textContent = text;
+ return;
+ }
+
+ const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi');
+ text.split(pattern).forEach(part => {
+ if (terms.some(term => term.toLocaleLowerCase() === part.toLocaleLowerCase())) {
+ const mark = document.createElement('mark');
+ mark.textContent = part;
+ container.appendChild(mark);
+ } else {
+ container.appendChild(document.createTextNode(part));
+ }
+ });
+ }
+
+ function queryTerms(query) {
+ const normalizedQuery = query.trim();
+ const terms = normalizedQuery.split(/\s+/).map(term => term.trim()).filter(Boolean);
+ return terms.length > 1 ? [normalizedQuery].concat(terms) : terms;
+ }
+
+ function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ function setStatus(message, state) {
+ status.textContent = message;
+ status.className = `book-search-status is-${state}`;
+ }
+
+ function clearStatus() {
+ status.textContent = '';
+ status.className = 'book-search-status hidden';
+ }
+
+ function clearResults() {
+ while (results.firstChild) {
+ results.removeChild(results.firstChild);
+ }
+ }
+
+ function scheduleSearchContextHighlight() {
+ const run = function () {
+ window.requestAnimationFrame(function () {
+ window.requestAnimationFrame(highlightSearchContext);
+ });
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', run, { once: true });
+ } else {
+ run();
+ }
+ }
+
+ function highlightSearchContext() {
+ const query = new URLSearchParams(window.location.search).get('search');
+ const article = document.querySelector('.book-page article.book-article');
+ if (!query || !article) {
+ return;
+ }
+
+ const terms = queryTerms(query).sort((left, right) => right.length - left.length);
+ if (!terms.length) {
+ return;
+ }
+
+ const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi');
+ const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, {
+ acceptNode: function (node) {
+ const parent = node.parentElement;
+ if (!parent || !node.nodeValue.trim()
+ || parent.closest('script, style, noscript, mark.goclub-search-hit, a.anchor')) {
+ return NodeFilter.FILTER_REJECT;
+ }
+ pattern.lastIndex = 0;
+ const matches = pattern.test(node.nodeValue);
+ pattern.lastIndex = 0;
+ return matches ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
+ }
+ });
+ const matchingNodes = [];
+ while (walker.nextNode()) {
+ matchingNodes.push(walker.currentNode);
+ }
+
+ matchingNodes.forEach(node => highlightTextNode(node, pattern, terms));
+
+ const fragmentTarget = document.querySelector(':target');
+ const preferredHit = findPreferredHit(fragmentTarget, article, query)
+ || findExactHit(article, query)
+ || article.querySelector('mark.goclub-search-hit');
+ const scrollTarget = preferredHit || fragmentTarget;
+ if (!scrollTarget) {
+ return;
+ }
+
+ const collapsedAnswer = scrollTarget.closest('details:not([open])');
+ if (collapsedAnswer) {
+ collapsedAnswer.open = true;
+ }
+ if (preferredHit) {
+ preferredHit.classList.add('is-current');
+ }
+ if ('scrollRestoration' in window.history) {
+ window.history.scrollRestoration = 'manual';
+ }
+ window.setTimeout(function () {
+ const targetRect = scrollTarget.getBoundingClientRect();
+ const targetTop = window.scrollY + targetRect.top
+ - (window.innerHeight / 2)
+ + (targetRect.height / 2);
+ window.scrollTo({ top: Math.max(0, targetTop), behavior: 'auto' });
+ }, 220);
+ }
+
+ function highlightTextNode(node, pattern, terms) {
+ const fragment = document.createDocumentFragment();
+ node.nodeValue.split(pattern).forEach(part => {
+ if (terms.some(term => term.toLocaleLowerCase() === part.toLocaleLowerCase())) {
+ const mark = document.createElement('mark');
+ mark.className = 'goclub-search-hit';
+ mark.textContent = part;
+ fragment.appendChild(mark);
+ } else {
+ fragment.appendChild(document.createTextNode(part));
+ }
+ });
+ node.parentNode.replaceChild(fragment, node);
+ }
+
+ function findPreferredHit(fragmentTarget, article, query) {
+ if (!fragmentTarget || !article.contains(fragmentTarget)) {
+ return null;
+ }
+
+ const targetLevel = headingLevel(fragmentTarget);
+ const hits = [];
+ let element = fragmentTarget;
+ while (element) {
+ if (element !== fragmentTarget && targetLevel && headingLevel(element) <= targetLevel) {
+ break;
+ }
+ if (element.matches && element.matches('mark.goclub-search-hit')) {
+ hits.push(element);
+ }
+ if (element.querySelectorAll) {
+ hits.push.apply(hits, element.querySelectorAll('mark.goclub-search-hit'));
+ }
+ element = element.nextElementSibling;
+ }
+ return hits.find(hit => hit.textContent.toLocaleLowerCase() === query.toLocaleLowerCase())
+ || hits[0]
+ || null;
+ }
+
+ function findExactHit(container, query) {
+ return Array.from(container.querySelectorAll('mark.goclub-search-hit'))
+ .find(hit => hit.textContent.toLocaleLowerCase() === query.toLocaleLowerCase())
+ || null;
+ }
+
+ function headingLevel(element) {
+ const match = element && /^H([1-6])$/.exec(element.tagName);
+ return match ? Number(match[1]) : 0;
+ }
+})();
diff --git "a/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md" "b/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md"
index 6f56d96..d902a1b 100644
--- "a/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md"
+++ "b/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md"
@@ -1270,3 +1270,12 @@ func main() {
## 2026.03.30
### 自旋锁和互斥锁的区别
+
+自旋锁在获取锁失败后会持续循环尝试,不主动让出 CPU;互斥锁在获取锁失败后会阻塞线程并让出 CPU,等锁可用时再被唤醒。
+
+1. 自旋锁避免了线程阻塞和唤醒的上下文切换开销,但等待期间会持续消耗 CPU。
+2. 互斥锁等待时不占用 CPU,但阻塞和唤醒需要进行线程调度,存在上下文切换开销。
+3. 自旋锁适合临界区很短、锁竞争较轻且运行在多核 CPU 上的场景;互斥锁适合临界区较长或竞争较激烈的场景。
+4. 如果持锁时间不可控,使用自旋锁可能浪费大量 CPU,甚至降低系统整体吞吐,因此通常优先使用互斥锁,只在能够证明临界区足够短时考虑自旋锁。
+
+结论:等待时间短时,自旋锁可以用 CPU 时间换取更低的切换延迟;等待时间较长时,互斥锁通过阻塞线程减少 CPU 浪费。
diff --git a/layouts/_partials/docs/search.html b/layouts/_partials/docs/search.html
new file mode 100644
index 0000000..eb70b5e
--- /dev/null
+++ b/layouts/_partials/docs/search.html
@@ -0,0 +1,34 @@
+{{ if default true .Site.Params.BookSearch }}
+
+
+
+ 搜索知识库
+ 按标题、正文、栏目或日期查找
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
diff --git a/layouts/baseof.html b/layouts/baseof.html
index 4ef7598..713c999 100644
--- a/layouts/baseof.html
+++ b/layouts/baseof.html
@@ -45,6 +45,9 @@
+
{{ end }}
diff --git a/layouts/partials/docs/inject/body.html b/layouts/partials/docs/inject/body.html
index 25e45a6..169e393 100644
--- a/layouts/partials/docs/inject/body.html
+++ b/layouts/partials/docs/inject/body.html
@@ -977,12 +977,21 @@ AI 助手
diff --git a/scripts/check_search_index.py b/scripts/check_search_index.py
new file mode 100644
index 0000000..5728599
--- /dev/null
+++ b/scripts/check_search_index.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+
+import argparse
+import csv
+import io
+import json
+import re
+import subprocess
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+
+
+ALLOWED_CATEGORIES = {
+ "面试真题",
+ "八股总结",
+ "资源荟萃",
+ "技术博客",
+ "配套文章",
+ "项目学习",
+ "其他",
+}
+REQUIRED_FIELDS = ("href", "title", "category", "type")
+SEARCH_EXCLUDE_PATTERN = re.compile(r"(?mi)^\s*bookSearchExclude\s*[:=]\s*true\s*$")
+
+
+class IdCollector(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.ids = set()
+
+ def handle_starttag(self, _tag, attrs):
+ for name, value in attrs:
+ if name == "id" and value:
+ self.ids.add(value)
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="校验 Hugo 搜索索引覆盖范围和结果链接")
+ parser.add_argument("--site", default="public", help="Hugo 输出目录,默认 public")
+ parser.add_argument("--hugo", default="hugo", help="Hugo 可执行文件,默认 hugo")
+ return parser.parse_args()
+
+
+def configure_output():
+ for stream in (sys.stdout, sys.stderr):
+ if hasattr(stream, "reconfigure"):
+ stream.reconfigure(encoding="utf-8")
+
+
+def split_front_matter(text):
+ lines = text.splitlines()
+ if not lines or lines[0].strip() not in {"---", "+++"}:
+ return "", text
+
+ delimiter = lines[0].strip()
+ for index in range(1, len(lines)):
+ if lines[index].strip() == delimiter:
+ return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
+ return "", text
+
+
+def normalize_route(value):
+ path = unquote(urlsplit(value).path)
+ if not path.startswith("/"):
+ path = f"/{path}"
+ if path != "/" and not Path(path).suffix:
+ path = f"{path.rstrip('/')}/"
+ return path
+
+
+def output_path_for_href(site_dir, href):
+ path = normalize_route(href).lstrip("/")
+ output = site_dir / Path(path)
+ if path.endswith("/") or not output.suffix:
+ output /= "index.html"
+ return output
+
+
+def load_current_index(site_dir):
+ candidates = list(site_dir.glob("*.search-data.min.*.json"))
+ if not candidates:
+ raise RuntimeError(f"{site_dir} 中没有生成搜索索引,请先运行 hugo --minify")
+ index_path = max(candidates, key=lambda path: path.stat().st_mtime_ns)
+ with index_path.open("r", encoding="utf-8") as file:
+ records = json.load(file)
+ if not isinstance(records, list) or not records:
+ raise RuntimeError(f"搜索索引为空或格式错误:{index_path}")
+ return index_path, records
+
+
+def list_published_content(hugo, repo_root):
+ result = subprocess.run(
+ [hugo, "list", "published"],
+ cwd=repo_root,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ return list(csv.DictReader(io.StringIO(result.stdout)))
+
+
+def expected_routes(rows, repo_root):
+ expected = {}
+ for row in rows:
+ if row.get("kind") not in {"page", "section"}:
+ continue
+ source_value = row.get("path", "")
+ permalink = row.get("permalink", "")
+ if not source_value or not permalink:
+ continue
+
+ source_path = repo_root / Path(source_value)
+ if not source_path.is_file():
+ continue
+ text = source_path.read_text(encoding="utf-8")
+ front_matter, body = split_front_matter(text)
+ if SEARCH_EXCLUDE_PATTERN.search(front_matter) or not body.strip():
+ continue
+
+ route = normalize_route(permalink)
+ if route.startswith("/docs/") or route == "/docs/":
+ expected[route] = source_value
+ return expected
+
+
+def collect_html_ids(path, cache):
+ if path not in cache:
+ parser = IdCollector()
+ parser.feed(path.read_text(encoding="utf-8"))
+ cache[path] = parser.ids
+ return cache[path]
+
+
+def validate_records(records, site_dir):
+ errors = []
+ indexed_routes = set()
+ seen_hrefs = set()
+ html_ids = {}
+
+ for position, record in enumerate(records):
+ if not isinstance(record, dict):
+ errors.append(f"第 {position + 1} 条记录不是对象")
+ continue
+ missing = [field for field in REQUIRED_FIELDS if not record.get(field)]
+ if missing:
+ errors.append(f"第 {position + 1} 条记录缺少字段:{', '.join(missing)}")
+ continue
+ category = record["category"]
+ if category not in ALLOWED_CATEGORIES:
+ errors.append(f"{record['href']} 使用了未归一化栏目:{category}")
+
+ href = record["href"]
+ if href in seen_hrefs:
+ errors.append(f"搜索记录链接重复:{href}")
+ seen_hrefs.add(href)
+ indexed_routes.add(normalize_route(href))
+
+ output_path = output_path_for_href(site_dir, href)
+ if not output_path.is_file():
+ errors.append(f"搜索结果页面不存在:{href} -> {output_path}")
+ continue
+
+ fragment = unquote(urlsplit(href).fragment)
+ if fragment and fragment not in collect_html_ids(output_path, html_ids):
+ errors.append(f"搜索结果锚点不存在:{href}")
+
+ return errors, indexed_routes
+
+
+def main():
+ configure_output()
+ args = parse_args()
+ repo_root = Path(__file__).resolve().parents[1]
+ site_dir = (repo_root / args.site).resolve()
+
+ try:
+ index_path, records = load_current_index(site_dir)
+ rows = list_published_content(args.hugo, repo_root)
+ expected = expected_routes(rows, repo_root)
+ errors, indexed_routes = validate_records(records, site_dir)
+ except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
+ print(f"搜索索引校验失败:{error}", file=sys.stderr)
+ return 1
+
+ missing_routes = sorted(set(expected) - indexed_routes)
+ errors.extend(
+ f"应收录内容未进入搜索索引:{route}({expected[route]})"
+ for route in missing_routes
+ )
+
+ if errors:
+ print("搜索索引校验失败:", file=sys.stderr)
+ for error in errors:
+ print(f"- {error}", file=sys.stderr)
+ return 1
+
+ category_counts = {}
+ for record in records:
+ category = record["category"]
+ category_counts[category] = category_counts.get(category, 0) + 1
+ summary = ",".join(
+ f"{category} {count} 条" for category, count in sorted(category_counts.items())
+ )
+ print(
+ f"搜索索引校验通过:{index_path.name},{len(records)} 条记录,"
+ f"覆盖 {len(expected)} 个内容页面;{summary}。"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())