このコミットが含まれているのは:
@@ -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 })) })
|
||||
新しい課題から参照
ユーザをブロックする