diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 27 | ||||
| -rw-r--r-- | providers/__init__.py | 18 | ||||
| -rw-r--r-- | providers/manual.py | 28 | ||||
| -rw-r--r-- | providers/sample.py | 13 | ||||
| -rw-r--r-- | server.py | 63 | ||||
| -rw-r--r-- | static/index.html | 159 | ||||
| -rw-r--r-- | studios.json | 50 |
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" + } +] |
