diff --git a/README.md b/README.md index e09f152f205..296996480ed 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ MX Space Core is a headless CMS server built with **NestJS**, **MongoDB**, and * | **AI Workflow** | Summary generation, multi-language translation, comment moderation, writing assistance, streaming responses | | **LLM Providers** | OpenAI, OpenAI-compatible, Anthropic, OpenRouter | | **Real-time** | WebSocket via Socket.IO with Redis adapter for multi-instance broadcast | -| **Distribution** | RSS/Atom feeds, sitemap, Algolia search, aggregate API | +| **Distribution** | RSS/Atom feeds, sitemap, local search, aggregate API | | **Auth** | JWT sessions, passkeys, OAuth, API keys (via better-auth) | | **Deployment** | Docker (multi-arch), PM2, standalone binary | diff --git a/apps/core/package.json b/apps/core/package.json index 5a1cfcb6da3..69ccadd57cd 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -52,7 +52,6 @@ "changelog": true }, "dependencies": { - "@algolia/client-search": "^5.49.1", "@antfu/install-pkg": "1.1.0", "@anthropic-ai/sdk": "^0.78.0", "@babel/core": "7.29.0", @@ -90,7 +89,6 @@ "@typegoose/auto-increment": "^5.0.0", "@typegoose/typegoose": "^13.2.0", "@types/jsonwebtoken": "9.0.10", - "algoliasearch": "5.49.1", "axios": "^1.13.3", "axios-retry": "4.5.0", "bcryptjs": "^3.0.3", diff --git a/apps/core/src/constants/db.constant.ts b/apps/core/src/constants/db.constant.ts index 14b13460198..81d6ffb8647 100644 --- a/apps/core/src/constants/db.constant.ts +++ b/apps/core/src/constants/db.constant.ts @@ -24,6 +24,7 @@ export const PROJECT_COLLECTION_NAME = 'projects' export const READER_COLLECTION_NAME = 'readers' export const RECENTLY_COLLECTION_NAME = 'recentlies' export const SAY_COLLECTION_NAME = 'says' +export const SEARCH_DOCUMENT_COLLECTION_NAME = 'search_documents' export const SERVERLESS_LOG_COLLECTION_NAME = 'serverless_logs' export const SERVERLESS_STORAGE_COLLECTION_NAME = 'serverless_storages' export const SLUG_TRACKER_COLLECTION_NAME = 'slug_trackers' diff --git a/apps/core/src/constants/error-code.constant.ts b/apps/core/src/constants/error-code.constant.ts index 99cdd6e9bd1..4cb8f6a10e9 100644 --- a/apps/core/src/constants/error-code.constant.ts +++ b/apps/core/src/constants/error-code.constant.ts @@ -51,8 +51,6 @@ export enum ErrorCodeEnum { // biz - disabled/not enabled (400/403) LinkDisabled = 13000, SubpathLinkDisabled = 13001, - AlgoliaNotEnabled = 13002, - AlgoliaNotConfigured = 13003, BackupNotEnabled = 13004, SubscribeNotEnabled = 13005, PasswordLoginDisabled = 13006, @@ -204,8 +202,6 @@ export const ErrorCode = Object.freeze>( '管理员当前禁用了子路径友链申请', 422, ], - [ErrorCodeEnum.AlgoliaNotEnabled]: ['Algolia 未开启', 400], - [ErrorCodeEnum.AlgoliaNotConfigured]: ['Algolia 未配置', 400], [ErrorCodeEnum.BackupNotEnabled]: ['请先在设置中开启备份功能', 400], [ErrorCodeEnum.SubscribeNotEnabled]: ['订阅功能未开启', 400], [ErrorCodeEnum.PasswordLoginDisabled]: ['密码登录已禁用', 400], diff --git a/apps/core/src/constants/event-bus.constant.ts b/apps/core/src/constants/event-bus.constant.ts index 1375e4dbc0d..09fb2301893 100644 --- a/apps/core/src/constants/event-bus.constant.ts +++ b/apps/core/src/constants/event-bus.constant.ts @@ -1,6 +1,5 @@ export enum EventBusEvents { EmailInit = 'email.init', - PushSearch = 'search.push', TokenExpired = 'token.expired', CleanAggregateCache = 'cache.aggregate', SystemException = 'system.exception', diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 65b5256e976..c18260827d8 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -82,13 +82,6 @@ export const generateDefaultConfig: () => IConfig = () => ({ }, baiduSearchOptions: { enable: false, token: null! }, bingSearchOptions: { enable: false, token: null! }, - algoliaSearchOptions: { - enable: false, - apiKey: '', - appId: '', - indexName: '', - maxTruncateSize: 10000, - }, adminExtra: { enableAdminProxy: true, diff --git a/apps/core/src/modules/configs/configs.dsl.util.ts b/apps/core/src/modules/configs/configs.dsl.util.ts index 7d9e060a6ec..150515e1387 100644 --- a/apps/core/src/modules/configs/configs.dsl.util.ts +++ b/apps/core/src/modules/configs/configs.dsl.util.ts @@ -1,4 +1,5 @@ import { z } from 'zod' + import { configSchemaMapping, FullConfigSchema } from './configs.schema' import { getMeta, type SchemaMetadata } from './configs.zod-schema.util' @@ -108,11 +109,7 @@ const groupConfigs: GroupConfig[] = [ title: '搜索推送', description: '搜索引擎、全文检索', icon: 'search', - sectionKeys: [ - 'baiduSearchOptions', - 'bingSearchOptions', - 'algoliaSearchOptions', - ], + sectionKeys: ['baiduSearchOptions', 'bingSearchOptions'], }, { key: 'storage', @@ -459,8 +456,7 @@ function formatProviderLabel(provider: AIProviderInfo): string { const id = provider.id || '' const nameLooksLikeUuid = - !!name && - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name) + !!name && /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(name) const displayName = !nameLooksLikeUuid && name ? name : '' diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index 722c1bd0311..2a1884fd20b 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -1,15 +1,15 @@ import type { z } from 'zod' + import { - configSchemaMapping, type AdminExtraSchema, type AISchema, - type AlgoliaSearchOptionsSchema, type AuthSecuritySchema, type BackupOptionsSchema, type BaiduSearchOptionsSchema, type BarkOptionsSchema, type BingSearchOptionsSchema, type CommentOptionsSchema, + configSchemaMapping, type FeatureListSchema, type FileUploadOptionsSchema, type FriendLinkOptionsSchema, @@ -43,7 +43,6 @@ export abstract class IConfig { fileUploadOptions: Required> baiduSearchOptions: Required> bingSearchOptions: Required> - algoliaSearchOptions: Required> featureList: Required> thirdPartyServiceIntegration: Required< z.infer diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index d8103633160..ded6e630ae0 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -206,31 +206,6 @@ export class BingSearchOptionsDto extends createZodDto( ) {} export type BingSearchOptionsConfig = z.infer -// ==================== Algolia Search Options ==================== -export const AlgoliaSearchOptionsSchema = section('Algolia Search', { - enable: field.plain(z.boolean().optional(), '开启 Algolia Search'), - apiKey: field.password(z.string().optional(), 'ApiKey'), - appId: field.plain(z.string().optional(), 'AppId'), - indexName: field.plain(z.string().optional(), 'IndexName'), - maxTruncateSize: field.plain( - z.preprocess( - (val) => (val ? Number(val) : val), - z.number().int().min(100).optional(), - ), - '最大文档大小', - { - description: - 'Algolia 文档大小限制,单位为字节,免费版本为 10K, 填写为 10000', - }, - ), -}) -export class AlgoliaSearchOptionsDto extends createZodDto( - AlgoliaSearchOptionsSchema, -) {} -export type AlgoliaSearchOptionsConfig = z.infer< - typeof AlgoliaSearchOptionsSchema -> - // ==================== Admin Extra ==================== export const AdminExtraSchema = section('后台附加设置', { enableAdminProxy: field.toggle(z.boolean().optional(), '开启后台管理反代', { @@ -445,7 +420,6 @@ export const configSchemaMapping = { fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, - algoliaSearchOptions: AlgoliaSearchOptionsSchema, featureList: FeatureListSchema, thirdPartyServiceIntegration: ThirdPartyServiceIntegrationSchema, authSecurity: AuthSecuritySchema, @@ -471,7 +445,6 @@ export const FullConfigSchema = withMeta( fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, - algoliaSearchOptions: AlgoliaSearchOptionsSchema, featureList: FeatureListSchema, thirdPartyServiceIntegration: ThirdPartyServiceIntegrationSchema, authSecurity: AuthSecuritySchema, diff --git a/apps/core/src/modules/configs/configs.service.ts b/apps/core/src/modules/configs/configs.service.ts index 0a1d5030baa..8b54553c61c 100644 --- a/apps/core/src/modules/configs/configs.service.ts +++ b/apps/core/src/modules/configs/configs.service.ts @@ -1,6 +1,10 @@ import cluster from 'node:cluster' + import { Injectable, Logger } from '@nestjs/common' import type { ReturnModelType } from '@typegoose/typegoose' +import { cloneDeep, merge, mergeWith } from 'es-toolkit/compat' +import type { z, ZodError } from 'zod' + import { BizException } from '~/common/exceptions/biz.exception' import { EventScope } from '~/constants/business-event.constant' import { RedisKeys } from '~/constants/cache.constant' @@ -13,8 +17,7 @@ import { SubPubBridgeService } from '~/processors/redis/subpub.service' import { InjectModel } from '~/transformers/model.transformer' import { getRedisKey } from '~/utils/redis.util' import { camelcaseKeys, sleep } from '~/utils/tool.util' -import { cloneDeep, merge, mergeWith } from 'es-toolkit/compat' -import type { z, ZodError } from 'zod' + import { generateDefaultConfig } from './configs.default' import { decryptObject, @@ -122,7 +125,7 @@ export class ConfigsService { } catch (error) { await this.configInit() if (errorRetryCount > 0) { - return await this.getConfig(--errorRetryCount) + return await this.getConfig(errorRetryCount - 1) } this.logger.error('获取配置失败') throw error @@ -251,19 +254,6 @@ export class ConfigsService { return option } - case 'algoliaSearchOptions': { - const option = await this.patch( - key as 'algoliaSearchOptions', - instanceValue as any, - ) - if (option.enable) { - this.eventManager.emit(EventBusEvents.PushSearch, null, { - scope: EventScope.TO_SYSTEM, - }) - } - return option - } - case 'oauth': { const value = instanceValue as unknown as OAuthConfig const current = await this.get('oauth') diff --git a/apps/core/src/modules/search/search-document.model.ts b/apps/core/src/modules/search/search-document.model.ts new file mode 100644 index 00000000000..c3f70713fe8 --- /dev/null +++ b/apps/core/src/modules/search/search-document.model.ts @@ -0,0 +1,65 @@ +import { index, modelOptions, prop } from '@typegoose/typegoose' + +import { SEARCH_DOCUMENT_COLLECTION_NAME } from '~/constants/db.constant' + +export type SearchDocumentRefType = 'post' | 'note' | 'page' + +@index({ refType: 1, refId: 1 }, { unique: true }) +@index({ title: 'text', searchText: 'text' }) +@index({ terms: 1 }) +@index({ refType: 1, modified: -1, created: -1 }) +@index({ refType: 1, isPublished: 1, publicAt: 1, hasPassword: 1 }) +@modelOptions({ + options: { + customName: SEARCH_DOCUMENT_COLLECTION_NAME, + }, +}) +export class SearchDocumentModel { + @prop({ required: true, enum: ['post', 'note', 'page'] }) + refType!: SearchDocumentRefType + + @prop({ required: true, index: true }) + refId!: string + + @prop({ required: true, trim: true }) + title!: string + + @prop({ required: true, trim: true }) + searchText!: string + + @prop({ type: () => [String], default: [] }) + terms!: string[] + + @prop({ type: () => [String], default: [] }) + titleTerms!: string[] + + @prop({ type: () => [String], default: [] }) + bodyTerms!: string[] + + @prop({ default: 0 }) + titleLength!: number + + @prop({ default: 0 }) + bodyLength!: number + + @prop({ trim: true }) + slug?: string + + @prop() + nid?: number + + @prop({ default: true }) + isPublished!: boolean + + @prop() + publicAt?: Date | null + + @prop({ default: false }) + hasPassword!: boolean + + @prop() + created?: Date | null + + @prop() + modified?: Date | null +} diff --git a/apps/core/src/modules/search/search.constants.ts b/apps/core/src/modules/search/search.constants.ts index 9783a100878..46eaee1dc93 100644 --- a/apps/core/src/modules/search/search.constants.ts +++ b/apps/core/src/modules/search/search.constants.ts @@ -1,4 +1,8 @@ -export const SEARCH_TITLE_WEIGHT = 3 -export const SEARCH_TEXT_WEIGHT = 20 - -export const DEFAULT_ALGOLIA_MAX_SIZE_IN_BYTES = 10_000 +export const SEARCH_EXACT_TITLE_BONUS = 80 +export const SEARCH_PREFIX_TITLE_BONUS = 30 +export const SEARCH_CANDIDATE_MULTIPLIER = 12 +export const SEARCH_MAX_CANDIDATES = 300 +export const SEARCH_BM25_TITLE_WEIGHT = 3 +export const SEARCH_BM25_BODY_WEIGHT = 1.2 +export const SEARCH_BM25_K1 = 1.2 +export const SEARCH_BM25_B = 0.75 diff --git a/apps/core/src/modules/search/search.controller.ts b/apps/core/src/modules/search/search.controller.ts index c1b6d85c006..27d2625be28 100644 --- a/apps/core/src/modules/search/search.controller.ts +++ b/apps/core/src/modules/search/search.controller.ts @@ -1,22 +1,36 @@ -import { Get, Param, Post, Query, Res } from '@nestjs/common' +import { Get, Param, Post, Query } from '@nestjs/common' + import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' -import { HTTPDecorators } from '~/common/decorators/http.decorator' import { IsAuthenticated } from '~/common/decorators/role.decorator' import { BizException } from '~/common/exceptions/biz.exception' import { ErrorCodeEnum } from '~/constants/error-code.constant' import { SearchDto } from '~/modules/search/search.schema' -import type { FastifyReply } from 'fastify' + import { SearchService } from './search.service' @ApiController('search') export class SearchController { constructor(private readonly searchService: SearchService) {} + @HttpCache.disable + @Get() + search( + @Query() query: SearchDto, + @IsAuthenticated() isAuthenticated: boolean, + ) { + return this.searchService.search(query, isAuthenticated) + } + + @Post('/rebuild') + @Auth() + rebuild() { + return this.searchService.rebuildSearchDocuments() + } + @Get('/:type') @HttpCache.disable - @HTTPDecorators.Paginator searchByType( @Query() query: SearchDto, @IsAuthenticated() isAuthenticated: boolean, @@ -25,33 +39,18 @@ export class SearchController { type = type.toLowerCase() switch (type) { case 'post': { - return this.searchService.searchPost(query) + return this.searchService.searchPost(query, isAuthenticated) } - case 'note': + case 'note': { return this.searchService.searchNote(query, isAuthenticated) + } + case 'page': { + return this.searchService.searchPage(query) + } - default: + default: { throw new BizException(ErrorCodeEnum.InvalidSearchType, type) + } } } - - @Get('/algolia') - async search(@Query() query: SearchDto) { - return this.searchService.searchAlgolia(query) - } - - @Post('/algolia/push') - @Auth() - async pushAlgoliaAllManually() { - return this.searchService.pushAllToAlgoliaSearch() - } - - @Get('/algolia/import-json') - @Auth() - async getAlgoliaIndexJsonFile(@Res() res: FastifyReply) { - const documents = await this.searchService.buildAlgoliaIndexData() - res.header('Content-Type', 'application/json') - res.header('Content-Disposition', 'attachment; filename=algolia-index.json') - res.send(JSON.stringify(documents)) - } } diff --git a/apps/core/src/modules/search/search.schema.ts b/apps/core/src/modules/search/search.schema.ts index 854a757fe48..e264af91a3e 100644 --- a/apps/core/src/modules/search/search.schema.ts +++ b/apps/core/src/modules/search/search.schema.ts @@ -1,8 +1,9 @@ -import { zNonEmptyString } from '~/common/zod' -import { PagerSchema } from '~/shared/dto/pager.dto' import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { zNonEmptyString } from '~/common/zod' +import { PagerSchema } from '~/shared/dto/pager.dto' + export const SearchSchema = PagerSchema.extend({ keyword: zNonEmptyString, orderBy: zNonEmptyString.optional(), @@ -10,12 +11,6 @@ export const SearchSchema = PagerSchema.extend({ (val) => (typeof val === 'string' ? Number.parseInt(val, 10) : val), z.union([z.literal(1), z.literal(-1)]).optional(), ), - rawAlgolia: z - .preprocess( - (val) => (val === 'true' || val === '1' ? 1 : 0), - z.union([z.literal(0), z.literal(1)]), - ) - .optional(), }) export class SearchDto extends createZodDto(SearchSchema) {} diff --git a/apps/core/src/modules/search/search.service.ts b/apps/core/src/modules/search/search.service.ts index c8b7cc596bb..0419b23b952 100644 --- a/apps/core/src/modules/search/search.service.ts +++ b/apps/core/src/modules/search/search.service.ts @@ -1,34 +1,48 @@ -import type { SearchClient, SearchResponse } from '@algolia/client-search' import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' -import { CronExpression } from '@nestjs/schedule' -import { CronDescription } from '~/common/decorators/cron-description.decorator' -import { CronOnce } from '~/common/decorators/cron-once.decorator' -import { BizException } from '~/common/exceptions/biz.exception' +import type { ReturnModelType } from '@typegoose/typegoose' +import type { QueryFilter } from 'mongoose' +import removeMdCodeblock from 'remove-md-codeblock' + import { BusinessEvents } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' -import { EventBusEvents } from '~/constants/event-bus.constant' import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' import type { SearchDto } from '~/modules/search/search.schema' -import { DatabaseService } from '~/processors/database/database.service' import type { Pagination } from '~/shared/interface/paginator.interface' -import { transformDataToPaginate } from '~/transformers/paginate.transformer' -import { algoliasearch } from 'algoliasearch' -import { omit } from 'es-toolkit/compat' -import removeMdCodeblock from 'remove-md-codeblock' -import { ConfigsService } from '../configs/configs.service' +import { InjectModel } from '~/transformers/model.transformer' + import { NoteService } from '../note/note.service' import { PageService } from '../page/page.service' import type { PostService } from '../post/post.service' import { - DEFAULT_ALGOLIA_MAX_SIZE_IN_BYTES, - SEARCH_TEXT_WEIGHT, - SEARCH_TITLE_WEIGHT, + SEARCH_BM25_B, + SEARCH_BM25_BODY_WEIGHT, + SEARCH_BM25_K1, + SEARCH_BM25_TITLE_WEIGHT, + SEARCH_CANDIDATE_MULTIPLIER, + SEARCH_EXACT_TITLE_BONUS, + SEARCH_MAX_CANDIDATES, + SEARCH_PREFIX_TITLE_BONUS, } from './search.constants' +import { + SearchDocumentModel, + type SearchDocumentRefType, +} from './search-document.model' + +type SearchDocumentLean = SearchDocumentModel & { + id?: string + _id?: { toString: () => string } +} + +type SearchCorpusStats = { + totalDocs: number + avgTitleLength: number + avgBodyLength: number +} @Injectable() export class SearchService { private readonly logger = new Logger(SearchService.name) + constructor( @Inject(forwardRef(() => NoteService)) private readonly noteService: NoteService, @@ -39,387 +53,632 @@ export class SearchService { @Inject(forwardRef(() => PageService)) private readonly pageService: PageService, - private readonly configs: ConfigsService, - private readonly databaseService: DatabaseService, + @InjectModel(SearchDocumentModel) + private readonly searchDocumentModel: ReturnModelType< + typeof SearchDocumentModel + >, ) {} - async searchNote(searchOption: SearchDto, showHidden: boolean) { - const { keyword, page, size } = searchOption - const select = '_id title created modified nid' - const keywordArr = this.buildSearchKeywordRegexes(keyword) - - const result = await this.noteService.model.paginate( - { - $or: [{ title: { $in: keywordArr } }, { text: { $in: keywordArr } }], - $and: [ - { password: { $ne: null } }, - { isPublished: { $in: showHidden ? [false, true] : [true] } }, - { - $or: [ - { publicAt: { $ne: null } }, - { publicAt: { $lte: new Date() } }, - ], - }, - ], - }, - { - limit: size, - page, - select: `${select} text`, - }, - ) - result.docs = this.applyWeightedSort(result.docs, keywordArr) - return transformDataToPaginate(result) + async search(searchOption: SearchDto, isAuthenticated = false) { + return this.searchIndex(searchOption, undefined, isAuthenticated) } - async searchPost(searchOption: SearchDto) { - const { keyword, page, size } = searchOption - const select = '_id title created modified categoryId slug' - const keywordArr = this.buildSearchKeywordRegexes(keyword) - const result = await this.postService.model.paginate( - { - $or: [{ title: { $in: keywordArr } }, { text: { $in: keywordArr } }], - }, - { - limit: size, - page, - select: `${select} text`, - }, - ) - result.docs = this.applyWeightedSort(result.docs, keywordArr) - return result + async searchNote(searchOption: SearchDto, isAuthenticated: boolean) { + return this.searchIndex(searchOption, 'note', isAuthenticated) } - public async getAlgoliaSearchClient() { - const { algoliaSearchOptions } = await this.configs.waitForConfigReady() - if (!algoliaSearchOptions.enable) { - throw new BizException(ErrorCodeEnum.AlgoliaNotEnabled) - } - if ( - !algoliaSearchOptions.appId || - !algoliaSearchOptions.apiKey || - !algoliaSearchOptions.indexName - ) { - throw new BizException(ErrorCodeEnum.AlgoliaNotConfigured) - } - const client = algoliasearch( - algoliaSearchOptions.appId, - algoliaSearchOptions.apiKey, - ) - return { client, indexName: algoliaSearchOptions.indexName } - } - - async searchAlgolia(searchOption: SearchDto): Promise< - | SearchResponse<{ - id: string - text: string - title: string - type: 'post' | 'note' | 'page' - }> - | (Pagination & { - raw: SearchResponse<{ - id: string - text: string - title: string - type: 'post' | 'note' | 'page' - }> - }) - > { - const { keyword, size, page } = searchOption - const { client, indexName } = await this.getAlgoliaSearchClient() - - const search = await client.searchSingleIndex<{ - id: string - text: string - title: string - type: 'post' | 'note' | 'page' - }>({ - indexName, - searchParams: { - query: keyword, - page: page - 1, - hitsPerPage: size, - attributesToRetrieve: ['*'], - snippetEllipsisText: '...', - facets: ['*'], - }, - }) - if (searchOption.rawAlgolia) { - return search - } - const data: any[] = [] - const tasks = search.hits.map(async (hit) => { - const { type, objectID } = hit + async searchPost(searchOption: SearchDto, isAuthenticated = false) { + return this.searchIndex(searchOption, 'post', isAuthenticated) + } - const model = this.databaseService.getModelByRefType(type as 'post') - if (!model) { - return - } - const doc = await model - .findById(objectID) - .select('_id title created modified categoryId slug nid') - .lean({ - getters: true, - autopopulate: true, - }) - if (doc) { - Reflect.set(doc, 'type', type) - data.push(doc) - } - }) - await Promise.all(tasks) - return { - data, - raw: search, - pagination: { - currentPage: page, - total: search.nbHits ?? 0, - hasNextPage: (search.nbPages ?? 0) > (search.page ?? 0), - hasPrevPage: (search.page ?? 0) > 1, - size: search.hitsPerPage ?? size, - totalPage: search.nbPages ?? 0, - }, - } + async searchPage(searchOption: SearchDto) { + return this.searchIndex(searchOption, 'page', false) } - /** - * @description 每天凌晨推送一遍 Algolia Search - */ - @CronOnce(CronExpression.EVERY_DAY_AT_MIDNIGHT, { - name: 'pushToAlgoliaSearch', - }) - @CronDescription('推送到 Algolia Search') - @OnEvent(EventBusEvents.PushSearch) - async pushAllToAlgoliaSearch() { - const configs = await this.configs.waitForConfigReady() - if (!configs.algoliaSearchOptions.enable || isDev) { - return - } - const { client, indexName } = await this.getAlgoliaSearchClient() + async rebuildSearchDocuments() { + const documents = await this.buildSearchDocuments() + await this.searchDocumentModel.deleteMany({}) - this.logger.log('--> 开始推送到 Algolia') + if (documents.length) { + await this.searchDocumentModel.insertMany(documents, { ordered: false }) + } - const documents = await this.buildAlgoliaIndexData() - try { - await Promise.all([ - client.replaceAllObjects({ - indexName, - objects: documents, - }), - client.setSettings({ - indexName, - indexSettings: { - attributesToHighlight: ['text', 'title'], - }, - }), - ]) + this.logger.log(`rebuilt local search index, total: ${documents.length}`) - this.logger.log('--> 推送到 algoliasearch 成功') - } catch (error) { - this.logger.error('algolia 推送错误') - throw error - } + return { total: documents.length } } - async buildAlgoliaIndexData() { - const combineDocuments = await Promise.all([ + async buildSearchDocuments() { + const [posts, pages, notes] = await Promise.all([ this.postService.model .find() - .select('title text categoryId category slug') - .populate('category', 'name slug') - .lean() - - .then((list) => { - return list.map((data) => { - Reflect.set(data, 'objectID', data._id) - Reflect.deleteProperty(data, '_id') - return { - ...data, - text: removeMdCodeblock(data.text), - type: 'post', - } - }) - }), + .select('title text slug created modified isPublished') + .lean(), this.pageService.model - .find({}, 'title text slug subtitle') - .lean() - .then((list) => { - return list.map((data) => { - Reflect.set(data, 'objectID', data._id) - Reflect.deleteProperty(data, '_id') - return { - ...data, - type: 'page', - } - }) - }), + .find() + .select('title text slug created modified') + .lean(), this.noteService.model - .find( - { - isPublished: true, - $or: [ - { password: undefined }, - { password: null }, - { password: { $exists: false } }, - ], - }, - 'title text nid', + .find() + .select( + 'title text nid slug created modified isPublished publicAt +password', ) - .lean() - .then((list) => { - return list.map((data) => { - const id = data.nid.toString() - Reflect.set(data, 'objectID', data._id) - Reflect.deleteProperty(data, '_id') - Reflect.deleteProperty(data, 'nid') - return { - ...data, - type: 'note', - id, - } - }) - }), + .lean(), ]) - const { algoliaSearchOptions } = await this.configs.waitForConfigReady() - - return combineDocuments - .flat() - .map((item) => - adjustObjectSizeEfficiently(item, algoliaSearchOptions.maxTruncateSize), - ) + return [ + ...posts.map((doc) => this.toSearchDocument('post', doc)), + ...pages.map((doc) => this.toSearchDocument('page', doc)), + ...notes.map((doc) => this.toSearchDocument('note', doc)), + ] } @OnEvent(BusinessEvents.POST_CREATE) @OnEvent(BusinessEvents.POST_UPDATE) async onPostCreate(post: { id: string }) { - const data = await this.postService.model.findById(post.id).lean() + await this.upsertSearchDocument('post', post.id) + } - if (!data) return + @OnEvent(BusinessEvents.NOTE_CREATE) + @OnEvent(BusinessEvents.NOTE_UPDATE) + async onNoteCreate(note: { id: string }) { + await this.upsertSearchDocument('note', note.id) + } - this.executeAlgoliaSearchOperationIfEnabled( - async ({ client, indexName }) => { - const { algoliaSearchOptions } = await this.configs.waitForConfigReady() + @OnEvent(BusinessEvents.PAGE_CREATE) + @OnEvent(BusinessEvents.PAGE_UPDATE) + async onPageCreate(page: { id: string }) { + await this.upsertSearchDocument('page', page.id) + } - this.logger.log( - `detect post created or update, save to algolia, data id:${data.id}`, - ) - await client.saveObject({ - indexName, - body: adjustObjectSizeEfficiently( - { - ...omit(data, '_id'), - objectID: data.id, - id: data.id, - type: 'post', - }, - algoliaSearchOptions.maxTruncateSize, - ), - }) - this.logger.log(`save to algolia success, id: ${data.id}`) - }, + @OnEvent(BusinessEvents.POST_DELETE) + async onPostDelete({ id }: { id: string }) { + await this.deleteSearchDocument('post', id) + } + + @OnEvent(BusinessEvents.NOTE_DELETE) + async onNoteDelete({ id }: { id: string }) { + await this.deleteSearchDocument('note', id) + } + + @OnEvent(BusinessEvents.PAGE_DELETE) + async onPageDelete({ id }: { id: string }) { + await this.deleteSearchDocument('page', id) + } + + private async searchIndex( + searchOption: SearchDto, + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + ): Promise> { + const { keyword, page, size } = searchOption + const searchTerms = this.buildSearchTerms(keyword) + const keywordRegexes = this.buildSearchKeywordRegexes(keyword) + const candidateLimit = Math.min( + SEARCH_MAX_CANDIDATES, + Math.max(size * page * SEARCH_CANDIDATE_MULTIPLIER, size * 4), + ) + + const [ + termCandidates, + textCandidates, + regexCandidates, + corpusStats, + termDocumentFrequency, + ] = await Promise.all([ + this.searchByTerms(searchTerms, refType, isAuthenticated, candidateLimit), + this.searchByText(keyword, refType, isAuthenticated, candidateLimit), + this.searchByRegex( + keywordRegexes, + refType, + isAuthenticated, + candidateLimit, + ), + this.getCorpusStats(refType, isAuthenticated), + this.getTermDocumentFrequency(searchTerms, refType, isAuthenticated), + ]) + + const merged = new Map() + for (const doc of [ + ...termCandidates, + ...textCandidates, + ...regexCandidates, + ]) { + merged.set(this.getSearchDocumentKey(doc), doc) + } + + const ranked = this.rankSearchHits( + [...merged.values()], + keywordRegexes, + searchTerms, + corpusStats, + termDocumentFrequency, ) + const start = (page - 1) * size + const pageHits = ranked.slice(start, start + size) + const data = await this.loadSearchResultData(pageHits, isAuthenticated) + const output = refType ? data.map(({ type, ...item }) => item) : data + + return { + data: output, + pagination: { + total: ranked.length, + currentPage: page, + totalPage: Math.ceil(ranked.length / size) || 1, + size, + hasNextPage: start + size < ranked.length, + hasPrevPage: page > 1, + }, + } } - @OnEvent(BusinessEvents.NOTE_CREATE) - @OnEvent(BusinessEvents.NOTE_UPDATE) - async onNoteCreate(note: { id: string }) { - const data = await this.noteService.model.findById(note.id).lean() + private async searchByTerms( + searchTerms: string[], + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + limit: number, + ) { + if (!searchTerms.length) { + return [] + } - if (!data) return + return this.searchDocumentModel + .find({ + $and: [ + this.buildVisibilityQuery(refType, isAuthenticated), + { terms: { $in: searchTerms } }, + ], + }) + .select(this.searchProjection) + .limit(limit) + .lean() + } - this.executeAlgoliaSearchOperationIfEnabled( - async ({ client, indexName }) => { - this.logger.log( - `detect post created or update, save to algolia, data id:${data.id}`, - ) - const { algoliaSearchOptions } = await this.configs.waitForConfigReady() - - await client.saveObject({ - indexName, - body: adjustObjectSizeEfficiently( - { - ...omit(data, '_id'), - objectID: data.id, - id: data.id, - type: 'note', - }, - algoliaSearchOptions.maxTruncateSize, - ), - }) - this.logger.log(`save to algolia success, id: ${data.id}`) + private async searchByText( + keyword: string, + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + limit: number, + ) { + if (!keyword.trim()) { + return [] + } + + return this.searchDocumentModel + .find({ + $and: [ + this.buildVisibilityQuery(refType, isAuthenticated), + { $text: { $search: keyword.trim() } }, + ], + }) + .select(this.searchProjection) + .limit(limit) + .lean() + } + + private async searchByRegex( + keywordRegexes: RegExp[], + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + limit: number, + ) { + const clauses = this.buildRegexClauses(keywordRegexes) + if (!clauses.length) { + return [] + } + + return this.searchDocumentModel + .find({ + $and: [ + this.buildVisibilityQuery(refType, isAuthenticated), + { $or: clauses }, + ], + }) + .select(this.searchProjection) + .limit(limit) + .lean() + } + + private async getCorpusStats( + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + ): Promise { + const visibilityMatch = this.buildVisibilityQuery( + refType, + isAuthenticated, + ) as Record + + const [stats] = await this.searchDocumentModel.aggregate<{ + totalDocs: number + avgTitleLength: number + avgBodyLength: number + }>([ + { $match: visibilityMatch }, + { + $group: { + _id: null, + totalDocs: { $sum: 1 }, + avgTitleLength: { $avg: '$titleLength' }, + avgBodyLength: { $avg: '$bodyLength' }, + }, }, - ) + ]) + + return { + totalDocs: stats?.totalDocs ?? 0, + avgTitleLength: stats?.avgTitleLength ?? 1, + avgBodyLength: stats?.avgBodyLength ?? 1, + } } - @OnEvent(BusinessEvents.POST_DELETE) - @OnEvent(BusinessEvents.NOTE_DELETE) - async onPostDelete({ id }: { id: string }) { - await this.executeAlgoliaSearchOperationIfEnabled( - async ({ client, indexName }) => { - this.logger.log(`detect data delete, save to algolia, data id: ${id}`) + private async getTermDocumentFrequency( + searchTerms: string[], + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + ) { + if (!searchTerms.length) { + return new Map() + } + + const visibilityMatch = this.buildVisibilityQuery( + refType, + isAuthenticated, + ) as Record - await client.deleteObject({ indexName, objectID: id }) + const matched = await this.searchDocumentModel.aggregate<{ + _id: string + count: number + }>([ + { + $match: { + $and: [visibilityMatch, { terms: { $in: searchTerms } }], + }, }, - ) + { $unwind: '$terms' }, + { $match: { terms: { $in: searchTerms } } }, + { $group: { _id: '$terms', count: { $sum: 1 } } }, + ]) + + return new Map(matched.map((item) => [item._id, item.count])) + } + + private buildVisibilityQuery( + refType: SearchDocumentRefType | undefined, + isAuthenticated: boolean, + ): QueryFilter { + if (isAuthenticated) { + return refType ? { refType } : {} + } + + const now = new Date() + if (refType === 'post') { + return { + refType, + isPublished: { $ne: false }, + } + } + if (refType === 'page') { + return { refType } + } + if (refType === 'note') { + return { + refType, + isPublished: true, + hasPassword: { $ne: true }, + $or: [ + { publicAt: null }, + { publicAt: { $exists: false } }, + { publicAt: { $lte: now } }, + ], + } + } + + return { + $or: [ + { refType: 'page' }, + { refType: 'post', isPublished: { $ne: false } }, + { + refType: 'note', + isPublished: true, + hasPassword: { $ne: true }, + $or: [ + { publicAt: null }, + { publicAt: { $exists: false } }, + { publicAt: { $lte: now } }, + ], + }, + ], + } + } + + private async loadSearchResultData( + hits: Array, + isAuthenticated: boolean, + ) { + if (!hits.length) { + return [] + } + + const idsByType = { + post: [] as string[], + note: [] as string[], + page: [] as string[], + } + + for (const hit of hits) { + idsByType[hit.refType].push(hit.refId) + } + + const now = new Date() + const [posts, notes, pages] = await Promise.all([ + idsByType.post.length + ? this.postService.model + .find({ + _id: { $in: idsByType.post }, + ...(isAuthenticated ? {} : { isPublished: { $ne: false } }), + }) + .select('_id title created modified categoryId slug') + .populate('category', 'name slug') + .lean({ getters: true, autopopulate: true }) + : [], + idsByType.note.length + ? this.noteService.model + .find({ + _id: { $in: idsByType.note }, + ...(isAuthenticated + ? {} + : { + isPublished: true, + $and: [ + { + $or: [ + { password: null }, + { password: '' }, + { password: { $exists: false } }, + ], + }, + { + $or: [ + { publicAt: null }, + { publicAt: { $exists: false } }, + { publicAt: { $lte: now } }, + ], + }, + ], + }), + }) + .select('_id title created modified nid slug') + .lean({ getters: true, autopopulate: true }) + : [], + idsByType.page.length + ? this.pageService.model + .find({ _id: { $in: idsByType.page } }) + .select('_id title created modified slug subtitle') + .lean({ getters: true }) + : [], + ]) + + const map = new Map() + for (const post of posts) { + map.set(`post:${post.id}`, { ...post, type: 'post' as const }) + } + for (const note of notes) { + map.set(`note:${note.id}`, { ...note, type: 'note' as const }) + } + for (const page of pages) { + map.set(`page:${page.id}`, { ...page, type: 'page' as const }) + } + + return hits + .map((hit) => map.get(`${hit.refType}:${hit.refId}`)) + .filter(Boolean) } - private async executeAlgoliaSearchOperationIfEnabled( - caller: (ctx: { client: SearchClient; indexName: string }) => Promise, + private async upsertSearchDocument( + refType: SearchDocumentRefType, + id: string, ) { - const configs = await this.configs.waitForConfigReady() - if (!configs.algoliaSearchOptions.enable || isDev) { + const sourceDocument = await this.loadSourceDocument(refType, id) + if (!sourceDocument) { + await this.deleteSearchDocument(refType, id) return } - const { client, indexName } = await this.getAlgoliaSearchClient() - return caller({ client, indexName }) + + await this.searchDocumentModel.updateOne( + { refType, refId: id }, + { $set: this.toSearchDocument(refType, sourceDocument) }, + { upsert: true }, + ) + } + + private async deleteSearchDocument( + refType: SearchDocumentRefType, + id: string, + ) { + await this.searchDocumentModel.deleteOne({ refType, refId: id }) + } + + private async loadSourceDocument(refType: SearchDocumentRefType, id: string) { + switch (refType) { + case 'post': { + return this.postService.model + .findById(id) + .select('title text slug created modified isPublished') + .lean() + } + case 'note': { + return this.noteService.model + .findById(id) + .select( + 'title text nid slug created modified isPublished publicAt +password', + ) + .lean() + } + case 'page': { + return this.pageService.model + .findById(id) + .select('title text slug created modified') + .lean() + } + } + } + + private toSearchDocument( + refType: SearchDocumentRefType, + data: Record, + ): SearchDocumentModel { + const normalizedTitle = this.normalizeSearchText(data.title) + const normalizedBody = this.normalizeSearchText(data.text) + const titleTerms = tokenizeSearchText(normalizedTitle, { + includeCjkUnigrams: true, + maxTokens: 96, + }) + const bodyTerms = tokenizeSearchText(normalizedBody, { + includeCjkUnigrams: false, + maxTokens: 512, + }) + + return { + refType, + refId: data.id ?? data._id?.toString?.(), + title: normalizedTitle, + searchText: normalizedBody, + terms: [...new Set([...titleTerms, ...bodyTerms])], + titleTerms, + bodyTerms, + titleLength: titleTerms.length, + bodyLength: bodyTerms.length, + slug: data.slug, + nid: data.nid, + isPublished: refType === 'page' ? true : data.isPublished !== false, + publicAt: data.publicAt ?? null, + hasPassword: Boolean(data.password), + created: data.created ?? null, + modified: data.modified ?? null, + } + } + + private normalizeSearchText(text: unknown) { + return removeMdCodeblock(typeof text === 'string' ? text : '') + .normalize('NFKC') + .toLowerCase() + .replaceAll(/\s+/g, ' ') + .trim() } private buildSearchKeywordRegexes(keyword: string) { return keyword + .trim() .split(/\s+/) .filter(Boolean) - .map((item) => new RegExp(String(item), 'gi')) + .map((item) => new RegExp(escapeRegExp(item), 'gi')) + } + + private buildSearchTerms(keyword: string) { + return [ + ...new Set( + tokenizeSearchText(this.normalizeSearchText(keyword), { + includeCjkUnigrams: true, + maxTokens: 48, + }), + ), + ] } - private applyWeightedSort>( - docs: T[], + private buildRegexClauses(keywordRegexes: RegExp[]) { + return keywordRegexes.flatMap((regex) => [ + { title: regex }, + { searchText: regex }, + ]) + } + + private rankSearchHits( + docs: SearchDocumentLean[], keywordRegexes: RegExp[], - ) { + searchTerms: string[], + corpusStats: SearchCorpusStats, + termDocumentFrequency: Map, + ): Array { return docs - .map((doc) => - typeof (doc as any).toObject === 'function' - ? (doc as any).toObject() - : doc, - ) .map((doc) => ({ ...doc, - __search_weight: this.calculateSearchWeight(doc, keywordRegexes), + __searchWeight: this.calculateSearchWeight( + doc, + keywordRegexes, + searchTerms, + corpusStats, + termDocumentFrequency, + ), })) .sort((a, b) => { - if (a.__search_weight !== b.__search_weight) { - return b.__search_weight - a.__search_weight + if (a.__searchWeight !== b.__searchWeight) { + return b.__searchWeight - a.__searchWeight } + const dateA = new Date(a.modified ?? a.created ?? 0).valueOf() const dateB = new Date(b.modified ?? b.created ?? 0).valueOf() return dateB - dateA }) - .map(({ __search_weight, text, ...rest }) => rest) + .map( + ({ __searchWeight, ...doc }) => + doc as SearchDocumentLean & { + refType: SearchDocumentRefType + }, + ) } private calculateSearchWeight( - doc: { title?: string; text?: string }, + doc: SearchDocumentLean, keywordRegexes: RegExp[], + searchTerms: string[], + corpusStats: SearchCorpusStats, + termDocumentFrequency: Map, ) { const title = doc.title ?? '' - const text = doc.text ?? '' + const text = doc.searchText ?? '' + const loweredTitle = title.toLowerCase() + const titleTermFrequency = countTerms(doc.titleTerms ?? []) + const bodyTermFrequency = countTerms(doc.bodyTerms ?? []) let score = 0 - for (const keywordRegex of keywordRegexes) { - const titleMatches = this.countKeywordMatches(title, keywordRegex) - const textMatches = this.countKeywordMatches(text, keywordRegex) + + for (const searchTerm of searchTerms) { + const df = termDocumentFrequency.get(searchTerm) ?? 0 + if (!df || !corpusStats.totalDocs) { + continue + } + + const idf = computeBm25Idf(corpusStats.totalDocs, df) + const titleTf = titleTermFrequency.get(searchTerm) ?? 0 + const bodyTf = bodyTermFrequency.get(searchTerm) ?? 0 + + score += + computeBm25Score({ + termFrequency: titleTf, + documentLength: doc.titleLength ?? title.length, + averageDocumentLength: corpusStats.avgTitleLength, + idf, + }) * SEARCH_BM25_TITLE_WEIGHT score += - titleMatches * SEARCH_TITLE_WEIGHT + textMatches * SEARCH_TEXT_WEIGHT + computeBm25Score({ + termFrequency: bodyTf, + documentLength: doc.bodyLength ?? text.length, + averageDocumentLength: corpusStats.avgBodyLength, + idf, + }) * SEARCH_BM25_BODY_WEIGHT } + + for (const keywordRegex of keywordRegexes) { + const keyword = keywordRegex.source.toLowerCase() + if (loweredTitle === keyword) { + score += SEARCH_EXACT_TITLE_BONUS + } else if (loweredTitle.startsWith(keyword)) { + score += SEARCH_PREFIX_TITLE_BONUS + } + + score += this.countKeywordMatches(title, keywordRegex) * 6 + score += this.countKeywordMatches(text, keywordRegex) * 1.5 + } + return score } @@ -428,41 +687,110 @@ export class SearchService { const safeRegex = new RegExp(keywordRegex.source, keywordRegex.flags) return text.match(safeRegex)?.length ?? 0 } + + private getSearchDocumentKey( + doc: Pick, + ) { + return `${doc.refType}:${doc.refId}` + } + + private get searchProjection() { + return { + refType: 1, + refId: 1, + title: 1, + searchText: 1, + terms: 1, + titleTerms: 1, + bodyTerms: 1, + titleLength: 1, + bodyLength: 1, + created: 1, + modified: 1, + } + } +} + +function escapeRegExp(input: string) { + return input.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&') } -function adjustObjectSizeEfficiently( - originalObject: T, - maxSizeInBytes: number = DEFAULT_ALGOLIA_MAX_SIZE_IN_BYTES, -): T { - const objectToAdjust = structuredClone(originalObject) - const text = objectToAdjust.text - - let low = 0 - let high = text.length - let mid: number - - while (low <= high) { - mid = Math.floor((low + high) / 2) - objectToAdjust.text = text.slice(0, mid) - const currentSize = new TextEncoder().encode( - JSON.stringify(objectToAdjust), - ).length - - if (currentSize > maxSizeInBytes) { - high = mid - 1 - } else if (currentSize < maxSizeInBytes) { - low = mid + 1 +function tokenizeSearchText( + text: string, + options: { includeCjkUnigrams: boolean; maxTokens: number }, +) { + if (!text) { + return [] + } + + const tokens: string[] = [] + const segments = text.match( + /[\da-z]+|[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]+/g, + ) + + for (const segment of segments ?? []) { + if (isCjkSegment(segment)) { + if (options.includeCjkUnigrams) { + for (const char of segment) { + tokens.push(char) + } + } + if (segment.length <= 8) { + tokens.push(segment) + } + for (let index = 0; index < segment.length - 1; index++) { + tokens.push(segment.slice(index, index + 2)) + } } else { + tokens.push(segment) + } + + if (tokens.length >= options.maxTokens) { break } } - while ( - new TextEncoder().encode(JSON.stringify(objectToAdjust)).length > - maxSizeInBytes - ) { - objectToAdjust.text = objectToAdjust.text.slice(0, -1) + return tokens.slice(0, options.maxTokens) +} + +function isCjkSegment(input: string) { + return /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/.test( + input, + ) +} + +function countTerms(terms: string[]) { + const map = new Map() + for (const term of terms) { + map.set(term, (map.get(term) ?? 0) + 1) } + return map +} + +function computeBm25Idf(totalDocs: number, documentFrequency: number) { + return Math.log( + 1 + (totalDocs - documentFrequency + 0.5) / (documentFrequency + 0.5), + ) +} + +function computeBm25Score(input: { + termFrequency: number + documentLength: number + averageDocumentLength: number + idf: number +}) { + if (!input.termFrequency) { + return 0 + } + + const normalizedLength = + 1 - + SEARCH_BM25_B + + SEARCH_BM25_B * + (input.documentLength / Math.max(input.averageDocumentLength, 1)) + + const numerator = input.termFrequency * (SEARCH_BM25_K1 + 1) + const denominator = input.termFrequency + SEARCH_BM25_K1 * normalizedLength - return objectToAdjust as T + return input.idf * (numerator / denominator) } diff --git a/apps/core/src/processors/database/database.models.ts b/apps/core/src/processors/database/database.models.ts index e23951f80fa..c19a5f1460b 100644 --- a/apps/core/src/processors/database/database.models.ts +++ b/apps/core/src/processors/database/database.models.ts @@ -18,6 +18,7 @@ import { ProjectModel } from '~/modules/project/project.model' import { ReaderModel } from '~/modules/reader/reader.model' import { RecentlyModel } from '~/modules/recently/recently.model' import { SayModel } from '~/modules/say/say.model' +import { SearchDocumentModel } from '~/modules/search/search-document.model' import { ServerlessStorageModel } from '~/modules/serverless/serverless.model' import { ServerlessLogModel } from '~/modules/serverless/serverless-log.model' import { SlugTrackerModel } from '~/modules/slug-tracker/slug-tracker.model' @@ -47,6 +48,7 @@ export const databaseModels = [ ReaderModel, RecentlyModel, SayModel, + SearchDocumentModel, ServerlessLogModel, ServerlessStorageModel, SlugTrackerModel, diff --git a/apps/core/test/src/modules/configs/configs.service.spec.ts b/apps/core/test/src/modules/configs/configs.service.spec.ts index dfe64202218..73ff082e70c 100644 --- a/apps/core/test/src/modules/configs/configs.service.spec.ts +++ b/apps/core/test/src/modules/configs/configs.service.spec.ts @@ -1,5 +1,9 @@ import { Test } from '@nestjs/testing' import { getModelForClass } from '@typegoose/typegoose' +import type { MockCacheService } from 'test/helper/redis-mock.helper' +import { redisHelper } from 'test/helper/redis-mock.helper' +import { vi } from 'vitest' + import { BizException } from '~/common/exceptions/biz.exception' import { extendedZodValidationPipeInstance } from '~/common/zod/validation.pipe' import { RedisKeys } from '~/constants/cache.constant' @@ -11,9 +15,6 @@ import { RedisService } from '~/processors/redis/redis.service' import { SubPubBridgeService } from '~/processors/redis/subpub.service' import { getModelToken } from '~/transformers/model.transformer' import { getRedisKey } from '~/utils/redis.util' -import { redisHelper } from 'test/helper/redis-mock.helper' -import type { MockCacheService } from 'test/helper/redis-mock.helper' -import { vi } from 'vitest' describe('Test ConfigsService', () => { let service: ConfigsService @@ -140,7 +141,7 @@ describe('Test ConfigsService', () => { // is tested via the resend test above which shows the pattern works. }) - it('should emit event if enable email option and update search', async () => { + it('should emit event if enable email option', async () => { // Clear mock from previous tests mockEmitFn.mockClear() @@ -165,26 +166,5 @@ describe('Test ConfigsService', () => { }) expect(mockEmitFn).toBeCalledTimes(1) mockEmitFn.mockClear() - - // ConfigChanged + PushSearch when enable: true - await service.patchAndValid('algoliaSearchOptions', { - enable: true, - }) - expect(mockEmitFn).toBeCalledTimes(2) - mockEmitFn.mockClear() - - // ConfigChanged + PushSearch (because enable is still true) - await service.patchAndValid('algoliaSearchOptions', { - indexName: 'x', - }) - expect(mockEmitFn).toBeCalledTimes(2) - mockEmitFn.mockClear() - - // Only ConfigChanged when enable: false - await service.patchAndValid('algoliaSearchOptions', { - enable: false, - }) - expect(mockEmitFn).toBeCalledTimes(1) - mockEmitFn.mockClear() }) }) diff --git a/apps/core/test/src/modules/configs/configs.util.spec.ts b/apps/core/test/src/modules/configs/configs.util.spec.ts index 79fea302be8..c6d6e7e8da5 100644 --- a/apps/core/test/src/modules/configs/configs.util.spec.ts +++ b/apps/core/test/src/modules/configs/configs.util.spec.ts @@ -16,7 +16,6 @@ describe('encrypt.util', () => { expect(paths).toContain('imageStorageOptions.secretKey') expect(paths).toContain('baiduSearchOptions.token') expect(paths).toContain('bingSearchOptions.token') - expect(paths).toContain('algoliaSearchOptions.apiKey') expect(paths).toContain('adminExtra.gaodemapKey') expect(paths).toContain('barkOptions.key') expect(paths).toContain('thirdPartyServiceIntegration.githubToken') @@ -24,7 +23,7 @@ describe('encrypt.util', () => { expect(paths).toContain('oauth.secrets.*.*') // Ensure exact count - expect(paths.length).toBe(12) + expect(paths.length).toBe(11) }) describe('path-based encryption', () => { test('should encrypt mailOptions.smtp.pass', () => { @@ -133,21 +132,6 @@ describe('encrypt.util', () => { expect(encrypted.enable).toBe(true) }) - test('should encrypt algoliaSearchOptions.apiKey', () => { - const config = { - enable: true, - apiKey: 'algolia-api-key', - appId: 'algolia-app-id', - indexName: 'my-index', - } - - const encrypted = encryptObject(config, 'algoliaSearchOptions') - - expect(encrypted.apiKey).toMatch(/^\$\$\{mx\}\$\$/) - expect(encrypted.appId).toBe('algolia-app-id') - expect(encrypted.indexName).toBe('my-index') - }) - test('should encrypt thirdPartyServiceIntegration.githubToken', () => { const config = { githubToken: 'ghp_xxxxxxxxxxxx', diff --git a/apps/core/test/src/modules/search/search.service.spec.ts b/apps/core/test/src/modules/search/search.service.spec.ts index a10906ae5f9..21256cbb294 100644 --- a/apps/core/test/src/modules/search/search.service.spec.ts +++ b/apps/core/test/src/modules/search/search.service.spec.ts @@ -1,46 +1,27 @@ import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' -import { ConfigsService } from '~/modules/configs/configs.service' import { NoteService } from '~/modules/note/note.service' import { PageService } from '~/modules/page/page.service' import { SearchService } from '~/modules/search/search.service' -import { DatabaseService } from '~/processors/database/database.service' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SearchDocumentModel } from '~/modules/search/search-document.model' +import { getModelToken } from '~/transformers/model.transformer' describe('SearchService', () => { let searchService: SearchService - let mockNoteService: { - model: { - paginate: ReturnType - } - } - let mockPostService: { - model: { - paginate: ReturnType - } - } beforeEach(async () => { - mockNoteService = { - model: { - paginate: vi.fn(), - }, - } - - mockPostService = { - model: { - paginate: vi.fn(), - }, - } - const module = await Test.createTestingModule({ providers: [ SearchService, - { provide: NoteService, useValue: mockNoteService }, - { provide: POST_SERVICE_TOKEN, useValue: mockPostService }, - { provide: PageService, useValue: {} }, - { provide: ConfigsService, useValue: {} }, - { provide: DatabaseService, useValue: {} }, + { provide: NoteService, useValue: { model: {} } }, + { provide: POST_SERVICE_TOKEN, useValue: { model: {} } }, + { provide: PageService, useValue: { model: {} } }, + { + provide: getModelToken(SearchDocumentModel.name), + useValue: {}, + }, ], }).compile() @@ -51,80 +32,88 @@ describe('SearchService', () => { vi.clearAllMocks() }) - it('should apply weighted sort for note search results', async () => { - mockNoteService.model.paginate.mockResolvedValue({ - docs: [ + it('should prefer exact title matches over body-only matches', () => { + const keywordRegexes = (searchService as any).buildSearchKeywordRegexes( + 'hello', + ) + const searchTerms = (searchService as any).buildSearchTerms('hello') + + const ranked = (searchService as any).rankSearchHits( + [ { - _id: 'note-a', - title: 'hello world', - text: 'content', + refType: 'note', + refId: 'note-a', + title: 'hello', + searchText: 'world', + titleTerms: ['hello'], + bodyTerms: ['world'], + titleLength: 1, + bodyLength: 1, created: new Date('2024-01-01'), }, { - _id: 'note-b', - title: '', - text: 'hello hello', + refType: 'note', + refId: 'note-b', + title: 'world', + searchText: 'hello hello hello', + titleTerms: ['world'], + bodyTerms: ['hello', 'hello', 'hello'], + titleLength: 1, + bodyLength: 3, created: new Date('2024-01-02'), }, { - _id: 'note-c', - title: 'hello', - text: 'hello', + refType: 'note', + refId: 'note-c', + title: 'hello world', + searchText: 'hello', + titleTerms: ['hello', 'world'], + bodyTerms: ['hello'], + titleLength: 2, + bodyLength: 1, created: new Date('2024-01-03'), - modified: new Date('2024-01-04'), }, ], - totalDocs: 3, - limit: 10, - page: 1, - totalPages: 1, - hasNextPage: false, - hasPrevPage: false, - }) - - const result = await searchService.searchNote( - { keyword: 'hello', page: 1, size: 10 } as any, - true, + keywordRegexes, + searchTerms, + { totalDocs: 3, avgTitleLength: 1.33, avgBodyLength: 1.66 }, + new Map([['hello', 3]]), ) - expect(result.data.map((item) => item._id)).toEqual([ - 'note-b', - 'note-c', + expect(ranked.map((item) => item.refId)).toEqual([ 'note-a', + 'note-c', + 'note-b', ]) - expect(result.data.some((item) => 'text' in item)).toBe(false) }) - it('should use modified/created as tie breaker for post search', async () => { - mockPostService.model.paginate.mockResolvedValue({ - docs: [ - { - _id: 'post-a', - title: 'hello', - text: 'content', - created: new Date('2024-01-01'), - }, - { - _id: 'post-b', - title: 'hello', - text: 'content', - created: new Date('2024-01-02'), - modified: new Date('2024-01-03'), - }, - ], - totalDocs: 2, - limit: 10, - page: 1, - totalPages: 1, - }) + it('should escape special regex characters in keyword', () => { + const keywordRegexes = (searchService as any).buildSearchKeywordRegexes( + 'hello.*', + ) + + expect(keywordRegexes).toHaveLength(1) + expect(keywordRegexes[0].source).toBe('hello\\.\\*') + expect( + (searchService as any).countKeywordMatches( + 'hello world', + keywordRegexes[0], + ), + ).toBe(0) + expect( + (searchService as any).countKeywordMatches( + 'hello.* world', + keywordRegexes[0], + ), + ).toBe(1) + }) - const result = await searchService.searchPost({ - keyword: 'hello', - page: 1, - size: 10, - } as any) + it('should tokenize cjk text into searchable terms', () => { + const searchTerms = (searchService as any).buildSearchTerms('中文搜索') - expect(result.docs.map((item) => item._id)).toEqual(['post-b', 'post-a']) - expect(result.docs.some((item) => 'text' in item)).toBe(false) + expect(searchTerms).toContain('中') + expect(searchTerms).toContain('中文') + expect(searchTerms).toContain('搜索') + expect(searchTerms).toContain('中文搜索') }) }) diff --git a/packages/api-client/__tests__/controllers/search.test.ts b/packages/api-client/__tests__/controllers/search.test.ts index 09551f6c0b8..4304c923d41 100644 --- a/packages/api-client/__tests__/controllers/search.test.ts +++ b/packages/api-client/__tests__/controllers/search.test.ts @@ -1,8 +1,8 @@ +import camelcaseKeys from 'camelcase-keys' + import { mockRequestInstance } from '~/__tests__/helpers/instance' import { mockResponse } from '~/__tests__/helpers/response' import { SearchController } from '~/controllers' -import camelcaseKeys from 'camelcase-keys' -import mockData from '../mock/algolia.json' describe('test search client, /search', () => { const client = mockRequestInstance(SearchController) @@ -67,13 +67,38 @@ describe('test search client, /search', () => { expect(data.data[0].id).toEqual('5eb35d6f5ae43bbd0c90b8c0') }) - test('GET /search/algolia', async () => { - mockResponse('/search/algolia', mockData) - const data = await client.search.searchByAlgolia('algolia') - - expect(data.data[0].id).toEqual('5fe97d1d5b11408f99ada0fd') - expect(data.raw).toBeDefined() + test('GET /search', async () => { + const mocked = mockResponse('/search?keyword=1', { + data: [ + { + modified: '2020-11-14T16:15:36.162Z', + id: '5eb2c62a613a5ab0642f1f80', + title: '打印沙漏(C#实现)', + slug: 'acm-test', + created: '2019-01-31T13:02:00.000Z', + type: 'post', + category: { + type: 0, + id: '5eb2c62a613a5ab0642f1f7a', + count: 56, + name: '编程', + slug: 'programming', + created: '2020-05-06T14:14:02.339Z', + }, + }, + ], + pagination: { + total: 86, + current_page: 1, + total_page: 9, + size: 10, + has_next_page: true, + has_prev_page: false, + }, + }) - expect(data.$raw.data).toEqual(mockData) + const data = await client.search.searchAll('1') + expect(data).toEqual(camelcaseKeys(mocked, { deep: true })) + expect(data.data[0].type).toEqual('post') }) }) diff --git a/packages/api-client/controllers/search.ts b/packages/api-client/controllers/search.ts index a0e03af467b..b7caf3f3b79 100644 --- a/packages/api-client/controllers/search.ts +++ b/packages/api-client/controllers/search.ts @@ -6,6 +6,7 @@ import type { NoteModel } from '~/models/note' import type { PageModel } from '~/models/page' import type { PostModel } from '~/models/post' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core' declare module '../core/client' { @@ -17,12 +18,11 @@ declare module '../core/client' { } } -export type SearchType = 'post' | 'note' +export type SearchType = 'post' | 'note' | 'page' export type SearchOption = { orderBy?: string order?: number - rawAlgolia?: boolean } export class SearchController implements IController { base = 'search' @@ -39,7 +39,7 @@ export class SearchController implements IController { search( type: 'note', keyword: string, - options?: Omit, + options?: SearchOption, ): Promise< RequestProxyResult< PaginateResult< @@ -51,7 +51,7 @@ export class SearchController implements IController { search( type: 'post', keyword: string, - options?: Omit, + options?: SearchOption, ): Promise< RequestProxyResult< PaginateResult< @@ -64,23 +64,25 @@ export class SearchController implements IController { > > search( - type: SearchType, + type: 'page', keyword: string, - options: Omit = {}, - ): any { + options?: SearchOption, + ): Promise< + RequestProxyResult< + PaginateResult< + Pick + >, + ResponseWrapper + > + > + search(type: SearchType, keyword: string, options: SearchOption = {}): any { return this.proxy(type).get({ params: { keyword, ...options }, }) } - /** - * 从 algolya 搜索 - * https://www.algolia.com/doc/api-reference/api-methods/search/ - * @param keyword - * @param options - * @returns - */ - searchByAlgolia(keyword: string, options?: SearchOption) { - return this.proxy('algolia').get< + + searchAll(keyword: string, options?: SearchOption) { + return this.proxy.get< RequestProxyResult< PaginateResult< | (Pick< @@ -89,18 +91,13 @@ export class SearchController implements IController { > & { type: 'post' }) | (Pick< NoteModel, - 'id' | 'created' | 'id' | 'modified' | 'title' | 'nid' + 'id' | 'created' | 'modified' | 'title' | 'nid' > & { type: 'note' }) | (Pick< PageModel, 'id' | 'title' | 'created' | 'modified' | 'slug' > & { type: 'page' }) - > & { - /** - * @see: algoliasearch - */ - raw?: any - }, + >, ResponseWrapper > >({ params: { keyword, ...options } }) diff --git a/packages/api-client/models/setting.ts b/packages/api-client/models/setting.ts index 21c3e68e472..b84de1a1430 100644 --- a/packages/api-client/models/setting.ts +++ b/packages/api-client/models/setting.ts @@ -55,13 +55,6 @@ export declare class BingSearchOptionsModel { token?: string } -export declare class AlgoliaSearchOptionsModel { - enable: boolean - apiKey?: string - appId?: string - indexName?: string -} - export declare class AdminExtraModel { background?: string @@ -84,7 +77,6 @@ export interface IConfig { commentOptions: CommentOptionsModel backupOptions: BackupOptionsModel baiduSearchOptions: BaiduSearchOptionsModel - algoliaSearchOptions: AlgoliaSearchOptionsModel adminExtra: AdminExtraModel thirdPartyServiceIntegration: ThirdPartyServiceIntegrationModel } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad71e943a35..c5875931fbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,9 +81,6 @@ importers: apps/core: dependencies: - '@algolia/client-search': - specifier: ^5.49.1 - version: 5.49.1 '@antfu/install-pkg': specifier: 1.1.0 version: 1.1.0 @@ -195,9 +192,6 @@ importers: '@types/jsonwebtoken': specifier: 9.0.10 version: 9.0.10 - algoliasearch: - specifier: 5.49.1 - version: 5.49.1 axios: specifier: ^1.13.3 version: 1.13.6 @@ -523,62 +517,6 @@ importers: packages: - '@algolia/abtesting@1.15.1': - resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-abtesting@5.49.1': - resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-analytics@5.49.1': - resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-common@5.49.1': - resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-insights@5.49.1': - resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-personalization@5.49.1': - resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-query-suggestions@5.49.1': - resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-search@5.49.1': - resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==} - engines: {node: '>= 14.0.0'} - - '@algolia/ingestion@1.49.1': - resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==} - engines: {node: '>= 14.0.0'} - - '@algolia/monitoring@1.49.1': - resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==} - engines: {node: '>= 14.0.0'} - - '@algolia/recommend@5.49.1': - resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-browser-xhr@5.49.1': - resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-fetch@5.49.1': - resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-node-http@5.49.1': - resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==} - engines: {node: '>= 14.0.0'} - '@angular-devkit/core@19.2.17': resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -3435,10 +3373,6 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - algoliasearch@5.49.1: - resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==} - engines: {node: '>= 14.0.0'} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -7581,90 +7515,6 @@ packages: snapshots: - '@algolia/abtesting@1.15.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-abtesting@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-analytics@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-common@5.49.1': {} - - '@algolia/client-insights@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-personalization@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-query-suggestions@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/client-search@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/ingestion@1.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/monitoring@1.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/recommend@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - - '@algolia/requester-browser-xhr@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - - '@algolia/requester-fetch@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - - '@algolia/requester-node-http@5.49.1': - dependencies: - '@algolia/client-common': 5.49.1 - '@angular-devkit/core@19.2.17(chokidar@4.0.3)': dependencies: ajv: 8.17.1 @@ -10501,23 +10351,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - algoliasearch@5.49.1: - dependencies: - '@algolia/abtesting': 1.15.1 - '@algolia/client-abtesting': 5.49.1 - '@algolia/client-analytics': 5.49.1 - '@algolia/client-common': 5.49.1 - '@algolia/client-insights': 5.49.1 - '@algolia/client-personalization': 5.49.1 - '@algolia/client-query-suggestions': 5.49.1 - '@algolia/client-search': 5.49.1 - '@algolia/ingestion': 1.49.1 - '@algolia/monitoring': 1.49.1 - '@algolia/recommend': 5.49.1 - '@algolia/requester-browser-xhr': 5.49.1 - '@algolia/requester-fetch': 5.49.1 - '@algolia/requester-node-http': 5.49.1 - ansi-colors@4.1.3: {} ansi-escapes@7.3.0: diff --git a/renovate.json b/renovate.json index 88b254f6ccc..ec6599af2b5 100644 --- a/renovate.json +++ b/renovate.json @@ -20,9 +20,7 @@ "ignoreDeps": [ "vitest", "vite", - "snakecase-keys", - "algoliasearch", - "@algolia/client-search" + "snakecase-keys" ], "enabled": true -} \ No newline at end of file +}