summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-14 23:14:49 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-14 23:14:49 +0900
commit1c0df5a6eadf20d3dce490b5c5c87a3ee750fe34 (patch)
tree7e8dfc88464032c6766c17363473b6d3cc09760b /app
parentca71f49b9cccb8ad83170b123eeaf9a6af7fc684 (diff)
Add JSON REST API: GET /api/events, /api/events/:id, /api/venues, /api/openapi.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
-rw-r--r--app/routes.ts4
-rw-r--r--app/routes/api.events.$id.ts22
-rw-r--r--app/routes/api.events._index.ts47
-rw-r--r--app/routes/api.openapi.ts337
-rw-r--r--app/routes/api.venues.ts13
5 files changed, 423 insertions, 0 deletions
diff --git a/app/routes.ts b/app/routes.ts
index 07ba8a9..91757b5 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -11,6 +11,10 @@ export default [
...prefix("api", [
route("scrape", "routes/api.scrape.ts"),
route("scrape-status", "routes/api.scrape-status.ts"),
+ route("events", "routes/api.events._index.ts"),
+ route("events/:id", "routes/api.events.$id.ts"),
route("events/:id/calendar.ics", "routes/api.events.$id.ics.ts"),
+ route("venues", "routes/api.venues.ts"),
+ route("openapi.json", "routes/api.openapi.ts"),
]),
] satisfies RouteConfig;
diff --git a/app/routes/api.events.$id.ts b/app/routes/api.events.$id.ts
new file mode 100644
index 0000000..0f0f2e8
--- /dev/null
+++ b/app/routes/api.events.$id.ts
@@ -0,0 +1,22 @@
+/**
+ * GET /api/events/:id
+ */
+import type { Route } from "./+types/api.events.$id";
+import { getEvent } from "~/lib/db.server";
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const id = parseInt(params.id, 10);
+ if (Number.isNaN(id)) {
+ return Response.json({ error: "Invalid id" }, { status: 400 });
+ }
+
+ const event = getEvent(id);
+ if (!event) {
+ return Response.json({ error: "Not found" }, { status: 404 });
+ }
+
+ return Response.json(
+ { event },
+ { headers: { "Cache-Control": "public, max-age=300" } }
+ );
+}
diff --git a/app/routes/api.events._index.ts b/app/routes/api.events._index.ts
new file mode 100644
index 0000000..cc5a288
--- /dev/null
+++ b/app/routes/api.events._index.ts
@@ -0,0 +1,47 @@
+/**
+ * GET /api/events
+ *
+ * Query params:
+ * date_from YYYY-MM-DD (default: today)
+ * date_to YYYY-MM-DD
+ * venue_id string
+ * keyword string (title or artist partial match)
+ * capacity_range small | medium | large
+ * area string
+ * limit 1–100 (default 60)
+ * offset integer (default 0)
+ */
+import type { Route } from "./+types/api.events._index";
+import { queryEvents } from "~/lib/db.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+ const p = url.searchParams;
+
+ const rawLimit = parseInt(p.get("limit") ?? "60", 10);
+ const limit = Number.isNaN(rawLimit) ? 60 : Math.min(Math.max(rawLimit, 1), 100);
+ const rawOffset = parseInt(p.get("offset") ?? "0", 10);
+ const offset = Number.isNaN(rawOffset) ? 0 : Math.max(rawOffset, 0);
+
+ const capacityRaw = p.get("capacity_range");
+ const capacity_range =
+ capacityRaw === "small" || capacityRaw === "medium" || capacityRaw === "large"
+ ? capacityRaw
+ : undefined;
+
+ const events = queryEvents({
+ date_from: p.get("date_from") ?? undefined,
+ date_to: p.get("date_to") ?? undefined,
+ venue_id: p.get("venue_id") ?? undefined,
+ keyword: p.get("keyword") ?? undefined,
+ area: p.get("area") ?? undefined,
+ capacity_range,
+ limit,
+ offset,
+ });
+
+ return Response.json(
+ { events, meta: { limit, offset, count: events.length } },
+ { headers: { "Cache-Control": "public, max-age=300" } }
+ );
+}
diff --git a/app/routes/api.openapi.ts b/app/routes/api.openapi.ts
new file mode 100644
index 0000000..8598d2c
--- /dev/null
+++ b/app/routes/api.openapi.ts
@@ -0,0 +1,337 @@
+/**
+ * GET /api/openapi.json — OpenAPI 3.1 spec
+ */
+import type { Route } from "./+types/api.openapi";
+
+const SPEC = {
+ openapi: "3.1.0",
+ info: {
+ title: "Tokyo Livehouse Events API",
+ version: "1.0.0",
+ description:
+ "東京・近郊のライブハウスのイベント情報を提供する REST API。" +
+ "スクレイパーが定期取得したデータを SQLite に保存し、JSON で返します。",
+ },
+ servers: [{ url: "/api", description: "本番 / ローカル共通" }],
+ tags: [
+ { name: "events", description: "イベント情報" },
+ { name: "venues", description: "会場情報" },
+ { name: "scrape", description: "スクレイプ操作" },
+ ],
+ paths: {
+ "/events": {
+ get: {
+ tags: ["events"],
+ summary: "イベント一覧",
+ description: "条件でフィルタリングしたイベントをページネーション付きで返します。",
+ operationId: "listEvents",
+ parameters: [
+ {
+ name: "date_from",
+ in: "query",
+ description: "開始日 (YYYY-MM-DD)。未指定時は当日。",
+ schema: { type: "string", format: "date", example: "2026-05-01" },
+ },
+ {
+ name: "date_to",
+ in: "query",
+ description: "終了日 (YYYY-MM-DD)。",
+ schema: { type: "string", format: "date", example: "2026-05-31" },
+ },
+ {
+ name: "venue_id",
+ in: "query",
+ description: "会場 ID で絞り込み。",
+ schema: { type: "string", example: "liquid-room" },
+ },
+ {
+ name: "keyword",
+ in: "query",
+ description: "タイトル・アーティスト名の部分一致。",
+ schema: { type: "string", example: "メリー" },
+ },
+ {
+ name: "area",
+ in: "query",
+ description: "エリア名で絞り込み (例: 渋谷、東高円寺)。",
+ schema: { type: "string", example: "東高円寺" },
+ },
+ {
+ name: "capacity_range",
+ in: "query",
+ description: "キャパシティ帯: small (≤100) / medium (101–299) / large (≥300)。",
+ schema: { type: "string", enum: ["small", "medium", "large"] },
+ },
+ {
+ name: "limit",
+ in: "query",
+ description: "取得件数 (1–100、デフォルト 60)。",
+ schema: { type: "integer", minimum: 1, maximum: 100, default: 60 },
+ },
+ {
+ name: "offset",
+ in: "query",
+ description: "スキップ件数 (デフォルト 0)。",
+ schema: { type: "integer", minimum: 0, default: 0 },
+ },
+ ],
+ responses: {
+ "200": {
+ description: "成功",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/EventListResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/events/{id}": {
+ get: {
+ tags: ["events"],
+ summary: "イベント詳細",
+ operationId: "getEvent",
+ parameters: [
+ {
+ name: "id",
+ in: "path",
+ required: true,
+ description: "イベント ID (整数)。",
+ schema: { type: "integer", example: 42 },
+ },
+ ],
+ responses: {
+ "200": {
+ description: "成功",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/EventDetailResponse" },
+ },
+ },
+ },
+ "400": { description: "id が不正" },
+ "404": { description: "イベントが見つからない" },
+ },
+ },
+ },
+ "/events/{id}/calendar.ics": {
+ get: {
+ tags: ["events"],
+ summary: "iCalendar 形式でイベントをダウンロード",
+ operationId: "getEventIcs",
+ parameters: [
+ {
+ name: "id",
+ in: "path",
+ required: true,
+ schema: { type: "integer" },
+ },
+ ],
+ responses: {
+ "200": {
+ description: ".ics ファイル",
+ content: { "text/calendar": { schema: { type: "string" } } },
+ },
+ "404": { description: "イベントが見つからない" },
+ },
+ },
+ },
+ "/venues": {
+ get: {
+ tags: ["venues"],
+ summary: "会場一覧",
+ description: "登録済み会場の一覧とイベント件数を返します。",
+ operationId: "listVenues",
+ responses: {
+ "200": {
+ description: "成功",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/VenueListResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/scrape": {
+ get: {
+ tags: ["scrape"],
+ summary: "スクレイプ開始 (GET)",
+ description: "`venue_id` を指定すると該当会場のみ、省略すると全会場をバックグラウンドでスクレイプします。",
+ operationId: "startScrapeGet",
+ parameters: [
+ {
+ name: "venue_id",
+ in: "query",
+ description: "会場 ID。省略で全会場。",
+ schema: { type: "string" },
+ },
+ ],
+ responses: {
+ "202": {
+ description: "スクレイプ開始",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ScrapeStartResponse" },
+ },
+ },
+ },
+ },
+ },
+ post: {
+ tags: ["scrape"],
+ summary: "スクレイプ開始 (POST)",
+ operationId: "startScrapePost",
+ requestBody: {
+ content: {
+ "application/x-www-form-urlencoded": {
+ schema: {
+ type: "object",
+ properties: {
+ venue_id: { type: "string", description: "省略で全会場" },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ "202": {
+ description: "スクレイプ開始",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ScrapeStartResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/scrape-status": {
+ get: {
+ tags: ["scrape"],
+ summary: "スクレイプ状況確認",
+ operationId: "getScrapeStatus",
+ parameters: [
+ {
+ name: "run_id",
+ in: "query",
+ description: "run_id を省略すると最新ランの状況を返します。",
+ schema: { type: "string", format: "uuid" },
+ },
+ ],
+ responses: {
+ "200": {
+ description: "成功",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ScrapeStatusResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ components: {
+ schemas: {
+ Event: {
+ type: "object",
+ properties: {
+ id: { type: "integer" },
+ venue_id: { type: "string" },
+ venue_name: { type: "string" },
+ venue_area: { type: ["string", "null"] },
+ venue_url: { type: ["string", "null"] },
+ title: { type: "string" },
+ artist: { type: ["string", "null"] },
+ date: { type: "string", format: "date", example: "2026-05-15" },
+ start_time: { type: ["string", "null"], example: "19:00" },
+ open_time: { type: ["string", "null"], example: "18:30" },
+ ticket_url: { type: ["string", "null"], format: "uri" },
+ price: { type: ["string", "null"], example: "前売 ¥3,000 / 当日 ¥3,500" },
+ image_url: { type: ["string", "null"], format: "uri" },
+ description: { type: ["string", "null"] },
+ source_url: { type: ["string", "null"], format: "uri" },
+ fetched_at: { type: "string", format: "date-time" },
+ },
+ },
+ Venue: {
+ type: "object",
+ properties: {
+ id: { type: "string", example: "liquid-room" },
+ name: { type: "string", example: "LIQUID ROOM" },
+ url: { type: "string", format: "uri" },
+ area: { type: ["string", "null"], example: "恵比寿" },
+ capacity: { type: ["integer", "null"], example: 1000 },
+ event_count: { type: "integer" },
+ },
+ },
+ EventListResponse: {
+ type: "object",
+ properties: {
+ events: { type: "array", items: { $ref: "#/components/schemas/Event" } },
+ meta: {
+ type: "object",
+ properties: {
+ limit: { type: "integer" },
+ offset: { type: "integer" },
+ count: { type: "integer", description: "今回返却した件数" },
+ },
+ },
+ },
+ },
+ EventDetailResponse: {
+ type: "object",
+ properties: {
+ event: { $ref: "#/components/schemas/Event" },
+ },
+ },
+ VenueListResponse: {
+ type: "object",
+ properties: {
+ venues: { type: "array", items: { $ref: "#/components/schemas/Venue" } },
+ },
+ },
+ ScrapeStartResponse: {
+ type: "object",
+ properties: {
+ run_id: { type: "string", format: "uuid" },
+ status: { type: "string", enum: ["started"] },
+ },
+ },
+ ScrapeStatusResponse: {
+ type: "object",
+ properties: {
+ running: { type: "boolean" },
+ results: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ run_id: { type: "string" },
+ venue_id: { type: "string" },
+ venue_name: { type: "string" },
+ status: { type: "string", enum: ["running", "ok", "error"] },
+ events_saved: { type: "integer" },
+ error: { type: ["string", "null"] },
+ started_at: { type: "string", format: "date-time" },
+ finished_at: { type: ["string", "null"], format: "date-time" },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+} as const;
+
+export async function loader(_: Route.LoaderArgs) {
+ return Response.json(SPEC, {
+ headers: {
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": "*",
+ },
+ });
+}
diff --git a/app/routes/api.venues.ts b/app/routes/api.venues.ts
new file mode 100644
index 0000000..2555d02
--- /dev/null
+++ b/app/routes/api.venues.ts
@@ -0,0 +1,13 @@
+/**
+ * GET /api/venues
+ */
+import type { Route } from "./+types/api.venues";
+import { getVenues } from "~/lib/db.server";
+
+export async function loader(_: Route.LoaderArgs) {
+ const venues = getVenues();
+ return Response.json(
+ { venues },
+ { headers: { "Cache-Control": "public, max-age=60" } }
+ );
+}