Skill: anthonymq/idfm-journey-navitia (version 0.1.0)

--- name: idfm-journey description: Query Île-de-France Mobilités (IDFM) PRIM/Navitia for place resolution, journey planning, and disruptions/incident checks. Use when asked to find routes in Île-de-France (e.g., "itinéraire de X à Y"), resolve station/stop ids, or check RER/metro line disruptions, and you have an IDFM PRIM API key. --- # IDFM Journey (PRIM/Navitia) Use the bundled script to call PRIM/Navitia endpoints without extra dependencies. ## Prereqs - Set `IDFM_PRIM_API_KEY` in the environment before running. ## Quick commands Run from anywhere (path is inside the skill folder): - Resolve places (best match + list): - `python3 scripts/idfm.py places "Ivry-sur-Seine" --count 5` - Journeys (free-text from/to; resolves place ids first): - `python3 scripts/idfm.py journeys --from "Ivry-sur-Seine" --to "Boulainvilliers" --count 3` - Incidents / disruptions (by line id or filter): - `python3 scripts/idfm.py incidents --line-id line:IDFM:C01727` - `python3 scripts/idfm.py incidents --filter 'disruption.status=active'` Add `--json` to print raw API output. ## Notes - If place resolution is ambiguous, increase `--count` and choose the right `stop_area` id. - For API details and examples, read: `references/idfm-prim.md`.

path: references/idfm-prim.md
content: # IDFM PRIM / Navitia quick notes

- Base URL (v2 navitia): `https://prim.iledefrance-mobilites.fr/marketplace/v2/navitia`
- Auth header: `[REDACTED_SECRET]`

Endpoints used by this skill script:
- `/places?q=<text>&count=<n>`
- `/journeys?from=<place_id>&to=<place_id>&count=<n>`
- `/disruptions?filter=<expr>`

Filter examples (disruptions):
- By line id: `line.id=line:IDFM:C01727`
- Generic: `disruption.status=active` (depends on Navitia filter syntax)


