Skill: barancaki/turkiye-afad-deprem (version 0.1.0)

--- name: turkiye-afad-deprem description: Turkiye AFAD deprem verisini kullanarak zaman, buyukluk ve bolge filtreli deprem ozeti uretir. metadata: openclaw: homepage: https://github.com/barancaki/turkey_earthquake_skill os: any user-invocable: true requires: network: true --- # Turkiye AFAD Deprem Skill ## Ne Zaman Cagrilir Bu skill, kullanici AFAD tabanli guncel deprem bilgisi istediginde cagrilir. Net trigger cumleleri: - "AFAD son depremleri goster" - "Son 24 saatte deprem var mi?" - "Son 7 gun M4+ depremleri listele" - "Izmir civarindaki depremleri getir" - "Bugun olan depremleri ozetle" - "4 ve ustu depremleri goster" ## Kullanici Girdisini Yorumlama ### Zaman Araligi - "son 1 saat" => simdiden geriye 1 saat - "bugun" => yerel saate gore bugunun 00:00-23:59 araligi - "son 24 saat" => simdiden geriye 24 saat - "son 7 gun" => simdiden geriye 7 gun - belirtilmezse varsayilan: son 24 saat ### Minimum Buyukluk - "M4+", "4+", "4 ve ustu" => `min_magnitude = 4.0` - "M5.2+" => `min_magnitude = 5.2` - belirtilmezse tum buyuklukler dahil edilir ### Bolge / Sehir - Metin filtreleme: kullanici bolge/sehir ifadesi (`"Izmir civari"`, `"Marmara"`, `"Van"`) yer bilgisinde aranir. - Opsiyonel yakin eslesme: birebir eslesme yoksa yazim farklari icin yakin eslesme uygulanabilir. - Birden fazla bolge verilirse herhangi birine uyan kayitlar dahil edilir. ## Cikti Formati Yaniti su sirada uret: 1. Tek satir ozet: `<N> deprem bulundu.` 2. En buyuk deprem: `En buyuk: <tarih-saat> | M<buyukluk> | <yer>` 3. Son 5 deprem tablo gibi: | Tarih-Saat | Buyukluk | Yer | |---|---:|---| | ... | ... | ... | 4. Kaynak linki: `Kaynak: <AFAD veri linki>` ## Hata Mesajlari ve Fallback - Veri kaynagi yanit vermedi: `AFAD veri kaynagina su anda erisilemiyor. Lutfen kisa bir sure sonra tekrar deneyin.` - Bos sonuc: `Secilen filtrelerle deprem kaydi bulunamadi. Zaman araligini genisletmeyi veya min buyuklugu dusurmeyi deneyin.` ## Guvenlik - Bu skill komut calistirmaz. - Yalnizca public AFAD deprem verisini okur ve filtreler. - Kullanici tarafinda sistem degisikligi, dosya yazma veya harici komut tetikleme yapmaz.

path: fixtures/afad_fixture_empty.json
content: {
  "data": []
}


