Add button-driven search filters

This commit is contained in:
2026-08-10 04:17:54 +09:00
parent 079aaa1664
commit 8eb299f46d
12 changed files with 984 additions and 72 deletions
+99 -2
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/
@@ -11,6 +12,23 @@ const tweetIdSchema = z
export class InputError extends Error {}
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
type SearchLanguage = z.infer<typeof searchLanguageSchema>
type SearchContent = z.infer<typeof searchContentSchema>
export type AdvancedSearchQuery = {
q: string
from: string
since: string
until: string
lang: SearchLanguage
content: SearchContent
excludeReplies: boolean
excludeReposts: boolean
}
export const threadPageInputSchema = z.union([
z.object({
tweetId: tweetIdSchema,
@@ -45,10 +63,87 @@ export const searchPageInputSchema = z.object({
export const postSearchRouteSchema = z.object({
q: z.string().catch(''),
from: z
.string()
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
.optional()
.catch(undefined),
since: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
until: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
lang: searchLanguageSchema.optional().catch(undefined),
content: searchContentSchema.optional().catch(undefined),
excludeReplies: z.boolean().optional().catch(undefined),
excludeReposts: z.boolean().optional().catch(undefined),
product: z.enum(['Top', 'Latest']).catch('Latest'),
following: z.boolean().catch(false),
})
function isValidDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() + 1 === month &&
date.getUTCDate() === day
)
}
function requireDate(value: string, label: string): string {
if (!isValidDate(value)) {
throw new InputError(`${label}を確認してください。`)
}
return value
}
function normalizeSearchAuthor(raw: string): string {
const value = raw.trim().replace(/^@/, '')
if (!HANDLE.test(value)) {
throw new InputError('投稿者のハンドルを確認してください。')
}
return value
}
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
const query = input.q.trim()
const author = input.from.trim()
if (!query && !author) {
throw new InputError('検索語または投稿者を入力してください。')
}
const parts = [query]
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
const since = input.since ? requireDate(input.since, '開始日') : ''
const until = input.until ? requireDate(input.until, '終了日') : ''
if (since && until && since >= until) {
throw new InputError('終了日は開始日より後の日付を選んでください。')
}
if (since) parts.push(`since:${since}`)
if (until) parts.push(`until:${until}`)
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
if (input.content !== 'all') parts.push(`filter:${input.content}`)
if (input.excludeReplies) parts.push('-filter:replies')
if (input.excludeReposts) parts.push('-filter:retweets')
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
}
export function buildFilteredSearchQuery(
input: AdvancedSearchQuery,
following: boolean,
): string {
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
}
export function normalizeTweetId(raw: string): string {
const value = raw.trim()
if (!TWEET_ID.test(value)) {
@@ -129,8 +224,10 @@ export function buildSearchQuery(raw: string, following: boolean): string {
if (!query) {
throw new InputError('検索語を入力してください。')
}
if (query.length > 512) {
const finalQuery =
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
if (finalQuery.length > 512) {
throw new InputError('検索語は 512 文字以内で入力してください。')
}
return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
return finalQuery
}