mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 23:59:45 +08:00
feat(webSearch): add Bocha web search provider integration (#5608)
* feat(webSearch): add Bocha web search provider integration - Introduced BochaProvider for handling web search queries. - Added Bocha logo and updated web search provider configuration. - Implemented API host and key validation for Bocha. - Enhanced web search settings to support Bocha provider. - Updated Redux store to include Bocha in the web search provider list. - Added validation schemas for Bocha search parameters and responses. * fix(WebSearch): improve error handling in BochaProvider and validate web search questions - Added error handling for failed Bocha search responses. - Enhanced validation for web search questions to ensure they are an array and not empty. * fix(WebSearch): add API host validation for Tavily provider * chore: remove api host check button * fix: add check for unnecessary web search in fetchExternalTool --------- Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
This commit is contained in:
parent
a8b183b2a6
commit
12b210dcce
BIN
src/renderer/src/assets/images/search/bocha.webp
Normal file
BIN
src/renderer/src/assets/images/search/bocha.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@ -1,6 +1,8 @@
|
|||||||
|
import BochaLogo from '@renderer/assets/images/search/bocha.webp'
|
||||||
import ExaLogo from '@renderer/assets/images/search/exa.png'
|
import ExaLogo from '@renderer/assets/images/search/exa.png'
|
||||||
import SearxngLogo from '@renderer/assets/images/search/searxng.svg'
|
import SearxngLogo from '@renderer/assets/images/search/searxng.svg'
|
||||||
import TavilyLogo from '@renderer/assets/images/search/tavily.png'
|
import TavilyLogo from '@renderer/assets/images/search/tavily.png'
|
||||||
|
|
||||||
export function getWebSearchProviderLogo(providerId: string) {
|
export function getWebSearchProviderLogo(providerId: string) {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'tavily':
|
case 'tavily':
|
||||||
@ -9,6 +11,8 @@ export function getWebSearchProviderLogo(providerId: string) {
|
|||||||
return SearxngLogo
|
return SearxngLogo
|
||||||
case 'exa':
|
case 'exa':
|
||||||
return ExaLogo
|
return ExaLogo
|
||||||
|
case 'bocha':
|
||||||
|
return BochaLogo
|
||||||
default:
|
default:
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@ -32,6 +36,12 @@ export const WEB_SEARCH_PROVIDER_CONFIG = {
|
|||||||
apiKey: 'https://dashboard.exa.ai/api-keys'
|
apiKey: 'https://dashboard.exa.ai/api-keys'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
bocha: {
|
||||||
|
websites: {
|
||||||
|
official: 'https://bochaai.com',
|
||||||
|
apiKey: 'https://open.bochaai.com/overview'
|
||||||
|
}
|
||||||
|
},
|
||||||
'local-google': {
|
'local-google': {
|
||||||
websites: {
|
websites: {
|
||||||
official: 'https://www.google.com'
|
official: 'https://www.google.com'
|
||||||
|
|||||||
@ -192,14 +192,11 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
|||||||
onChange={(e) => setApiHost(e.target.value)}
|
onChange={(e) => setApiHost(e.target.value)}
|
||||||
onBlur={onUpdateApiHost}
|
onBlur={onUpdateApiHost}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
ghost={apiValid}
|
|
||||||
type={apiValid ? 'primary' : 'default'}
|
|
||||||
onClick={checkSearch}
|
|
||||||
disabled={apiChecking}>
|
|
||||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasObjectKey(provider, 'basicAuthUsername') && (
|
||||||
|
<>
|
||||||
<SettingDivider style={{ marginTop: 12, marginBottom: 12 }} />
|
<SettingDivider style={{ marginTop: 12, marginBottom: 12 }} />
|
||||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||||
{t('settings.provider.basic_auth')}
|
{t('settings.provider.basic_auth')}
|
||||||
|
|||||||
@ -4,18 +4,32 @@ import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
|||||||
export default abstract class BaseWebSearchProvider {
|
export default abstract class BaseWebSearchProvider {
|
||||||
// @ts-ignore this
|
// @ts-ignore this
|
||||||
protected provider: WebSearchProvider
|
protected provider: WebSearchProvider
|
||||||
|
protected apiHost?: string
|
||||||
protected apiKey: string
|
protected apiKey: string
|
||||||
|
|
||||||
constructor(provider: WebSearchProvider) {
|
constructor(provider: WebSearchProvider) {
|
||||||
this.provider = provider
|
this.provider = provider
|
||||||
|
this.apiHost = this.getApiHost()
|
||||||
this.apiKey = this.getApiKey()
|
this.apiKey = this.getApiKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract search(
|
abstract search(
|
||||||
query: string,
|
query: string,
|
||||||
websearch: WebSearchState,
|
websearch: WebSearchState,
|
||||||
httpOptions?: RequestInit
|
httpOptions?: RequestInit
|
||||||
): Promise<WebSearchProviderResponse>
|
): Promise<WebSearchProviderResponse>
|
||||||
|
|
||||||
|
public getApiHost() {
|
||||||
|
return this.provider.apiHost
|
||||||
|
}
|
||||||
|
|
||||||
|
public defaultHeaders() {
|
||||||
|
return {
|
||||||
|
'HTTP-Referer': 'https://cherry-ai.com',
|
||||||
|
'X-Title': 'Cherry Studio'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getApiKey() {
|
public getApiKey() {
|
||||||
const keys = this.provider.apiKey?.split(',').map((key) => key.trim()) || []
|
const keys = this.provider.apiKey?.split(',').map((key) => key.trim()) || []
|
||||||
const keyName = `web-search-provider:${this.provider.id}:last_used_key`
|
const keyName = `web-search-provider:${this.provider.id}:last_used_key`
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { WebSearchState } from '@renderer/store/websearch'
|
||||||
|
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
|
import { BochaSearchParams, BochaSearchResponse } from '@renderer/types/bocha'
|
||||||
|
|
||||||
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
|
||||||
|
export default class BochaProvider extends BaseWebSearchProvider {
|
||||||
|
constructor(provider: WebSearchProvider) {
|
||||||
|
super(provider)
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('API key is required for Bocha provider')
|
||||||
|
}
|
||||||
|
if (!this.apiHost) {
|
||||||
|
throw new Error('API host is required for Bocha provider')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
||||||
|
try {
|
||||||
|
if (!query.trim()) {
|
||||||
|
throw new Error('Search query cannot be empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.apiKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLimit = websearch.contentLimit
|
||||||
|
|
||||||
|
const params: BochaSearchParams = {
|
||||||
|
query,
|
||||||
|
count: websearch.maxResults,
|
||||||
|
exclude: websearch.excludeDomains.join(','),
|
||||||
|
freshness: websearch.searchWithTime ? 'oneDay' : 'noLimit',
|
||||||
|
summary: false,
|
||||||
|
page: contentLimit ? Math.ceil(contentLimit / websearch.maxResults) : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.apiHost}/v1/web-search`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
headers: {
|
||||||
|
...this.defaultHeaders(),
|
||||||
|
...headers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Bocha search failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp: BochaSearchResponse = await response.json()
|
||||||
|
if (resp.code !== 200) {
|
||||||
|
throw new Error(`Bocha search failed: ${resp.msg}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
query: resp.data.queryContext.originalQuery,
|
||||||
|
results: resp.data.webPages.value.map((result) => ({
|
||||||
|
title: result.name,
|
||||||
|
content: result.snippet,
|
||||||
|
url: result.url
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bocha search failed:', error)
|
||||||
|
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,10 @@ export default class ExaProvider extends BaseWebSearchProvider {
|
|||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
throw new Error('API key is required for Exa provider')
|
throw new Error('API key is required for Exa provider')
|
||||||
}
|
}
|
||||||
this.exa = new ExaClient({ apiKey: this.apiKey })
|
if (!this.apiHost) {
|
||||||
|
throw new Error('API host is required for Exa provider')
|
||||||
|
}
|
||||||
|
this.exa = new ExaClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import BaseWebSearchProvider from './BaseWebSearchProvider'
|
|||||||
export default class SearxngProvider extends BaseWebSearchProvider {
|
export default class SearxngProvider extends BaseWebSearchProvider {
|
||||||
private searxng: SearxngClient
|
private searxng: SearxngClient
|
||||||
private engines: string[] = []
|
private engines: string[] = []
|
||||||
private readonly apiHost: string
|
|
||||||
private readonly basicAuthUsername?: string
|
private readonly basicAuthUsername?: string
|
||||||
private readonly basicAuthPassword?: string
|
private readonly basicAuthPassword?: string
|
||||||
private isInitialized = false
|
private isInitialized = false
|
||||||
|
|||||||
@ -12,7 +12,10 @@ export default class TavilyProvider extends BaseWebSearchProvider {
|
|||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
throw new Error('API key is required for Tavily provider')
|
throw new Error('API key is required for Tavily provider')
|
||||||
}
|
}
|
||||||
this.tvly = new TavilyClient({ apiKey: this.apiKey })
|
if (!this.apiHost) {
|
||||||
|
throw new Error('API host is required for Tavily provider')
|
||||||
|
}
|
||||||
|
this.tvly = new TavilyClient({ apiKey: this.apiKey, apiBaseUrl: this.apiHost })
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { WebSearchProvider } from '@renderer/types'
|
import { WebSearchProvider } from '@renderer/types'
|
||||||
|
|
||||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||||
|
import BochaProvider from './BochaProvider'
|
||||||
import DefaultProvider from './DefaultProvider'
|
import DefaultProvider from './DefaultProvider'
|
||||||
import ExaProvider from './ExaProvider'
|
import ExaProvider from './ExaProvider'
|
||||||
import LocalBaiduProvider from './LocalBaiduProvider'
|
import LocalBaiduProvider from './LocalBaiduProvider'
|
||||||
@ -14,6 +15,8 @@ export default class WebSearchProviderFactory {
|
|||||||
switch (provider.id) {
|
switch (provider.id) {
|
||||||
case 'tavily':
|
case 'tavily':
|
||||||
return new TavilyProvider(provider)
|
return new TavilyProvider(provider)
|
||||||
|
case 'bocha':
|
||||||
|
return new BochaProvider(provider)
|
||||||
case 'searxng':
|
case 'searxng':
|
||||||
return new SearxngProvider(provider)
|
return new SearxngProvider(provider)
|
||||||
case 'exa':
|
case 'exa':
|
||||||
|
|||||||
@ -125,6 +125,8 @@ async function fetchExternalTool(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (extractResults.websearch.question[0] === 'not_needed') return
|
||||||
|
|
||||||
// Add check for assistant.model before using it
|
// Add check for assistant.model before using it
|
||||||
if (!assistant.model) {
|
if (!assistant.model) {
|
||||||
console.warn('searchTheWeb called without assistant.model')
|
console.warn('searchTheWeb called without assistant.model')
|
||||||
|
|||||||
@ -106,7 +106,7 @@ class WebSearchService {
|
|||||||
const webSearchEngine = new WebSearchEngineProvider(provider)
|
const webSearchEngine = new WebSearchEngineProvider(provider)
|
||||||
|
|
||||||
let formattedQuery = query
|
let formattedQuery = query
|
||||||
// 有待商榷,效果一般
|
// FIXME: 有待商榷,效果一般
|
||||||
if (websearch.searchWithTime) {
|
if (websearch.searchWithTime) {
|
||||||
formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
|
formattedQuery = `today is ${dayjs().format('YYYY-MM-DD')} \r\n ${query}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { SYSTEM_MODELS } from '@renderer/config/models'
|
|||||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import { Assistant } from '@renderer/types'
|
import { Assistant, WebSearchProvider } from '@renderer/types'
|
||||||
import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils'
|
import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { createMigrate } from 'redux-persist'
|
import { createMigrate } from 'redux-persist'
|
||||||
@ -64,6 +64,18 @@ function addWebSearchProvider(state: RootState, id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateWebSearchProvider(state: RootState, provider: Partial<WebSearchProvider>) {
|
||||||
|
if (state.websearch && state.websearch.providers) {
|
||||||
|
const index = state.websearch.providers.findIndex((p) => p.id === provider.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
state.websearch.providers[index] = {
|
||||||
|
...state.websearch.providers[index],
|
||||||
|
...provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const migrateConfig = {
|
const migrateConfig = {
|
||||||
'2': (state: RootState) => {
|
'2': (state: RootState) => {
|
||||||
try {
|
try {
|
||||||
@ -1252,6 +1264,34 @@ const migrateConfig = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'99': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
addWebSearchProvider(state, 'bocha')
|
||||||
|
updateWebSearchProvider(state, {
|
||||||
|
id: 'exa',
|
||||||
|
apiHost: 'https://api.exa.ai'
|
||||||
|
})
|
||||||
|
updateWebSearchProvider(state, {
|
||||||
|
id: 'tavily',
|
||||||
|
apiHost: 'https://api.tavily.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove basic auth fields from exa and tavily
|
||||||
|
if (state.websearch?.providers) {
|
||||||
|
state.websearch.providers = state.websearch.providers.map((provider) => {
|
||||||
|
if (provider.id === 'exa' || provider.id === 'tavily') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { basicAuthUsername, basicAuthPassword, ...rest } = provider
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export interface WebSearchState {
|
|||||||
/** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */
|
/** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */
|
||||||
overwrite: boolean
|
overwrite: boolean
|
||||||
contentLimit?: number
|
contentLimit?: number
|
||||||
|
// 具体供应商的配置
|
||||||
|
providerConfig: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: WebSearchState = {
|
const initialState: WebSearchState = {
|
||||||
@ -33,16 +35,26 @@ const initialState: WebSearchState = {
|
|||||||
{
|
{
|
||||||
id: 'tavily',
|
id: 'tavily',
|
||||||
name: 'Tavily',
|
name: 'Tavily',
|
||||||
|
apiHost: 'https://api.tavily.com',
|
||||||
apiKey: ''
|
apiKey: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'searxng',
|
id: 'searxng',
|
||||||
name: 'Searxng',
|
name: 'Searxng',
|
||||||
apiHost: ''
|
apiHost: '',
|
||||||
|
basicAuthUsername: '',
|
||||||
|
basicAuthPassword: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'exa',
|
id: 'exa',
|
||||||
name: 'Exa',
|
name: 'Exa',
|
||||||
|
apiHost: 'https://api.exa.ai',
|
||||||
|
apiKey: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bocha',
|
||||||
|
name: 'Bocha',
|
||||||
|
apiHost: 'https://api.bochaai.com',
|
||||||
apiKey: ''
|
apiKey: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -65,7 +77,8 @@ const initialState: WebSearchState = {
|
|||||||
maxResults: 5,
|
maxResults: 5,
|
||||||
excludeDomains: [],
|
excludeDomains: [],
|
||||||
subscribeSources: [],
|
subscribeSources: [],
|
||||||
overwrite: false
|
overwrite: false,
|
||||||
|
providerConfig: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultWebSearchProviders = initialState.providers
|
export const defaultWebSearchProviders = initialState.providers
|
||||||
@ -139,6 +152,12 @@ const websearchSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setContentLimit: (state, action: PayloadAction<number | undefined>) => {
|
setContentLimit: (state, action: PayloadAction<number | undefined>) => {
|
||||||
state.contentLimit = action.payload
|
state.contentLimit = action.payload
|
||||||
|
},
|
||||||
|
setProviderConfig: (state, action: PayloadAction<Record<string, any>>) => {
|
||||||
|
state.providerConfig = action.payload
|
||||||
|
},
|
||||||
|
updateProviderConfig: (state, action: PayloadAction<Record<string, any>>) => {
|
||||||
|
state.providerConfig = { ...state.providerConfig, ...action.payload }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -157,7 +176,9 @@ export const {
|
|||||||
setSubscribeSources,
|
setSubscribeSources,
|
||||||
setOverwrite,
|
setOverwrite,
|
||||||
addWebSearchProvider,
|
addWebSearchProvider,
|
||||||
setContentLimit
|
setContentLimit,
|
||||||
|
setProviderConfig,
|
||||||
|
updateProviderConfig
|
||||||
} = websearchSlice.actions
|
} = websearchSlice.actions
|
||||||
|
|
||||||
export default websearchSlice.reducer
|
export default websearchSlice.reducer
|
||||||
|
|||||||
207
src/renderer/src/types/bocha.ts
Normal file
207
src/renderer/src/types/bocha.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const freshnessOptions = ['oneDay', 'oneWeek', 'oneMonth', 'oneYear', 'noLimit'] as const
|
||||||
|
|
||||||
|
const isValidDate = (dateStr: string): boolean => {
|
||||||
|
// First check basic format
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number)
|
||||||
|
|
||||||
|
if (year < 1900 || year > 2100) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check month range
|
||||||
|
if (month < 1 || month > 12) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last day of the month
|
||||||
|
const lastDay = new Date(year, month, 0).getDate()
|
||||||
|
|
||||||
|
// Check day range
|
||||||
|
if (day < 1 || day > lastDay) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidDateRange = (dateRangeStr: string): boolean => {
|
||||||
|
// Check if it's a single date
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateRangeStr)) {
|
||||||
|
return isValidDate(dateRangeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a date range
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/.test(dateRangeStr)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startDate, endDate] = dateRangeStr.split('..')
|
||||||
|
|
||||||
|
// Validate both dates
|
||||||
|
if (!isValidDate(startDate) || !isValidDate(endDate)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if start date is before or equal to end date
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
return start <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidExcludeDomains = (excludeStr: string): boolean => {
|
||||||
|
if (!excludeStr) return true
|
||||||
|
|
||||||
|
// Split by either | or ,
|
||||||
|
const domains = excludeStr
|
||||||
|
.split(/[|,]/)
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// Check number of domains
|
||||||
|
if (domains.length > 20) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain name regex (supports both root domains and subdomains)
|
||||||
|
const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
|
// Check each domain
|
||||||
|
return domains.every((domain) => domainRegex.test(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
const BochaSearchParamsSchema = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
freshness: z
|
||||||
|
.union([
|
||||||
|
z.enum(freshnessOptions),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})(\.\.\d{4}-\d{2}-\d{2})?$/,
|
||||||
|
'Date must be in YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD format'
|
||||||
|
)
|
||||||
|
.refine(isValidDateRange, {
|
||||||
|
message: 'Invalid date range - please provide valid dates in YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD format'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.default('noLimit'),
|
||||||
|
summary: z.boolean().optional().default(false),
|
||||||
|
exclude: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((val) => !val || isValidExcludeDomains(val), {
|
||||||
|
message:
|
||||||
|
'Invalid exclude format. Please provide valid domain names separated by | or ,. Maximum 20 domains allowed.'
|
||||||
|
}),
|
||||||
|
page: z.number().optional().default(1),
|
||||||
|
count: z.number().optional().default(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const BochaSearchResponseDataSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
queryContext: z.object({
|
||||||
|
originalQuery: z.string()
|
||||||
|
}),
|
||||||
|
webPages: z.object({
|
||||||
|
webSearchUrl: z.string(),
|
||||||
|
totalEstimatedMatches: z.number(),
|
||||||
|
value: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
displayUrl: z.string(),
|
||||||
|
snippet: z.string(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
siteName: z.string(),
|
||||||
|
siteIcon: z.string(),
|
||||||
|
datePublished: z.string(),
|
||||||
|
dateLastCrawled: z.string(),
|
||||||
|
cachedPageUrl: z.string(),
|
||||||
|
language: z.string(),
|
||||||
|
isFamilyFriendly: z.boolean(),
|
||||||
|
isNavigational: z.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
someResultsRemoved: z.boolean()
|
||||||
|
}),
|
||||||
|
images: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
readLink: z.string(),
|
||||||
|
webSearchUrl: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
value: z.array(
|
||||||
|
z.object({
|
||||||
|
webSearchUrl: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
thumbnailUrl: z.string(),
|
||||||
|
datePublished: z.string(),
|
||||||
|
contentUrl: z.string(),
|
||||||
|
hostPageUrl: z.string(),
|
||||||
|
contentSize: z.string(),
|
||||||
|
encodingFormat: z.string(),
|
||||||
|
hostPageDisplayUrl: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
thumbnail: z.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
videos: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
readLink: z.string(),
|
||||||
|
webSearchUrl: z.string(),
|
||||||
|
isFamilyFriendly: z.boolean(),
|
||||||
|
scenario: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
value: z.array(
|
||||||
|
z.object({
|
||||||
|
webSearchUrl: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
thumbnailUrl: z.string(),
|
||||||
|
publisher: z.string(),
|
||||||
|
creator: z.string(),
|
||||||
|
contentUrl: z.string(),
|
||||||
|
hostPageUrl: z.string(),
|
||||||
|
encodingFormat: z.string(),
|
||||||
|
hostPageDisplayUrl: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
duration: z.number(),
|
||||||
|
motionThumbnailUrl: z.string(),
|
||||||
|
embedHtml: z.string(),
|
||||||
|
allowHttpsEmbed: z.boolean(),
|
||||||
|
viewCount: z.number(),
|
||||||
|
thumbnail: z.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number()
|
||||||
|
}),
|
||||||
|
allowMobileEmbed: z.boolean(),
|
||||||
|
isSuperfresh: z.boolean(),
|
||||||
|
datePublished: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const BochaSearchResponseSchema = z.object({
|
||||||
|
code: z.number(),
|
||||||
|
logId: z.string(),
|
||||||
|
data: BochaSearchResponseDataSchema,
|
||||||
|
msg: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type BochaSearchParams = z.infer<typeof BochaSearchParamsSchema>
|
||||||
|
export type BochaSearchResponse = z.infer<typeof BochaSearchResponseSchema>
|
||||||
|
export { BochaSearchParamsSchema, BochaSearchResponseSchema }
|
||||||
Loading…
Reference in New Issue
Block a user