summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <8nmdh5w22f@privaterelay.appleid.com>2026-11-01 08:59:15 +0900
committeryyamashita <8nmdh5w22f@privaterelay.appleid.com>2026-11-01 08:59:15 +0900
commita661eedbd30206e940ed8fb6a19f139311adbdfb (patch)
tree6eed45d0a1d7b9be04de04c6d93f491a5071109c
西荻窪周辺スタジオの空き枠一覧ページを追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--.gitignore3
-rw-r--r--README.md27
-rw-r--r--providers/__init__.py18
-rw-r--r--providers/manual.py28
-rw-r--r--providers/sample.py13
-rw-r--r--server.py63
-rw-r--r--static/index.html159
-rw-r--r--studios.json50
8 files changed, 361 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ca1628
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.env
+__pycache__/
+manual_slots.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1c041e2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# studio-slots
+
+西荻窪周辺の音楽スタジオの空き枠を、日付ごとに一覧で見るためのローカルWebページ。
+
+```sh
+python3 server.py # → http://localhost:8000
+```
+
+依存ライブラリなし(Python 3.9 以上)。
+
+## 構成
+
+- `studios.json` … スタジオの一覧(名前、部屋、営業時間、予約ページ、電話番号、使う provider)
+- `providers/` … 空き枠の取得元。`fetch(studio, date)` が `[{"room", "hour", "status"}]` を返す
+ - `sample` … 画面確認用のダミーデータ(今はすべてこれ)
+ - `manual` … `manual_slots.json` に手で書いた予約済み枠を読む
+- `server.py` … `/api/slots?date=YYYY-MM-DD` と `static/` を配信
+- `static/index.html` … 一覧画面
+
+## 実データに切り替えるには
+
+1. `studios.json` の部屋名と営業時間を実際のものに直す(今はすべて仮の値)
+2. `providers/` に取得用のモジュールを足して `providers/__init__.py` の `PROVIDERS` に登録する
+3. 該当スタジオの `"provider"` をそのモジュール名に変える
+
+リンキィディンクは Reserve Mart(reserve1.jp)で予約を受けていて、空き枠を見るには会員ログインが必要。
+ID とパスワードは `.env` などに置き、リポジトリには入れないこと。
diff --git a/providers/__init__.py b/providers/__init__.py
new file mode 100644
index 0000000..a32ed09
--- /dev/null
+++ b/providers/__init__.py
@@ -0,0 +1,18 @@
+"""空き枠の取得元。
+
+各 provider は fetch(studio, date) -> list[dict] を持つモジュール。
+返す dict は {"room": str, "hour": int, "status": "free" | "booked"}。
+hour は開始時刻(1時間枠)。studios.json の "provider" で使う provider を選ぶ。
+"""
+
+from . import manual, sample
+
+PROVIDERS = {
+ "sample": sample,
+ "manual": manual,
+}
+
+
+def fetch(studio, date):
+ provider = PROVIDERS[studio["provider"]]
+ return provider.fetch(studio, date)
diff --git a/providers/manual.py b/providers/manual.py
new file mode 100644
index 0000000..4623b26
--- /dev/null
+++ b/providers/manual.py
@@ -0,0 +1,28 @@
+"""manual_slots.json に手で書いた予約済み枠を読む。書いていない枠は空きとみなす。
+
+形式:
+{
+ "uen": {
+ "2026-11-03": {"1st": [18, 19, 20], "2nd": [21]}
+ }
+}
+"""
+
+import json
+from pathlib import Path
+
+PATH = Path(__file__).resolve().parent.parent / "manual_slots.json"
+
+
+def fetch(studio, date):
+ data = json.loads(PATH.read_text(encoding="utf-8")) if PATH.exists() else {}
+ booked = data.get(studio["id"], {}).get(date.isoformat(), {})
+ return [
+ {
+ "room": room,
+ "hour": hour,
+ "status": "booked" if hour in booked.get(room, []) else "free",
+ }
+ for room in studio["rooms"]
+ for hour in range(studio["open"], studio["close"])
+ ]
diff --git a/providers/sample.py b/providers/sample.py
new file mode 100644
index 0000000..86708da
--- /dev/null
+++ b/providers/sample.py
@@ -0,0 +1,13 @@
+"""画面確認用のダミーデータ。日付とスタジオごとに毎回同じ結果を返す。"""
+
+import random
+
+
+def fetch(studio, date):
+ rng = random.Random(f"{studio['id']}:{date.isoformat()}")
+ slots = []
+ for room in studio["rooms"]:
+ for hour in range(studio["open"], studio["close"]):
+ status = "booked" if rng.random() < 0.45 else "free"
+ slots.append({"room": room, "hour": hour, "status": status})
+ return slots
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..0629e67
--- /dev/null
+++ b/server.py
@@ -0,0 +1,63 @@
+"""スタジオ空き枠一覧のローカルサーバー。
+
+ python3 server.py # http://localhost:8000
+ python3 server.py 8080 # ポート指定
+"""
+
+import json
+import sys
+from datetime import date
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+import providers
+
+ROOT = Path(__file__).resolve().parent
+STATIC = ROOT / "static"
+
+
+def load_studios():
+ return json.loads((ROOT / "studios.json").read_text(encoding="utf-8"))
+
+
+def slots_for(day):
+ result = []
+ for studio in load_studios():
+ try:
+ slots = providers.fetch(studio, day)
+ error = None
+ except Exception as e: # 1軒の取得失敗で一覧全体を落とさない
+ slots, error = [], str(e)
+ result.append({**studio, "slots": slots, "error": error})
+ return result
+
+
+class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=str(STATIC), **kwargs)
+
+ def do_GET(self):
+ url = urlparse(self.path)
+ if url.path != "/api/slots":
+ return super().do_GET()
+ qs = parse_qs(url.query)
+ try:
+ day = date.fromisoformat(qs.get("date", [date.today().isoformat()])[0])
+ except ValueError:
+ return self.send_json({"error": "date は YYYY-MM-DD で指定してください"}, 400)
+ self.send_json({"date": day.isoformat(), "studios": slots_for(day)})
+
+ def send_json(self, body, status=200):
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+
+if __name__ == "__main__":
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
+ print(f"http://localhost:{port}")
+ ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..55c99e8
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,159 @@
+<!doctype html>
+<html lang="ja">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>西荻窪周辺 スタジオ空き枠</title>
+<style>
+ :root { --free: #2f9e5b; --free-bg: #e3f5ea; --booked: #b8b8b8; --line: #ddd; }
+ * { box-sizing: border-box; }
+ body { font-family: system-ui, "Hiragino Sans", "Noto Sans JP", sans-serif; margin: 0; padding: 16px; color: #222; }
+ h1 { font-size: 20px; margin: 0 0 12px; }
+ .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
+ .days { display: flex; gap: 4px; overflow-x: auto; }
+ .days button { min-width: 64px; }
+ button, input, select { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: #fff; cursor: pointer; }
+ button.active { background: #222; color: #fff; border-color: #222; }
+ .sat { color: #2463c2; } .sun { color: #c23a3a; }
+ button.active.sat, button.active.sun { color: #fff; }
+ .wrap { overflow-x: auto; }
+ table { border-collapse: collapse; min-width: 100%; }
+ th, td { border: 1px solid var(--line); padding: 0; text-align: center; font-size: 13px; }
+ thead th { background: #f6f6f6; padding: 6px 4px; position: sticky; top: 0; }
+ th.studio { text-align: left; padding: 6px 10px; white-space: nowrap; background: #fafafa; }
+ th.studio a { color: inherit; }
+ th.studio .meta { display: block; font-weight: normal; font-size: 11px; color: #777; }
+ td.room { padding: 6px 8px; white-space: nowrap; font-weight: bold; }
+ td.cell { width: 44px; height: 32px; }
+ td.free { background: var(--free-bg); color: var(--free); font-weight: bold; }
+ td.free a { display: block; line-height: 32px; color: inherit; text-decoration: none; }
+ td.booked { background: repeating-linear-gradient(45deg, #f2f2f2, #f2f2f2 4px, #e8e8e8 4px, #e8e8e8 8px); color: var(--booked); }
+ td.closed { background: #fff; }
+ tr.hidden { display: none; }
+ .err { color: #c23a3a; font-size: 12px; }
+ .note { font-size: 12px; color: #777; margin-top: 10px; }
+</style>
+</head>
+<body>
+<h1>西荻窪周辺 スタジオ空き枠</h1>
+
+<div class="bar">
+ <div class="days" id="days"></div>
+ <input type="date" id="date">
+</div>
+<div class="bar">
+ <label>時間帯
+ <select id="from"></select> 〜 <select id="to"></select>
+ </label>
+ <label>連続
+ <select id="span">
+ <option value="1">1時間以上</option>
+ <option value="2" selected>2時間以上</option>
+ <option value="3">3時間以上</option>
+ </select>
+ </label>
+ <label><input type="checkbox" id="onlyFree" checked> 空きがある部屋だけ</label>
+</div>
+
+<div class="wrap"><table id="grid"></table></div>
+<p class="note" id="note"></p>
+
+<script>
+const $ = (id) => document.getElementById(id);
+const WEEK = ["日", "月", "火", "水", "木", "金", "土"];
+const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+let data = null;
+
+for (let h = 0; h <= 24; h++) {
+ $("from").add(new Option(`${h}時`, h));
+ $("to").add(new Option(`${h}時`, h));
+}
+$("from").value = 10;
+$("to").value = 24;
+
+function renderDays(selected) {
+ const today = new Date();
+ $("days").innerHTML = "";
+ for (let i = 0; i < 14; i++) {
+ const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + i);
+ const b = document.createElement("button");
+ b.textContent = `${d.getMonth() + 1}/${d.getDate()}(${WEEK[d.getDay()]})`;
+ if (d.getDay() === 6) b.classList.add("sat");
+ if (d.getDay() === 0) b.classList.add("sun");
+ if (ymd(d) === selected) b.classList.add("active");
+ b.onclick = () => load(ymd(d));
+ $("days").append(b);
+ }
+}
+
+async function load(day) {
+ $("date").value = day;
+ renderDays(day);
+ const res = await fetch(`/api/slots?date=${day}`);
+ data = await res.json();
+ render();
+}
+
+// 指定した時間帯の中に、span 時間以上続く空きがあるか
+function hasFreeRun(byHour, from, to, span) {
+ let run = 0;
+ for (let h = from; h < to; h++) {
+ run = byHour[h] === "free" ? run + 1 : 0;
+ if (run >= span) return true;
+ }
+ return false;
+}
+
+function render() {
+ const from = +$("from").value, to = +$("to").value, span = +$("span").value;
+ const onlyFree = $("onlyFree").checked;
+ const hours = [];
+ for (let h = from; h < to; h++) hours.push(h);
+
+ const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hours.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>`];
+ let shown = 0;
+
+ for (const s of data.studios) {
+ const rooms = s.rooms.map((room) => {
+ const byHour = {};
+ for (const slot of s.slots) if (slot.room === room) byHour[slot.hour] = slot.status;
+ return { room, byHour, ok: hasFreeRun(byHour, from, to, span) };
+ });
+ const visible = rooms.filter((r) => !onlyFree || r.ok);
+ if (!visible.length && !s.error) continue;
+ shown += visible.length;
+
+ const link = s.booking ? `<a href="${s.booking}" target="_blank" rel="noopener">${s.name}</a>` : `<a href="${s.url}" target="_blank" rel="noopener">${s.name}</a>`;
+ const meta = [s.area, s.phone, s.provider === "sample" ? "サンプルデータ" : ""].filter(Boolean).join(" / ");
+ const head = `<th class="studio" rowspan="${Math.max(visible.length, 1)}">${link}<span class="meta">${meta}</span>${s.error ? `<span class="err">取得失敗: ${s.error}</span>` : ""}</th>`;
+
+ if (!visible.length) {
+ rows.push(`<tr>${head}<td colspan="${hours.length + 1}"></td></tr>`);
+ continue;
+ }
+ visible.forEach((r, i) => {
+ const cells = hours.map((h) => {
+ const st = r.byHour[h];
+ if (st === "free") {
+ const href = s.booking || s.url;
+ return `<td class="cell free" title="${r.room} ${h}:00-${h + 1}:00 空き"><a href="${href}" target="_blank" rel="noopener">○</a></td>`;
+ }
+ if (st === "booked") return `<td class="cell booked">×</td>`;
+ return `<td class="cell closed"></td>`;
+ }).join("");
+ rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${r.room}</td>${cells}</tr>`);
+ });
+ }
+ rows.push("</tbody>");
+ $("grid").innerHTML = rows.join("");
+ $("note").textContent = shown
+ ? "○ をクリックすると予約ページ(なければ公式サイト)を開きます。電話予約のみのスタジオは電話番号を表示しています。"
+ : "条件に合う空き枠がありません。";
+}
+
+["from", "to", "span", "onlyFree"].forEach((id) => $(id).addEventListener("change", () => data && render()));
+$("date").addEventListener("change", (e) => e.target.value && load(e.target.value));
+load(ymd(new Date()));
+</script>
+</body>
+</html>
diff --git a/studios.json b/studios.json
new file mode 100644
index 0000000..e319441
--- /dev/null
+++ b/studios.json
@@ -0,0 +1,50 @@
+[
+ {
+ "id": "rinky-nishiogi",
+ "name": "リンキィディンク西荻",
+ "area": "西荻窪",
+ "url": "https://rinky.info/studio/nishiogi/",
+ "booking": "https://www.reserve1.jp/studio/visitor/VisitorLogin.php?lc=olcatdves&mn=1&gr=10&stt=MLRRX",
+ "phone": "03-3335-9131",
+ "open": 10,
+ "close": 24,
+ "rooms": ["A", "B", "C"],
+ "provider": "sample"
+ },
+ {
+ "id": "uen",
+ "name": "STUDIO UEN",
+ "area": "西荻窪",
+ "url": "https://uen.co.jp/sp/rehearsal/",
+ "booking": null,
+ "phone": "03-5382-3631",
+ "open": 10,
+ "close": 24,
+ "rooms": ["1st", "2nd"],
+ "provider": "sample"
+ },
+ {
+ "id": "akseli",
+ "name": "Studio Akseli",
+ "area": "西荻窪",
+ "url": "http://www.akseli.jp/rehearsalall/reserve-rehearsal/",
+ "booking": "http://www.akseli.jp/contact/",
+ "phone": null,
+ "open": 11,
+ "close": 23,
+ "rooms": ["Main"],
+ "provider": "sample"
+ },
+ {
+ "id": "revival",
+ "name": "SOUND STUDIO REVIVAL",
+ "area": "荻窪",
+ "url": "https://heavysick.co.jp/revival/",
+ "booking": null,
+ "phone": null,
+ "open": 10,
+ "close": 24,
+ "rooms": ["A", "B"],
+ "provider": "sample"
+ }
+]