396 行
12 KiB
TypeScript
396 行
12 KiB
TypeScript
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 = <T extends string | number> (values: T[]): Map<T, number> => {
|
|
const counts = new Map<T, number> ()
|
|
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<Post[]> => {
|
|
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
|
return data.posts
|
|
}
|
|
|
|
|
|
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
|
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 = <T extends string | number> (counts: Map<T, number>) =>
|
|
[...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 })
|