fix(search): 업로드 파일명 매칭 제거
- 본문 검색에서 /uploads 경로와 마크다운 이미지 토큰을 제거해 파일명/해시 노이즈를 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,15 +14,28 @@ const isComposing = ref(false)
|
|||||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||||
let debounceTimer = null
|
let debounceTimer = null
|
||||||
|
|
||||||
watch(query, (value) => {
|
/**
|
||||||
|
* 입력값을 디바운스로 검색어에 반영한다.
|
||||||
|
* @param {string} value - 현재 입력값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const scheduleDebouncedQuery = (value) => {
|
||||||
clearTimeout(debounceTimer)
|
clearTimeout(debounceTimer)
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
debouncedQuery.value = value
|
debouncedQuery.value = value
|
||||||
}, 200)
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (value) => {
|
||||||
|
scheduleDebouncedQuery(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchKey = computed(() => `public-search:${debouncedQuery.value}`)
|
||||||
|
|
||||||
const { data, pending } = await useFetch('/api/search', {
|
const { data, pending } = await useFetch('/api/search', {
|
||||||
|
key: fetchKey,
|
||||||
query: { q: debouncedQuery },
|
query: { q: debouncedQuery },
|
||||||
|
watch: [debouncedQuery],
|
||||||
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,6 +112,22 @@ const onCompositionStart = () => {
|
|||||||
isComposing.value = true
|
isComposing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
|
||||||
|
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
|
||||||
|
* @param {CompositionEvent} event - 조합 업데이트 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onCompositionUpdate = (event) => {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLInputElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = target.value
|
||||||
|
query.value = value
|
||||||
|
scheduleDebouncedQuery(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -179,6 +208,7 @@ onBeforeUnmount(() => {
|
|||||||
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||||
placeholder="글 제목, 본문, 태그 검색"
|
placeholder="글 제목, 본문, 태그 검색"
|
||||||
@compositionstart="onCompositionStart"
|
@compositionstart="onCompositionStart"
|
||||||
|
@compositionupdate="onCompositionUpdate"
|
||||||
@compositionend="onCompositionEnd"
|
@compositionend="onCompositionEnd"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다.
|
- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다.
|
||||||
- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다.
|
- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다.
|
||||||
|
|
||||||
|
## v0.0.67
|
||||||
|
|
||||||
|
- 공개 검색에서 `/uploads/...` 파일 경로와 마크다운 이미지 토큰(``)은 노이즈로 간주해 매칭 대상에서 제거, 이미지 파일명 때문에 숫자 검색이 걸리던 문제를 해결.
|
||||||
|
|
||||||
## v0.0.65
|
## v0.0.65
|
||||||
|
|
||||||
- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다.
|
- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.66",
|
"version": "0.0.67",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -550,6 +550,20 @@ export const listTags = async () => {
|
|||||||
|
|
||||||
const SEARCH_TAG_LIMIT = 12
|
const SEARCH_TAG_LIMIT = 12
|
||||||
const SEARCH_POST_LIMIT = 12
|
const SEARCH_POST_LIMIT = 12
|
||||||
|
const SEARCH_POST_CANDIDATE_LIMIT = 48
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 검색에서 업로드 경로/파일명 같은 노이즈를 제거한다.
|
||||||
|
* - 마크다운 이미지 문법 `` 제거
|
||||||
|
* - 업로드 경로(`/uploads/...`) 제거
|
||||||
|
* @param {string} value - 원문
|
||||||
|
* @returns {string} 정규화된 문자열
|
||||||
|
*/
|
||||||
|
const normalizeSearchContent = (value) => String(value || '')
|
||||||
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||||
|
.replace(/\/uploads\/[^\s)"]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
|
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
|
||||||
@@ -568,7 +582,7 @@ export const searchPublicContent = async (rawQuery) => {
|
|||||||
const needle = q.toLowerCase()
|
const needle = q.toLowerCase()
|
||||||
const posts = getSamplePosts()
|
const posts = getSamplePosts()
|
||||||
.filter((post) => {
|
.filter((post) => {
|
||||||
const hay = `${post.title}\n${post.excerpt || ''}\n${post.content || ''}`.toLowerCase()
|
const hay = normalizeSearchContent(`${post.title}\n${post.excerpt || ''}\n${post.content || ''}`).toLowerCase()
|
||||||
return hay.includes(needle)
|
return hay.includes(needle)
|
||||||
})
|
})
|
||||||
.slice(0, SEARCH_POST_LIMIT)
|
.slice(0, SEARCH_POST_LIMIT)
|
||||||
@@ -601,7 +615,7 @@ export const searchPublicContent = async (rawQuery) => {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const postRows = await sql`
|
const postRows = await sql`
|
||||||
SELECT posts.slug, posts.title, posts.excerpt
|
SELECT posts.slug, posts.title, posts.excerpt, posts.content
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE posts.status = 'published'
|
WHERE posts.status = 'published'
|
||||||
AND (posts.published_at IS NULL OR posts.published_at <= now())
|
AND (posts.published_at IS NULL OR posts.published_at <= now())
|
||||||
@@ -611,12 +625,20 @@ export const searchPublicContent = async (rawQuery) => {
|
|||||||
OR position(lower(${q}) in lower(posts.content)) > 0
|
OR position(lower(${q}) in lower(posts.content)) > 0
|
||||||
)
|
)
|
||||||
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
||||||
LIMIT ${SEARCH_POST_LIMIT}
|
LIMIT ${SEARCH_POST_CANDIDATE_LIMIT}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const needle = q.toLowerCase()
|
||||||
|
const posts = postRows
|
||||||
|
.filter((row) => {
|
||||||
|
const hay = normalizeSearchContent(`${row.title}\n${row.excerpt || ''}\n${row.content || ''}`).toLowerCase()
|
||||||
|
return hay.includes(needle)
|
||||||
|
})
|
||||||
|
.slice(0, SEARCH_POST_LIMIT)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
|
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
|
||||||
posts: postRows.map((row) => ({
|
posts: posts.map((row) => ({
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
excerpt: row.excerpt || ''
|
excerpt: row.excerpt || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user