diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-11-01 09:19:55 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-11-01 09:19:55 +0900 |
| commit | dcb35334905f914dfd3eaa7453f0b019bd777588 (patch) | |
| tree | 2a78d999c28b40abf682b0c2b7c3f1802b98c4f3 | |
| parent | f0044f886359d942fe479a9a8a940af17c7047ac (diff) | |
スタジオセットごとにリンキィディンクの実際の空き枠を表示する
ダミーデータをやめ、Reserve Mart の会員ページから日別状況を読む。
取得結果は 5 分キャッシュし、ノアなど取れないスタジオは電話番号とリンクを出す。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 37 | ||||
| -rw-r--r-- | docker-compose.yml | 5 | ||||
| -rw-r--r-- | providers/__init__.py | 44 | ||||
| -rw-r--r-- | providers/manual.py | 28 | ||||
| -rw-r--r-- | providers/rinky.py | 115 | ||||
| -rw-r--r-- | providers/sample.py | 13 | ||||
| -rw-r--r-- | server.py | 67 | ||||
| -rw-r--r-- | static/index.html | 189 | ||||
| -rw-r--r-- | studios.json | 130 |
10 files changed, 423 insertions, 206 deletions
@@ -1,3 +1,2 @@ .env __pycache__/ -manual_slots.json @@ -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'<form name="form1".*?</form>', page, re.S) + if not form: + raise RuntimeError("予約状況の表が見つかりません") + rows = re.findall(r"<tr>(.*?)</tr>", form.group(0), re.S) + times = [] + for h1, m1, h2, m2 in re.findall(r'<td class="item_base">\s*(\d+):(\d+)\s*<br\s*/?>\s*[~~]\s*<br\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"<td([^>]*)>(.*?)</td>", 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 @@ -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 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>西荻窪周辺 スタジオ空き枠</title> +<title>スタジオ空き枠</title> <style> - :root { --free: #2f9e5b; --free-bg: #e3f5ea; --booked: #b8b8b8; --line: #ddd; } + :root { --free: #2f9e5b; --free-bg: #e3f5ea; --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; } + .days { display: flex; gap: 4px; overflow-x: auto; max-width: 100%; } + .days button { min-width: 64px; flex: none; } 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; } + table { border-collapse: collapse; } + th, td { border: 1px solid var(--line); padding: 0; text-align: center; font-size: 12px; } + thead th { background: #f6f6f6; padding: 4px 2px; font-weight: normal; } + thead th.hour { border-right-color: #bbb; } + th.studio { text-align: left; padding: 6px 10px; white-space: nowrap; background: #fafafa; vertical-align: top; } 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); } + .meta { display: block; font-weight: normal; font-size: 11px; color: #777; } + td.room { padding: 4px 8px; white-space: nowrap; text-align: left; position: sticky; left: 0; background: #fff; } + td.cell { width: 22px; min-width: 22px; height: 28px; } + td.free { background: var(--free-bg); } + td.free a { display: block; height: 28px; } + td.free a:hover { background: #bfe8cf; } + td.booked { background: #e6e6e6; } td.closed { background: #fff; } - tr.hidden { display: none; } - .err { color: #c23a3a; font-size: 12px; } + td.hour { border-right-color: #bbb; } + .err { color: #c23a3a; font-size: 12px; font-weight: normal; } + .offline { margin-top: 16px; } + .offline h2 { font-size: 15px; margin: 0 0 6px; } + .offline li { margin: 4px 0; font-size: 14px; } .note { font-size: 12px; color: #777; margin-top: 10px; } + .legend span { display: inline-block; width: 14px; height: 14px; vertical-align: middle; border: 1px solid var(--line); margin: 0 4px 0 10px; } + #status { font-size: 13px; color: #777; } </style> </head> <body> -<h1>西荻窪周辺 スタジオ空き枠</h1> +<h1>スタジオ空き枠</h1> <div class="bar"> - <div class="days" id="days"></div> + <select id="set"></select> <input type="date" id="date"> + <span id="status"></span> </div> +<div class="bar"><div class="days" id="days"></div></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> + <label>時間帯 <select id="from"></select> 〜 <select id="to"></select></label> + <label>連続 <select id="span"> + <option value="60">1時間以上</option> + <option value="90">1.5時間以上</option> + <option value="120" selected>2時間以上</option> + <option value="180">3時間以上</option> + </select></label> + <label><input type="checkbox" id="onlyFree" checked> 条件に合う部屋だけ</label> + <span class="legend"><span style="background:var(--free-bg)"></span>空き<span style="background:#e6e6e6"></span>予約済み</span> </div> <div class="wrap"><table id="grid"></table></div> <p class="note" id="note"></p> +<div class="offline" id="offline"></div> <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")}`; +const hm = (m) => `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`; +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); +const params = new URLSearchParams(location.search); let data = null; -for (let h = 0; h <= 24; h++) { - $("from").add(new Option(`${h}時`, h)); - $("to").add(new Option(`${h}時`, h)); +for (let h = 0; h <= 30; h++) { + $("from").add(new Option(`${h}時`, h * 60)); + $("to").add(new Option(`${h}時`, h * 60)); } -$("from").value = 10; -$("to").value = 24; +$("from").value = 0; +$("to").value = 30 * 60; function renderDays(selected) { const today = new Date(); $("days").innerHTML = ""; - for (let i = 0; i < 14; i++) { + for (let i = 0; i < 21; 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()]})`; @@ -87,19 +98,26 @@ function renderDays(selected) { } async function load(day) { + day = day || $("date").value; $("date").value = day; renderDays(day); - const res = await fetch(`/api/slots?date=${day}`); - data = await res.json(); + history.replaceState(null, "", `?set=${$("set").value}&date=${day}`); + $("status").textContent = "読み込み中…"; + const res = await fetch(`/api/slots?set=${$("set").value}&date=${day}`); + const body = await res.json(); + if (!res.ok) { $("status").textContent = body.error; return; } + data = body; + $("status").textContent = ""; render(); } -// 指定した時間帯の中に、span 時間以上続く空きがあるか -function hasFreeRun(byHour, from, to, span) { +// 時間帯の中に minutes 分以上続く空きがあるか +function hasFreeRun(room, from, to, minutes) { let run = 0; - for (let h = from; h < to; h++) { - run = byHour[h] === "free" ? run + 1 : 0; - if (run >= span) return true; + for (const s of room.slots) { + if (s.start < from || s.start >= to) { run = 0; continue; } + run = s.status === "free" ? run + (s.end - s.start) : 0; + if (run >= minutes) return true; } return false; } @@ -107,53 +125,72 @@ function hasFreeRun(byHour, from, to, span) { 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 online = data.studios.filter((s) => s.online); - const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hours.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>`]; - let shown = 0; + // 表示する列はセット内の全スタジオの枠の和集合 + const starts = [...new Set(online.flatMap((s) => (s.rooms || []).flatMap((r) => r.slots.map((x) => x.start))))] + .filter((t) => t >= from && t < to).sort((a, b) => a - b); - 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 hourCols = []; + for (const t of starts) { + const last = hourCols[hourCols.length - 1]; + if (last && Math.floor(last.t / 60) === Math.floor(t / 60)) last.n++; + else hourCols.push({ t, n: 1 }); + } + const isHourEnd = (t) => { const i = starts.indexOf(t); return i === starts.length - 1 || Math.floor(starts[i + 1] / 60) !== Math.floor(t / 60); }; - 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>`; + const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hourCols.map((c) => `<th class="hour" colspan="${c.n}">${Math.floor(c.t / 60)}</th>`).join("")}</tr></thead><tbody>`]; + let shown = 0; - if (!visible.length) { - rows.push(`<tr>${head}<td colspan="${hours.length + 1}"></td></tr>`); + for (const s of online) { + const rooms = (s.rooms || []).filter((r) => !onlyFree || hasFreeRun(r, from, to, span)); + if (!rooms.length && !s.error) continue; + shown += rooms.length; + const href = s.booking || s.url; + const updated = s.fetched_at ? `${new Date(s.fetched_at * 1000).toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit" })} 取得` : ""; + const meta = [s.area, s.phone && `<a href="tel:${esc(s.phone)}">${esc(s.phone)}</a>`, updated].filter(Boolean).join(" / "); + const head = `<th class="studio" rowspan="${Math.max(rooms.length, 1)}"><a href="${esc(href)}" target="_blank" rel="noopener">${esc(s.name)}</a><span class="meta">${meta}</span>${s.error ? `<span class="err">取得失敗: ${esc(s.error)}</span>` : ""}</th>`; + + if (!rooms.length) { + rows.push(`<tr>${head}<td colspan="${starts.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>`; + rooms.forEach((r, i) => { + const byStart = Object.fromEntries(r.slots.map((x) => [x.start, x])); + const cells = starts.map((t) => { + const slot = byStart[t]; + const st = slot ? slot.status : "closed"; + const cls = `cell ${st}${isHourEnd(t) ? " hour" : ""}`; + const title = `${r.name} ${hm(t)}〜${hm(slot ? slot.end : t + 30)}`; + if (st === "free") return `<td class="${cls}" title="${esc(title)} 空き"><a href="${esc(href)}" target="_blank" rel="noopener"></a></td>`; + return `<td class="${cls}" title="${esc(title)}"></td>`; }).join(""); - rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${r.room}</td>${cells}</tr>`); + rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${esc(r.name)}</td>${cells}</tr>`); }); } rows.push("</tbody>"); $("grid").innerHTML = rows.join(""); - $("note").textContent = shown - ? "○ をクリックすると予約ページ(なければ公式サイト)を開きます。電話予約のみのスタジオは電話番号を表示しています。" - : "条件に合う空き枠がありません。"; + $("note").textContent = online.length + ? (shown ? "緑の枠をクリックすると予約ページを開きます(要ログイン)。24 時以降の列は翌日の深夜です。" : "条件に合う空き枠がありません。") + : ""; + + const offline = data.studios.filter((s) => !s.online); + $("offline").innerHTML = offline.length + ? `<h2>オンラインで空きを確認できないスタジオ</h2><ul>${offline.map((s) => + `<li><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.name)}</a>(${esc(s.area)})${s.phone ? ` <a href="tel:${esc(s.phone)}">${esc(s.phone)}</a>` : " 問い合わせフォーム"}</li>`).join("")}</ul>` + : ""; } -["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())); +async function init() { + const sets = await (await fetch("/api/sets")).json(); + for (const s of sets) $("set").add(new Option(s.name, s.id)); + if (params.get("set")) $("set").value = params.get("set"); + $("set").addEventListener("change", () => load()); + $("date").addEventListener("change", (e) => e.target.value && load(e.target.value)); + ["from", "to", "span", "onlyFree"].forEach((id) => $(id).addEventListener("change", () => data && render())); + load(params.get("date") || ymd(new Date())); +} +init(); </script> </body> </html> 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 + } } -] +} |
