From 59a9f4984c3acd29bc746a78e30849f50fa555ee Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 15 Aug 2026 18:57:59 +0900 Subject: Add Claude-powered band auto-registration from live info text Extracts band/artist names from pasted live info via Claude (API or CLI adapter), matches against existing bands, and lets the user review and bulk-register new ones through a polling job queue. Also adds a livehouse events import script and recent-bands/artists home page sections. Co-Authored-By: Claude Sonnet 5 --- app/routes/band-auto-register-job.tsx | 211 ++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 app/routes/band-auto-register-job.tsx (limited to 'app/routes/band-auto-register-job.tsx') diff --git a/app/routes/band-auto-register-job.tsx b/app/routes/band-auto-register-job.tsx new file mode 100644 index 0000000..31f8ba4 --- /dev/null +++ b/app/routes/band-auto-register-job.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { data, Form, Link, redirect, useLoaderData, useNavigation, useRevalidator } from "react-router"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { createBand, getIpAddress, getParseJob, toSlug } from "~/lib/db.server"; +import type { BandMatch } from "~/lib/claude.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const job = getParseJob(params.id as string); + if (!job) throw data("Not found", { status: 404 }); + return { job }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + const fd = await request.formData(); + const toRegister: { name: string }[] = JSON.parse( + (fd.get("toRegister") as string) || "[]" + ); + const ip = getIpAddress(request); + const registered: { id: string; name: string }[] = []; + const errors: string[] = []; + + for (const { name } of toRegister) { + const id = crypto.randomUUID(); + const baseSlug = toSlug(name) || id.slice(0, 8); + let ok = false; + for (let attempt = 0; attempt < 3 && !ok; attempt++) { + const slug = attempt === 0 ? baseSlug : `${baseSlug}-${id.slice(0, 6)}`; + try { + createBand({ id, slug, name, area: null, description: null, status: "active", links: [], members: [], message: "ライブ情報から自動登録", ip_address: ip }); + registered.push({ id, name }); + ok = true; + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue; + errors.push(`${name}: ${e instanceof Error ? e.message : "不明なエラー"}`); + ok = true; + } + } + } + + return redirect(`/bands/auto-register/jobs/${params.id as string}?registered=${registered.length}&errors=${errors.length}`); +} + +export default function ParseJobPage() { + const { job } = useLoaderData(); + const revalidator = useRevalidator(); + const nav = useNavigation(); + const busy = nav.state !== "idle"; + + // read flash params from URL + const [flashMsg, setFlashMsg] = useState(null); + useEffect(() => { + const sp = new URLSearchParams(window.location.search); + const reg = sp.get("registered"); + const err = sp.get("errors"); + if (reg !== null) { + const parts: string[] = []; + if (Number(reg) > 0) parts.push(`${reg}件を登録しました`); + if (Number(err) > 0) parts.push(`${err}件が失敗`); + setFlashMsg(parts.join("、") || "変更なし"); + // clean URL + window.history.replaceState(null, "", window.location.pathname); + } + }, []); + + // poll while job is still running + useEffect(() => { + if (job.status !== "pending" && job.status !== "processing") return; + const t = setTimeout(() => revalidator.revalidate(), 2000); + return () => clearTimeout(t); + }, [job.status, job.updated_at, revalidator]); + + const matches: BandMatch[] = job.status === "done" && job.result + ? (JSON.parse(job.result) as BandMatch[]) + : []; + + return ( +
+
+ ← +

解析ジョブ

+
+ + {flashMsg && ( +

{flashMsg}

+ )} + +
+ + + {job.created_at.slice(0, 16).replace("T", " ")} + +
+ +
+ {job.input_text} +
+ + {(job.status === "pending" || job.status === "processing") && ( +
+ + Claudeが解析中です… +
+ )} + + {job.status === "error" && ( +

{job.error_message}

+ )} + + {job.status === "done" && matches.length > 0 && ( + + )} +
+ ); +} + +function JobStatusBadge({ status }: { status: string }) { + const map: Record = { + pending: { label: "待機中", color: "#6b7280" }, + processing: { label: "解析中", color: "#fbbf24" }, + done: { label: "完了", color: "#34d399" }, + error: { label: "エラー", color: "#f87171" }, + }; + const s = map[status] ?? { label: status, color: "#6b7280" }; + return ( + + ● {s.label} + + ); +} + +function ReviewSection({ matches, busy }: { matches: BandMatch[]; busy: boolean }) { + type Sel = { include: boolean; name: string; treatAsNew: boolean }; + const [sels, setSels] = useState>(() => { + const init: Record = {}; + for (let i = 0; i < matches.length; i++) { + const m = matches[i]; + init[i] = { include: m.confidence !== "exact", name: m.extracted, treatAsNew: m.confidence === "new" }; + } + return init; + }); + + function update(i: number, patch: Partial) { + setSels((prev) => ({ ...prev, [i]: { ...prev[i], ...patch } })); + } + + const toRegister = matches + .map((m, i) => ({ m, s: sels[i] })) + .filter(({ s }) => s.include && s.treatAsNew) + .map(({ s }) => ({ name: s.name })); + + return ( + <> +
+

解析結果

+
+ {matches.map((m, i) => { + const s = sels[i]; + return ( +
+
+ {(m.confidence === "new" || (m.confidence === "fuzzy" && s.treatAsNew)) && ( + update(i, { include: e.target.checked })} /> + )} +
+
+
+ {s.include && s.treatAsNew + ? update(i, { name: e.target.value })} /> + : {m.extracted} + } +
+ {m.confidence === "exact" && ( +
+ 登録済 + {m.matched_id && {m.matched_name}} +
+ )} + {m.confidence === "fuzzy" && ( +
+ 要確認 + 候補: {m.matched_name} + +
+ )} + {m.confidence === "new" && ( +
+ 新規 +
+ )} +
+
+ ); + })} +
+
+ +
+ +
+ + 新しいテキストを投入 + バンド一覧へ +
+
+ + ); +} -- cgit v1.2.3