このコミットが含まれているのは:
2026-06-08 00:30:20 +09:00
コミット 96df2a4eaa
12個のファイルの変更1288行の追加1行の削除
+227
ファイルの表示
@@ -0,0 +1,227 @@
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'
| 'title'
| 'date'
| 'media'
| 'source'
| 'structure'
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
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 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<Post[]> => {
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 tagMedian = median (posts.map (post => post.tags.length))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const currentYear = new Date ().getFullYear ()
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: 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 }))
return [
...sourceQuestions,
{
id: 'title:present',
text: '題名が付いてゐる投稿?',
kind: 'title',
test: post => Boolean (post.title) },
{
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title',
test: post => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title',
test: post => /[A-Za-z0-9]/.test (post.title ?? '') },
{
id: 'media:thumbnail',
text: 'ぱっと見でサムネが付いてゐる投稿?',
kind: 'media',
test: post => Boolean (post.thumbnail || post.thumbnailBase) },
{
id: 'media:video-source',
text: '動画として見られる投稿?',
kind: 'media',
test: post => /nicovideo|youtube|youtu\.be/.test (post.url) },
{
id: 'structure:many-tags',
text: 'タグが多めに付いてゐる投稿?',
kind: 'structure',
test: post => post.tags.length > tagMedian },
{
id: 'structure:no-title',
text: '題名がまだ付いてゐない投稿?',
kind: 'structure',
test: post => !(post.title) },
{
id: 'date:recent',
text: '最近追加されたほうの投稿?',
kind: 'date',
test: post => new Date (post.createdAt).getFullYear () >= currentYear - 1 },
{
id: 'date:old',
text: 'むかし追加されたほうの投稿?',
kind: 'date',
test: post => new Date (post.createdAt).getFullYear () <= currentYear - 3 },
{
id: 'date:original-known',
text: 'オリジナルの投稿日時が分かってゐる投稿?',
kind: 'date',
test: post => Boolean (post.originalCreatedFrom || post.originalCreatedBefore) },
...tagQuestions]
}
export const saveGekanatorGame = async ({
guessedPostId,
correctPostId,
won,
answers,
}: {
guessedPostId: number
correctPostId: number | null
won: boolean
answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
won,
question_count: answers.length,
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
answer: answer.answer })) })