import { apiGet, apiPost } from '@/lib/api' import type { Post } from '@/types' export type GekanatorAnswerValue = | 'yes' | 'no' | 'partial' | 'probably_no' | 'unknown' export type GekanatorAnswerLog = { questionId: string questionText: string questionCondition?: GekanatorQuestionCondition answer: GekanatorAnswerValue originalAnswer: GekanatorAnswerValue } export type GekanatorQuestionKind = | 'tag' | 'source' | 'title' | 'original_date' export type GekanatorQuestionSource = | 'default' | 'user_suggested' | 'ai_generated' | 'admin_curated' export type GekanatorQuestionCondition = | { type: 'tag'; key: string } | { type: 'source'; host: string } | { type: 'original-year'; year: number } | { type: 'original-month'; month: number } | { type: 'original-month-day'; monthDay: string } | { type: 'title-length-greater-than'; length: number } | { type: 'title-has-ascii' } export type StoredGekanatorQuestion = { id: string text: string kind: GekanatorQuestionKind condition: GekanatorQuestionCondition source?: GekanatorQuestionSource priorityWeight?: number } export type GekanatorQuestion = { id: string text: string kind: GekanatorQuestionKind condition: GekanatorQuestionCondition source: GekanatorQuestionSource priorityWeight: number test: (post: Post) => boolean } const countBy = (values: T[]): Map => { const counts = new Map () values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) return counts } const median = (values: number[]): number => { const sorted = [...values].sort ((a, b) => a - b) return sorted[Math.floor (sorted.length / 2)] ?? 0 } const hostOf = (post: Post): string | null => { try { return new URL (post.url).hostname.replace (/^www\./, '') } catch { return null } } const originalYearOf = (post: Post): number | null => { const value = post.originalCreatedFrom || post.originalCreatedBefore if (!(value)) return null const date = new Date (value) if (Number.isNaN (date.getTime ())) return null return date.getFullYear () } const originalDateOf = (post: Post): Date | null => { const value = post.originalCreatedFrom || post.originalCreatedBefore if (!(value)) return null const date = new Date (value) if (Number.isNaN (date.getTime ())) return null return date } const originalMonthOf = (post: Post): number | null => { const date = originalDateOf (post) if (!(date)) return null return date.getMonth () + 1 } const originalMonthDayOf = (post: Post): string | null => { const date = originalDateOf (post) if (!(date)) return null return `${ date.getMonth () + 1 }-${ date.getDate () }` } const tagQuestionKey = ({ category, name }: { category: string; name: string }): string => `${ category }:${ name }` const tagFromQuestionKey = (key: string): { category: string; name: string } => { const [category, ...rest] = key.split (':') return { category: category ?? '', name: rest.join (':') } } const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '') const tagQuestionText = (category: string, label: string): string => { switch (category) { case 'deerjikist': return `作者・ニジラーとして「${ label }」に関係してゐる?` case 'meme': return `元ネタ・ミームとして「${ label }」に関係しさう?` case 'character': return `「${ label }」といふキャラクターが関係してゐる?` case 'material': return `素材として「${ label }」に関係してゐる?` case 'nico': return `ニコニコに「${ label }」といふタグが付いてゐる?` case 'general': case 'meta': default: return `内容として「${ label }」に関係しさう?` } } const questionableTag = (post: Post, key: string): boolean => { const { category, name } = tagFromQuestionKey (key) return ( post.tags.some (tag => tag.name === name && tag.category === category && !(tag.category === 'meta') && !(tag.name.includes ('タグ希望')) && !(tag.name.includes ('bot操作')))) } const questionMatches = ( post: Post, condition: GekanatorQuestionCondition, ): boolean => { switch (condition.type) { case 'tag': return questionableTag (post, condition.key) case 'source': return hostOf (post) === condition.host case 'original-year': return originalYearOf (post) === condition.year case 'original-month': return originalMonthOf (post) === condition.month case 'original-month-day': return originalMonthDayOf (post) === condition.monthDay case 'title-length-greater-than': return (post.title?.length ?? 0) > condition.length case 'title-has-ascii': return /[A-Za-z0-9]/.test (post.title ?? '') } } export const restoreGekanatorQuestion = ( question: StoredGekanatorQuestion, ): GekanatorQuestion => ({ ...question, source: question.source ?? 'default', priorityWeight: question.priorityWeight ?? 1, test: (post: Post) => questionMatches (post, question.condition) }) export const storeGekanatorQuestion = ( question: GekanatorQuestion, ): StoredGekanatorQuestion => ({ id: question.id, text: question.text, kind: question.kind, condition: question.condition, source: question.source, priorityWeight: question.priorityWeight }) export const fetchGekanatorPosts = async (): Promise => { const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts') return data.posts } export const fetchGekanatorQuestions = async (): Promise => { const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions') return data.questions } export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { const tagCounts = countBy (posts.flatMap (post => post.tags .filter (tag => !(tag.category === 'meta') && !(tag.name.includes ('タグ希望')) && !(tag.name.includes ('bot操作'))) .map (tag => tagQuestionKey (tag)))) const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host))) const originalYears = countBy ( posts .map (originalYearOf) .filter ((year): year is number => year !== null)) const originalMonths = countBy ( posts .map (originalMonthOf) .filter ((month): month is number => month !== null)) const originalMonthDays = countBy ( posts .map (originalMonthDayOf) .filter ((monthDay): monthDay is string => monthDay !== null)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const usefulEntries = (counts: Map) => [...counts.entries ()] .filter (([, count]) => count > 0 && count < posts.length) .sort ((a, b) => Math.abs (posts.length / 2 - a[1]) - Math.abs (posts.length / 2 - b[1])) .slice (0, 80) const tagQuestions = usefulEntries (tagCounts) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 80) .map (([key]) => { const { category, name } = tagFromQuestionKey (String (key)) const label = category === 'nico' ? nicoTagLabel (name) : name return { id: `tag:${ key }`, text: tagQuestionText (category, label), kind: 'tag' as const, condition: { type: 'tag' as const, key: String (key) }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => questionableTag (post, String (key)) } }) const sourceQuestions = usefulEntries (hosts) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([host]) => ({ id: `source:${ host }`, text: `${ host } の投稿を思ひ浮かべてゐる?`, kind: 'source' as const, condition: { type: 'source' as const, host }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => hostOf (post) === host })) const originalYearQuestions = usefulEntries (originalYears) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([year]) => ({ id: `original-year:${ year }`, text: `オリジナルの投稿年は ${ year } 年?`, kind: 'original_date' as const, condition: { type: 'original-year' as const, year }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => originalYearOf (post) === year })) const originalMonthQuestions = usefulEntries (originalMonths) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([month]) => ({ id: `original-month:${ month }`, text: `オリジナルの投稿月は ${ month } 月?`, kind: 'original_date' as const, condition: { type: 'original-month' as const, month }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => originalMonthOf (post) === month })) const originalMonthDayQuestions = usefulEntries (originalMonthDays) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([monthDay]) => { const [month, day] = String (monthDay).split ('-') return { id: `original-month-day:${ monthDay }`, text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`, kind: 'original_date' as const, condition: { type: 'original-month-day' as const, monthDay: String (monthDay) }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => originalMonthDayOf (post) === monthDay } }) const titleQuestions = [ { id: 'title:long', text: '題名が長めの投稿?', kind: 'title' as const, condition: { type: 'title-length-greater-than' as const, length: titleLengthMedian }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian }, { id: 'title:ascii', text: '題名に英数字が混じってゐる?', kind: 'title' as const, condition: { type: 'title-has-ascii' as const }, source: 'default' as const, priorityWeight: 1, test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }] .filter (question => { const yes = posts.filter (post => question.test (post)).length const no = posts.length - yes return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7 }) return [ ...sourceQuestions, ...originalYearQuestions, ...originalMonthQuestions, ...originalMonthDayQuestions, ...titleQuestions, ...tagQuestions] } export const saveGekanatorGame = async ({ guessedPostId, correctPostId, answers, }: { guessedPostId: number correctPostId: number answers: GekanatorAnswerLog[] }): Promise<{ id: number }> => await apiPost ('/gekanator/games', { guessed_post_id: guessedPostId, correct_post_id: correctPostId, answers: answers.map (answer => ({ question_id: answer.questionId, question_text: answer.questionText, question_condition: answer.questionCondition ?? null, answer: answer.answer, original_answer: answer.originalAnswer })) }) export const saveGekanatorQuestionSuggestion = async ({ gekanatorGameId, questionText, answer, }: { gekanatorGameId: number questionText: string answer: GekanatorAnswerValue }): Promise<{ id: number; count: number }> => await apiPost ('/gekanator/question_suggestions', { gekanator_game_id: gekanatorGameId, question_text: questionText, answer })