summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--SCRAPE_TARGETS.md5
-rw-r--r--TODO.md12
-rw-r--r--app/components/FilterBar.tsx24
-rw-r--r--app/components/ScrapeButton.tsx115
-rw-r--r--app/lib/db.server.ts7
-rw-r--r--app/routes/events.$id.tsx1
-rw-r--r--app/routes/events._index.tsx14
-rw-r--r--app/routes/venues.tsx29
8 files changed, 188 insertions, 19 deletions
diff --git a/SCRAPE_TARGETS.md b/SCRAPE_TARGETS.md
index ab94f39..461573f 100644
--- a/SCRAPE_TARGETS.md
+++ b/SCRAPE_TARGETS.md
@@ -19,6 +19,11 @@
| `fad-yokohama` | F.A.D YOKOHAMA | 横浜 | http://www.fad-music.com/fad/?page_id=3 | [fad-yokohama.ts](app/scrapers/fad-yokohama.ts) | ✅ |
| `navey-floor` | navey floor | 赤坂 | https://navey-floor.com/event/ | [navey-floor.ts](app/scrapers/navey-floor.ts) | ✅ |
| `shimokitazawa-era` | 下北沢ERA | 下北沢 | http://s-era.jp/schedule | [shimokitazawa-era.ts](app/scrapers/shimokitazawa-era.ts) | ✅ |
+| `nine-spices` | Nine Spices | 新宿 | https://9spices.rinky.info/schedule/ | [nine-spices.ts](app/scrapers/nine-spices.ts) | ✅ |
+| `nishieifuku-jam` | 西永福JAM | 西永福 | https://jam.rinky.info/events | [nishieifuku-jam.ts](app/scrapers/nishieifuku-jam.ts) | ✅ |
+| `fever-shindaita` | 新代田 FEVER | 新代田 | https://www.fever-popo.com | [fever-shindaita.ts](app/scrapers/fever-shindaita.ts) | ✅ |
+| `moon-step-nakano` | 中野 MOON STEP | 中野 | https://nakano-dynamite.com/moonstep | [moon-step-nakano.ts](app/scrapers/moon-step-nakano.ts) | ✅ |
+| `mod-shibasaki` | shibasaki mod | 柴崎 | https://shibasakimod.com/schedule | [mod-shibasaki.ts](app/scrapers/mod-shibasaki.ts) | ✅ |
### 状態凡例
- ✅ 動作中
diff --git a/TODO.md b/TODO.md
index 2c076a4..c576d7c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,24 +2,24 @@
## バグ・不整合
-- [ ] **SCRAPE_TARGETS.md が古い** — `index.ts` には17会場登録済みだが、MD には12会場しか掲載されていない。未掲載5会場:
+- [x] **SCRAPE_TARGETS.md が古い** — `index.ts` には17会場登録済みだが、MD には12会場しか掲載されていない。未掲載5会場:
- `nine-spices`(Nine Spices)
- `nishieifuku-jam`(西永福JAM)
- `fever-shindaita`(新代田FEVER)
- `moon-step-nakano`(MOON STEP 中野)
- `mod-shibasaki`(MOD 芝崎)
-- [ ] **ナビゲーションの不一致** — `venues.tsx` と `events.$id.tsx` のヘッダーナビに「日付別」リンクがない(`events._index.tsx` には存在する)
+- [x] **ナビゲーションの不一致** — `venues.tsx` と `events.$id.tsx` のヘッダーナビに「日付別」リンクがない(`events._index.tsx` には存在する)
## 機能追加
-- [ ] **スクレイプ実行ボタン** — 会場一覧ページ・イベント一覧ページからUIで `/api/scrape` を叩けるようにする(現在はCLIか直接APIアクセスのみ)
+- [x] **スクレイプ実行ボタン** — 会場一覧ページ・イベント一覧ページからUIで `/api/scrape` を叩けるようにする(現在はCLIか直接APIアクセスのみ)
-- [ ] **会場公式サイトリンク** — 会場一覧ページの各会場に、公式スケジュールページへの外部リンクを追加する
+- [x] **会場公式サイトリンク** — 会場一覧ページの各会場に、公式スケジュールページへの外部リンクを追加する
-- [ ] **エリアフィルター** — FilterBar に `area` 絞り込みを追加する(渋谷・下北沢・新宿など)
+- [x] **エリアフィルター** — FilterBar に `area` 絞り込みを追加する(渋谷・下北沢・新宿など)
-- [ ] **スクレイプ進捗UI** — `/api/scrape-status` をポーリングしてスクレイプの進捗・結果をUIに表示する
+- [x] **スクレイプ進捗UI** — `/api/scrape-status` をポーリングしてスクレイプの進捗・結果をUIに表示する
## スクレイパー
diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx
index 7b8ca0c..7a95d2c 100644
--- a/app/components/FilterBar.tsx
+++ b/app/components/FilterBar.tsx
@@ -3,11 +3,12 @@ import type { Venue } from "~/lib/db.server";
interface Props {
venues: Venue[];
+ areas?: string[];
defaultDateFrom?: string;
defaultDateTo?: string;
}
-export default function FilterBar({ venues, defaultDateFrom, defaultDateTo }: Props) {
+export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo }: Props) {
const [searchParams] = useSearchParams();
return (
@@ -63,6 +64,25 @@ export default function FilterBar({ venues, defaultDateFrom, defaultDateTo }: Pr
/>
</div>
+ {/* Area */}
+ {areas && areas.length > 0 && (
+ <div className="flex flex-col gap-1">
+ <label className="text-xs text-gray-400">エリア</label>
+ <select
+ name="area"
+ defaultValue={searchParams.get("area") ?? ""}
+ className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
+ >
+ <option value="">すべて</option>
+ {areas.map((a) => (
+ <option key={a} value={a}>
+ {a}
+ </option>
+ ))}
+ </select>
+ </div>
+ )}
+
{/* Capacity */}
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">キャパシティ</label>
@@ -98,5 +118,5 @@ export default function FilterBar({ venues, defaultDateFrom, defaultDateTo }: Pr
}
function hasFilters(params: URLSearchParams): boolean {
- return ["keyword", "venue_id", "date_from", "date_to", "capacity_range"].some((k) => params.get(k));
+ return ["keyword", "venue_id", "date_from", "date_to", "capacity_range", "area"].some((k) => params.get(k));
}
diff --git a/app/components/ScrapeButton.tsx b/app/components/ScrapeButton.tsx
new file mode 100644
index 0000000..75e48b1
--- /dev/null
+++ b/app/components/ScrapeButton.tsx
@@ -0,0 +1,115 @@
+import { useState, useEffect, useRef } from "react";
+import { useFetcher } from "react-router";
+
+interface ScrapeLog {
+ id: number;
+ run_id: string;
+ venue_id: string;
+ venue_name: string;
+ status: "running" | "ok" | "error";
+ events_saved: number;
+ error: string | null;
+}
+
+interface StatusData {
+ running: boolean;
+ results: ScrapeLog[];
+}
+
+interface ScrapeStartData {
+ run_id: string;
+ status: string;
+}
+
+export default function ScrapeButton({ venueId }: { venueId?: string }) {
+ const triggerFetcher = useFetcher<ScrapeStartData>();
+ const statusFetcher = useFetcher<StatusData>();
+ const statusFetcherRef = useRef(statusFetcher);
+ useEffect(() => { statusFetcherRef.current = statusFetcher; });
+
+ const [runId, setRunId] = useState<string | null>(null);
+ const [polling, setPolling] = useState(false);
+ const [done, setDone] = useState(false);
+
+ useEffect(() => {
+ const data = triggerFetcher.data;
+ if (data?.run_id) {
+ setRunId(data.run_id);
+ setPolling(true);
+ setDone(false);
+ }
+ }, [triggerFetcher.data]);
+
+ useEffect(() => {
+ if (!polling || !runId) return;
+ const id = setInterval(() => {
+ statusFetcherRef.current.load(`/api/scrape-status?run_id=${runId}`);
+ }, 2000);
+ return () => clearInterval(id);
+ }, [polling, runId]);
+
+ useEffect(() => {
+ const data = statusFetcher.data;
+ if (data && !data.running) {
+ setPolling(false);
+ setDone(true);
+ }
+ }, [statusFetcher.data]);
+
+ const results: ScrapeLog[] = statusFetcher.data?.results ?? [];
+ const running = statusFetcher.data?.running ?? false;
+ const isActive = triggerFetcher.state !== "idle" || polling;
+ const okCount = results.filter((r) => r.status === "ok").length;
+ const errCount = results.filter((r) => r.status === "error").length;
+ const apiUrl = venueId ? `/api/scrape?venue_id=${venueId}` : "/api/scrape";
+
+ return (
+ <div className="flex flex-col items-end gap-2">
+ <button
+ onClick={() => triggerFetcher.load(apiUrl)}
+ disabled={isActive}
+ className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors whitespace-nowrap ${
+ isActive
+ ? "bg-gray-700 text-gray-500 cursor-not-allowed"
+ : "bg-indigo-600 text-white hover:bg-indigo-500"
+ }`}
+ >
+ {isActive ? "更新中..." : "情報を更新"}
+ </button>
+
+ {(isActive || done) && results.length > 0 && (
+ <div className="w-72 rounded-lg bg-gray-800/80 border border-gray-700/60 p-3 text-xs space-y-1">
+ <p className="text-gray-400 font-medium mb-2">
+ {running
+ ? `スクレイプ中... ${okCount + errCount} / ${results.length} 完了`
+ : `完了 — ✔ ${okCount}件成功 / ✖ ${errCount}件失敗`}
+ </p>
+ {results.map((r) => (
+ <div key={r.id} className="flex items-center gap-2">
+ <span
+ className={
+ r.status === "ok"
+ ? "text-emerald-400"
+ : r.status === "error"
+ ? "text-red-400"
+ : "text-yellow-400"
+ }
+ >
+ {r.status === "ok" ? "✔" : r.status === "error" ? "✖" : "⟳"}
+ </span>
+ <span className="text-gray-300 flex-1 truncate">{r.venue_name}</span>
+ {r.status === "ok" && (
+ <span className="text-gray-500 shrink-0">{r.events_saved}件</span>
+ )}
+ {r.status === "error" && (
+ <span className="text-red-400 shrink-0" title={r.error ?? ""}>
+ エラー
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index a4671b4..82a0025 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -172,12 +172,13 @@ export interface QueryEventsParams {
venue_id?: string;
keyword?: string;
capacity_range?: CapacityRange;
+ area?: string;
limit?: number;
offset?: number;
}
export function queryEvents(params: QueryEventsParams = {}): Event[] {
- const { date_from, date_to, venue_id, keyword, capacity_range, limit = 60, offset = 0 } =
+ const { date_from, date_to, venue_id, keyword, capacity_range, area, limit = 60, offset = 0 } =
params;
const clauses: string[] = [];
@@ -206,6 +207,10 @@ export function queryEvents(params: QueryEventsParams = {}): Event[] {
} else if (capacity_range === "large") {
clauses.push("v.capacity >= 300");
}
+ if (area) {
+ clauses.push("v.area = ?");
+ args.push(area);
+ }
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx
index 4a84308..423cda5 100644
--- a/app/routes/events.$id.tsx
+++ b/app/routes/events.$id.tsx
@@ -21,6 +21,7 @@ export default function EventDetail() {
</Link>
<nav className="flex gap-4 sm:gap-6 text-sm text-gray-400">
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
+ <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
<Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
</nav>
</header>
diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx
index cb1a019..890e0fd 100644
--- a/app/routes/events._index.tsx
+++ b/app/routes/events._index.tsx
@@ -4,6 +4,7 @@ import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server";
import EventCard from "~/components/EventCard";
import EventListRow from "~/components/EventListRow";
import FilterBar from "~/components/FilterBar";
+import ScrapeButton from "~/components/ScrapeButton";
function defaultWindow() {
const today = new Date();
@@ -24,18 +25,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const venue_id = url.searchParams.get("venue_id") ?? undefined;
const keyword = url.searchParams.get("keyword") ?? undefined;
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
+ const area = url.searchParams.get("area") ?? 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, capacity_range, limit, offset });
+ const events = queryEvents({ date_from, date_to, venue_id, keyword, capacity_range, area, limit, offset });
const venues = getVenues();
+ const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
- return { events, venues, page, hasMore: events.length === limit, date_from, date_to };
+ return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to };
}
export default function EventsIndex() {
- const { events, venues, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>();
+ const { events, venues, areas, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const view = (searchParams.get("view") ?? "card") as "card" | "list";
@@ -62,6 +65,8 @@ export default function EventsIndex() {
<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>
+ <div className="flex items-center gap-3">
+ <ScrapeButton />
<div className="flex rounded-lg overflow-hidden border border-gray-700 text-sm">
<button
onClick={() => switchView("card")}
@@ -78,9 +83,10 @@ export default function EventsIndex() {
</button>
</div>
+ </div>
</div>
- <FilterBar venues={venues} defaultDateFrom={date_from} defaultDateTo={date_to} />
+ <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} />
{events.length === 0 ? (
<div className="mt-16 text-center text-gray-500">
diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx
index b027707..f2a7d54 100644
--- a/app/routes/venues.tsx
+++ b/app/routes/venues.tsx
@@ -1,6 +1,7 @@
import { useLoaderData, Link } from "react-router";
import type { Route } from "./+types/venues";
import { getVenues, getLastScrapePerVenue, type ScrapeLog } from "~/lib/db.server";
+import ScrapeButton from "~/components/ScrapeButton";
export async function loader(_: Route.LoaderArgs) {
const venues = getVenues();
@@ -20,16 +21,20 @@ export default function Venues() {
</Link>
<nav className="flex gap-4 sm:gap-6 text-sm text-gray-400">
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
+ <Link to="/events/by-date" 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">
- 現在 {venues.length} 会場が登録されています。
- </p>
+ <div className="mb-8 flex items-start justify-between gap-4">
+ <div>
+ <h1 className="text-2xl font-bold">会場一覧</h1>
+ <p className="mt-1 text-sm text-gray-400">
+ 現在 {venues.length} 会場が登録されています。
+ </p>
+ </div>
+ <ScrapeButton />
</div>
{venues.length === 0 ? (
@@ -51,7 +56,19 @@ export default function Venues() {
>
{v.name}
</Link>
- {v.area && <p className="text-xs text-gray-400">{v.area}</p>}
+ <div className="flex items-center gap-2 mt-0.5">
+ {v.area && <span className="text-xs text-gray-400">{v.area}</span>}
+ {v.url && (
+ <a
+ href={v.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-xs text-gray-600 hover:text-indigo-400 transition-colors"
+ >
+ 公式サイト ↗
+ </a>
+ )}
+ </div>
</div>
{/* イベント件数 */}