import { apiPost } from '@/lib/api' import { fetchPosts } from '@/lib/posts' import type { Post } from '@/types' export type GekanatorAnswerValue = | 'yes' | 'no' | 'partial' | 'probably_no' | 'unknown' export type GekanatorAnswerLog = { questionId: string questionText: string answer: GekanatorAnswerValue } export type GekanatorQuestionKind = | 'tag' | 'source' | 'title' | 'original_date' export type GekanatorQuestion = { id: string text: string kind: GekanatorQuestionKind 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 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 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操作')))) } export const fetchGekanatorPosts = async (): Promise => { const limit = 200 const first = await fetchPosts ({ url: '', title: '', tags: '', match: 'all', originalCreatedFrom: '', originalCreatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', page: 1, limit, order: 'original_created_at:desc' }) const posts = [...first.posts] const totalPages = Math.ceil (first.count / limit) for (let page = 2; page <= totalPages; page++) { const data = await fetchPosts ({ url: '', title: '', tags: '', match: 'all', originalCreatedFrom: '', originalCreatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', page, limit, order: 'original_created_at:desc' }) posts.push (...data.posts) } return posts } 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 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: category === 'nico' ? `ニコニコに「${ label }」といふタグが付いてゐる?` : `内容として「${ label }」に関係しさう?`, kind: 'tag' as const, 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, 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, test: (post: Post) => originalYearOf (post) === year })) const titleQuestions = [ { id: 'title:long', text: '題名が長めの投稿?', kind: 'title' as const, test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian }, { id: 'title:ascii', text: '題名に英数字が混じってゐる?', kind: 'title' as const, 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, ...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, answer: answer.answer })) })