summaryrefslogtreecommitdiff
path: root/providers
diff options
context:
space:
mode:
Diffstat (limited to 'providers')
-rw-r--r--providers/__init__.py44
-rw-r--r--providers/manual.py28
-rw-r--r--providers/rinky.py115
-rw-r--r--providers/sample.py13
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