path: fixtures/afad_fixture_normal_10.json
content: {
  "data": [
    {
      "eventID": "202602280101",
      "date": "2026-02-28T12:00:00",
      "latitude": 38.123,
      "longitude": 39.456,
      "depth": 7.2,
      "m": 4.5,
      "location": "PULUMUR (TUNCELI)"
    },
    {
      "eventID": "202602280102",
      "date": "2026-02-28T11:45:00",
      "latitude": 38.401,
      "longitude": 27.122,
      "depth": 8.1,
      "m": 3.2,
      "location": "BORNOVA (IZMIR)"
    },
    {
      "eventID": "202602280103",
      "date": "2026-02-28T11:30:00",
      "latitude": 37.066,
      "longitude": 37.383,
      "depth": 5.0,
      "m": 4.0,
      "location": "SEHITKAMIL (GAZIANTEP)"
    },
    {
      "eventID": "202602280104",
      "date": "2026-02-28T11:15:00",
      "latitude": 39.91,
      "longitude": 32.85,
      "depth": 10.0,
      "m": 2.9,
      "location": "CANKAYA (ANKARA)"
    },
    {
      "eventID": "202602280105",
      "date": "2026-02-28T11:00:00",
      "latitude": 40.188,
      "longitude": 29.061,
      "depth": 6.5,
      "m": 4.7,
      "location": "NILUFER (BURSA)"
    },
    {
      "eventID": "202602280106",
      "date": "2026-02-28T10:45:00",
      "latitude": 38.423,
      "longitude": 27.142,
      "depth": 4.3,
      "m": 3.8,
      "location": "KONAK (IZMIR)"
    },
    {
      "eventID": "202602280107",
      "date": "2026-02-28T10:30:00",
      "latitude": 41.008,
      "longitude": 28.978,
      "depth": 9.7,
      "m": 2.1,
      "location": "FATIH (ISTANBUL)"
    },
    {
      "eventID": "202602280108",
      "date": "2026-02-28T10:15:00",
      "latitude": 36.202,
      "longitude": 36.161,
      "depth": 12.4,
      "m": 5.1,
      "location": "ANTAKYA (HATAY)"
    },
    {
      "eventID": "202602280109",
      "date": "2026-02-28T10:00:00",
      "latitude": 38.419,
      "longitude": 27.128,
      "depth": 3.9,
      "m": 3.4,
      "location": "KARSIYAKA (IZMIR)"
    },
    {
      "eventID": "202602280110",
      "date": "2026-02-28T09:45:00",
      "latitude": 40.773,
      "longitude": 29.400,
      "depth": 11.1,
      "m": 2.7,
      "location": "GEBZE (KOCAELI)"
    }
  ]
}


path: fixtures/afad_sample_response.json
content: {
  "data": [
    {
      "eventID": "202602280001",
      "date": "2026-02-28T12:00:00",
      "latitude": 38.456,
      "longitude": 39.123,
      "depth": 7.1,
      "m": 4.2,
      "location": "PULUMUR (TUNCELI)"
    },
    {
      "eventID": "202602280002",
      "date": "2026-02-28T10:30:00",
      "latitude": 37.889,
      "longitude": 37.991,
      "depth": 5.0,
      "m": 3.1,
      "location": "GOKSUN (KAHRAMANMARAS)"
    },
    {
      "eventID": "202602280003",
      "date": "invalid-date",
      "latitude": 36.901,
      "longitude": 30.701,
      "depth": 15.0,
      "m": 2.8,
      "location": "AKDENIZ"
    }
  ]
}


path: references/afad_source.md
content: # AFAD Source Notes

Primary source links:

- https://deprem.afad.gov.tr/
- https://deprem.afad.gov.tr/event-service

Endpoint used by this skill script (default):

- https://deprem.afad.gov.tr/apiv2/event/filter

Notes:

- The script performs HTTP GET only.
- Query parameters are used to set time window and limits.
- If endpoint behavior changes, override with `--source-url`.


path: scripts/fetch_afad_earthquakes.py
content: #!/usr/bin/env python3
"""Fetch and normalize latest AFAD earthquake records."""

from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

DEFAULT_SOURCE_URL = "https://deprem.afad.gov.tr/apiv2/event/filter"
SOURCE_TZ = timezone(timedelta(hours=3))


class AppError(Exception):
    def __init__(self, code: str, message: str, exit_code: int) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.exit_code = exit_code


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Fetch and normalize AFAD recent earthquakes as JSON."
    )
    parser.add_argument("--hours", type=int, default=None)
    parser.add_argument("--days", type=int, default=None)
    parser.add_argument("--minMag", dest="min_mag", type=float, default=None)
    parser.add_argument("--query", type=str, default=None)
    parser.add_argument("--timeout", type=int, default=20)
    parser.add_argument("--source-url", default=DEFAULT_SOURCE_URL)
    parser.add_argument("--fixture", default=None)
    parser.add_argument("--pretty", action="store_true")
    return parser.parse_args(argv)


def to_utc_iso(dt: datetime) -> str:
    return (
        dt.astimezone(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z")
    )


def parse_float(value: Any) -> float | None:
    if value is None or value == "":
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def parse_datetime(value: Any) -> datetime | None:
    if value is None:
        return None

    text = str(value).strip()
    if not text:
        return None

    try:
        dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
    except ValueError:
        dt = None

    if dt is None:
        for fmt in (
            "%Y-%m-%d %H:%M:%S",
            "%Y-%m-%dT%H:%M:%S",
            "%Y.%m.%d %H:%M:%S",
        ):
            try:
                dt = datetime.strptime(text, fmt)
                break
            except ValueError:
                continue

    if dt is None:
        return None

    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=SOURCE_TZ)

    return dt.astimezone(timezone.utc)


