diff options
Diffstat (limited to 'providers/rinky.py')
| -rw-r--r-- | providers/rinky.py | 115 |
1 files changed, 115 insertions, 0 deletions
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("予約状況を取得できませんでした") |
