"""リンキィディンク(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("予約状況を取得できませんでした")