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 +1 -0
- maitabi/__main__.py +6 -0
- maitabi/cli/__init__.py +1 -0
- maitabi/cli/__main__.py +6 -0
- maitabi/cli/commands.py +75 -0
- maitabi/cli/fields.py +108 -0
- maitabi/cli/main.py +36 -0
- maitabi/cli/parser.py +75 -0
- maitabi/config.py +11 -0
- maitabi/core/days.py +42 -0
- maitabi/core/enums.py +54 -0
- maitabi/core/envelope.py +65 -0
- maitabi/core/errors.py +50 -0
- maitabi/core/filters.py +66 -0
- maitabi/core/inputs.py +297 -0
- maitabi/mcp/server.py +69 -0
- maitabi/mcp/tools.py +53 -0
- maitabi/mcp_args.py +38 -0
- maitabi/py.typed +0 -0
- maitabi/registry.py +164 -0
- maitabi/services/bus.py +137 -0
- maitabi/services/general.py +156 -0
- maitabi/services/http.py +82 -0
- maitabi-1.0.0.dist-info/METADATA +204 -0
- maitabi-1.0.0.dist-info/RECORD +28 -0
- maitabi-1.0.0.dist-info/WHEEL +4 -0
- maitabi-1.0.0.dist-info/entry_points.txt +2 -0
- maitabi-1.0.0.dist-info/licenses/LICENSE +201 -0
maitabi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Maitabi MCP Server package."""
|
maitabi/__main__.py
ADDED
maitabi/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Maitabi CLI package (thin wrapper over core services/models)."""
|
maitabi/cli/__main__.py
ADDED
maitabi/cli/commands.py
ADDED
|
@@ -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
|
maitabi/core/envelope.py
ADDED
|
@@ -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
|
maitabi/core/filters.py
ADDED
|
@@ -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}"
|