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' | 'post_similarity' 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-at-least'; length: number } | { type: 'title-length-greater-than'; length: number } | { type: 'title-has-ascii' } | { type: 'post-similarity' postId: number answer: GekanatorAnswerValue threshold: number } type NonPostSimilarityCondition = Exclude< GekanatorQuestionCondition, { type: 'post-similarity' } > export type GekanatorExtraQuestion = { id: number text: string source: GekanatorQuestionSource priorityWeight: number } export type StoredGekanatorQuestion = { id: string text: string kind: GekanatorQuestionKind condition: GekanatorQuestionCondition source?: GekanatorQuestionSource priorityWeight?: number exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> } export type GekanatorQuestion = { id: string text: string kind: GekanatorQuestionKind condition: GekanatorQuestionCondition source: GekanatorQuestionSource priorityWeight: number exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> test: (post: Post) => boolean } export const normalizeTitleLengthCondition = ( condition: GekanatorQuestionCondition, ): GekanatorQuestionCondition => { switch (condition.type) { case 'title-length-greater-than': return { type: 'title-length-at-least', length: condition.length + 1 } default: return condition } } export const titleLengthMinimumForCondition = ( condition: GekanatorQuestionCondition, ): number | null => { switch (condition.type) { case 'title-length-at-least': return condition.length case 'title-length-greater-than': return condition.length + 1 default: return null } } export const questionIdForCondition = ( condition: NonPostSimilarityCondition, ): string => { switch (condition.type) { case 'tag': return `tag:${ condition.key }` case 'source': return `source:${ condition.host }` case 'original-year': return `original-year:${ condition.year }` case 'original-month': return `original-month:${ condition.month }` case 'original-month-day': return `original-month-day:${ condition.monthDay }` case 'title-length-at-least': case 'title-length-greater-than': return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }` case 'title-has-ascii': return 'title:ascii' } } const directExampleAnswerFor = ( question: StoredGekanatorQuestion, post: Post, ): GekanatorAnswerValue | null => { if (question.kind !== 'post_similarity') return null const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] if (direct) return direct if (question.condition.type === 'post-similarity' && question.condition.postId === post.id) return question.condition.answer return null } 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, question: StoredGekanatorQuestion, ): boolean => { const directAnswer = directExampleAnswerFor (question, post) if (directAnswer) return question.condition.type === 'post-similarity' ? directAnswer === question.condition.answer : directAnswer === 'yes' switch (question.condition.type) { case 'tag': return questionableTag (post, question.condition.key) case 'source': return hostOf (post) === question.condition.host case 'original-year': return originalYearOf (post) === question.condition.year case 'original-month': return originalMonthOf (post) === question.condition.month case 'original-month-day': return originalMonthDayOf (post) === question.condition.monthDay case 'title-length-at-least': return (post.title?.length ?? 0) >= question.condition.length case 'title-length-greater-than': return (post.title?.length ?? 0) > question.condition.length case 'title-has-ascii': return /[A-Za-z0-9]/.test (post.title ?? '') case 'post-similarity': return false } } export const expectedAnswerForQuestion = ( question: StoredGekanatorQuestion | GekanatorQuestion | undefined, post: Post | null, ): GekanatorAnswerValue | null => { if (!(question) || !(post)) return null const directAnswer = directExampleAnswerFor (question, post) if (directAnswer) return directAnswer switch (question.condition.type) { case 'tag': case 'source': case 'original-year': case 'original-month': case 'original-month-day': case 'title-length-at-least': case 'title-length-greater-than': case 'title-has-ascii': return questionMatches (post, question) ? 'yes' : 'no' case 'post-similarity': return null } } export const restoreGekanatorQuestion = ( question: StoredGekanatorQuestion, ): GekanatorQuestion => { const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedQuestion = { ...question, id: normalizedCondition.type === 'title-length-at-least' ? `title:length-at-least:${ normalizedCondition.length }` : question.id, condition: normalizedCondition, source: question.source ?? 'default', priorityWeight: question.priorityWeight ?? 1 } return { ...normalizedQuestion, test: (post: Post) => questionMatches (post, normalizedQuestion) } } export const storeGekanatorQuestion = ( question: GekanatorQuestion, ): StoredGekanatorQuestion => ({ id: question.condition.type === 'title-length-greater-than' ? `title:length-at-least:${ question.condition.length + 1 }` : question.id, text: question.text, kind: question.kind, condition: normalizeTitleLengthCondition (question.condition), source: question.source, priorityWeight: question.priorityWeight, exampleAnswers: question.exampleAnswers }) 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 fetchGekanatorExtraQuestions = async ( gameId: number, nonce?: string, ): Promise => { const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( `/gekanator/games/${ gameId }/extra_questions`, { params: nonce ? { nonce } : undefined }) 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:length-at-least:${ titleLengthMedian }`, text: `タイトルは ${ titleLengthMedian } 文字以上?`, kind: 'title' as const, condition: { type: 'title-length-at-least' 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 }) export const saveGekanatorExtraQuestionAnswers = async ({ gameId, answers, }: { gameId: number answers: { questionId: number; answer: GekanatorAnswerValue }[] }) => await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, { answers: answers.map (item => ({ question_id: item.questionId, answer: item.answer })) })