summaryrefslogtreecommitdiff
path: root/providers/rinky.py
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-11-01 09:19:55 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-11-01 09:19:55 +0900
commitdcb35334905f914dfd3eaa7453f0b019bd777588 (patch)
tree2a78d999c28b40abf682b0c2b7c3f1802b98c4f3 /providers/rinky.py
parentf0044f886359d942fe479a9a8a940af17c7047ac (diff)
スタジオセットごとにリンキィディンクの実際の空き枠を表示する
ダミーデータをやめ、Reserve Mart の会員ページから日別状況を読む。 取得結果は 5 分キャッシュし、ノアなど取れないスタジオは電話番号とリンクを出す。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'providers/rinky.py')
-rw-r--r--providers/rinky.py115
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("予約状況を取得できませんでした")