diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/import-livehouse-events.ts | 295 |
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); }); |
