maitabi 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
maitabi/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Maitabi MCP Server package."""
maitabi/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support `python -m maitabi` — same entrypoint as the `maitabi` script."""
2
+
3
+ from maitabi.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Maitabi CLI package (thin wrapper over core services/models)."""
@@ -0,0 +1,6 @@
1
+ """Support `python -m maitabi.cli`."""
2
+
3
+ from maitabi.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ """CLI command dispatch — one generic handler over the action registry.
2
+
3
+ Flow: argparse namespace → Pydantic Input (CSV/string flags coerced per
4
+ `fields.plan_field`) → `asyncio.run(service())` → JSON to stdout or
5
+ `--output` file. Exit codes: 0 OK, 1 upstream/network error, 2 input error.
6
+ Must NOT import fastmcp/mcp/server/mcp.
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from collections.abc import Awaitable
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, ValidationError
17
+
18
+ from maitabi.cli.fields import plan_field
19
+ from maitabi.registry import Action
20
+ from maitabi.services.http import close_http_client
21
+
22
+ EXIT_OK = 0
23
+ EXIT_UPSTREAM_ERROR = 1
24
+ EXIT_INPUT_ERROR = 2
25
+
26
+
27
+ def _emit(data: dict[str, Any], args: argparse.Namespace) -> None:
28
+ """Serialize the service dict to the CLI's JSON wire format.
29
+
30
+ Always indent=2 / ensure_ascii=False — byte-identical to the historic
31
+ output (services used to pre-serialize with the same settings), so
32
+ `--pretty` stays a compat no-op.
33
+ """
34
+ text = json.dumps(data, ensure_ascii=False, indent=2)
35
+ output = getattr(args, "output", None)
36
+ if output:
37
+ with open(output, "w", encoding="utf-8") as f:
38
+ f.write(text + "\n")
39
+ else:
40
+ print(text)
41
+
42
+
43
+ async def _run(service_coro: Awaitable[dict[str, Any]]) -> dict[str, Any]:
44
+ try:
45
+ return await service_coro
46
+ finally:
47
+ await close_http_client()
48
+
49
+
50
+ def build_input(action: Action, args: argparse.Namespace) -> BaseModel:
51
+ """Map the parsed namespace onto the action's Input model.
52
+
53
+ Every model field exists as a flag (both are generated from the same
54
+ model), so this mapping is total; string flags are coerced per plan.
55
+ """
56
+ kwargs = {}
57
+ for name, info in action.input_model.model_fields.items():
58
+ raw = getattr(args, name, None)
59
+ plan = plan_field(name, info)
60
+ if plan.coerce is not None and isinstance(raw, str):
61
+ raw = plan.coerce(raw)
62
+ kwargs[name] = raw
63
+ return action.input_model(**kwargs)
64
+
65
+
66
+ def run_action(action: Action, args: argparse.Namespace) -> int:
67
+ """Execute one action and return its process exit code."""
68
+ try:
69
+ payload = build_input(action, args)
70
+ except (ValidationError, ValueError) as exc:
71
+ print(f"maitabi: invalid arguments for {action.domain} {action.command}: {exc}", file=sys.stderr)
72
+ return EXIT_INPUT_ERROR
73
+ data = asyncio.run(_run(action.service(payload)))
74
+ _emit(data, args)
75
+ return EXIT_UPSTREAM_ERROR if "error" in data else EXIT_OK
maitabi/cli/fields.py ADDED
@@ -0,0 +1,108 @@
1
+ """Field-level CLI plan derived from Pydantic `FieldInfo` (presentation + coercion).
2
+
3
+ Both `parser.py` (argparse flags) and `commands.py` (namespace → Input model)
4
+ go through `plan_field`, so a field's flag type and its coercion can never
5
+ drift apart. Must NOT import fastmcp/mcp/server/mcp.
6
+ """
7
+
8
+ import types
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from enum import IntEnum
12
+ from typing import Union, get_args, get_origin
13
+
14
+ from pydantic.fields import FieldInfo
15
+
16
+ # Presentation-only metavars for string-ish flags (argparse's default is the
17
+ # uppercased dest, which is noisy for CSV/range inputs).
18
+ _METAVARS = {"day": "DAY", "course_no": "NO", "year_month": "YYYY-MM"}
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class FieldPlan:
23
+ """How one model field is exposed on the CLI."""
24
+
25
+ flag: str # e.g. "--course-cd"
26
+ metavar: str | None
27
+ required: bool
28
+ default: object
29
+ arg_type: type | None # None when store_true
30
+ store_true: bool
31
+ choices: list[object] | None
32
+ coerce: Callable[[str], object] | None # applied to string input, if any
33
+
34
+
35
+ def annotation_members(annotation: object) -> list[object]:
36
+ """Flatten a (possibly Optional/union) annotation into its members."""
37
+ if get_origin(annotation) in (Union, types.UnionType):
38
+ return list(get_args(annotation))
39
+ return [annotation]
40
+
41
+
42
+ def _parse_course_nos(raw: str) -> int | list[int]:
43
+ """CSV → int when single (single-vs-batch changes the response shape)."""
44
+ nums = [int(p.strip()) for p in raw.split(",") if p.strip()]
45
+ return nums[0] if len(nums) == 1 else nums
46
+
47
+
48
+ def _parse_id_list(raw: str) -> list[int] | None:
49
+ if not raw:
50
+ return None
51
+ return [int(p.strip()) for p in raw.split(",") if p.strip()]
52
+
53
+
54
+ def _enum_of(members: list[object]) -> type[IntEnum] | None:
55
+ for member in members:
56
+ if get_origin(member) is None and isinstance(member, type):
57
+ try:
58
+ if issubclass(member, IntEnum):
59
+ return member
60
+ except TypeError: # pragma: no cover - defensive
61
+ continue
62
+ return None
63
+
64
+
65
+ def plan_field(name: str, info: FieldInfo) -> FieldPlan:
66
+ """Classify a model field into an argparse flag + string coercion.
67
+
68
+ Rules (in precedence order):
69
+ - bool → --flag (store_true)
70
+ - IntEnum (+Optional) → --flag int with enum choices (defaults kept)
71
+ - plain int (+Optional) → --flag int (model's ge/le validate ranges)
72
+ - anything else (str, CSV lists, day unions) → --flag str; unions
73
+ containing str pass through raw (the model parses ranges), CSV list
74
+ fields coerce to int/list here.
75
+ """
76
+ flag = "--" + name.replace("_", "-")
77
+ metavar = "IDS" if name.startswith("category_nos") else _METAVARS.get(name)
78
+ required = info.is_required()
79
+ default = None if required else info.default
80
+ members = [m for m in annotation_members(info.annotation) if m is not type(None)]
81
+
82
+ if info.annotation is bool:
83
+ return FieldPlan(flag, metavar, False, bool(default), None, True, None, None)
84
+
85
+ enum_cls = _enum_of(members)
86
+ if enum_cls is not None:
87
+ if isinstance(default, IntEnum):
88
+ default = int(default)
89
+ return FieldPlan(
90
+ flag,
91
+ metavar,
92
+ required,
93
+ default,
94
+ int,
95
+ False,
96
+ [member.value for member in enum_cls],
97
+ None,
98
+ )
99
+
100
+ if all(m is int for m in members):
101
+ return FieldPlan(flag, metavar, required, default, int, False, None, None)
102
+
103
+ coerce: Callable[[str], object] | None = None
104
+ if any(m == list[int] for m in members) and not any(m is str for m in members):
105
+ # `int | list[int]` (course_no) keeps single-as-int; bare `list[int]`
106
+ # (category_nos*) always yields a list.
107
+ coerce = _parse_course_nos if any(m is int for m in members) else _parse_id_list
108
+ return FieldPlan(flag, metavar, required, default, str, False, None, coerce)
maitabi/cli/main.py ADDED
@@ -0,0 +1,36 @@
1
+ """Entrypoint for the `maitabi` CLI.
2
+
3
+ Usage: `maitabi <bus|general> <command>` or `maitabi mcp [--transport ...]`.
4
+
5
+ Exit codes: 0 OK · 1 upstream/network error · 2 input error
6
+ (argparse rejects bad flags with 2 as well).
7
+ """
8
+
9
+ from maitabi.cli.commands import EXIT_OK, run_action
10
+ from maitabi.cli.parser import build_parser
11
+ from maitabi.registry import ACTION_BY_KEY
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = build_parser()
16
+ args = parser.parse_args(argv)
17
+ if not args.domain:
18
+ parser.print_help()
19
+ return EXIT_OK
20
+ if args.domain == "mcp":
21
+ # Imported lazily: the CLI must stay importable without fastmcp.
22
+ from maitabi.mcp.server import run
23
+
24
+ run(transport=args.transport, host=args.host, port=args.port, path=args.path)
25
+ return EXIT_OK
26
+ if not args.command:
27
+ args._group.print_help()
28
+ return EXIT_OK
29
+ action = ACTION_BY_KEY.get((args.domain, args.command))
30
+ if action is None: # pragma: no cover - argparse restricts choices
31
+ parser.error(f"unknown command: {args.domain} {args.command}")
32
+ return run_action(action, args)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ raise SystemExit(main())
maitabi/cli/parser.py ADDED
@@ -0,0 +1,75 @@
1
+ """Argparse definition for the `maitabi` CLI — generated from core models.
2
+
3
+ Two levels: `maitabi <domain> <command>`, where the domain is the upstream
4
+ family (`bus` = api.bus.maitabi.jp, `general` = www.maitabi.jp) and the
5
+ command mirrors the MCP tool name minus its domain prefix. `maitabi mcp`
6
+ starts the MCP server, so one executable covers both interfaces.
7
+
8
+ Every subcommand comes from `registry.ACTIONS` and every flag from the
9
+ action's Pydantic `model_fields`, so CLI flags cannot drift from the MCP
10
+ schema. No network or parsing logic lives here — see `commands.py` and
11
+ `services/`. Must NOT import fastmcp/mcp/server/mcp so the CLI stays
12
+ importable without MCP dependencies installed.
13
+ """
14
+
15
+ import argparse
16
+ from typing import Any
17
+
18
+ from pydantic.fields import FieldInfo
19
+
20
+ from maitabi.cli.fields import plan_field
21
+ from maitabi.mcp_args import add_mcp_args
22
+ from maitabi.registry import ACTIONS, DOMAINS
23
+
24
+ DOMAIN_HELP = {
25
+ "bus": "Mountain bus tours (api.bus.maitabi.jp)",
26
+ "general": "General tours & calendar (www.maitabi.jp)",
27
+ }
28
+
29
+
30
+ def _add_field(p: argparse.ArgumentParser, name: str, info: FieldInfo) -> None:
31
+ plan = plan_field(name, info)
32
+ kwargs: dict[str, Any] = {"dest": name, "help": info.description}
33
+ if plan.metavar is not None:
34
+ kwargs["metavar"] = plan.metavar
35
+ if plan.store_true:
36
+ kwargs["action"] = "store_true"
37
+ kwargs["default"] = plan.default
38
+ else:
39
+ kwargs["type"] = plan.arg_type
40
+ kwargs["default"] = plan.default
41
+ if plan.choices is not None:
42
+ kwargs["choices"] = plan.choices
43
+ if plan.required:
44
+ kwargs["required"] = True
45
+ p.add_argument(plan.flag, **kwargs)
46
+
47
+
48
+ def _add_output_args(p: argparse.ArgumentParser) -> None:
49
+ p.add_argument("--pretty", action="store_true", help="Pretty-print JSON with indent=2")
50
+ p.add_argument(
51
+ "--output", type=str, default=None, metavar="FILE", help="Write JSON output to FILE instead of stdout"
52
+ )
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ parser = argparse.ArgumentParser(
57
+ prog="maitabi",
58
+ description="Maitabi CLI — bus tours, general tours, and the MCP server in one command",
59
+ )
60
+ domains = parser.add_subparsers(dest="domain", metavar="<group>")
61
+
62
+ for domain in DOMAINS:
63
+ group = domains.add_parser(domain, help=DOMAIN_HELP[domain])
64
+ group.set_defaults(_group=group)
65
+ commands = group.add_subparsers(dest="command", metavar="<command>")
66
+ for action in (a for a in ACTIONS if a.domain == domain):
67
+ p = commands.add_parser(action.command, help=action.help)
68
+ for name, info in action.input_model.model_fields.items():
69
+ _add_field(p, name, info)
70
+ _add_output_args(p)
71
+
72
+ mcp_parser = domains.add_parser("mcp", help="Run the MCP server (stdio, sse, streamable-http)")
73
+ add_mcp_args(mcp_parser)
74
+
75
+ return parser
maitabi/config.py ADDED
@@ -0,0 +1,11 @@
1
+ """Configuration constants for Maitabi MCP Server.
2
+
3
+ Base URLs are env-overridable so tests and mirrors can repoint the clients
4
+ without touching service code.
5
+ """
6
+
7
+ import os
8
+
9
+ API_BASE = os.getenv("MAITABI_API_BASE", "https://api.bus.maitabi.jp")
10
+ WEB_BASE = os.getenv("MAITABI_WEB_BASE", "https://bus.maitabi.jp")
11
+ MAIN_BASE = os.getenv("MAITABI_MAIN_BASE", "https://www.maitabi.jp")
maitabi/core/days.py ADDED
@@ -0,0 +1,42 @@
1
+ """Day-input parsing shared by bus and general search services."""
2
+
3
+
4
+ def parse_days(day_input: object) -> list[int] | None:
5
+ """Parse day input (int, list of ints/strings, or string like '14-16, 20').
6
+
7
+ Unrecognised shapes yield None (no day filter) instead of raising: callers
8
+ map the result straight onto upstream query params.
9
+ """
10
+ if day_input is None:
11
+ return None
12
+ if isinstance(day_input, int):
13
+ return [day_input]
14
+ if isinstance(day_input, list):
15
+ days: set[int] = set()
16
+ for item in day_input:
17
+ if isinstance(item, int):
18
+ days.add(item)
19
+ elif isinstance(item, str):
20
+ nested = parse_days(item)
21
+ if nested is not None:
22
+ days.update(nested)
23
+ return sorted(days) if days else None
24
+
25
+ if isinstance(day_input, str):
26
+ days = set()
27
+ for part in day_input.split(","):
28
+ part = part.strip()
29
+ if not part:
30
+ continue
31
+ if "-" in part:
32
+ try:
33
+ start_str, end_str = part.split("-", 1)
34
+ start, end = int(start_str), int(end_str)
35
+ if start <= end:
36
+ days.update(range(start, end + 1))
37
+ except ValueError:
38
+ continue
39
+ elif part.isdigit():
40
+ days.add(int(part))
41
+ return sorted(days) if days else None
42
+ return None
maitabi/core/enums.py ADDED
@@ -0,0 +1,54 @@
1
+ """Enumeration IDs shared by MCP tools, CLI flags, and raw-HTTP extraction."""
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class TravelType(IntEnum):
7
+ """Category ID for Maitabi general tours (www.maitabi.jp)."""
8
+
9
+ DOMESTIC_MOUNTAIN = 1 # Domestic Mountain Climbing / Trekking
10
+ DOMESTIC_TRAVEL = 2 # Domestic Travel / Hiking
11
+ MOUNTAIN_BUS = 3 # Mountain Bus
12
+ OVERSEAS_MOUNTAIN = 4 # Overseas Mountain Climbing / Trekking
13
+ OVERSEAS_TRAVEL = 5 # Overseas Travel
14
+
15
+
16
+ class DeparturePoint(IntEnum):
17
+ """Point of departure for Maitabi bus tours (bus.maitabi.jp)."""
18
+
19
+ TOKYO = 1 # Departure from Tokyo (Takebashi / Shinjuku)
20
+ OSAKA_KYOTO = 2 # Departure from Osaka / Kyoto
21
+ NAGOYA = 3 # Departure from Nagoya
22
+
23
+
24
+ class TourStyle(IntEnum):
25
+ """Tour style ID for mountain bus tours."""
26
+
27
+ ROUND_TRIP_BUS = 1 # Round-trip bus only (daytime departure, no mountain lodge)
28
+ OUTBOUND_BUS = 2 # Outbound bus only (daytime departure)
29
+ INBOUND_BUS = 3 # Inbound bus only (daytime departure)
30
+ ROUND_TRIP_LODGE = 4 # Round-trip bus with mountain lodge (daytime departure, stay overnight at lodge)
31
+ OUTBOUND_LODGE = 5 # Outbound bus with mountain lodge (daytime departure, stay overnight at lodge)
32
+ OVERNIGHT_DAY_TRIP = 6 # Night bus departure from Tokyo → arrive at mountain early morning → day hike → return same day (no lodge stay); 夜行日帰り/往復夜行
33
+ TAXI_PLAN = 7 # Taxi plan
34
+
35
+
36
+ class ReturnDayOption(IntEnum):
37
+ """Return date option ID for mountain bus tours relative to departure."""
38
+
39
+ DAY_1 = 1 # Return 1 day after departure — used for style=6 (night bus: depart day 0 night, return day 1 evening)
40
+ DAY_2 = 2 # Return 2 days after departure (1 night stay at mountain lodge)
41
+ DAY_3 = 3 # Return 3 days after departure (2 nights stay)
42
+ DAY_4 = 4 # Return 4 days after departure (3 nights stay)
43
+ DAY_5 = 5 # Return 5 days after departure (4 nights stay)
44
+
45
+
46
+ class BusSeatType(IntEnum):
47
+ """Bus seat configuration ID for mountain bus tours."""
48
+
49
+ STANDARD = 1 # Standard seat
50
+ PREMIUM = 2 # Premium seat
51
+ OUTBOUND_PREMIUM_INBOUND_STANDARD = 3 # Outbound Premium, Inbound Standard
52
+ OUTBOUND_STANDARD_INBOUND_PREMIUM = 4 # Outbound Standard, Inbound Premium
53
+ DOUBLE_SEAT = 5 # Double seat option
54
+ TAXI = 6 # Taxi option
@@ -0,0 +1,65 @@
1
+ """The uniform result envelope shared by every search/collection endpoint.
2
+
3
+ Contract (stable since 1.0.0 — additive changes only):
4
+
5
+ - `count`: total matches across all pages, or null when upstream does not say.
6
+ Never `len(items)`, which would silently become wrong once pagination bites.
7
+ - `page`: the requested page, or null for endpoints that do not paginate.
8
+ - `items`: always a list, empty rather than absent.
9
+ - `query`: the parameters actually sent, typed (ints stay ints).
10
+
11
+ Single-resource endpoints (tour detail, filter options, calendar matrix) return
12
+ their own object instead of an envelope: a list of one would only add a level
13
+ of indexing without adding information. Errors are always
14
+ `{"error", "detail", "retry_hint"?}`.
15
+ """
16
+
17
+ from enum import IntEnum
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from maitabi.core.days import parse_days
23
+
24
+
25
+ def as_int(value: object) -> int | None:
26
+ """Coerce an upstream numeric string to int, or None when it is not one."""
27
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
28
+ return None
29
+ try:
30
+ return int(value)
31
+ except (TypeError, ValueError):
32
+ return None
33
+
34
+
35
+ def dict_items(raw: object) -> list[dict[str, Any]]:
36
+ """Coerce an upstream list into a list of dict items, dropping anything else."""
37
+ return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
38
+
39
+
40
+ def query_of(model: BaseModel) -> dict[str, Any]:
41
+ """The effective parameters of a request, typed and without empty values."""
42
+ out: dict[str, Any] = {}
43
+ for name, value in model.model_dump(exclude_none=True).items():
44
+ if isinstance(value, IntEnum):
45
+ value = int(value)
46
+ if value is False or value in ("", [], {}):
47
+ continue # not sent upstream, so not part of the query
48
+ out[name] = value
49
+ if "day" in out:
50
+ # Search models accept "14-16, 20"; report the days actually requested.
51
+ days = parse_days(out["day"])
52
+ if days is not None:
53
+ out["day"] = days
54
+ return out
55
+
56
+
57
+ def envelope(
58
+ *,
59
+ items: list[Any],
60
+ count: int | None,
61
+ page: int | None,
62
+ query: dict[str, Any],
63
+ ) -> dict[str, Any]:
64
+ """Build the uniform result envelope."""
65
+ return {"count": count, "page": page, "items": items, "query": query}
maitabi/core/errors.py ADDED
@@ -0,0 +1,50 @@
1
+ """Uniform error payloads returned inside successful HTTP responses.
2
+
3
+ Upstream failures never raise: services embed these shapes in the JSON body so
4
+ MCP clients, CLI scripts, and raw-HTTP consumers all read the same contract:
5
+ `{"error": <human message>, "detail": <technical cause>, "retry_hint": <optional>}`.
6
+ """
7
+
8
+ RATE_LIMIT_ERROR = "Rate limited by www.maitabi.jp (60 requests/min per IP)"
9
+ RATE_LIMIT_RETRY_HINT = "wait ~60s and retry manually"
10
+ THROTTLED_BODY_MESSAGE = "Too Many Attempts."
11
+
12
+
13
+ def upstream_http_error(status_code: int, detail: str) -> dict[str, str]:
14
+ """Non-2xx upstream response."""
15
+ return {
16
+ "error": f"Upstream service returned error: {status_code}",
17
+ "detail": detail,
18
+ }
19
+
20
+
21
+ def connection_error(detail: str) -> dict[str, str]:
22
+ """Network-level failure reaching the upstream host."""
23
+ return {
24
+ "error": "Failed to connect to Maitabi server",
25
+ "detail": detail,
26
+ }
27
+
28
+
29
+ def rate_limit_error() -> dict[str, str]:
30
+ """www.maitabi.jp throttling (HTTP 200 + 'Too Many Attempts.' body)."""
31
+ return {
32
+ "error": RATE_LIMIT_ERROR,
33
+ "detail": THROTTLED_BODY_MESSAGE,
34
+ "retry_hint": RATE_LIMIT_RETRY_HINT,
35
+ }
36
+
37
+
38
+ def is_rate_limited_body(data: object) -> bool:
39
+ """Detect www.maitabi.jp's HTTP-200 throttle body (no Retry-After header)."""
40
+ if not isinstance(data, dict):
41
+ return False
42
+ errors = data.get("errors")
43
+ if not isinstance(errors, dict):
44
+ return False
45
+ return bool(errors.get("message") == THROTTLED_BODY_MESSAGE)
46
+
47
+
48
+ def is_error_payload(data: object) -> bool:
49
+ """True when a service result carries an error instead of data."""
50
+ return isinstance(data, dict) and "error" in data
@@ -0,0 +1,66 @@
1
+ """Result post-processing shared by bus and general search services.
2
+
3
+ These helpers run on upstream payloads AFTER the HTTP call: price-range
4
+ filtering, availability filtering, and `detail_url` enrichment. They keep the
5
+ exact wire semantics that both the MCP tools and the CLI expose.
6
+ """
7
+
8
+ from datetime import datetime
9
+
10
+ from maitabi.config import MAIN_BASE, WEB_BASE
11
+
12
+ # Upstream statuses that mean "not bookable" per domain.
13
+ #
14
+ # bus.maitabi.jp reports Japanese strings; www.maitabi.jp reports numbers where
15
+ # 1 = maru-double, 2 = maru, 3 = batsu (closed). Only the explicit closed values
16
+ # code must never make an existing tour disappear from results.
17
+ BUS_CLOSED_STATUSES: tuple[object, ...] = ("満席", "受付終了", "")
18
+ GENERAL_CLOSED_STATUSES: tuple[object, ...] = (3,)
19
+
20
+
21
+ def parse_price_jpy(raw: object) -> int | None:
22
+ """Parse an upstream price string ('15,500円~') into an int, or None.
23
+
24
+ Upstream prices carry ',' thousands separators, the 円 suffix, and a
25
+ trailing fullwidth tilde (\uff5e) for ranges. Unparseable values yield None
26
+ (never 0) so callers can distinguish "no price" from a real zero.
27
+ """
28
+ cleaned = str(raw).replace(",", "").replace("円", "").replace("\uff5e", "").strip()
29
+ return int(cleaned) if cleaned.isdigit() else None
30
+
31
+
32
+ def within_price_range(price_jpy: int | None, min_price: int | None, max_price: int | None) -> bool:
33
+ """True when price fits [min_price, max_price]. Unknown price (None) always fits."""
34
+ if price_jpy is None:
35
+ return True
36
+ if min_price is not None and price_jpy < min_price:
37
+ return False
38
+ return not (max_price is not None and price_jpy > max_price)
39
+
40
+
41
+ def is_open_status(status: object, closed_statuses: tuple[object, ...]) -> bool:
42
+ """True when an upstream status (string or numeric code) is still bookable."""
43
+ return status not in closed_statuses
44
+
45
+
46
+ def derive_bus_detail_url(course_no: int | str, date_str: str, fallback_month: int | None = None) -> str:
47
+ """Build the bus.maitabi.jp detail link, deriving year/month from the date field.
48
+
49
+ Upstream `date` looks like '2026年09月12日(土)'. When it can't be parsed,
50
+ fall back to the current year and the search month (or current month).
51
+ """
52
+ now = datetime.now()
53
+ year, month = now.year, fallback_month or now.month
54
+ if "年" in date_str and "月" in date_str:
55
+ parts = date_str.split("年")
56
+ if len(parts) == 2:
57
+ y_str = parts[0]
58
+ m_str = parts[1].split("月")[0]
59
+ if y_str.isdigit() and m_str.isdigit():
60
+ year, month = int(y_str), int(m_str)
61
+ return f"{WEB_BASE}/detail.html?course_no={course_no}&year={year}&month={month}"
62
+
63
+
64
+ def derive_general_detail_url(course_no: int | str) -> str:
65
+ """Build the www.maitabi.jp detail link (no date derivation needed)."""
66
+ return f"{MAIN_BASE}/detail.php?courseNo={course_no}"