path: scripts/idfm.py
content: #!/usr/bin/env python3
"""Query Île-de-France Mobilités PRIM / Navitia endpoints.

No third-party deps: uses urllib.
Requires env var: IDFM_PRIM_API_KEY
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.parse
import urllib.request

BASE_URL = "https://prim.iledefrance-mobilites.fr/marketplace/v2/navitia"


class PrimError(RuntimeError):
    pass


def _http_get_json(url: str, api_key: str, timeout_s: int = 20) -> dict:
    req = urllib.request.Request(url)
    req.add_header("apikey", api_key)
    req.add_header("accept", "application/json")

    try:
        with urllib.request.urlopen(req, timeout=timeout_s) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw)
    except urllib.error.HTTPError as e:
        body = None
        try:
            body = e.read().decode("utf-8", errors="replace")
        except Exception:
            pass
        raise PrimError(f"HTTP {e.code} {e.reason}: {body or ''}".strip()) from e
    except urllib.error.URLError as e:
        raise PrimError(f"Network error: {e}") from e


class PrimClient:
    def __init__(self, api_key: str | None = None, base_url: str = BASE_URL):
        self.[REDACTED_SECRET] or os.environ.get("IDFM_PRIM_API_KEY")
        if not self.api_key:
            raise PrimError("Missing IDFM_PRIM_API_KEY env var")
        self.base_url = base_url.rstrip("/")

    def get(self, path: str, params: dict | None = None) -> dict:
        base = f"{self.base_url}/{path.lstrip('/')}"
        qs = urllib.parse.urlencode(params or {}, doseq=True)
        url = f"{base}?{qs}" if qs else base
        return _http_get_json(url, self.api_key)

    def places(self, q: str, count: int = 5) -> dict:
        return self.get("places", {"q": q, "count": count})

    def journeys(self, from_id: str, to_id: str, count: int = 3) -> dict:
        return self.get("journeys", {"from": from_id, "to": to_id, "count": count})

    def disruptions(self, filter_expr: str) -> dict:
        return self.get("disruptions", {"filter": filter_expr})


def _pick_best_place(places_json: dict) -> dict | None:
    places = places_json.get("places") or []
    if not places:
        return None
    for p in places:
        if p.get("embedded_type") == "stop_area":
            return p
    return places[0]


def cmd_places(client: PrimClient, args: argparse.Namespace) -> int:
    data = client.places(args.query, count=args.count)
    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return 0

    best = _pick_best_place(data)
    if not best:
        print("No results")
        return 0

    print(f"best: {best.get('name')} ({best.get('embedded_type')} / {best.get('id')})")
    for p in (data.get("places") or [])[: args.count]:
        print(f"- {p.get('name')}\t{p.get('embedded_type')}\t{p.get('id')}")
    return 0


def _fmt_navitia_dt(dt: str | None) -> str | None:
    # Navitia format: YYYYMMDDTHHMMSS
    if not dt or len(dt) < 15:
        return dt
    try:
        hh = dt[9:11]
        mm = dt[11:13]
        return f"{hh}:{mm}"
    except Exception:
        return dt


def _fmt_duration_s(seconds: int | None) -> str:
    if seconds is None:
        return "?"
    m, s = divmod(int(seconds), 60)
    if m < 60:
        return f"{m}m{str(s).zfill(2)}"
    h, m = divmod(m, 60)
    return f"{h}h{str(m).zfill(2)}"


def _section_to_human(s: dict) -> str:
    st = s.get("type")

    # walking/transfer/waiting sections
    if st in {"waiting"}:
        return "⏳ attente"

    mode = (s.get("mode") or "").lower()
    if st in {"street_network", "transfer", "crow_fly"} and mode == "walking":
        return "🚶 marche"

    # public transport
    if st == "public_transport":
        di = s.get("display_informations") or {}
        label = di.get("label")
        commercial = di.get("commercial_mode")
        direction = di.get("direction")

        from_name = None
        to_name = None
        fr = (s.get("from") or {}).get("stop_point") or (s.get("from") or {}).get("stop_area")
        to = (s.get("to") or {}).get("stop_point") or (s.get("to") or {}).get("stop_area")
        if isinstance(fr, dict):
            from_name = fr.get("name")
        if isinstance(to, dict):
            to_name = to.get("name")

        head = " ".join(x for x in [commercial, label] if x)
        tail = " → ".join(x for x in [from_name, to_name] if x)
        extra = f" ({direction})" if direction else ""

        if head and tail:
            return f"🚇 {head}{extra}: {tail}"
        if head:
            return f"🚇 {head}{extra}"

    # fallback
    st_str = st or "section"
    mode_up = (s.get("mode") or "").upper()
    return " - ".join(x for x in [st_str, mode_up] if x)


def _summarize_journey(j: dict) -> dict:
    dur = j.get("duration")
    dep = j.get("departure_date_time")
    arr = j.get("arrival_date_time")
    sections = j.get("sections") or []

    parts: list[str] = []
    for s in sections:
        parts.append(_section_to_human(s))

    return {
        "departure": dep,
        "arrival": arr,
        "departure_h": _fmt_navitia_dt(dep),
        "arrival_h": _fmt_navitia_dt(arr),
        "duration_s": dur,
        "duration_h": _fmt_duration_s(dur),
        "sections": parts,
    }


def cmd_journeys(client: PrimClient, args: argparse.Namespace) -> int:
    from_data = client.places(args.from_query, count=5)
    to_data = client.places(args.to_query, count=5)
    from_best = _pick_best_place(from_data)
    to_best = _pick_best_place(to_data)

    if not from_best or not to_best:
        raise PrimError("Could not resolve from/to")

    data = client.journeys(from_best["id"], to_best["id"], count=args.count)

    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return 0

    print(f"from: {from_best.get('name')} ({from_best.get('id')})")
    print(f"to:   {to_best.get('name')} ({to_best.get('id')})")

    journeys = data.get("journeys") or []
    if not journeys:
        print("No journeys")
        return 0

    for i, j in enumerate(journeys[: args.count], 1):
        s = _summarize_journey(j)
        dep = s.get("departure_h") or s.get("departure")
        arr = s.get("arrival_h") or s.get("arrival")
        dur = s.get("duration_h") or f"{s.get('duration_s')}s"

        if i > 1:
            print("\n" + ("-" * 44))

        print(f"Option {i}: {dep} → {arr}  |  durée {dur}")
        for line in s["sections"]:
            print(f"  - {line}")

    return 0


def cmd_incidents(client: PrimClient, args: argparse.Namespace) -> int:
    filt = args.filter
    if args.line_id:
        filt = f"line.id={args.line_id}"
    if not filt:
        raise PrimError("Provide --line-id or --filter")

    data = client.disruptions(filt)

    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return 0

    disruptions = data.get("disruptions") or []
    if not disruptions:
        print("No disruptions")
        return 0

    for d in disruptions:
        status = d.get("status")
        severity = (d.get("severity") or {}).get("name")
        msg = (d.get("messages") or [{}])[0].get("text")
        print(f"- [{status}] {severity}: {msg}")

    return 0


def main(argv: list[str] | None = None) -> int:
    argv = argv if argv is not None else sys.argv[1:]

    p = argparse.ArgumentParser(prog="idfm")
    p.add_argument("--json", action="store_true", help="print raw JSON")
    p.add_argument(
        "--base-url",
        default=BASE_URL,
        help="override PRIM base URL (default: %(default)s)",
    )

    sp = p.add_subparsers(dest="cmd", required=True)

    p_places = sp.add_parser("places", help="resolve places via /places")
    p_places.add_argument("query")
    p_places.add_argument("--count", type=int, default=5)

    p_j = sp.add_parser("journeys", help="plan a journey via /journeys")
    p_j.add_argument("--from", dest="from_query", required=True)
    p_j.add_argument("--to", dest="to_query", required=True)
    p_j.add_argument("--count", type=int, default=3)

    p_i = sp.add_parser("incidents", help="fetch disruptions via /disruptions")
    p_i.add_argument("--line-id")
    p_i.add_argument("--filter")

    args = p.parse_args(argv)
    client = PrimClient(base_url=args.base_url)

    if args.cmd == "places":
        return cmd_places(client, args)
    if args.cmd == "journeys":
        return cmd_journeys(client, args)
    if args.cmd == "incidents":
        return cmd_incidents(client, args)

    raise SystemExit("Unknown command")


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except PrimError as e:
        print(f"ERROR: {e}", file=sys.stderr)
        raise SystemExit(2)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)