コミットを比較
1 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| ec7462344a |
@@ -126,16 +126,12 @@ npm run preview
|
|||||||
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
|
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
|
||||||
- Tabs are only for leading indentation, never for spaces after non-space text.
|
- Tabs are only for leading indentation, never for spaces after non-space text.
|
||||||
- Do not add production dependencies without explicit approval.
|
- Do not add production dependencies without explicit approval.
|
||||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
|
||||||
test work. When the user asks for tests, keep working and rerun them until
|
|
||||||
they pass or the remaining failure is clearly blocked.
|
|
||||||
|
|
||||||
## Backend rules
|
## Backend rules
|
||||||
|
|
||||||
- Inspect existing routes, controllers, models, services, and specs before
|
- Inspect existing routes, controllers, models, services, and specs before
|
||||||
editing backend behavior.
|
editing backend behavior.
|
||||||
- For API behavior changes, add or update request specs under
|
- For API behavior changes, add or update request specs under `backend/spec/requests`.
|
||||||
`backend/spec/requests` only when the user explicitly asks for tests.
|
|
||||||
- Prefer RSpec for new backend tests; existing minitest files under
|
- Prefer RSpec for new backend tests; existing minitest files under
|
||||||
`backend/test` do not make minitest the default for new coverage.
|
`backend/test` do not make minitest the default for new coverage.
|
||||||
- Do not weaken authentication, BAN user checks, or IP BAN checks.
|
- Do not weaken authentication, BAN user checks, or IP BAN checks.
|
||||||
@@ -215,11 +211,10 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
|||||||
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
||||||
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
||||||
behavior, inspect the related request specs and service objects.
|
behavior, inspect the related request specs and service objects.
|
||||||
- If frontend code changes, run only non-test verification commands that
|
- If frontend code changes, run the existing frontend verification commands
|
||||||
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
|
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
|
||||||
only when the user explicitly asks for tests.
|
- If backend code changes, run the relevant RSpec command; for broad backend
|
||||||
- If backend code changes, do not run RSpec unless the user explicitly asks
|
changes, run `bundle exec rspec`.
|
||||||
for tests.
|
|
||||||
- If a verification command cannot be run or fails, report the exact command and failure.
|
- If a verification command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
## Completion criteria
|
## Completion criteria
|
||||||
@@ -227,8 +222,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
|||||||
A task is complete only when:
|
A task is complete only when:
|
||||||
|
|
||||||
- implementation is complete,
|
- implementation is complete,
|
||||||
- relevant non-test verification commands pass, or failures are clearly
|
- relevant verification commands pass, or failures are clearly explained,
|
||||||
explained,
|
|
||||||
- unrelated files are not changed,
|
- unrelated files are not changed,
|
||||||
- migrations and schema are consistent when schema changes are made,
|
- migrations and schema are consistent when schema changes are made,
|
||||||
- user-facing behavior is documented when needed.
|
- user-facing behavior is documented when needed.
|
||||||
|
|||||||
+4
-11
@@ -47,10 +47,6 @@ bundle exec rspec
|
|||||||
|
|
||||||
If a command cannot be run or fails, report the exact command and failure.
|
If a command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
Do not create, modify, or run tests unless the user explicitly asks for test
|
|
||||||
work. When the user asks for tests, keep working and rerun them until they
|
|
||||||
pass or the remaining failure is clearly blocked.
|
|
||||||
|
|
||||||
## Rails structure
|
## Rails structure
|
||||||
|
|
||||||
- `app/controllers`: API controllers.
|
- `app/controllers`: API controllers.
|
||||||
@@ -120,8 +116,7 @@ service, representation, and spec.
|
|||||||
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
||||||
- Do not weaken BAN or IP BAN behavior.
|
- Do not weaken BAN or IP BAN behavior.
|
||||||
- If changing request authentication or controller before actions, add or
|
- If changing request authentication or controller before actions, add or
|
||||||
update request specs covering banned users and banned IP addresses only when
|
update request specs covering banned users and banned IP addresses.
|
||||||
the user explicitly asks for tests.
|
|
||||||
|
|
||||||
## RSpec
|
## RSpec
|
||||||
|
|
||||||
@@ -135,9 +130,8 @@ service, representation, and spec.
|
|||||||
- `AuthHelper#sign_in_as(user)` stubs
|
- `AuthHelper#sign_in_as(user)` stubs
|
||||||
`ApplicationController#current_user`; use it when matching existing
|
`ApplicationController#current_user`; use it when matching existing
|
||||||
request spec style.
|
request spec style.
|
||||||
- Add or update request specs for API behavior changes only when the user
|
- Add or update request specs for API behavior changes, especially status
|
||||||
explicitly asks for tests, especially status codes, permissions, response
|
codes, permissions, response shape, and version conflict behavior.
|
||||||
shape, and version conflict behavior.
|
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
@@ -170,8 +164,7 @@ service, representation, and spec.
|
|||||||
the record `version_no`.
|
the record `version_no`.
|
||||||
- Do not update versioned records without considering whether a version snapshot must be created.
|
- Do not update versioned records without considering whether a version snapshot must be created.
|
||||||
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
|
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
|
||||||
`merge` semantics. Cover conflicts in request specs only when the user
|
`merge` semantics and cover conflicts in request specs.
|
||||||
explicitly asks for tests.
|
|
||||||
|
|
||||||
## Domain cautions
|
## Domain cautions
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
class GekanatorGamesController < ApplicationController
|
|
||||||
def create
|
|
||||||
return head :not_found unless current_user&.admin?
|
|
||||||
|
|
||||||
guessed_post_id = params.require(:guessed_post_id)
|
|
||||||
correct_post_id = params[:correct_post_id].presence
|
|
||||||
answers = params.require(:answers).as_json
|
|
||||||
|
|
||||||
game = GekanatorGame.new(
|
|
||||||
user: current_user,
|
|
||||||
guessed_post_id:,
|
|
||||||
correct_post_id:,
|
|
||||||
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
|
|
||||||
question_count: answers.length,
|
|
||||||
answers:)
|
|
||||||
|
|
||||||
if game.save
|
|
||||||
render json: { id: game.id }, status: :created
|
|
||||||
else
|
|
||||||
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
class GekanatorGame < ApplicationRecord
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :guessed_post, class_name: 'Post'
|
|
||||||
belongs_to :correct_post, class_name: 'Post'
|
|
||||||
|
|
||||||
validates :answers, presence: true
|
|
||||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :won, inclusion: { in: [true, false] }
|
|
||||||
end
|
|
||||||
@@ -11,16 +11,6 @@ class Post < ApplicationRecord
|
|||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
has_many :post_versions
|
has_many :post_versions
|
||||||
has_many :gekanator_guessed_games,
|
|
||||||
class_name: 'GekanatorGame',
|
|
||||||
foreign_key: :guessed_post_id,
|
|
||||||
dependent: :delete_all,
|
|
||||||
inverse_of: :guessed_post
|
|
||||||
has_many :gekanator_correct_games,
|
|
||||||
class_name: 'GekanatorGame',
|
|
||||||
foreign_key: :correct_post_id,
|
|
||||||
dependent: :nullify,
|
|
||||||
inverse_of: :correct_post
|
|
||||||
|
|
||||||
has_many :parent_post_implications,
|
has_many :parent_post_implications,
|
||||||
class_name: 'PostImplication',
|
class_name: 'PostImplication',
|
||||||
|
|||||||
@@ -63,10 +63,6 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
namespace :gekanator do
|
|
||||||
resources :games, only: [:create], controller: '/gekanator_games'
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :gekanator_games do |t|
|
|
||||||
t.references :user, null: false, foreign_key: true
|
|
||||||
t.references :guessed_post, null: false, foreign_key: { to_table: :posts }
|
|
||||||
t.references :correct_post, null: false, foreign_key: { to_table: :posts }
|
|
||||||
t.boolean :won, null: false
|
|
||||||
t.integer :question_count, null: false
|
|
||||||
t.json :answers, null: false
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_check_constraint :gekanator_games,
|
|
||||||
'question_count >= 0',
|
|
||||||
name: 'chk_gekanator_games_question_count_nonnegative'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
生成ファイル
+1
-19
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -48,21 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
|||||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.bigint "user_id", null: false
|
|
||||||
t.bigint "guessed_post_id", null: false
|
|
||||||
t.bigint "correct_post_id", null: false
|
|
||||||
t.boolean "won", null: false
|
|
||||||
t.integer "question_count", null: false
|
|
||||||
t.json "answers", null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
|
|
||||||
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
|
||||||
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
|
|
||||||
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.binary "ip_address", limit: 16, null: false
|
t.binary "ip_address", limit: 16, null: false
|
||||||
t.datetime "banned_at"
|
t.datetime "banned_at"
|
||||||
@@ -493,9 +478,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
|
||||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
|
||||||
add_foreign_key "gekanator_games", "users"
|
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "tags"
|
add_foreign_key "material_versions", "tags"
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Gekanator games API', type: :request do
|
|
||||||
let!(:admin) { create_admin_user! }
|
|
||||||
let!(:user) { create_member_user! }
|
|
||||||
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
|
||||||
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
|
||||||
|
|
||||||
describe 'POST /gekanator/games' do
|
|
||||||
it 'stores a won game' do
|
|
||||||
sign_in_as admin
|
|
||||||
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
correct_post_id: guessed_post.id,
|
|
||||||
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
game = GekanatorGame.find(json['id'])
|
|
||||||
expect(game.user).to eq(admin)
|
|
||||||
expect(game.guessed_post).to eq(guessed_post)
|
|
||||||
expect(game.correct_post).to eq(guessed_post)
|
|
||||||
expect(game.won).to eq(true)
|
|
||||||
expect(game.question_count).to eq(1)
|
|
||||||
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores a lost game with the correct post' do
|
|
||||||
sign_in_as admin
|
|
||||||
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
correct_post_id: correct_post.id,
|
|
||||||
question_count: 4,
|
|
||||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
game = GekanatorGame.find(json['id'])
|
|
||||||
expect(game.correct_post).to eq(correct_post)
|
|
||||||
expect(game.won).to eq(false)
|
|
||||||
expect(game.question_count).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects a game without the correct post' do
|
|
||||||
sign_in_as admin
|
|
||||||
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
question_count: 4,
|
|
||||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns not found without an admin user' do
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
correct_post_id: guessed_post.id,
|
|
||||||
answers: [] }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns not found for a non-admin user' do
|
|
||||||
sign_in_as user
|
|
||||||
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
correct_post_id: guessed_post.id,
|
|
||||||
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -33,10 +33,6 @@ npm run lint
|
|||||||
|
|
||||||
If either command cannot be run or fails, report the exact command and failure.
|
If either command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
Do not create, modify, or run tests unless the user explicitly asks for test
|
|
||||||
work. When the user asks for tests, keep working and rerun them until they
|
|
||||||
pass or the remaining failure is clearly blocked.
|
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|
||||||
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
||||||
|
|||||||
+1
-16
@@ -18,7 +18,6 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||||
import MorePage from '@/pages/MorePage'
|
import MorePage from '@/pages/MorePage'
|
||||||
import GekanatorPage from '@/pages/GekanatorPage'
|
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
@@ -40,7 +39,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
|
|||||||
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
||||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||||
|
|
||||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
@@ -81,10 +80,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
<Route path="/tos" element={<TOSPage/>}/>
|
||||||
<Route path="/gekanator" element={
|
|
||||||
<AdminOnly user={user}>
|
|
||||||
<GekanatorPage/>
|
|
||||||
</AdminOnly>}/>
|
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
<Route path="/more" element={<MorePage/>}/>
|
||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -92,16 +87,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const AdminOnly = ({ user, children }: {
|
|
||||||
user: User | null
|
|
||||||
children: ReactNode }) => {
|
|
||||||
if (user?.role !== 'admin')
|
|
||||||
return <NotFound/>
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const PostDetailRoute = ({ user }: { user: User | null }) => {
|
const PostDetailRoute = ({ user }: { user: User | null }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const key = location.pathname
|
const key = location.pathname
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
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 = <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 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 originalYears = countBy (
|
|
||||||
posts
|
|
||||||
.map (originalYearOf)
|
|
||||||
.filter ((year): year is number => year !== 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: 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 })) })
|
|
||||||
@@ -8,10 +8,6 @@ export const postsKeys = {
|
|||||||
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
||||||
['posts', 'changes', p] as const }
|
['posts', 'changes', p] as const }
|
||||||
|
|
||||||
export const gekanatorKeys = {
|
|
||||||
root: ['gekanator'] as const,
|
|
||||||
posts: () => ['gekanator', 'posts'] as const }
|
|
||||||
|
|
||||||
export const tagsKeys = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
||||||
|
|||||||
ファイル差分が大きすぎるため省略します
差分を読込み
新しい課題から参照
ユーザをブロックする