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 +21 -0
- package/README.md +145 -0
- package/bin/cli.js +52 -0
- package/package.json +46 -0
- package/src/config.js +39 -0
- package/src/index.js +26 -0
- package/src/kandilli/client.js +92 -0
- package/src/kandilli/parser.js +124 -0
- package/src/lib/errors.js +11 -0
- package/src/lib/filters.js +189 -0
- package/src/routes/depremler.js +96 -0
- package/src/server.js +80 -0
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
|
+
}
|