def pick_first(raw: dict[str, Any], *keys: str) -> Any:
    for key in keys:
        if key in raw and raw[key] not in (None, ""):
            return raw[key]
    return None


def split_location(location: str) -> tuple[str | None, str | None]:
    text = location.strip()
    if not text:
        return None, None

    # Common AFAD style can be "DISTRICT (PROVINCE)"
    if "(" in text and text.endswith(")"):
        head, tail = text.rsplit("(", 1)
        district = head.strip(" -") or None
        province = tail[:-1].strip() or None
        return province, district

    return None, None


def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
    location = str(pick_first(raw, "location", "place", "title") or "Unknown").strip()
    province, district = split_location(location)

    raw_time = pick_first(raw, "date", "time", "eventDate", "datetime")
    dt = parse_datetime(raw_time)

    normalized = {
        "id": str(pick_first(raw, "eventID", "eventId", "id") or ""),
        "time_utc": to_utc_iso(dt) if dt else None,
        "magnitude": parse_float(pick_first(raw, "m", "mag", "magnitude")),
        "depth_km": parse_float(pick_first(raw, "depth", "depth_km")),
        "latitude": parse_float(pick_first(raw, "lat", "latitude")),
        "longitude": parse_float(pick_first(raw, "lon", "longitude")),
        "location": location or "Unknown",
        "province": (
            str(pick_first(raw, "province", "il") or province)
            if (pick_first(raw, "province", "il") or province)
            else None
        ),
        "district": (
            str(pick_first(raw, "district", "ilce") or district)
            if (pick_first(raw, "district", "ilce") or district)
            else None
        ),
        "source": "AFAD",
    }
    return normalized


def extract_events(payload: Any) -> list[dict[str, Any]]:
    if isinstance(payload, list):
        events = payload
    elif isinstance(payload, dict):
        events = pick_first(payload, "data", "result", "items", "events")
    else:
        events = None

    if not isinstance(events, list):
        raise AppError(
            "PARSE_ERROR",
            "AFAD yaniti icinde deprem listesi bulunamadi",
            exit_code=5,
        )

    output: list[dict[str, Any]] = []
    for event in events:
        if isinstance(event, dict):
            output.append(event)

    return output


def resolve_time_window(hours: int | None, days: int | None) -> timedelta | None:
    if hours is None and days is None:
        return None

    total_hours = 0
    if hours is not None:
        if hours < 0:
            raise AppError(
                "INPUT_ERROR",
                "--hours degeri sifirdan kucuk olamaz",
                exit_code=2,
            )
        total_hours += hours
    if days is not None:
        if days < 0:
            raise AppError(
                "INPUT_ERROR",
                "--days degeri sifirdan kucuk olamaz",
                exit_code=2,
            )
        total_hours += days * 24

    if total_hours <= 0:
        raise AppError(
            "INPUT_ERROR",
            "--hours/--days toplam zaman araligi sifirdan buyuk olmali",
            exit_code=2,
        )

    return timedelta(hours=total_hours)


def build_url(source_url: str, window: timedelta | None) -> str:
    params: dict[str, str] = {"orderby": "timedesc"}

    if window is not None:
        now_local = datetime.now(timezone.utc).astimezone(SOURCE_TZ)
        start_local = now_local - window
        params["start"] = start_local.replace(tzinfo=None, microsecond=0).isoformat(
            sep="T"
        )
        params["end"] = now_local.replace(tzinfo=None, microsecond=0).isoformat(sep="T")

    return f"{source_url}?{urlencode(params)}"


