motion-depremapi 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 motion-depremapi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Deprem API
2
+
3
+ **Boğaziçi Üniversitesi Kandilli Rasathanesi (KOERI)** tarafından yayımlanan son depremleri JSON formatında sunan küçük bir REST API.
4
+
5
+ Veri kaynağı: [KOERI – Son Depremler](http://www.koeri.boun.edu.tr/scripts/lst5.asp) (Türkiye ve yakın çevresi, son ~500 kayıt).
6
+
7
+ > **Telif / kullanım:** Sayfada yayımlanan bilgi, veri ve haritalara ilişkin telif hakları Boğaziçi Üniversitesi Rektörlüğü'ne aittir. Kaynak gösterilerek kullanılabilir; **ticari kullanım** için üniversitenin yazılı izni gerekir.
8
+
9
+ ## Gereksinimler
10
+
11
+ - Node.js 18+ (fetch API yerleşiktir)
12
+
13
+ ## Kurulum ve Çalıştırma
14
+
15
+ ```bash
16
+ npm install
17
+ npm start # üretim
18
+ npm run dev # dosya değişikliklerinde otomatik yeniden başlatma
19
+ ```
20
+
21
+ Varsayılan adres: `http://localhost:3000`
22
+
23
+ ## Uç Noktalar
24
+
25
+ | Metot | Yol | Açıklama |
26
+ |---|---|---|
27
+ | `GET` | `/` | API künyesi ve örnekler |
28
+ | `GET` | `/api/depremler` | Filtrelenebilir deprem listesi |
29
+ | `GET` | `/api/depremler/ozet` | Liste özeti, en büyük deprem, büyüklük dağılımı |
30
+ | `GET` | `/api/saglik` | Servis ve kaynak durumu |
31
+
32
+ ## Filtre Parametreleri
33
+
34
+ Tümü isteğe bağlıdır ve birlikte kullanılabilir:
35
+
36
+ | Parametre | Açıklama | Örnek |
37
+ |---|---|---|
38
+ | `minMag` / `maxMag` | Büyüklük (ML/Mw/MD) aralığı | `minMag=4` |
39
+ | `minDepth` / `maxDepth` | Derinlik (km) aralığı | `maxDepth=30` |
40
+ | `minLat` / `maxLat` | Enlem aralığı | `minLat=38&maxLat=42` |
41
+ | `minLon` / `maxLon` | Boylam aralığı | `minLon=26&maxLon=32` |
42
+ | `q` | Yer adında arama (Türkçe karakter duyarsız) | `q=marmara`, `q=muğla` → `mugla` ile eşleşir |
43
+ | `start` / `end` | Tarih aralığı. Sadece `YYYY-MM-DD` verilirse TR günü kabul edilir | `start=2026-09-28&end=2026-09-29` |
44
+ | `limit` | Dönecek kayıt sayısı. **Verilmezse filtrede eşleşen hepsi döner** (en fazla `500`) | `limit=20` |
45
+ | `offset` | Atlanacak kayıt sayısı (sayfalama) | `offset=100` |
46
+ | `sort` | `date` \| `mag` \| `depth` (varsayılan `date`) | `sort=mag` |
47
+ | `order` | `asc` \| `desc` (varsayılan `desc`) | `order=asc` |
48
+ | `fresh=true` | Cache'i atlayıp kaynağı yeniden çeker (tek başına kullanılır) | `?fresh=true` |
49
+
50
+ Geçersiz bir parametre verilirse `400` ve Türkçe hata mesajı döner.
51
+
52
+ Yanıttaki `filters` nesnesi **yalnızca gönderdiğiniz** filtreleri içerir; kullanmadığınız parametreler `null` olarak geri yazılmaz.
53
+
54
+ ## Örnekler
55
+
56
+ ```bash
57
+ # Son 24 saatteki 4 ve üzeri depremler
58
+ curl "http://localhost:3000/api/depremler?minMag=4"
59
+
60
+ # Marmara'da 30 km'den sığ depremler
61
+ curl "http://localhost:3000/api/depremler?q=marmara&maxDepth=30"
62
+
63
+ # Belirli bir tarih aralığı, en büyükten en küçüğe
64
+ curl "http://localhost:3000/api/depremler?start=2026-09-28&end=2026-09-29&sort=mag"
65
+
66
+ # Harita için koordinat kutusu (İstanbul çevresi)
67
+ curl "http://localhost:3000/api/depremler?minLat=40.5&maxLat=41.5&minLon=28&maxLon=30"
68
+
69
+ # Özet
70
+ curl "http://localhost:3000/api/depremler/ozet"
71
+ ```
72
+
73
+ ## Yanıt Formatı
74
+
75
+ ```json
76
+ {
77
+ "source": "Boğaziçi Üniversitesi Kandilli Rasathanesi (KOERI)",
78
+ "updated": "2026-09-29T18:20:31.000Z",
79
+ "stale": false,
80
+ "total": 42,
81
+ "count": 20,
82
+ "limit": 20,
83
+ "offset": 0,
84
+ "filters": { "sort": "date", "order": "desc", "minMag": 4 },
85
+ "data": [
86
+ {
87
+ "id": "3a2845e66e5ae995",
88
+ "date": "2026.09.29",
89
+ "time": "20:38:52",
90
+ "datetime": "2026-09-29T17:38:52.000Z",
91
+ "timestamp": 1790703532,
92
+ "latitude": 38.0092,
93
+ "longitude": 37.7045,
94
+ "depthKm": 3.2,
95
+ "magnitude": 2.2,
96
+ "magnitudeType": "ML",
97
+ "ml": 2.2,
98
+ "location": "KUCUKLU-DOGANSEHIR (MALATYA)",
99
+ "region": "MALATYA",
100
+ "solutionType": "İlksel",
101
+ "revised": false
102
+ }
103
+ ]
104
+ }
105
+ ```
106
+
107
+ Alan notları:
108
+
109
+ - **Boş değeri olan alanlar hiç dönmez** (`null` yazılmaz). Örneğin kaynakta `-.-` olan `md` / `mw`, parantezsiz yerlerde `region`, revize edilmemiş satırlarda `revisedAt` yanıtta görünmez.
110
+ - `date` / `time` kaynaktaki **Türkiye saati (UTC+03:00)** değeridir. `datetime` ISO 8601 (UTC), `timestamp` ise Unix saniyesi olarak döner.
111
+ - `magnitude`, kaynaktaki **ML** varsa ML; yoksa Mw, o da yoksa MD'dir. Ham değerler `md` / `ml` / `mw` alanlarında ayrıca durur.
112
+ - `region`, yer adının sondaki `(İL)` parantezinden çıkarılır (`"KUCUKLU-DOGANSEHIR (MALATYA)"` → `"MALATYA"`); parantezsiz yerlerde (ör. `"AKDENIZ"`) alan hiç dönmez.
113
+ - `solutionType` Kandilli'nin çözüm niteliğidir (ör. `İlksel`, `REVIZE01`).
114
+ - `revised` satırın revize edildiğini; `revisedAt` revize damgasını belirtir.
115
+
116
+ ## Yapılandırma (ortam değişkenleri)
117
+
118
+ | Değişken | Varsayılan | Açıklama |
119
+ |---|---|---|
120
+ | `PORT` | `3000` | Dinlenen port |
121
+ | `CACHE_TTL_MS` | `60000` | Kaynağın kaç ms'de bir çekileceği |
122
+ | `RETRY_TTL_MS` | `20000` | Kaynak hata verirse tekrar deneme aralığı |
123
+ | `KANDILLI_URL` | KOERI `lst5.asp` | Veri kaynağı adresi |
124
+ | `REQUEST_TIMEOUT_MS` | `15000` | Kaynağa istek zaman aşımı |
125
+
126
+ ## Mimari
127
+
128
+ ```
129
+ src/
130
+ index.js Sunucu başlatma
131
+ server.js Express uygulaması, CORS, hata yakalayıcı
132
+ config.js Ortam değişkenleri
133
+ kandilli/
134
+ client.js Kaynak isteği + bellek içi cache + stale fallback
135
+ parser.js KOERI sabit genişlikli tablosunun ayrıştırılması
136
+ routes/
137
+ depremler.js /api/depremler uçları
138
+ lib/
139
+ filters.js Filtreleme, sıralama, sayfalama, Türkçe normalize
140
+ errors.js HttpError yardımcıları
141
+ ```
142
+
143
+ - Kaynak sayfa her istekte çekilmez; `CACHE_TTL_MS` boyunca cache'den cevap verilir.
144
+ - Kaynak erişilemezse en son başarılı veri `stale: true` işaretiyle döndürülür.
145
+ - Kaynak HTML'i `windows-1254` (Türkçe) kodlamasıyla çözümlenir.
package/bin/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * motion-depremapi CLI giriş noktası.
4
+ *
5
+ * motion-depremapi # 3000 portunda başlat
6
+ * motion-depremapi --port 8080 # farklı port
7
+ * motion-depremapi --help # kullanım
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const pkg = JSON.parse(
15
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
16
+ );
17
+
18
+ const argv = process.argv.slice(2);
19
+
20
+ if (argv.includes("--help") || argv.includes("-h")) {
21
+ console.log(`${pkg.name} v${pkg.version}
22
+ ${pkg.description}
23
+
24
+ Kullanım:
25
+ ${pkg.name} [seçenekler]
26
+
27
+ Seçenekler:
28
+ --port <n> Dinlenecek port (varsayılan: PORT ortam değişkeni veya 3000)
29
+ -v, --version Sürümü yazdır
30
+ -h, --help Bu yardımı yazdır
31
+
32
+ Ortam değişkenleri:
33
+ PORT Dinlenecek port
34
+ CACHE_TTL_MS Kaynağın kaç ms'de bir çekileceği (varsayılan 60000)
35
+ KANDILLI_URL Veri kaynağı adresi
36
+
37
+ Örnekler:
38
+ ${pkg.name}
39
+ ${pkg.name} --port 8080
40
+ curl "http://localhost:3000/api/depremler?minMag=4"
41
+
42
+ Veri kaynağı: Boğaziçi Üniversitesi Kandilli Rasathanesi (KOERI).
43
+ Ticari kullanım için Boğaziçi Üniversitesi Rektörlüğü'nden izin gerekir.`);
44
+ process.exit(0);
45
+ }
46
+
47
+ if (argv.includes("--version") || argv.includes("-v")) {
48
+ console.log(pkg.version);
49
+ process.exit(0);
50
+ }
51
+
52
+ await import("../src/index.js");
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "motion-depremapi",
3
+ "version": "1.0.0",
4
+ "description": "Kandilli Rasathanesi (KOERI) son depremlerini filtreleyerek JSON sunan hazır REST API",
5
+ "type": "module",
6
+ "main": "src/server.js",
7
+ "exports": {
8
+ ".": "./src/server.js"
9
+ },
10
+ "bin": {
11
+ "motion-depremapi": "bin/cli.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "LICENSE",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "start": "node src/index.js",
21
+ "dev": "node --watch src/index.js",
22
+ "prepublishOnly": "node bin/cli.js --version"
23
+ },
24
+ "keywords": [
25
+ "deprem",
26
+ "earthquake",
27
+ "earthquakes",
28
+ "kandilli",
29
+ "koeri",
30
+ "bogazici",
31
+ "turkey",
32
+ "rest-api",
33
+ "express",
34
+ "seismic"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "express": "^5.1.0"
45
+ }
46
+ }
package/src/config.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Uygulama yapılandırması.
3
+ * Öncelik: komut satırı argümanı > ortam değişkeni > varsayılan.
4
+ */
5
+
6
+ /** `--anahtar değer` veya `--anahtar=value` biçimini okur. */
7
+ function readArg(name) {
8
+ const argv = process.argv.slice(2);
9
+ const index = argv.indexOf(`--${name}`);
10
+ if (index !== -1 && argv[index + 1] && !argv[index + 1].startsWith("--")) {
11
+ return argv[index + 1];
12
+ }
13
+ const inline = argv.find((a) => a.startsWith(`--${name}=`));
14
+ if (inline) return inline.slice(name.length + 3);
15
+ return null;
16
+ }
17
+
18
+ /** Komut satırı bayrağı > ortam değişkeni > varsayılan sırasıyla okur. */
19
+ const read = (flag, envName, fallback) =>
20
+ readArg(flag) ?? process.env[envName] ?? fallback;
21
+
22
+ export const config = {
23
+ /** API'nin dinleyeceği port */
24
+ port: Number(read("port", "PORT", 3000)),
25
+
26
+ /** Kandilli sayfasının en fazla kaç ms'de bir çekileceği (cache süresi) */
27
+ cacheTtlMs: Number(read("cache-ttl-ms", "CACHE_TTL_MS", 60_000)),
28
+
29
+ /** Cache dolduktan sonra tekrar deneme aralığı */
30
+ retryTtlMs: Number(process.env.RETRY_TTL_MS || 20_000),
31
+
32
+ /** Kaynak sayfa adresi (KOERI "son depremler" listesi) */
33
+ sourceUrl:
34
+ process.env.KANDILLI_URL ||
35
+ "http://www.koeri.boun.edu.tr/scripts/lst5.asp",
36
+
37
+ /** Kaynağa yapılacak isteğin zaman aşımı */
38
+ requestTimeoutMs: Number(process.env.REQUEST_TIMEOUT_MS || 15_000),
39
+ };
package/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { createApp } from "./server.js";
2
+ import { config } from "./config.js";
3
+ import { getEvents } from "./kandilli/client.js";
4
+
5
+ const app = createApp();
6
+
7
+ const server = app.listen(config.port, () => {
8
+ console.log(`✔ Deprem API çalışıyor: http://localhost:${config.port}`);
9
+ console.log(` Örnek: http://localhost:${config.port}/api/depremler?minMag=3&limit=10`);
10
+
11
+ // İlk veriyi arka planda çek ki ilk istek beklemesin.
12
+ getEvents()
13
+ .then((s) =>
14
+ console.log(` Kaynak yüklendi: ${s.events.length} kayıt (KOERI)`),
15
+ )
16
+ .catch((err) => console.warn(` Kaynak ilk denemede alınamadı: ${err.message}`));
17
+ });
18
+
19
+ function shutdown(signal) {
20
+ console.log(`\n${signal} alındı, kapatılıyor...`);
21
+ server.close(() => process.exit(0));
22
+ setTimeout(() => process.exit(0), 3000).unref();
23
+ }
24
+
25
+ process.on("SIGINT", () => shutdown("SIGINT"));
26
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Kandilli Rasathanesi kaynağından veri çeken ve sonucu bellekte cache'leyen istemci.
3
+ *
4
+ * KOERI'ye her istekte tekrar gitmemek için TTL tabanlı bir cache kullanılır.
5
+ * Kaynak geçici olarak erişilemezse en son başarılı veri "stale" olarak döndürülür.
6
+ */
7
+
8
+ import { config } from "../config.js";
9
+ import { parseKandilliPage } from "./parser.js";
10
+ import { HttpError } from "../lib/errors.js";
11
+
12
+ const state = {
13
+ events: [],
14
+ fetchedAt: null,
15
+ expiresAt: 0,
16
+ error: null,
17
+ sourceUrl: config.sourceUrl,
18
+ };
19
+
20
+ /** Kaynak sayfayı indirir ve metne çevirir (windows-1254). */
21
+ async function fetchSource() {
22
+ const response = await fetch(config.sourceUrl, {
23
+ headers: {
24
+ "User-Agent":
25
+ "deprem-api/1.0 (Kandilli Rasathanesi verisi; kisisel/egitim amacli)",
26
+ Accept: "text/html,application/xhtml+xml",
27
+ },
28
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
29
+ });
30
+
31
+ if (!response.ok) {
32
+ throw new Error(`Kaynak HTTP ${response.status} döndürdü`);
33
+ }
34
+
35
+ const buffer = Buffer.from(await response.arrayBuffer());
36
+ // KOERI sayfası windows-1254 kodlamalıdır; desteklenmiyorsa latin1'e düşülür.
37
+ try {
38
+ return new TextDecoder("windows-1254").decode(buffer);
39
+ } catch {
40
+ return new TextDecoder("latin1").decode(buffer);
41
+ }
42
+ }
43
+
44
+ function snapshot() {
45
+ return {
46
+ events: state.events,
47
+ fetchedAt: state.fetchedAt,
48
+ expiresAt: state.expiresAt,
49
+ stale: state.error !== null,
50
+ error: state.error,
51
+ sourceUrl: state.sourceUrl,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Güncel deprem listesini döndürür.
57
+ * @param {{ force?: boolean }} [options] force=true ile cache'i yok sayar.
58
+ */
59
+ export async function getEvents({ force = false } = {}) {
60
+ const now = Date.now();
61
+
62
+ if (!force && state.fetchedAt && now < state.expiresAt) {
63
+ return snapshot();
64
+ }
65
+
66
+ try {
67
+ const html = await fetchSource();
68
+ const events = parseKandilliPage(html);
69
+
70
+ if (events.length === 0) {
71
+ throw new Error("Kaynak sayfadan hiç deprem kaydı çözümlenemedi");
72
+ }
73
+
74
+ state.events = events;
75
+ state.fetchedAt = new Date().toISOString();
76
+ state.expiresAt = now + config.cacheTtlMs;
77
+ state.error = null;
78
+ } catch (error) {
79
+ // Kaynak geçici olarak yoksa eldeki veriyi koru, kısa süre sonra tekrar dene.
80
+ state.error = error.message;
81
+ state.expiresAt = now + config.retryTtlMs;
82
+ // Hiç kayıt yoksa (ilk istekte kaynak yoksa) 503 döndür.
83
+ if (state.events.length === 0) {
84
+ throw new HttpError(
85
+ 503,
86
+ `Kandilli kaynağına şu anda ulaşılamıyor: ${error.message}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ return snapshot();
92
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * KOERI (Kandilli Rasathanesi) "Son Depremler" sayfasının ayrıştırılması.
3
+ *
4
+ * Sayfa içindeki <pre> bloğunda sabit genişlikli bir tablo bulunur:
5
+ *
6
+ * Tarih Saat Enlem(N) Boylam(E) Derinlik(km) MD ML Mw Yer Çözüm Niteliği
7
+ * ---------- -------- -------- ------- ---------- ------------ -------------- --------------
8
+ * 2026.09.29 20:38:52 38.0092 37.7045 3.2 -.- 2.2 -.- KUCUKLU-DOGANSEHIR (MALATYA) İlksel
9
+ *
10
+ * Kaynak sayfa windows-1254 (Türkçe) kodlamalıdır; çözümleme istemci tarafında yapılır.
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ /** Satırın bir veri satırı olup olmadığını anlayan başlangıç kalıbı. */
16
+ const ROW_START = /^(\d{4}\.\d{2}\.\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.*)$/;
17
+
18
+ /** Enlem, boylam, derinlik ve üç büyüklük değerini sırayla yakalar. */
19
+ const NUMBERS =
20
+ /^\s*(-?\d+(?:\.\d+)?|-\.-)\s+(-?\d+(?:\.\d+)?|-\.-)\s+(-?\d+(?:\.\d+)?|-\.-)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/;
21
+
22
+ /** "-.-" gibi eksik değerleri null'a çevirir. */
23
+ const toNumber = (value) => {
24
+ if (!value || value === "-.-" || value === "..." || value === "-") return null;
25
+ const parsed = Number(value);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ };
28
+
29
+ /** Yer adının sondaki "(İL)" parantezinden bölge/il bilgisini çıkarır. */
30
+ const extractRegion = (location) => {
31
+ const match = /\(([^()]+)\)\s*$/.exec(location);
32
+ return match ? match[1].trim() : null;
33
+ };
34
+
35
+ /**
36
+ * Değeri olmayan (null/undefined) alanları çıktıdan çıkarır.
37
+ * Kaynakta "-.-" görülen büyüklükler gibi boş veriler `null` olarak
38
+ * değil, hiç dönmez.
39
+ */
40
+ const compact = (record) =>
41
+ Object.fromEntries(
42
+ Object.entries(record).filter(([, value]) => value !== null && value !== undefined),
43
+ );
44
+
45
+ /** Tek bir tablo satırını olay (event) nesnesine çevirir. */
46
+ function parseRow(line) {
47
+ const head = ROW_START.exec(line);
48
+ if (!head) return null;
49
+
50
+ const [, date, time, rest] = head;
51
+ const body = NUMBERS.exec(rest);
52
+ if (!body) return null;
53
+
54
+ const latitude = toNumber(body[1]);
55
+ const longitude = toNumber(body[2]);
56
+ const depthKm = toNumber(body[3]);
57
+ const md = toNumber(body[4]);
58
+ const ml = toNumber(body[5]);
59
+ const mw = toNumber(body[6]);
60
+
61
+ // Geçerli bir koordinat yoksa satırı atla.
62
+ if (latitude === null || longitude === null) return null;
63
+
64
+ // Yer adı ile çözüm niteliği arasında en az iki boşluk vardır.
65
+ let tail = body[7].trim();
66
+
67
+ // Revize edilen satırların sonunda "REVIZE01 (2026.09.24 10:40:55)" gibi
68
+ // bir damga bulunur; önce onu ayırırız.
69
+ let revisedAt = null;
70
+ const stamp = /\s{2,}\((\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})\)\s*$/.exec(tail);
71
+ if (stamp) {
72
+ revisedAt = stamp[1];
73
+ tail = tail.slice(0, stamp.index).trim();
74
+ }
75
+
76
+ const split = /^(.*?)\s{2,}(\S+)$/.exec(tail);
77
+ const location = (split ? split[1] : tail).trim();
78
+ const solutionType = split ? split[2] : null;
79
+
80
+ // Kandilli zamanları Türkiye saat dilimindedir (UTC+03:00, yaz saati uygulanmaz).
81
+ const iso = `${date.replace(/\./g, "-")}T${time}+03:00`;
82
+ const dateObj = new Date(iso);
83
+
84
+ // Kullanılan büyüklük: tercihen ML, yoksa Mw, yoksa MD.
85
+ const magnitude = ml ?? mw ?? md;
86
+
87
+ return compact({
88
+ id: createHash("sha1").update(line).digest("hex").slice(0, 16),
89
+ date,
90
+ time,
91
+ datetime: dateObj.toISOString(),
92
+ timestamp: Math.floor(dateObj.getTime() / 1000),
93
+ latitude,
94
+ longitude,
95
+ depthKm,
96
+ magnitude,
97
+ magnitudeType: ml !== null ? "ML" : mw !== null ? "Mw" : md !== null ? "MD" : null,
98
+ md,
99
+ ml,
100
+ mw,
101
+ location,
102
+ region: extractRegion(location),
103
+ solutionType,
104
+ revised: revisedAt !== null,
105
+ revisedAt,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * KOERI sayfa kaynağından tüm deprem kayıtlarını çıkarır.
111
+ * @param {string} html Kaynak sayfanın metni (windows-1254 çözümlenmiş).
112
+ * @returns {Array<object>} En yeniden en eskiye sıralı olay listesi.
113
+ */
114
+ export function parseKandilliPage(html) {
115
+ const events = [];
116
+
117
+ for (const rawLine of String(html).split(/\r?\n/)) {
118
+ // trimEnd: sondaki satır sonu ve olası kırık HTML artıkları temizlenir.
119
+ const event = parseRow(rawLine.trimEnd());
120
+ if (event) events.push(event);
121
+ }
122
+
123
+ return events;
124
+ }
@@ -0,0 +1,11 @@
1
+ /** HTTP durum kodu taşıyan hata sınıfı. */
2
+ export class HttpError extends Error {
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.name = "HttpError";
6
+ this.status = status;
7
+ }
8
+ }
9
+
10
+ /** 400 - geçersiz istek parametresi */
11
+ export const badRequest = (message) => new HttpError(400, message);
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Deprem listesi üzerinde filtreleme, sıralama ve sayfalama.
3
+ * Tüm sorgu parametreleri isteğe bağlıdır ve doğrulama hatalarında 400 döner.
4
+ */
5
+
6
+ import { badRequest } from "./errors.js";
7
+
8
+ const MAX_LIMIT = 500;
9
+
10
+ /** Türkçe karakterleri ASCII'ye indirger; "MUĞLA" ile "mugla" eşleşsin diye. */
11
+ const CHAR_MAP = {
12
+ I: "i", İ: "i", ı: "i",
13
+ Ş: "s", ş: "s",
14
+ Ğ: "g", ğ: "g",
15
+ Ü: "u", ü: "u",
16
+ Ö: "o", ö: "o",
17
+ Ç: "c", ç: "c",
18
+ Â: "a", â: "a",
19
+ Î: "i", î: "i",
20
+ Û: "u", û: "u",
21
+ };
22
+
23
+ export function normalizeText(value) {
24
+ return String(value ?? "")
25
+ .replace(/[IİıŞşĞğÜüÖöÇçÂâÎîÛû]/g, (ch) => CHAR_MAP[ch])
26
+ .toLowerCase()
27
+ .normalize("NFD")
28
+ .replace(/[\u0300-\u036f]/g, "");
29
+ }
30
+
31
+ /** Sorgu değerini sayıya çevirir; geçersizse 400 fırlatır. */
32
+ function readNumber(query, key) {
33
+ const raw = query[key];
34
+ if (raw === undefined || raw === "") return null;
35
+ const value = Number(raw);
36
+ if (!Number.isFinite(value)) {
37
+ throw badRequest(`"${key}" geçerli bir sayı olmalı. Gelen değer: ${raw}`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Tarih parametresini Date'e çevirir.
44
+ * Sadece "YYYY-MM-DD" verildiğinde TR saat dilimine göre gün başı/sonu kabul edilir.
45
+ */
46
+ function readDate(query, key, endOfDay) {
47
+ const raw = query[key];
48
+ if (raw === undefined || raw === "") return null;
49
+
50
+ let value = String(raw).trim();
51
+
52
+ // Query string'de "+" işareti boşluk olarak çözüldüğü için
53
+ // "2026-09-28T12:00:00+03:00" geleni "… 03:00" olur; offset'i geri kazan.
54
+ value = value.replace(
55
+ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s+(\d{2}:\d{2})$/,
56
+ "$1+$2",
57
+ );
58
+
59
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
60
+ value = `${value}T${endOfDay ? "23:59:59" : "00:00:00"}+03:00`;
61
+ }
62
+
63
+ const parsed = new Date(value);
64
+ if (Number.isNaN(parsed.getTime())) {
65
+ throw badRequest(
66
+ `"${key}" geçerli bir tarih olmalı (YYYY-MM-DD veya ISO 8601). Gelen değer: ${raw}`,
67
+ );
68
+ }
69
+ return parsed;
70
+ }
71
+
72
+ function readList(query, key, allowed) {
73
+ const raw = query[key];
74
+ if (raw === undefined || raw === "") return null;
75
+ if (!allowed.includes(raw)) {
76
+ throw badRequest(`"${key}" şunlardan biri olmalı: ${allowed.join(", ")}`);
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ const rangeFilter = (items, field, min, max) => {
82
+ if (min !== null) items.push((e) => (e[field] ?? -Infinity) >= min);
83
+ if (max !== null) items.push((e) => (e[field] ?? Infinity) <= max);
84
+ };
85
+
86
+ /**
87
+ * @param {Array<object>} events Ham kayıtlar
88
+ * @param {object} query Express query nesnesi
89
+ * @returns {{ items: Array, total: number, limit: number, offset: number, applied: object }}
90
+ */
91
+ export function applyFilters(events, query) {
92
+ const minMag = readNumber(query, "minMag");
93
+ const maxMag = readNumber(query, "maxMag");
94
+ const minDepth = readNumber(query, "minDepth");
95
+ const maxDepth = readNumber(query, "maxDepth");
96
+ const minLat = readNumber(query, "minLat");
97
+ const maxLat = readNumber(query, "maxLat");
98
+ const minLon = readNumber(query, "minLon");
99
+ const maxLon = readNumber(query, "maxLon");
100
+
101
+ const start = readDate(query, "start", false);
102
+ const end = readDate(query, "end", true);
103
+ const search = query.q ? normalizeText(query.q) : null;
104
+
105
+ const sort = readList(query, "sort", ["date", "mag", "depth"]) ?? "date";
106
+ const order = readList(query, "order", ["asc", "desc"]) ?? "desc";
107
+
108
+ if (start && end && start > end) {
109
+ throw badRequest('"start", "end" tarihinden sonra olamaz');
110
+ }
111
+
112
+ const limitRaw = readNumber(query, "limit");
113
+ const offsetRaw = readNumber(query, "offset");
114
+
115
+ // "limit" verilmezse filtrede eşleşen TÜM kayıtlar döndürülür.
116
+ const limit =
117
+ limitRaw === null
118
+ ? null
119
+ : Math.min(Math.max(Math.trunc(limitRaw), 1), MAX_LIMIT);
120
+ const offset = Math.max(Math.trunc(offsetRaw ?? 0), 0);
121
+
122
+ // --- Filtre zinciri ---
123
+ const predicates = [];
124
+
125
+ if (search) {
126
+ predicates.push((e) => normalizeText(e.location).includes(search));
127
+ }
128
+ if (start) predicates.push((e) => e.timestamp * 1000 >= start.getTime());
129
+ if (end) predicates.push((e) => e.timestamp * 1000 <= end.getTime());
130
+
131
+ rangeFilter(predicates, "magnitude", minMag, maxMag);
132
+ rangeFilter(predicates, "depthKm", minDepth, maxDepth);
133
+ rangeFilter(predicates, "latitude", minLat, maxLat);
134
+ rangeFilter(predicates, "longitude", minLon, maxLon);
135
+
136
+ const filtered = predicates.length
137
+ ? events.filter((event) => predicates.every((test) => test(event)))
138
+ : events.slice();
139
+
140
+ // --- Sıralama ---
141
+ const getter =
142
+ sort === "mag"
143
+ ? (e) => e.magnitude ?? -Infinity
144
+ : sort === "depth"
145
+ ? (e) => e.depthKm ?? Infinity
146
+ : (e) => e.timestamp;
147
+
148
+ const direction = order === "asc" ? 1 : -1;
149
+ filtered.sort((a, b) => {
150
+ const va = getter(a);
151
+ const vb = getter(b);
152
+ return va === vb ? 0 : va > vb ? direction : -direction;
153
+ });
154
+
155
+ const total = filtered.length;
156
+ const items =
157
+ limit === null ? filtered.slice(offset) : filtered.slice(offset, offset + limit);
158
+
159
+ // Sadece kullanıcı tarafından gerçekten gönderilen filtreler döndürülür;
160
+ // kullanılmayan parametreler null olarak geri yazılmaz.
161
+ const applied = { sort, order };
162
+ const put = (key, value) => {
163
+ if (value !== null) applied[key] = value;
164
+ };
165
+
166
+ put("minMag", minMag);
167
+ put("maxMag", maxMag);
168
+ put("minDepth", minDepth);
169
+ put("maxDepth", maxDepth);
170
+ put("minLat", minLat);
171
+ put("maxLat", maxLat);
172
+ put("minLon", minLon);
173
+ put("maxLon", maxLon);
174
+ // Tarih filtreleri, kullanıcı ne yazdıysa aynen geri yazılır (UTC'ye çevrilmez).
175
+ const echoDate = (raw, parsed) =>
176
+ !parsed ? null : /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : parsed.toISOString();
177
+
178
+ put("start", echoDate(query.start ?? "", start));
179
+ put("end", echoDate(query.end ?? "", end));
180
+ put("q", query.q ?? null);
181
+
182
+ return {
183
+ items,
184
+ total,
185
+ limit,
186
+ offset,
187
+ applied,
188
+ };
189
+ }
@@ -0,0 +1,96 @@
1
+ import { Router } from "express";
2
+ import { getEvents } from "../kandilli/client.js";
3
+ import { applyFilters } from "../lib/filters.js";
4
+ import { badRequest } from "../lib/errors.js";
5
+
6
+ export const depremlerRouter = Router();
7
+
8
+ /**
9
+ * GET /api/depremler
10
+ *
11
+ * Kandilli Rasathanesi'ndeki son depremleri filtreleyerek listeler.
12
+ *
13
+ * Query parametreleri (hepsi isteğe bağlı):
14
+ * minMag, maxMag büyüklük aralığı
15
+ * minDepth, maxDepth derinlik (km) aralığı
16
+ * minLat, maxLat enlem aralığı
17
+ * minLon, maxLon boylam aralığı
18
+ * q yer adında arama (türkçe karakter duyarsız)
19
+ * start, end tarih aralığı (YYYY-MM-DD veya ISO 8601)
20
+ * limit kaç kayıt döneceği; verilmezse eşleşen HEPSİ (en fazla 500)
21
+ * offset atlanacak kayıt sayısı
22
+ * sort date | mag | depth (varsayılan date)
23
+ * order asc | desc (varsayılan desc)
24
+ * fresh=true cache'i atlayıp kaynağı yeniden çeker
25
+ */
26
+ depremlerRouter.get("/depremler", async (req, res) => {
27
+ const force = req.query.fresh === "true";
28
+
29
+ if (force && Object.keys(req.query).some((k) => k !== "fresh")) {
30
+ throw badRequest('"fresh=true" diğer filtrelerle birlikte kullanılamaz');
31
+ }
32
+
33
+ const snapshot = await getEvents({ force });
34
+ const { items, total, limit, offset, applied } = applyFilters(
35
+ snapshot.events,
36
+ req.query,
37
+ );
38
+
39
+ const result = {
40
+ source: "Boğaziçi Üniversitesi Kandilli Rasathanesi (KOERI)",
41
+ sourceUrl: snapshot.sourceUrl,
42
+ attribution:
43
+ "Veri kaynağı: KOERI-Bölgesel Deprem-Tsunami İzleme ve Değerlendirme Merkezi. " +
44
+ "Ticari kullanım için Boğaziçi Üniversitesi Rektörlüğü'nden izin alınmalıdır.",
45
+ updated: snapshot.fetchedAt,
46
+ stale: snapshot.stale,
47
+ total,
48
+ count: items.length,
49
+ };
50
+
51
+ // "limit" verilmediyse alan hiç görünmez (hepsi gönderilmiş demektir).
52
+ if (limit !== null) result.limit = limit;
53
+
54
+ Object.assign(result, {
55
+ offset,
56
+ filters: applied,
57
+ data: items,
58
+ });
59
+
60
+ // Kaynak erişilemiyorsa uyarı eklenir; sorun yoksa alan hiç görünmez.
61
+ if (snapshot.stale) {
62
+ result.warning = `Kaynak şu anda erişilemiyor, en son başarılı veri gösteriliyor: ${snapshot.error}`;
63
+ }
64
+
65
+ res.json(result);
66
+ });
67
+
68
+ /**
69
+ * GET /api/depremler/ozet
70
+ * Filtre uygulanmamış; liste künyesi ve büyüklük dağılımı.
71
+ */
72
+ depremlerRouter.get("/depremler/ozet", async (req, res) => {
73
+ const snapshot = await getEvents();
74
+ const events = snapshot.events;
75
+
76
+ const magnitudeBuckets = { "0-1": 0, "1-2": 0, "2-3": 0, "3-4": 0, "4+": 0 };
77
+ for (const e of events) {
78
+ const m = e.magnitude ?? 0;
79
+ if (m >= 4) magnitudeBuckets["4+"] += 1;
80
+ else magnitudeBuckets[`${Math.floor(m)}-${Math.floor(m) + 1}`] += 1;
81
+ }
82
+
83
+ res.json({
84
+ source: "Boğaziçi Üniversitesi Kandilli Rasathanesi (KOERI)",
85
+ updated: snapshot.fetchedAt,
86
+ stale: snapshot.stale,
87
+ count: events.length,
88
+ newest: events[0] ?? null,
89
+ oldest: events.at(-1) ?? null,
90
+ strongest: events.reduce(
91
+ (max, e) => (!max || (e.magnitude ?? -1) > (max.magnitude ?? -1) ? e : max),
92
+ null,
93
+ ),
94
+ magnitudeBuckets,
95
+ });
96
+ });
package/src/server.js ADDED
@@ -0,0 +1,80 @@
1
+ import express from "express";
2
+ import { depremlerRouter } from "./routes/depremler.js";
3
+ import { getEvents } from "./kandilli/client.js";
4
+ import { HttpError } from "./lib/errors.js";
5
+
6
+ export function createApp() {
7
+ const app = express();
8
+
9
+ app.disable("x-powered-by");
10
+ app.use(express.json());
11
+
12
+ // Tarayıcıdan (Leaflet vb.) erişilebilsin diye basit CORS.
13
+ app.use((req, res, next) => {
14
+ res.setHeader("Access-Control-Allow-Origin", "*");
15
+ res.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS");
16
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
17
+ if (req.method === "OPTIONS") return res.sendStatus(204);
18
+ next();
19
+ });
20
+
21
+ app.get("/", (_req, res) => {
22
+ res.json({
23
+ name: "Deprem API",
24
+ description:
25
+ "Kandilli Rasathanesi (KOERI) son depremlerini JSON olarak sunar.",
26
+ endpoints: {
27
+ "GET /api/depremler": "Filtrelenebilir deprem listesi",
28
+ "GET /api/depremler/ozet": "Liste özeti ve büyüklük dağılımı",
29
+ "GET /api/saglik": "Servis ve kaynak durumu",
30
+ },
31
+ examples: [
32
+ "/api/depremler?minMag=4&limit=20",
33
+ "/api/depremler?q=marmara&minDepth=0&maxDepth=30",
34
+ "/api/depremler?start=2026-09-28&end=2026-09-29&sort=mag&order=desc",
35
+ "/api/depremler?minLat=38&maxLat=42&minLon=26&maxLon=32",
36
+ ],
37
+ });
38
+ });
39
+
40
+ app.use("/api", depremlerRouter);
41
+
42
+ app.get("/api/saglik", async (_req, res) => {
43
+ try {
44
+ const snapshot = await getEvents();
45
+ res.json({
46
+ status: "ok",
47
+ source: snapshot.sourceUrl,
48
+ records: snapshot.events.length,
49
+ updated: snapshot.fetchedAt,
50
+ cacheExpiresAt: new Date(snapshot.expiresAt).toISOString(),
51
+ stale: snapshot.stale,
52
+ error: snapshot.error,
53
+ });
54
+ } catch (error) {
55
+ res.status(503).json({
56
+ status: "error",
57
+ message: "Kandilli kaynağına şu anda ulaşılamıyor",
58
+ detail: error.message,
59
+ });
60
+ }
61
+ });
62
+
63
+ app.use((_req, res) => {
64
+ res.status(404).json({ error: "Uç nokta bulunamadı", path: _req.originalUrl });
65
+ });
66
+
67
+ // Hata yakalayıcı — en sonda olmalı.
68
+ app.use((err, _req, res, _next) => {
69
+ if (err instanceof HttpError) {
70
+ return res.status(err.status).json({ error: err.message });
71
+ }
72
+ console.error("[hata]", err);
73
+ res.status(500).json({
74
+ error: "Sunucu hatası",
75
+ detail: err.message,
76
+ });
77
+ });
78
+
79
+ return app;
80
+ }