From dcb35334905f914dfd3eaa7453f0b019bd777588 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sun, 1 Nov 2026 09:19:55 +0900 Subject: スタジオセットごとにリンキィディンクの実際の空き枠を表示する MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ダミーデータをやめ、Reserve Mart の会員ページから日別状況を読む。 取得結果は 5 分キャッシュし、ノアなど取れないスタジオは電話番号とリンクを出す。 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - README.md | 37 ++++++---- docker-compose.yml | 5 +- providers/__init__.py | 44 +++++++++--- providers/manual.py | 28 -------- providers/rinky.py | 115 ++++++++++++++++++++++++++++++ providers/sample.py | 13 ---- server.py | 67 ++++++++++++++---- static/index.html | 189 ++++++++++++++++++++++++++++++-------------------- studios.json | 130 +++++++++++++++++++++------------- 10 files changed, 423 insertions(+), 206 deletions(-) delete mode 100644 providers/manual.py create mode 100644 providers/rinky.py delete mode 100644 providers/sample.py diff --git a/.gitignore b/.gitignore index 3ca1628..d50a09f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ .env __pycache__/ -manual_slots.json diff --git a/README.md b/README.md index 1eb452e..39dda67 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,39 @@ # studio-slots -西荻窪周辺の音楽スタジオの空き枠を、日付ごとに一覧で見るためのローカルWebページ。 +スタジオの空き枠を「スタジオセット」(西荻窪周辺、吉祥寺など)ごとにまとめて見る Web ページ。 +https://studio.yyamashita.com ```sh python3 server.py # → http://localhost:8000 ``` -依存ライブラリなし(Python 3.9 以上)。 +依存ライブラリなし(Python 3.9 以上)。リンキィディンクの空き枠は会員ページから読むので、 +`.env` に会員情報が要る(リポジトリには入れない)。 + +``` +RINKY_ID=会員番号またはメールアドレス +RINKY_PASSWORD=パスワード +``` ## 構成 -- `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` … 一覧画面 +- `studios.json` … スタジオセットとスタジオ(名前、公式サイト、電話番号、使う provider) +- `providers/` … 空き枠の取得元。取得結果は 5 分キャッシュする + - `rinky` … リンキィディンク(Reserve Mart / reserve1.jp)。1 回ログインして店舗(`grand`)と日付を切り替えて読む +- `server.py` … `/api/sets`、`/api/slots?set=&date=YYYY-MM-DD` と `static/` を配信 +- `static/index.html` … 一覧画面。`?set=kichijoji&date=2026-11-07` のように URL で開ける -## 実データに切り替えるには +`provider` が `null` のスタジオ(ノア、UEN、Akseli)は空き枠を取れないので、電話番号とリンクだけ出す。 +サウンドスタジオノアは公式サイトが自動アクセスを 403 で弾くため取得していない。 -1. `studios.json` の部屋名と営業時間を実際のものに直す(今はすべて仮の値) -2. `providers/` に取得用のモジュールを足して `providers/__init__.py` の `PROVIDERS` に登録する -3. 該当スタジオの `"provider"` をそのモジュール名に変える +## スタジオを足すには -リンキィディンクは Reserve Mart(reserve1.jp)で予約を受けていて、空き枠を見るには会員ログインが必要。 -ID とパスワードは `.env` などに置き、リポジトリには入れないこと。 +1. `studios.json` の `studios` に追加し、見せたいセットの `studios` に id を並べる +2. リンキィディンクの別店舗なら `"provider": "rinky"` と `"grand"`(店舗番号。公式サイトの予約ボタンの `gr`)だけでよい +3. 別の予約システムなら `providers/` にモジュールを足して `PROVIDERS` に登録する ## デプロイ `git push` で post-receive フックが `/app/studio-slots` にチェックアウトし、`docker compose up -d --build` する。 -コンテナ名は `studio-app`(web ネットワーク、ポート 8000)。設定は hetzner-infra 側(`git/repos.txt`、`git/hooks/studio-slots/`)。 +コンテナ名は `studio-app`(web ネットワーク、ポート 8000)。Caddy が studio.yyamashita.com で HTTPS を終端する。 +設定は hetzner-infra 側(`git/repos.txt`、`git/hooks/studio-slots/`、`caddy/Caddyfile`)。 diff --git a/docker-compose.yml b/docker-compose.yml index ef0b274..51a7ea9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,8 @@ services: build: . expose: - "8000" - volumes: - # 手入力の予約済み枠はリポジトリ外に置く - - ./manual_slots.json:/app/manual_slots.json + # RINKY_ID / RINKY_PASSWORD。リポジトリには入れず、サーバーの /app/studio-slots/.env に置く + env_file: .env restart: unless-stopped networks: - web diff --git a/providers/__init__.py b/providers/__init__.py index a32ed09..b9b07fc 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -1,18 +1,44 @@ """空き枠の取得元。 -各 provider は fetch(studio, date) -> list[dict] を持つモジュール。 -返す dict は {"room": str, "hour": int, "status": "free" | "booked"}。 -hour は開始時刻(1時間枠)。studios.json の "provider" で使う provider を選ぶ。 +各 provider は fetch(studio, date) -> [{"name": 部屋名, "slots": [{"start": 分, "status"}]}] +と booking_url(studio) を持つモジュール。status は "free" | "booked" | "closed"。 +studios.json の "provider" で使う provider を選ぶ(null ならオンラインで見られないスタジオ)。 + +取得結果は CACHE_SECONDS の間メモリに置き、同じ日付を何度開いても相手サイトに負荷をかけない。 """ -from . import manual, sample +import threading +import time + +from . import rinky PROVIDERS = { - "sample": sample, - "manual": manual, + "rinky": rinky, } +CACHE_SECONDS = 300 + +_cache = {} +_cache_lock = threading.Lock() + + +def booking_url(studio): + provider = PROVIDERS.get(studio.get("provider")) + return provider.booking_url(studio) if provider else None + -def fetch(studio, date): - provider = PROVIDERS[studio["provider"]] - return provider.fetch(studio, date) +def fetch(studio_id, studio, day): + """(rooms, fetched_at) を返す。provider が無ければ (None, None)。""" + provider = PROVIDERS.get(studio.get("provider")) + if not provider: + return None, None + key = (studio_id, day.isoformat()) + with _cache_lock: + hit = _cache.get(key) + if hit and time.time() - hit[1] < CACHE_SECONDS: + return hit + rooms = provider.fetch(studio, day) + entry = (rooms, time.time()) + with _cache_lock: + _cache[key] = entry + return entry diff --git a/providers/manual.py b/providers/manual.py deleted file mode 100644 index 4623b26..0000000 --- a/providers/manual.py +++ /dev/null @@ -1,28 +0,0 @@ -"""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/rinky.py b/providers/rinky.py new file mode 100644 index 0000000..c8e30fb --- /dev/null +++ b/providers/rinky.py @@ -0,0 +1,115 @@ +"""リンキィディンク(Reserve Mart / reserve1.jp)の予約状況を会員ページから読む。 + +RINKY_ID / RINKY_PASSWORD 環境変数の会員でログインし、日別状況の表を解析する。 +1回ログインすれば form0 の grand(店舗)と day_btn(日付)を変えて全店舗を見られる。 +""" + +import html +import http.cookiejar +import os +import re +import threading +import urllib.parse +import urllib.request + +BASE = "https://www.reserve1.jp/studio/member/" +OFFICE = "1000528" +TIMEOUT = 20 + +_lock = threading.Lock() +_opener = None + + +def booking_url(studio): + return f"https://www.reserve1.jp/studio/visitor/VisitorLogin.php?lc=olcatdves&mn=1&gr={studio['grand']}&stt=MLRRX" + + +def _post(data): + body = urllib.parse.urlencode(data, encoding="euc-jp").encode() + res = _opener.open(BASE + "member_select.php", body, timeout=TIMEOUT) + return res.read().decode("euc-jp", "replace") + + +def _login(grand): + global _opener + user, password = os.environ.get("RINKY_ID"), os.environ.get("RINKY_PASSWORD") + if not user or not password: + raise RuntimeError("RINKY_ID / RINKY_PASSWORD が設定されていません") + _opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + _opener.open(f"{BASE}VisitorLogin.php?lc=olcatdves&mn=1&gr={grand}", timeout=TIMEOUT).read() + page = _post({ + "in_member": user, "in_mpassword": password, "office": OFFICE, "grand": grand, + "ct": "", "mngfg": "1", "c_menu": "", "member_select": "2", + }) + if 'name="form1"' not in page: + _opener = None + raise RuntimeError("リンキィディンクにログインできませんでした") + + +def _day_page(grand, day): + return _post({ + "office": OFFICE, "mngfg": "1", "grand": grand, "rdate": day, + "member_select": "3", "month_btn": "", "day_btn": day, + }) + + +def parse(page): + """日別状況の表を [{"name", "slots": [{"start": 分, "end": 分, "status"}]}] にする。 + + start / end は表示日の 0:00 からの分。翌日にかかる枠は 1440 以上になる。 + 通常店は 30 分刻み、深夜/早朝専用店は 90 分刻み。 + """ + form = re.search(r'
', page, re.S) + if not form: + raise RuntimeError("予約状況の表が見つかりません") + rows = re.findall(r"(.*?)", form.group(0), re.S) + times = [] + for h1, m1, h2, m2 in re.findall(r'\s*(\d+):(\d+)\s*\s*[~~]\s*\s*(\d+):(\d+)', rows[0]): + start, end = int(h1) * 60 + int(m1), int(h2) * 60 + int(m2) + # 「翌日」表示の列は時刻が巻き戻るので 24 時間足す + while times and start < times[-1][0]: + start += 24 * 60 + while end <= start: + end += 24 * 60 + times.append((start, end)) + + rooms = [] + for row in rows[1:]: + cells = re.findall(r"]*)>(.*?)", row, re.S) + if not cells: + continue + name = html.unescape(re.sub(r"<[^>]*>", "", cells[0][1])).strip() + if not name: # 表の最後にある見出しの繰り返し行 + continue + slots, col = [], 0 + for attrs, body in cells[1:]: + if col >= len(times): + break + span = int(m.group(1)) if (m := re.search(r'colspan="(\d+)"', attrs)) else 1 + cls = m.group(1) if (m := re.search(r'class="([^"]*)"', attrs)) else "" + if "checkbox" in body: + status = "closed" if "disabled" in body else "free" + else: + status = "booked" if cls.startswith("koma") else "closed" + for i in range(span): + if col + i < len(times): + start, end = times[col + i] + slots.append({"start": start, "end": end, "status": status}) + col += span + rooms.append({"name": name, "slots": slots}) + return rooms + + +def fetch(studio, day): + global _opener + grand, day = studio["grand"], day.isoformat() + with _lock: + for _ in range(2): + if _opener is None: + _login(grand) + page = _day_page(grand, day) + if 'name="form1"' in page: + return parse(page) + # セッション切れなど。ログインし直して 1 回だけやり直す + _opener = None + raise RuntimeError("予約状況を取得できませんでした") diff --git a/providers/sample.py b/providers/sample.py deleted file mode 100644 index 86708da..0000000 --- a/providers/sample.py +++ /dev/null @@ -1,13 +0,0 @@ -"""画面確認用のダミーデータ。日付とスタジオごとに毎回同じ結果を返す。""" - -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 index 286fec8..9763aae 100644 --- a/server.py +++ b/server.py @@ -3,12 +3,15 @@ python3 server.py # http://localhost:8000 python3 server.py 8080 # ポート指定 HOST=0.0.0.0 python3 server.py # コンテナ内など外から受ける場合 + +リンキィディンクの取得には RINKY_ID / RINKY_PASSWORD が要る(.env に書けば読み込む)。 """ import json import os import sys -from datetime import date +from concurrent.futures import ThreadPoolExecutor +from datetime import date, timedelta from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -17,22 +20,53 @@ import providers ROOT = Path(__file__).resolve().parent STATIC = ROOT / "static" +MAX_DAYS_AHEAD = 92 # リンキィディンクは 3 ヶ月先まで予約可 + + +def load_env(): + env = ROOT / ".env" + if not env.exists(): + return + for line in env.read_text(encoding="utf-8").splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) -def load_studios(): +def load_config(): return json.loads((ROOT / "studios.json").read_text(encoding="utf-8")) -def slots_for(day): - result = [] - for studio in load_studios(): +def studio_info(studio_id, studio): + return { + "id": studio_id, + "name": studio["name"], + "area": studio["area"], + "url": studio["url"], + "phone": studio["phone"], + "booking": providers.booking_url(studio), + "online": bool(studio.get("provider")), + } + + +def slots_for(set_id, day): + config = load_config() + target = next((s for s in config["sets"] if s["id"] == set_id), None) + if not target: + return None + + def one(studio_id): + studio = config["studios"][studio_id] + info = studio_info(studio_id, studio) try: - slots = providers.fetch(studio, day) - error = None + rooms, fetched_at = providers.fetch(studio_id, studio, day) + return {**info, "rooms": rooms, "fetched_at": fetched_at, "error": None} except Exception as e: # 1軒の取得失敗で一覧全体を落とさない - slots, error = [], str(e) - result.append({**studio, "slots": slots, "error": error}) - return result + return {**info, "rooms": None, "fetched_at": None, "error": str(e)} + + with ThreadPoolExecutor(max_workers=4) as pool: + studios = list(pool.map(one, target["studios"])) + return {"set": target["id"], "date": day.isoformat(), "studios": studios} class Handler(SimpleHTTPRequestHandler): @@ -41,14 +75,22 @@ class Handler(SimpleHTTPRequestHandler): def do_GET(self): url = urlparse(self.path) + qs = parse_qs(url.query) + if url.path == "/api/sets": + config = load_config() + return self.send_json([{"id": s["id"], "name": s["name"]} for s in config["sets"]]) 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)}) + if not date.today() - timedelta(days=1) <= day <= date.today() + timedelta(days=MAX_DAYS_AHEAD): + return self.send_json({"error": f"date は今日から {MAX_DAYS_AHEAD} 日先までです"}, 400) + body = slots_for(qs.get("set", [""])[0], day) + if body is None: + return self.send_json({"error": "set が見つかりません"}, 404) + self.send_json(body) def send_json(self, body, status=200): data = json.dumps(body, ensure_ascii=False).encode("utf-8") @@ -60,6 +102,7 @@ class Handler(SimpleHTTPRequestHandler): if __name__ == "__main__": + load_env() port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 host = os.environ.get("HOST", "127.0.0.1") print(f"http://{host}:{port}", flush=True) diff --git a/static/index.html b/static/index.html index 55c99e8..ed7a2da 100644 --- a/static/index.html +++ b/static/index.html @@ -3,78 +3,89 @@ -西荻窪周辺 スタジオ空き枠 +スタジオ空き枠 -

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