def fetch_events(
    source_url: str,
    timeout: int,
    window: timedelta | None,
) -> list[dict[str, Any]]:
    url = build_url(source_url=source_url, window=window)
    request = Request(
        url,
        headers={
            "Accept": "application/json",
            "User-Agent": "turkiye-afad-deprem-skill/0.1",
        },
        method="GET",
    )

    try:
        with urlopen(request, timeout=timeout) as response:
            status = getattr(response, "status", 200)
            if int(status) >= 400:
                raise AppError(
                    "HTTP_ERROR",
                    f"AFAD servisi HTTP {status} dondu",
                    exit_code=4,
                )
            body = response.read().decode("utf-8")
    except HTTPError as exc:
        raise AppError(
            "HTTP_ERROR",
            f"AFAD servisi HTTP {exc.code} dondu",
            exit_code=4,
        ) from exc
    except URLError as exc:
        raise AppError(
            "NETWORK_ERROR",
            f"AFAD servisine erisilemedi: {exc.reason}",
            exit_code=3,
        ) from exc

    try:
        payload = json.loads(body)
    except json.JSONDecodeError as exc:
        raise AppError(
            "PARSE_ERROR",
            "AFAD JSON yaniti cozumlenemedi",
            exit_code=5,
        ) from exc

    return extract_events(payload)


def load_fixture(path: str) -> list[dict[str, Any]]:
    fixture_path = Path(path)
    if not fixture_path.exists():
        raise AppError(
            "INPUT_ERROR",
            f"Fixture dosyasi bulunamadi: {fixture_path}",
            exit_code=2,
        )

    try:
        payload = json.loads(fixture_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise AppError(
            "PARSE_ERROR",
            "Fixture JSON gecersiz",
            exit_code=5,
        ) from exc

    return extract_events(payload)


def normalize_events(
    raw_events: list[dict[str, Any]],
    window: timedelta | None,
    min_magnitude: float | None,
    query: str | None,
    now_utc: datetime | None = None,
) -> list[dict[str, Any]]:
    now = now_utc or datetime.now(timezone.utc)
    earliest = now - window if window else None
    query_norm = query.strip().casefold() if query else None

    items: list[dict[str, Any]] = []
    for raw in raw_events:
        normalized = normalize_event(raw)

        if normalized["time_utc"] is None:
            continue
        event_time = datetime.fromisoformat(normalized["time_utc"].replace("Z", "+00:00"))

        if earliest is not None and event_time < earliest:
            continue

        magnitude = normalized["magnitude"]
        if min_magnitude is not None:
            if magnitude is None or magnitude < min_magnitude:
                continue

        if query_norm is not None:
            if query_norm not in normalized["location"].casefold():
                continue

        items.append(normalized)

    # Sort newest first by UTC time string
    items.sort(key=lambda item: item["time_utc"], reverse=True)
    return items


def validate_args(args: argparse.Namespace) -> None:
    if args.timeout <= 0:
        raise AppError(
            "INPUT_ERROR",
            "--timeout degeri sifirdan buyuk olmali",
            exit_code=2,
        )
    if args.min_mag is not None and args.min_mag < 0:
        raise AppError(
            "INPUT_ERROR",
            "--minMag degeri sifirdan kucuk olamaz",
            exit_code=2,
        )


def run(argv: list[str]) -> int:
    args = parse_args(argv)
    validate_args(args)
    window = resolve_time_window(args.hours, args.days)

    if args.fixture:
        raw_events = load_fixture(args.fixture)
    else:
        raw_events = fetch_events(
            source_url=args.source_url,
            timeout=args.timeout,
            window=window,
        )

    items = normalize_events(
        raw_events,
        window=window,
        min_magnitude=args.min_mag,
        query=args.query,
    )

    output = {
        "source": "AFAD",
        "fetched_at_utc": to_utc_iso(datetime.now(timezone.utc)),
        "count": len(items),
        "items": items,
    }

    if args.pretty:
        json.dump(output, sys.stdout, indent=2, ensure_ascii=False)
    else:
        json.dump(output, sys.stdout, separators=(",", ":"), ensure_ascii=False)
    sys.stdout.write("\n")
    return 0


def main() -> int:
    try:
        return run(sys.argv[1:])
    except AppError as exc:
        print(
            json.dumps({"error": exc.code, "message": exc.message}, ensure_ascii=False),
            file=sys.stderr,
        )
        return exc.exit_code


if __name__ == "__main__":
    raise SystemExit(main())


path: tests/test_fetch_afad_earthquakes.py
content: from __future__ import annotations

import importlib.util
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest


SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "fetch_afad_earthquakes.py"
FIXTURE_PATH = Path(__file__).resolve().parents[1] / "fixtures" / "afad_sample_response.json"


def load_script_module():
    spec = importlib.util.spec_from_file_location("fetch_afad_earthquakes", SCRIPT_PATH)
    if spec is None or spec.loader is None:
        raise RuntimeError("Unable to load script module")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def test_normalize_event_converts_types_and_time():
    mod = load_script_module()
    raw = {
        "eventID": "abc-1",
        "date": "2026-02-28T12:00:00",
        "m": "4.5",
        "depth": "8.2",
        "lat": "38.10",
        "lon": "39.20",
        "location": "PULUMUR (TUNCELI)",
    }

    out = mod.normalize_event(raw)

    assert out["id"] == "abc-1"
    assert out["time_utc"] == "2026-02-28T09:00:00Z"
    assert out["magnitude"] == 4.5
    assert out["depth_km"] == 8.2
    assert out["latitude"] == 38.10
    assert out["longitude"] == 39.20
    assert out["province"] == "TUNCELI"
    assert out["district"] == "PULUMUR"


def test_run_with_fixture_and_magnitude_filter(capsys):
    mod = load_script_module()

    exit_code = mod.run(
        [
            "--fixture",
            str(FIXTURE_PATH),
            "--minMag",
            "3.5",
            "--query",
            "pul",
        ]
    )
    captured = capsys.readouterr()

    assert exit_code == 0
    payload = json.loads(captured.out)
    assert payload["source"] == "AFAD"
    assert payload["count"] == 1
    assert payload["items"][0]["id"] == "202602280001"


def test_resolve_time_window_raises_input_error_for_zero_window():
    mod = load_script_module()

    with pytest.raises(mod.AppError) as exc:
        mod.resolve_time_window(hours=0, days=0)

    assert exc.value.code == "INPUT_ERROR"


def test_normalize_events_applies_time_window_query_and_magnitude():
    mod = load_script_module()
    raw_events = [
        {
            "eventID": "1",
            "date": "2026-02-28T12:00:00",
            "m": "4.2",
            "location": "PULUMUR (TUNCELI)",
        },
        {
            "eventID": "2",
            "date": "2026-02-27T08:00:00",
            "m": "4.7",
            "location": "GOKSUN (KAHRAMANMARAS)",
        },
    ]

    now_utc = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc)
    items = mod.normalize_events(
        raw_events,
        window=timedelta(hours=30),
        min_magnitude=4.0,
        query="pul",
        now_utc=now_utc,
    )

    assert len(items) == 1
    assert items[0]["id"] == "1"


