From a661eedbd30206e940ed8fb6a19f139311adbdfb Mon Sep 17 00:00:00 2001 From: yyamashita <8nmdh5w22f@privaterelay.appleid.com> Date: Sun, 1 Nov 2026 08:59:15 +0900 Subject: 西荻窪周辺スタジオの空き枠一覧ページを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + README.md | 27 +++++++++ providers/__init__.py | 18 ++++++ providers/manual.py | 28 +++++++++ providers/sample.py | 13 +++++ server.py | 63 ++++++++++++++++++++ static/index.html | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++ studios.json | 50 ++++++++++++++++ 8 files changed, 361 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 providers/__init__.py create mode 100644 providers/manual.py create mode 100644 providers/sample.py create mode 100644 server.py create mode 100644 static/index.html create mode 100644 studios.json 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 @@ + + + + + +西荻窪周辺 スタジオ空き枠 + + + +

西荻窪周辺 スタジオ空き枠

+ +
+
+ +
+
+ + + +
+ +
+

+ + + + 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" + } +] -- cgit v1.3