+

スタジオ空き枠

-
+ +
+
- - - + + + + 空き予約済み

+
diff --git a/studios.json b/studios.json index e319441..9969d42 100644 --- a/studios.json +++ b/studios.json @@ -1,50 +1,82 @@ -[ - { - "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" +{ + "sets": [ + { + "id": "nishiogi", + "name": "西荻窪周辺", + "studios": ["rinky-nishiogi", "rinky-warp", "rinky-ores", "noah-kichijoji", "uen", "akseli"] + }, + { + "id": "kichijoji", + "name": "吉祥寺", + "studios": ["rinky-warp", "rinky-ores", "noah-kichijoji"] + }, + { + "id": "rinky-midnight", + "name": "西荻・吉祥寺 深夜/早朝", + "studios": ["rinky-nishiogi-midnight", "rinky-ores-midnight"] + } + ], + "studios": { + "rinky-nishiogi": { + "name": "リンキィディンク西荻", + "area": "西荻窪", + "url": "https://rinky.info/studio/nishiogi/", + "phone": "03-3335-9131", + "provider": "rinky", + "grand": 10 + }, + "rinky-nishiogi-midnight": { + "name": "リンキィディンク西荻 深夜/早朝", + "area": "西荻窪", + "url": "https://rinky.info/studio/midnight_nishiogi/", + "phone": "03-3335-9131", + "provider": "rinky", + "grand": 19 + }, + "rinky-warp": { + "name": "リンキィディンク吉祥寺WARP", + "area": "吉祥寺", + "url": "https://rinky.info/studio/kichijoji1/", + "phone": "0422-22-3516", + "provider": "rinky", + "grand": 11 + }, + "rinky-ores": { + "name": "リンキィディンク吉祥寺ORES", + "area": "吉祥寺", + "url": "https://rinky.info/studio/ores/", + "phone": "0422-20-2355", + "provider": "rinky", + "grand": 12 + }, + "rinky-ores-midnight": { + "name": "リンキィディンク吉祥寺ORES 深夜/早朝", + "area": "吉祥寺", + "url": "https://rinky.info/studio/ores/", + "phone": "0422-20-2355", + "provider": "rinky", + "grand": 18 + }, + "noah-kichijoji": { + "name": "サウンドスタジオノア吉祥寺", + "area": "吉祥寺", + "url": "https://www.studionoah.jp/book/rentalstudio/kantou/tokyo/musashino/post_9.html", + "phone": "0422-23-1741", + "provider": null + }, + "uen": { + "name": "STUDIO UEN", + "area": "西荻窪", + "url": "https://uen.co.jp/sp/rehearsal/", + "phone": "03-5382-3631", + "provider": null + }, + "akseli": { + "name": "Studio Akseli", + "area": "西荻窪", + "url": "http://www.akseli.jp/rehearsalall/reserve-rehearsal/", + "phone": null, + "provider": null + } } -] +} -- cgit v1.3