path: tests/test_filters_and_date_parsing_minimal.py
content: from __future__ import annotations

import importlib.util
from pathlib import Path


SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "fetch_afad_earthquakes.py"
FIXTURE_NORMAL = (
    Path(__file__).resolve().parents[1] / "fixtures" / "afad_fixture_normal_10.json"
)


def load_script_module():
    spec = importlib.util.spec_from_file_location("fetch_afad_earthquakes", SCRIPT_PATH)
    if spec is None or spec.loader is None:
        raise RuntimeError("Unable to load script module")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def test_min_mag_filter_works():
    mod = load_script_module()
    raw_events = mod.load_fixture(str(FIXTURE_NORMAL))

    items = mod.normalize_events(
        raw_events,
        window=None,
        min_magnitude=4.0,
        query=None,
    )

    assert len(items) == 4
    assert all(item["magnitude"] >= 4.0 for item in items)


def test_query_filter_works():
    mod = load_script_module()
    raw_events = mod.load_fixture(str(FIXTURE_NORMAL))

    items = mod.normalize_events(
        raw_events,
        window=None,
        min_magnitude=None,
        query="izmir",
    )

    assert len(items) == 3
    assert all("izmir" in item["location"].casefold() for item in items)


def test_date_is_parsed():
    mod = load_script_module()

    dt = mod.parse_datetime("2026.02.28 12:00:00")

    assert dt is not None
    assert dt.isoformat() == "2026-02-28T09:00:00+00:00"


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