diff options
Diffstat (limited to 'providers')
| -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 |
4 files changed, 150 insertions, 50 deletions
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 |
