diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 18:57:59 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 18:57:59 +0900 |
| commit | 59a9f4984c3acd29bc746a78e30849f50fa555ee (patch) | |
| tree | b9dab024c0c5d1d68e023b8f49eb0716b97965bd | |
| parent | a100fb28f92fa7c5d2cf701d118c4c7b898c9bf2 (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | app/app.css | 39 | ||||
| -rw-r--r-- | app/lib/claude.server.ts | 125 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 79 | ||||
| -rw-r--r-- | app/lib/worker.server.ts | 32 | ||||
| -rw-r--r-- | app/root.tsx | 1 | ||||
| -rw-r--r-- | app/routes.ts | 2 | ||||
| -rw-r--r-- | app/routes/band-auto-register-job.tsx | 211 | ||||
| -rw-r--r-- | app/routes/band-auto-register.tsx | 88 | ||||
| -rw-r--r-- | app/routes/band-index.tsx | 1 | ||||
| -rw-r--r-- | app/routes/home.tsx | 108 | ||||
| -rw-r--r-- | package-lock.json | 128 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | scripts/import-livehouse-events.ts | 295 |
13 files changed, 1044 insertions, 66 deletions
diff --git a/app/app.css b/app/app.css index 90dc85f..9125f90 100644 --- a/app/app.css +++ b/app/app.css @@ -163,6 +163,45 @@ form input, form select, form textarea { width: 100%; } .entry-band { font-weight: 500; color: #e5e7eb; } .entry-note { font-size: .8rem; color: #6b7280; margin-top: .125rem; } +/* ── Job list ── */ + +.job-list { display: flex; flex-direction: column; gap: .25rem; } +.job-row { display: flex; align-items: baseline; gap: .75rem; padding: .5rem .75rem; background: #111827; border-radius: 4px; text-decoration: none; } +.job-row:hover { background: #1f2937; text-decoration: none; } +.job-status { flex-shrink: 0; font-size: .75rem; font-weight: 600; width: 4rem; } +.job-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .875rem; } +.job-time { flex-shrink: 0; font-size: .75rem; color: #6b7280; font-variant-numeric: tabular-nums; } +.job-meta { display: flex; align-items: center; gap: 1rem; margin-bottom: .5rem; } + +/* ── Spinner ── */ + +@keyframes spin { to { transform: rotate(360deg); } } +.spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid #374151; border-top-color: #6366f1; border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; } + +/* ── Auto-register ── */ + +.auto-register-list { display: flex; flex-direction: column; gap: .5rem; } + +.auto-register-row { display: flex; gap: .75rem; align-items: flex-start; padding: .75rem; border-radius: 6px; border-left: 3px solid #374151; background: #111827; } +.auto-register-row.conf-exact { border-left-color: #374151; opacity: .7; } +.auto-register-row.conf-fuzzy { border-left-color: #d97706; } +.auto-register-row.conf-new { border-left-color: #4f46e5; } + +.ar-left { width: 1.25rem; padding-top: .125rem; flex-shrink: 0; } +.ar-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .35rem; } +.ar-name { font-weight: 500; color: #e5e7eb; } +.ar-name-input { font-size: .9rem; width: 100%; background: #1f2937; } + +.ar-status { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; font-size: .8rem; } +.ar-badge { display: inline-block; padding: .1rem .45rem; border-radius: 9999px; font-size: .7rem; font-weight: 600; } +.ar-exact .ar-badge { background: #1f2937; color: #6b7280; } +.ar-fuzzy .ar-badge { background: #451a03; color: #fbbf24; } +.ar-new .ar-badge { background: #1e1b4b; color: #818cf8; } + +.ar-link { color: #60a5fa; font-size: .8rem; } +.ar-link:hover { color: #93c5fd; } +.ar-candidate { color: #9ca3af; } + /* ── Entry form rows (new/edit) ── */ .entry-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .375rem; } diff --git a/app/lib/claude.server.ts b/app/lib/claude.server.ts new file mode 100644 index 0000000..4dcce6e --- /dev/null +++ b/app/lib/claude.server.ts @@ -0,0 +1,125 @@ +import { spawn } from "child_process"; +import Anthropic from "@anthropic-ai/sdk"; +import type { Band } from "./db.server"; + +export interface BandMatch { + extracted: string; + matched_id: string | null; + matched_name: string | null; + confidence: "exact" | "fuzzy" | "new"; +} + +// ── Shared logic ────────────────────────────────────────────────────────────── + +function buildPrompt(liveText: string, existingBands: Band[]): string { + const bandList = existingBands.map((b) => ` ${b.id}\t${b.name}`).join("\n"); + return `ライブ情報テキストからバンド・アーティスト名を抽出し、既存バンドリストと照合してください。 + +## ライブ情報テキスト +${liveText} + +## 既存バンドリスト(UUID\t名前) +${bandList || " (なし)"} + +## タスク +1. ライブ情報からバンド・アーティスト名をすべて抽出する +2. 各名前を既存バンドリストと照合する(表記揺れ・全角半角・英語/日本語表記の違いなどを考慮) +3. 以下のJSON配列のみを返す(説明文不要): + +[ + { + "extracted": "抽出した名前(元テキストのまま)", + "matched_id": "一致した既存バンドのUUID(なければnull)", + "matched_name": "一致した既存バンドの名前(なければnull)", + "confidence": "exact | fuzzy | new" + } +] + +confidenceの定義: +- "exact": 完全一致または明らかに同一 +- "fuzzy": 表記揺れの可能性が高い(人間の確認が必要) +- "new": 既存リストに存在しない新しいバンド`; +} + +function parseOutput(text: string): BandMatch[] { + const jsonMatch = text.match(/\[[\s\S]*\]/); + if (!jsonMatch) throw new Error("予期しない出力形式です"); + return JSON.parse(jsonMatch[0]) as BandMatch[]; +} + +// ── Adapter interface ───────────────────────────────────────────────────────── + +interface BandMatchAdapter { + parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]>; +} + +// ── Anthropic API adapter ───────────────────────────────────────────────────── + +class AnthropicAPIAdapter implements BandMatchAdapter { + async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> { + const client = new Anthropic(); + const response = await client.messages.create({ + model: "claude-sonnet-4-6", + max_tokens: 2048, + messages: [{ role: "user", content: buildPrompt(liveText, existingBands) }], + }); + const text = response.content[0].type === "text" ? response.content[0].text : ""; + return parseOutput(text); + } +} + +// ── Claude Code CLI adapter ─────────────────────────────────────────────────── + +class ClaudeCodeCLIAdapter implements BandMatchAdapter { + async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> { + const text = await this.runCLI(buildPrompt(liveText, existingBands)); + return parseOutput(text); + } + + private runCLI(prompt: string): Promise<string> { + return new Promise((resolve, reject) => { + const proc = spawn( + "claude", + ["--print", "--output-format", "json", "--no-session-persistence", "--model", "sonnet"], + { stdio: ["pipe", "pipe", "pipe"] } + ); + + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); + + proc.on("error", (err) => reject(new Error(`claude CLI 起動失敗: ${err.message}`))); + proc.on("close", (code) => { + if (code !== 0) { + reject(new Error(`claude CLI がコード ${code} で終了: ${stderr.trim()}`)); + return; + } + try { + // --output-format json wraps result in { "result": "..." } + const json = JSON.parse(stdout) as { result?: string }; + resolve(json.result ?? stdout); + } catch { + resolve(stdout); + } + }); + + proc.stdin.write(prompt); + proc.stdin.end(); + }); + } +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +function getAdapter(): BandMatchAdapter { + const type = (process.env.CLAUDE_ADAPTER ?? "api").toLowerCase(); + if (type === "cli") return new ClaudeCodeCLIAdapter(); + return new AnthropicAPIAdapter(); +} + +// ── Public API (unchanged for callers) ─────────────────────────────────────── + +export function parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> { + return getAdapter().parseLiveInfoBands(liveText, existingBands); +} diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index c4b11b4..37d819b 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -142,7 +142,22 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id); CREATE INDEX IF NOT EXISTS idx_list_entries_list_id ON list_entries(list_id); CREATE INDEX IF NOT EXISTS idx_list_revisions_list_id ON list_revisions(list_id); + + CREATE TABLE IF NOT EXISTS parse_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + input_text TEXT NOT NULL, + result TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_parse_jobs_status ON parse_jobs(status, created_at); `); + + // reset stuck jobs from previous server run + db.prepare("UPDATE parse_jobs SET status = 'pending', updated_at = datetime('now') WHERE status = 'processing'").run(); } // ── Interfaces ──────────────────────────────────────────────────────────────── @@ -330,6 +345,14 @@ export function listBands(): Band[] { return getDb().prepare("SELECT * FROM bands ORDER BY slug").all() as Band[]; } +export function listRecentBands(limit = 8): Band[] { + return getDb().prepare("SELECT * FROM bands ORDER BY created_at DESC LIMIT ?").all(limit) as Band[]; +} + +export function listRecentArtists(limit = 8): Artist[] { + return getDb().prepare("SELECT * FROM artists ORDER BY created_at DESC LIMIT ?").all(limit) as Artist[]; +} + export function getBandById(id: string): Band | null { return getDb().prepare("SELECT * FROM bands WHERE id = ?").get(id) as Band | null; } @@ -796,3 +819,59 @@ export function importDb(data: DbExport): ImportResult { }; })() as ImportResult; } + +// ── Parse job queue ─────────────────────────────────────────────────────────── + +export interface ParseJob { + id: string; + status: "pending" | "processing" | "done" | "error"; + input_text: string; + result: string | null; + error_message: string | null; + created_at: string; + updated_at: string; +} + +export function createParseJob(inputText: string): ParseJob { + const id = crypto.randomUUID(); + getDb().prepare( + "INSERT INTO parse_jobs (id, input_text) VALUES (?, ?)" + ).run(id, inputText); + return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob; +} + +export function getParseJob(id: string): ParseJob | null { + return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob | null; +} + +export function listRecentParseJobs(limit = 20): ParseJob[] { + return getDb() + .prepare("SELECT * FROM parse_jobs ORDER BY created_at DESC LIMIT ?") + .all(limit) as ParseJob[]; +} + +export function claimNextParseJob(): ParseJob | null { + const db = getDb(); + return db.transaction(() => { + const job = db.prepare( + "SELECT * FROM parse_jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1" + ).get() as ParseJob | null; + if (!job) return null; + db.prepare( + "UPDATE parse_jobs SET status = 'processing', updated_at = datetime('now') WHERE id = ?" + ).run(job.id); + return { ...job, status: "processing" as const }; + })(); +} + +export function completeParseJob(id: string, result: string): void { + getDb().prepare( + "UPDATE parse_jobs SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?" + ).run(result, id); +} + +export function failParseJob(id: string, errorMessage: string): void { + getDb().prepare( + "UPDATE parse_jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE id = ?" + ).run(errorMessage, id); +} diff --git a/app/lib/worker.server.ts b/app/lib/worker.server.ts new file mode 100644 index 0000000..f71ea40 --- /dev/null +++ b/app/lib/worker.server.ts @@ -0,0 +1,32 @@ +import { claimNextParseJob, completeParseJob, failParseJob, listBands } from "./db.server"; +import { parseLiveInfoBands } from "./claude.server"; + +const POLL_INTERVAL_MS = 2000; + +let started = false; + +function startWorker() { + if (started) return; + started = true; + + let busy = false; + + setInterval(async () => { + if (busy) return; + const job = claimNextParseJob(); + if (!job) return; + + busy = true; + try { + const bands = listBands(); + const matches = await parseLiveInfoBands(job.input_text, bands); + completeParseJob(job.id, JSON.stringify(matches)); + } catch (e) { + failParseJob(job.id, e instanceof Error ? e.message : "不明なエラー"); + } finally { + busy = false; + } + }, POLL_INTERVAL_MS); +} + +startWorker(); diff --git a/app/root.tsx b/app/root.tsx index 15cc68b..a60527e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -10,6 +10,7 @@ import { import type { Route } from "./+types/root"; import "./app.css"; +import "./lib/worker.server"; export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, diff --git a/app/routes.ts b/app/routes.ts index 0a2c028..6125a73 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -8,6 +8,8 @@ export default [ route("/api/import", "routes/api-import.tsx"), route("/bands", "routes/band-index.tsx"), route("/bands/new", "routes/band-new.tsx"), + route("/bands/auto-register", "routes/band-auto-register.tsx"), + route("/bands/auto-register/jobs/:id", "routes/band-auto-register-job.tsx"), route("/bands/of/:uuid", "routes/band-by-uuid.tsx"), route("/bands/named/:slug", "routes/band-by-slug.tsx"), route("/bands/of/:uuid/edit", "routes/band-edit.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<typeof loader>(); + const revalidator = useRevalidator(); + const nav = useNavigation(); + const busy = nav.state !== "idle"; + + // read flash params from URL + const [flashMsg, setFlashMsg] = useState<string | null>(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 ( + <main> + <div className="page-header"> + <Link to="/bands/auto-register" className="back">←</Link> + <h1>解析ジョブ</h1> + </div> + + {flashMsg && ( + <p style={{ color: "#34d399", marginBottom: "1rem" }}>{flashMsg}</p> + )} + + <div className="job-meta"> + <JobStatusBadge status={job.status} /> + <span className="muted" style={{ fontSize: ".8rem" }}> + {job.created_at.slice(0, 16).replace("T", " ")} + </span> + </div> + + <div style={{ background: "#111827", borderRadius: 6, padding: ".75rem 1rem", marginTop: ".75rem", fontSize: ".8rem", color: "#6b7280", whiteSpace: "pre-wrap", maxHeight: "8rem", overflow: "auto" }}> + {job.input_text} + </div> + + {(job.status === "pending" || job.status === "processing") && ( + <div style={{ marginTop: "2rem", display: "flex", alignItems: "center", gap: ".75rem" }}> + <span className="spinner" /> + <span className="muted">Claudeが解析中です…</span> + </div> + )} + + {job.status === "error" && ( + <p className="error" style={{ marginTop: "1rem" }}>{job.error_message}</p> + )} + + {job.status === "done" && matches.length > 0 && ( + <ReviewSection matches={matches} busy={busy} /> + )} + </main> + ); +} + +function JobStatusBadge({ status }: { status: string }) { + const map: Record<string, { label: string; color: string }> = { + 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 ( + <span style={{ color: s.color, fontWeight: 600, fontSize: ".875rem" }}> + ● {s.label} + </span> + ); +} + +function ReviewSection({ matches, busy }: { matches: BandMatch[]; busy: boolean }) { + type Sel = { include: boolean; name: string; treatAsNew: boolean }; + const [sels, setSels] = useState<Record<number, Sel>>(() => { + const init: Record<number, Sel> = {}; + 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<Sel>) { + 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 ( + <> + <section style={{ marginTop: "1.5rem" }}> + <h2>解析結果</h2> + <div className="auto-register-list"> + {matches.map((m, i) => { + const s = sels[i]; + return ( + <div key={i} className={`auto-register-row conf-${m.confidence}`}> + <div className="ar-left"> + {(m.confidence === "new" || (m.confidence === "fuzzy" && s.treatAsNew)) && ( + <input type="checkbox" checked={s.include} onChange={(e) => update(i, { include: e.target.checked })} /> + )} + </div> + <div className="ar-body"> + <div className="ar-name"> + {s.include && s.treatAsNew + ? <input className="ar-name-input" value={s.name} onChange={(e) => update(i, { name: e.target.value })} /> + : <span>{m.extracted}</span> + } + </div> + {m.confidence === "exact" && ( + <div className="ar-status ar-exact"> + <span className="ar-badge">登録済</span> + {m.matched_id && <Link to={`/bands/of/${m.matched_id}`} className="ar-link">{m.matched_name}</Link>} + </div> + )} + {m.confidence === "fuzzy" && ( + <div className="ar-status ar-fuzzy"> + <span className="ar-badge">要確認</span> + <span className="ar-candidate">候補: {m.matched_name}</span> + <button type="button" className="btn-text" onClick={() => update(i, { treatAsNew: !s.treatAsNew, include: !s.treatAsNew })}> + {s.treatAsNew ? "既存バンドとして扱う" : "新規として登録する"} + </button> + </div> + )} + {m.confidence === "new" && ( + <div className="ar-status ar-new"> + <span className="ar-badge">新規</span> + </div> + )} + </div> + </div> + ); + })} + </div> + </section> + + <Form method="post"> + <input type="hidden" name="toRegister" value={JSON.stringify(toRegister)} /> + <div className="actions" style={{ marginTop: "1rem" }}> + <button type="submit" disabled={busy || toRegister.length === 0}> + {busy ? "登録中..." : `${toRegister.length}件を登録する`} + </button> + <Link to="/bands/auto-register" className="btn">新しいテキストを投入</Link> + <Link to="/bands" className="btn">バンド一覧へ</Link> + </div> + </Form> + </> + ); +} diff --git a/app/routes/band-auto-register.tsx b/app/routes/band-auto-register.tsx new file mode 100644 index 0000000..227908d --- /dev/null +++ b/app/routes/band-auto-register.tsx @@ -0,0 +1,88 @@ +import { Form, Link, redirect, useLoaderData, useNavigation } from "react-router"; +import type { ActionFunctionArgs } from "react-router"; +import { createParseJob, getIpAddress, listRecentParseJobs } from "~/lib/db.server"; + +export function loader() { + return { jobs: listRecentParseJobs(10) }; +} + +export async function action({ request }: ActionFunctionArgs) { + const fd = await request.formData(); + const liveText = (fd.get("liveText") as string | null)?.trim() ?? ""; + if (!liveText) return { error: "テキストを入力してください" }; + const job = createParseJob(liveText); + void getIpAddress(request); // unused but kept for symmetry + return redirect(`/bands/auto-register/jobs/${job.id}`); +} + +const STATUS_LABEL: Record<string, string> = { + pending: "待機中", + processing: "解析中", + done: "完了", + error: "エラー", +}; + +const STATUS_COLOR: Record<string, string> = { + pending: "#6b7280", + processing: "#fbbf24", + done: "#34d399", + error: "#f87171", +}; + +export default function BandAutoRegister() { + const { jobs } = useLoaderData<typeof loader>(); + const nav = useNavigation(); + const busy = nav.state !== "idle"; + + return ( + <main> + <div className="page-header"> + <Link to="/bands" className="back">←</Link> + <h1>ライブ情報から自動登録</h1> + </div> + + <Form method="post"> + <div> + <label>ライブ情報テキスト <span className="req">*</span></label> + <textarea + name="liveText" + rows={10} + placeholder={"出演バンド・アーティストの情報をペーストしてください。\n\n例:\n2024.08.10 @ 渋谷CLUB QUATTRO\nopen 17:30 / start 18:00\n\nband1 / Band Two / バンド三"} + /> + </div> + <div className="actions"> + <button type="submit" disabled={busy}> + {busy ? "送信中..." : "キューに追加する"} + </button> + </div> + </Form> + + {jobs.length > 0 && ( + <section style={{ marginTop: "2rem" }}> + <h2>最近のジョブ</h2> + <div className="job-list"> + {jobs.map((job) => ( + <Link + key={job.id} + to={`/bands/auto-register/jobs/${job.id}`} + className="job-row" + > + <span + className="job-status" + style={{ color: STATUS_COLOR[job.status] ?? "#6b7280" }} + > + {STATUS_LABEL[job.status] ?? job.status} + </span> + <span className="job-preview"> + {job.input_text.slice(0, 60).replace(/\n/g, " ")} + {job.input_text.length > 60 ? "…" : ""} + </span> + <span className="job-time">{job.created_at.slice(0, 16).replace("T", " ")}</span> + </Link> + ))} + </div> + </section> + )} + </main> + ); +} diff --git a/app/routes/band-index.tsx b/app/routes/band-index.tsx index 0b47da5..bed6df9 100644 --- a/app/routes/band-index.tsx +++ b/app/routes/band-index.tsx @@ -11,6 +11,7 @@ export default function BandIndex() { <main> <div className="page-header"> <h1>Bands</h1> + <Link to="/bands/auto-register" className="btn" style={{ fontSize: ".8rem" }}>ライブから一括登録</Link> <Link to="/bands/new">+ Band</Link> </div> diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 9795ba8..591585d 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,9 +1,111 @@ -import { redirect } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { listBands, listArtists, listBandLists, listRecentBands, listRecentArtists } from "~/lib/db.server"; export function loader() { - return redirect("/bands"); + return { + bandCount: listBands().length, + artistCount: listArtists().length, + listCount: listBandLists().length, + recentBands: listRecentBands(8), + recentArtists: listRecentArtists(8), + }; } export default function Home() { - return null; + const { bandCount, artistCount, listCount, recentBands, recentArtists } = useLoaderData<typeof loader>(); + return ( + <main> + <div style={{ marginBottom: "2.5rem" }}> + <h1 style={{ fontSize: "2rem", fontWeight: 700, color: "#f9fafb", marginBottom: ".5rem" }}> + whois.band + </h1> + <p className="muted">バンドとアーティストの情報管理サイト</p> + </div> + + <div style={{ display: "flex", flexDirection: "column", gap: "1px", background: "#1f2937", border: "1px solid #1f2937", borderRadius: "6px", overflow: "hidden", marginBottom: "2.5rem" }}> + <SectionRow to="/bands" label="Bands" count={bandCount} newTo="/bands/new" /> + <SectionRow to="/artists" label="Artists" count={artistCount} newTo="/artists/new" /> + <SectionRow to="/lists" label="Lists" count={listCount} newTo="/lists/new" /> + </div> + + <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}> + <RecentSection title="最近追加されたバンド" viewAllTo="/bands"> + {recentBands.length === 0 ? ( + <p className="muted" style={{ fontSize: ".875rem" }}>まだありません</p> + ) : ( + <ul className="band-list"> + {recentBands.map((band) => ( + <li key={band.id}> + <Link to={`/bands/of/${band.id}`}>{band.name}</Link> + {band.status === "hiatus" && ( + <span className="muted" style={{ fontSize: ".75rem" }}>活動休止</span> + )} + </li> + ))} + </ul> + )} + </RecentSection> + + <RecentSection title="最近追加されたアーティスト" viewAllTo="/artists"> + {recentArtists.length === 0 ? ( + <p className="muted" style={{ fontSize: ".875rem" }}>まだありません</p> + ) : ( + <ul className="band-list"> + {recentArtists.map((artist) => ( + <li key={artist.id}> + <Link to={`/artists/of/${artist.id}`}>{artist.name}</Link> + </li> + ))} + </ul> + )} + </RecentSection> + </div> + </main> + ); +} + +function SectionRow({ + to, + label, + count, + newTo, +}: { + to: string; + label: string; + count: number; + newTo: string; +}) { + return ( + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: ".875rem 1.25rem", background: "#0f1117" }}> + <div style={{ display: "flex", alignItems: "baseline", gap: "1rem" }}> + <Link to={to} style={{ fontWeight: 600, fontSize: "1rem", color: "#e5e7eb" }}> + {label} + </Link> + <span className="muted" style={{ fontSize: ".8rem" }}>{count}件</span> + </div> + <Link to={newTo} className="btn" style={{ fontSize: ".8rem" }}> + + 追加 + </Link> + </div> + ); +} + +function RecentSection({ + title, + viewAllTo, + children, +}: { + title: string; + viewAllTo: string; + children: React.ReactNode; +}) { + return ( + <section style={{ marginBottom: 0 }}> + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".75rem" }}> + <h2 style={{ margin: 0 }}>{title}</h2> + <Link to={viewAllTo} className="muted" style={{ fontSize: ".75rem" }}>すべて見る</Link> + </div> + {children} + </section> + ); } diff --git a/package-lock.json b/package-lock.json index d559179..0fa01fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "whois-band", "dependencies": { + "@anthropic-ai/sdk": "^0.110.0", "@react-router/node": "^7.3.0", "@react-router/serve": "^7.3.0", "better-sqlite3": "^12.9.0", @@ -28,6 +29,26 @@ "vite-tsconfig-paths": "^6.1.1" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -443,6 +464,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1225,9 +1254,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1242,9 +1268,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1259,9 +1282,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1276,9 +1296,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1293,9 +1310,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1310,9 +1324,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1327,9 +1338,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1344,9 +1352,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1361,9 +1366,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1378,9 +1380,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1395,9 +1394,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1412,9 +1408,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1429,9 +1422,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1522,6 +1512,11 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1655,9 +1650,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1675,9 +1667,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1695,9 +1684,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1715,9 +1701,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2635,6 +2618,11 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2980,6 +2968,18 @@ "node": ">=6" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3136,9 +3136,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3160,9 +3157,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3184,9 +3178,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3208,9 +3199,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4170,6 +4158,15 @@ "source-map": "^0.6.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4272,6 +4269,11 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "node_modules/tsconfck": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", diff --git a/package.json b/package.json index fefa788..9b1e756 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "add": "tsx scripts/add.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.110.0", "@react-router/node": "^7.3.0", "@react-router/serve": "^7.3.0", "better-sqlite3": "^12.9.0", diff --git a/scripts/import-livehouse-events.ts b/scripts/import-livehouse-events.ts new file mode 100644 index 0000000..4dbb91e --- /dev/null +++ b/scripts/import-livehouse-events.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env npx tsx +/** + * tokyo-livehouse-events の events.db から全出演者を取得し、 + * whois-band に自動登録するスクリプト。 + * + * Usage: + * npx tsx scripts/import-livehouse-events.ts + * + * Optional env vars: + * DB_PATH whois.db のパス (default: /app/whois-band/data/whois.db) + * EVENTS_DB_PATH events.db のパス (default: /app/tokyo-livehouse-events/data/events.db) + * DRY_RUN=1 登録せず結果だけ表示 + */ + +import Database from "better-sqlite3"; +import { randomUUID } from "crypto"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const WHOIS_DB_PATH = process.env.DB_PATH ?? "/app/whois-band/data/whois.db"; +const EVENTS_DB_PATH = process.env.EVENTS_DB_PATH ?? "/app/tokyo-livehouse-events/data/events.db"; +const DRY_RUN = process.env.DRY_RUN === "1"; + +// ── Artist name parser ──────────────────────────────────────────────────────── + +const NOISE_WORDS = new Set([ + "tour", "presents", "present", "and", "feat", "feat.", "with", "guest", + "vs", "vs.", "×", "starring", +]); + +const NOISE_PATTERNS = [ + /\d{1,2}:\d{2}/, // time HH:MM + /^[\d\s\/\-\.]+$/, // numbers/dates/slashes only + /\.(com|net|jp|org)\b/i, // domain names + /^(OPEN|START|ADV|DOOR)/i, // ticket info + /^(出演|単独公演|二日間|開催|開場|ゲスト)/, +]; + +function isNoise(name: string): boolean { + const trimmed = name.trim(); + if (!trimmed || trimmed.length < 2 || trimmed.length > 60) return true; + if (NOISE_WORDS.has(trimmed.toLowerCase())) return true; + return NOISE_PATTERNS.some((re) => re.test(trimmed)); +} + +/** + * Remove all balanced parenthetical content from a string. + * e.g. "KENNY DOPE (MASTERS AT WORK)" → "KENNY DOPE" + * e.g. "MyM (Miyuki・Yoshiko・Mahiru)" → "MyM" + * Keeps the content if there's nothing else left (entire string is in parens). + */ +function stripParens(s: string): string { + let result = s; + // Strip standard round parens and their Japanese variants + result = result.replace(/[\((][^)))]*[\))]/g, ""); + // Strip any leftover unmatched parens + result = result.replace(/[\((()))]/g, ""); + const stripped = result.trim(); + // Fall back to original if nothing remains + return stripped.length >= 2 ? stripped : s.trim(); +} + +/** + * Find indices of top-level "/" separators (not inside parens). + */ +function findTopLevelSlashes(s: string): number[] { + const positions: number[] = []; + let depth = 0; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === "(" || ch === "(") depth++; + else if (ch === ")" || ch === ")") depth = Math.max(0, depth - 1); + else if (ch === "/" && depth === 0) positions.push(i); + } + return positions; +} + +function splitOnTopLevelSlash(s: string): string[] { + const slashes = findTopLevelSlashes(s); + if (slashes.length === 0) return [s]; + + const parts: string[] = []; + let last = 0; + + // Handle "//" (double slash) as a single separator + const merged: number[] = []; + for (let i = 0; i < slashes.length; i++) { + if (i > 0 && slashes[i] === slashes[i - 1] + 1) continue; // skip second of "//" + merged.push(slashes[i]); + // If next slash is consecutive, advance last by 2 after processing + } + + for (const pos of merged) { + const part = s.slice(last, pos).trim(); + if (part) parts.push(part); + // Skip consecutive slash (for "//") + last = (s[pos + 1] === "/") ? pos + 2 : pos + 1; + } + const tail = s.slice(last).trim(); + if (tail) parts.push(tail); + return parts; +} + +function extractNames(raw: string): string[] { + let s = raw; + + // 1. Replace venue markers like =O-EAST= =AZUMAYA= with "/" so they act as separators + s = s.replace(/=([^=]+)=\s*/g, " / "); + + // 2. Strip bracket blocks [members] <detail> 【...】 [...] + s = s.replace(/[\[【<<[][^\]】>>]]*[\]】>>]]/g, ""); + + // 3. Strip "ARTIST Presents EVENT" → keep only ARTIST + // (match " presents " followed by text until "/" or end) + s = s.replace(/\s+[Pp]resents\s+"?[^/"、/]+/g, ""); + s = s.replace(/\s+[Pp]resent\s+"?[^/"、/]+/g, ""); + + // 4. Split on top-level "/" (not inside parens), then on 「、」 + const slashParts = splitOnTopLevelSlash(s); + + const allParts: string[] = []; + for (const part of slashParts) { + // Further split on 「、」 (Japanese comma) — this is always a top-level separator + const jpParts = part.split(/[、]/).map((p) => p.trim()).filter(Boolean); + allParts.push(...jpParts); + } + + // 5. Clean each part + return allParts + .map((name) => { + // Remove trailing parenthetical content (label names, member lists, roles) + let cleaned = stripParens(name); + // Remove leading/trailing punctuation noise + cleaned = cleaned + .replace(/^[「」『』【】〈〉《》\[\]"'・\s]+/, "") + .replace(/[「」『』【】〈〉《》\[\]"'・\s]+$/, "") + .trim(); + return cleaned; + }) + .filter((name) => !isNoise(name)); +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +function toSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w-ヿ一-鿿--]/g, "") + .replace(/^-+|-+$/g, ""); +} + +interface Band { id: string; name: string; slug: string; } + +function insertBand(db: Database.Database, name: string): string { + const id = randomUUID(); + const baseSlug = toSlug(name) || id.slice(0, 8); + + for (let attempt = 0; attempt < 3; attempt++) { + const slug = attempt === 0 ? baseSlug : `${baseSlug}-${id.slice(0, 6)}`; + try { + db.transaction(() => { + db.prepare( + "INSERT INTO bands (id, slug, name, area, description, status) VALUES (?, ?, ?, ?, ?, ?)" + ).run(id, slug, name, null, null, "active"); + + const band = db.prepare("SELECT * FROM bands WHERE id = ?").get(id); + db.prepare( + "INSERT INTO band_revisions (id, band_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)" + ).run( + randomUUID(), + id, + JSON.stringify({ band, links: [], members: [] }), + "tokyo-livehouse-events から自動インポート", + "cli-import" + ); + })(); + return id; + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue; + throw e; + } + } + throw new Error(`スラッグ競合で登録失敗: ${name}`); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + console.log("=== tokyo-livehouse-events → whois-band インポーター ==="); + console.log(`events DB: ${EVENTS_DB_PATH}`); + console.log(`whois DB: ${WHOIS_DB_PATH}`); + if (DRY_RUN) console.log("⚠️ DRY_RUN モード(登録しません)"); + console.log(); + + // 1. events.db から全出演者文字列を取得 + const eventsDb = new Database(EVENTS_DB_PATH, { readonly: true }); + const artistRows = eventsDb + .prepare("SELECT DISTINCT artist FROM events WHERE artist IS NOT NULL AND artist != ''") + .all() as { artist: string }[]; + eventsDb.close(); + + console.log(`出演者フィールド数: ${artistRows.length}`); + + // 2. 全名前を抽出・重複排除(スラッグで判定) + const extracted = new Map<string, string>(); // slug → name (最初に出現したものを採用) + for (const { artist } of artistRows) { + for (const name of extractNames(artist)) { + const slug = toSlug(name); + if (slug && !extracted.has(slug)) { + extracted.set(slug, name); + } + } + } + + console.log(`抽出したユニーク名(スラッグ重複排除後): ${extracted.size}`); + + // 3. whois.db を開いて既存バンドと照合 + const whoisDb = new Database(WHOIS_DB_PATH); + whoisDb.pragma("journal_mode = WAL"); + whoisDb.pragma("foreign_keys = ON"); + + const existingBands = whoisDb + .prepare("SELECT id, name, slug FROM bands") + .all() as Band[]; + const existingSlugs = new Set(existingBands.map((b) => b.slug)); + const existingNames = new Set(existingBands.map((b) => b.name.toLowerCase())); + + console.log(`既存バンド数: ${existingBands.length}\n`); + + const toRegister: string[] = []; + const skipped: string[] = []; + + for (const [slug, name] of extracted) { + if (existingSlugs.has(slug) || existingNames.has(name.toLowerCase())) { + skipped.push(name); + } else { + toRegister.push(name); + } + } + + console.log(`新規登録対象: ${toRegister.length}件`); + console.log(`既存スキップ: ${skipped.length}件\n`); + + if (DRY_RUN) { + console.log("=== ドライランサマリ ==="); + console.log("登録予定バンド(先頭100件):"); + toRegister.slice(0, 100).forEach((n) => console.log(` + ${n}`)); + if (toRegister.length > 100) console.log(` ... 他 ${toRegister.length - 100} 件`); + return; + } + + // 4. 登録 + let registered = 0; + let errors = 0; + + for (const name of toRegister) { + try { + insertBand(whoisDb, name); + registered++; + if (registered % 100 === 0) { + process.stdout.write(` ... ${registered}件登録済\n`); + } + } catch (e) { + console.error(` ❌ "${name}": ${e instanceof Error ? e.message : e}`); + errors++; + } + } + + whoisDb.close(); + + console.log("\n=== 結果サマリ ==="); + console.log(`登録完了: ${registered}件`); + console.log(`スキップ(既存): ${skipped.length}件`); + if (errors > 0) console.log(`エラー: ${errors}件`); + + const finalCount = whoisDb + ? 0 + : (() => { + const db2 = new Database(WHOIS_DB_PATH, { readonly: true }); + const c = (db2.prepare("SELECT COUNT(*) as c FROM bands").get() as { c: number }).c; + db2.close(); + return c; + })(); + + const db2 = new Database(WHOIS_DB_PATH, { readonly: true }); + const finalTotal = (db2.prepare("SELECT COUNT(*) as c FROM bands").get() as { c: number }).c; + db2.close(); + console.log(`DBのバンド総数: ${finalTotal}件`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); |
