summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 18:57:59 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 18:57:59 +0900
commit59a9f4984c3acd29bc746a78e30849f50fa555ee (patch)
treeb9dab024c0c5d1d68e023b8f49eb0716b97965bd /scripts
parenta100fb28f92fa7c5d2cf701d118c4c7b898c9bf2 (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>
Diffstat (limited to 'scripts')
-rw-r--r--scripts/import-livehouse-events.ts295
1 files changed, 295 insertions, 0 deletions
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); });