diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-06 22:07:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-06 22:07:53 +0900 |
| commit | be55729482296663da8c96723bfd22080e6762c1 (patch) | |
| tree | fcd94b1dc5c55f3a80796c90a555863d13fc9a95 /app/routes | |
| parent | 014b29bc22b1c207a03dd560051ecdd5df63f0b1 (diff) | |
Add Tokyo livehouse event aggregator service
Full-stack React Router v7 app that scrapes event listings from major
Tokyo live venues (Liquid Room, WWW/WWW X, Shibuya O-EAST, Shinjuku LOFT,
Club Quattro) and stores them in SQLite for browsing and search.
- Modular scraper architecture: add a new venue by dropping a file in
app/scrapers/ and registering it in index.ts
- Routes: /events (filter by keyword/venue/date), /events/:id, /venues,
GET /api/scrape
- EventCard shows artist, date/time, venue, ticket URL, and fee
- Post-scrape per-venue Markdown files generated to events/ (dev reference)
- /add-livehouse Claude Code skill defined in .claude/commands/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes')
| -rw-r--r-- | app/routes/api.scrape.ts | 37 | ||||
| -rw-r--r-- | app/routes/events.$id.tsx | 124 | ||||
| -rw-r--r-- | app/routes/events._index.tsx | 94 | ||||
| -rw-r--r-- | app/routes/index.tsx | 5 | ||||
| -rw-r--r-- | app/routes/venues.tsx | 68 |
5 files changed, 328 insertions, 0 deletions
diff --git a/app/routes/api.scrape.ts b/app/routes/api.scrape.ts new file mode 100644 index 0000000..4071985 --- /dev/null +++ b/app/routes/api.scrape.ts @@ -0,0 +1,37 @@ +/** + * Resource route: POST /api/scrape + * Triggers scraping for all venues (or a specific one via ?venue_id=xxx). + * Returns JSON results and redirects back if called from a form. + */ +import { redirect } from "react-router"; +import type { Route } from "./+types/api.scrape"; +import { runAllScrapers, runScraper } from "~/lib/scraper-runner.server"; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + const venueId = formData.get("venue_id"); + + const results = venueId + ? [await runScraper(String(venueId))] + : await runAllScrapers(); + + // If called from a browser form, redirect back + const referer = request.headers.get("Referer"); + if (referer) { + return redirect(referer); + } + + return Response.json({ results }); +} + +// Allow GET for quick testing in the browser +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const venueId = url.searchParams.get("venue_id"); + + const results = venueId + ? [await runScraper(venueId)] + : await runAllScrapers(); + + return Response.json({ results }); +} diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx new file mode 100644 index 0000000..cecb282 --- /dev/null +++ b/app/routes/events.$id.tsx @@ -0,0 +1,124 @@ +import { useLoaderData, Link } from "react-router"; +import type { Route } from "./+types/events.$id"; +import { getEvent } from "~/lib/db.server"; + +export async function loader({ params }: Route.LoaderArgs) { + const id = parseInt(params.id, 10); + if (isNaN(id)) throw new Response("Not Found", { status: 404 }); + const event = getEvent(id); + if (!event) throw new Response("Not Found", { status: 404 }); + return { event }; +} + +export default function EventDetail() { + const { event } = useLoaderData<typeof loader>(); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100"> + <header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between"> + <Link to="/" className="text-xl font-bold tracking-tight text-white"> + 🎸 東京ライブハウス + </Link> + <nav className="flex gap-6 text-sm text-gray-400"> + <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + </nav> + </header> + + <main className="max-w-3xl mx-auto px-4 py-10"> + <Link to="/events" className="text-sm text-indigo-400 hover:underline"> + ← イベント一覧に戻る + </Link> + + <div className="mt-6"> + {event.image_url && ( + <img + src={event.image_url} + alt={event.title} + className="w-full max-h-72 object-cover rounded-xl mb-6" + /> + )} + + <div className="flex items-start justify-between gap-4 flex-wrap"> + <div> + <h1 className="text-3xl font-bold leading-tight">{event.title}</h1> + {event.artist && ( + <p className="mt-1 text-lg text-gray-300">{event.artist}</p> + )} + </div> + <span className="rounded-full bg-indigo-700/60 px-3 py-1 text-sm font-medium whitespace-nowrap"> + {event.venue_name} + </span> + </div> + + <dl className="mt-8 grid grid-cols-2 gap-4 text-sm"> + <Detail label="日付" value={formatDate(event.date)} /> + {event.open_time && <Detail label="OPEN" value={event.open_time} />} + {event.start_time && <Detail label="START" value={event.start_time} />} + {event.price && <Detail label="料金" value={event.price} />} + {event.venue_area && <Detail label="エリア" value={event.venue_area} />} + </dl> + + {event.description && ( + <p className="mt-8 text-gray-300 leading-relaxed whitespace-pre-line"> + {event.description} + </p> + )} + + <div className="mt-8 flex gap-4 flex-wrap"> + {event.ticket_url && ( + <a + href={event.ticket_url} + target="_blank" + rel="noopener noreferrer" + className="rounded-md bg-indigo-600 px-5 py-2 text-sm font-medium hover:bg-indigo-500 transition-colors" + > + チケット購入 + </a> + )} + {event.source_url && ( + <a + href={event.source_url} + target="_blank" + rel="noopener noreferrer" + className="rounded-md bg-gray-700 px-5 py-2 text-sm font-medium hover:bg-gray-600 transition-colors" + > + 詳細ページ + </a> + )} + {event.venue_url && ( + <a + href={event.venue_url} + target="_blank" + rel="noopener noreferrer" + className="rounded-md bg-gray-700 px-5 py-2 text-sm font-medium hover:bg-gray-600 transition-colors" + > + 会場サイト + </a> + )} + </div> + + <p className="mt-10 text-xs text-gray-600"> + 最終取得: {event.fetched_at} + </p> + </div> + </main> + </div> + ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( + <div className="rounded-lg bg-gray-800/60 p-3"> + <dt className="text-xs text-gray-500 mb-1">{label}</dt> + <dd className="font-medium">{value}</dd> + </div> + ); +} + +function formatDate(iso: string): string { + const [y, m, d] = iso.split("-"); + const days = ["日", "月", "火", "水", "木", "金", "土"]; + const day = days[new Date(iso).getDay()]; + return `${y}年${m}月${d}日(${day})`; +} diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx new file mode 100644 index 0000000..3883d37 --- /dev/null +++ b/app/routes/events._index.tsx @@ -0,0 +1,94 @@ +import { useLoaderData, useSearchParams, Form, Link } from "react-router"; +import type { Route } from "./+types/events._index"; +import { queryEvents, getVenues } from "~/lib/db.server"; +import EventCard from "~/components/EventCard"; +import FilterBar from "~/components/FilterBar"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const date_from = url.searchParams.get("date_from") ?? undefined; + const date_to = url.searchParams.get("date_to") ?? undefined; + const venue_id = url.searchParams.get("venue_id") ?? undefined; + const keyword = url.searchParams.get("keyword") ?? undefined; + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); + const limit = 30; + const offset = (page - 1) * limit; + + const events = queryEvents({ date_from, date_to, venue_id, keyword, limit, offset }); + const venues = getVenues(); + + return { events, venues, page, hasMore: events.length === limit }; +} + +export default function EventsIndex() { + const { events, venues, page, hasMore } = useLoaderData<typeof loader>(); + const [searchParams] = useSearchParams(); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100"> + <header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between"> + <Link to="/" className="text-xl font-bold tracking-tight text-white"> + 🎸 東京ライブハウス + </Link> + <nav className="flex gap-6 text-sm text-gray-400"> + <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + </nav> + </header> + + <main className="max-w-6xl mx-auto px-4 py-8"> + <div className="mb-6 flex items-center justify-between"> + <h1 className="text-2xl font-bold">イベント一覧</h1> + <Form method="post" action="/api/scrape"> + <button + type="submit" + className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium hover:bg-indigo-500 transition-colors" + > + 情報を更新 + </button> + </Form> + </div> + + <FilterBar venues={venues} /> + + {events.length === 0 ? ( + <div className="mt-16 text-center text-gray-500"> + <p className="text-lg">イベントが見つかりません</p> + <p className="mt-2 text-sm">「情報を更新」ボタンでデータを取得してください。</p> + </div> + ) : ( + <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + {events.map((event) => ( + <EventCard key={event.id} event={event} /> + ))} + </div> + )} + + <div className="mt-8 flex justify-center gap-4"> + {page > 1 && ( + <Link + to={`?${buildPageParams(searchParams, page - 1)}`} + className="rounded bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700" + > + ← 前のページ + </Link> + )} + {hasMore && ( + <Link + to={`?${buildPageParams(searchParams, page + 1)}`} + className="rounded bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700" + > + 次のページ → + </Link> + )} + </div> + </main> + </div> + ); +} + +function buildPageParams(params: URLSearchParams, page: number): string { + const next = new URLSearchParams(params); + next.set("page", String(page)); + return next.toString(); +} diff --git a/app/routes/index.tsx b/app/routes/index.tsx new file mode 100644 index 0000000..1cdb9a4 --- /dev/null +++ b/app/routes/index.tsx @@ -0,0 +1,5 @@ +import { redirect } from "react-router"; + +export function loader() { + return redirect("/events"); +} diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx new file mode 100644 index 0000000..23b052f --- /dev/null +++ b/app/routes/venues.tsx @@ -0,0 +1,68 @@ +import { useLoaderData, Link } from "react-router"; +import type { Route } from "./+types/venues"; +import { getVenues } from "~/lib/db.server"; +import { getScraperIds } from "~/lib/venue-meta.server"; + +export async function loader(_: Route.LoaderArgs) { + const venues = getVenues(); + const scraperIds = getScraperIds(); + return { venues, scraperIds }; +} + +export default function Venues() { + const { venues, scraperIds: scraperIdList } = useLoaderData<typeof loader>(); + const scraperIds = new Set(scraperIdList); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100"> + <header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between"> + <Link to="/" className="text-xl font-bold tracking-tight text-white"> + 🎸 東京ライブハウス + </Link> + <nav className="flex gap-6 text-sm text-gray-400"> + <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/venues" className="text-white font-medium">会場一覧</Link> + </nav> + </header> + + <main className="max-w-4xl mx-auto px-4 py-10"> + <div className="mb-8"> + <h1 className="text-2xl font-bold">会場一覧</h1> + <p className="mt-1 text-sm text-gray-400"> + 現在 {scraperIdList.length} 会場のスクレイパーが登録されています。 + 新しい会場を追加するには <code className="bg-gray-800 px-1 rounded">app/scrapers/</code> に + モジュールを追加して <code className="bg-gray-800 px-1 rounded">index.ts</code> に登録してください。 + </p> + </div> + + {venues.length === 0 ? ( + <p className="text-gray-500">まだ会場データがありません。「情報を更新」してください。</p> + ) : ( + <div className="grid gap-4 sm:grid-cols-2"> + {venues.map((v) => ( + <Link + key={v.id} + to={`/events?venue_id=${v.id}`} + className="flex items-center justify-between rounded-xl bg-gray-800/60 p-4 hover:bg-gray-800 transition-colors border border-gray-700/50" + > + <div> + <p className="font-semibold">{v.name}</p> + {v.area && <p className="text-sm text-gray-400">{v.area}</p>} + {scraperIds.has(v.id) && ( + <span className="mt-1 inline-block rounded-full bg-emerald-700/40 px-2 py-0.5 text-xs text-emerald-300"> + スクレイパー登録済 + </span> + )} + </div> + <span className="text-2xl font-bold text-gray-500"> + {v.event_count ?? 0} + <span className="text-sm font-normal ml-1">件</span> + </span> + </Link> + ))} + </div> + )} + </main> + </div> + ); +} |
