1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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("予約状況を取得できませんでした")
|