kuramanime-api 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 mmoaa
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,263 @@
1
+ # Kuramanime API
2
+
3
+ REST API untuk scraping data anime dari Kuramanime — portal streaming anime sub Indo.
4
+
5
+ ```bash
6
+ # Coba langsung via curl — no install!
7
+ curl https://musician-optics-mental-carey.trycloudflare.com/api/anime?page=1
8
+ ```
9
+
10
+ ## 🎬 Demo
11
+
12
+ https://github.com/user-attachments/assets/files/kuramanime-api/docs/demo.mp4
13
+
14
+ [▶️ **Tonton demo**](docs/demo.mp4) — 26 detik showcase semua endpoint live via curl.
15
+
16
+ ## ⚡ Quick Start
17
+
18
+ ### Opsi 1: NPM Global Install
19
+
20
+ ```bash
21
+ npm install -g kuramanime-api
22
+ kuramanime-api
23
+ # → http://localhost:3000
24
+ ```
25
+
26
+ ### Opsi 2: Self-host (clone repo)
27
+
28
+ ```bash
29
+ git clone https://github.com/mocasus/kuramanime-api.git
30
+ cd kuramanime-api
31
+ npm install
32
+ npm start
33
+ # → http://localhost:3000
34
+ ```
35
+
36
+ ### Opsi 3: Vercel Deploy 🚀
37
+
38
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mocasus/kuramanime-api)
39
+
40
+ 1-click deploy — gratis, auto-scale, langsung dapat endpoint publik. Set env `BROWSER_FARM_URL` + `BROWSER_FARM_KEY`.
41
+
42
+ ### Opsi 4: Pakai cURL (no install, langsung coba)
43
+
44
+ ```bash
45
+ # List anime terbaru
46
+ curl "https://musician-optics-mental-carey.trycloudflare.com/api/anime?page=1"
47
+
48
+ # Search anime
49
+ curl "https://musician-optics-mental-carey.trycloudflare.com/api/search?q=naruto"
50
+
51
+ # Detail anime
52
+ curl "https://musician-optics-mental-carey.trycloudflare.com/api/anime/fuguushoku-kanteishi-ga-jitsu-wa-saikyou-datta"
53
+
54
+ # Episode + server streaming
55
+ curl "https://musician-optics-mental-carey.trycloudflare.com/api/anime/fuguushoku-kanteishi-ga-jitsu-wa-saikyou-datta/1"
56
+
57
+ # Cek status source
58
+ curl "https://musician-optics-mental-carey.trycloudflare.com/api/status"
59
+ ```
60
+
61
+ > **Demo URL:** `musician-optics-mental-carey.trycloudflare.com` _(Cloudflare Tunnel — uptime tidak dijamin. Untuk production, self-host!)_
62
+
63
+
64
+ ### Opsi 5: Docker
65
+
66
+ ```bash
67
+ docker compose up -d
68
+ ```
69
+
70
+ ## Endpoints API
71
+
72
+ | Method | Endpoint | Deskripsi |
73
+ |--------|----------|-----------|
74
+ | `GET` | `/api/anime?page=1` | List anime terbaru (5/page) |
75
+ | `GET` | `/api/anime/:slug` | Detail anime + daftar episode |
76
+ | `GET` | `/api/anime/:slug/:episode` | Server streaming + video URL |
77
+ | `GET` | `/api/search?q=judul` | Search anime (min 2 karakter) |
78
+ | `GET` | `/api/status` | Cek status source (alive/down) |
79
+ | `GET` | `/api/sources` | List semua URL source aktif |
80
+ | `GET` | `/api/health` | Server health check |
81
+ | `POST` | `/api/sources/check` | Trigger manual source update |
82
+ | `POST` | `/api/cache/clear` | Clear semua cache |
83
+
84
+ ### Contoh Response
85
+
86
+ <details>
87
+ <summary><b>GET /api/anime?page=1</b></summary>
88
+
89
+ ```json
90
+ {
91
+ "success": true,
92
+ "page": 1,
93
+ "count": 5,
94
+ "data": [{
95
+ "id": 5000,
96
+ "slug": "super-no-ura-de-yani-suu-futari",
97
+ "title": "Super no Ura de Yani Suu Futari",
98
+ "image": "https://r2.nyomo.my.id/images/...",
99
+ "quality": "Loading...",
100
+ "score": "8.06"
101
+ }]
102
+ }
103
+ ```
104
+ </details>
105
+
106
+ <details>
107
+ <summary><b>GET /api/search?q=one</b></summary>
108
+
109
+ ```json
110
+ {
111
+ "success": true,
112
+ "query": "one",
113
+ "count": 18,
114
+ "data": [{
115
+ "id": 4508,
116
+ "slug": "omae-gotoki-ga-maou-ni-kateru-to-omouna...",
117
+ "title": "Omae Gotoki ga Maou ni Kateru to Omouna...",
118
+ "altTitles": "Winter 2026 • TV • HD"
119
+ }]
120
+ }
121
+ ```
122
+ </details>
123
+
124
+ <details>
125
+ <summary><b>GET /api/anime/:slug (detail)</b></summary>
126
+
127
+ ```json
128
+ {
129
+ "success": true,
130
+ "data": {
131
+ "id": 3380,
132
+ "title": "Fuguushoku \"Kanteishi\" ga Jitsu wa Saikyou Datta",
133
+ "synopsis": "Di dunia fantasi...",
134
+ "genres": ["Action", "Adventure", "Fantasy"],
135
+ "score": "6.34",
136
+ "metadata": {
137
+ "tipe": "TV",
138
+ "episode": "12",
139
+ "studio": "Okuruto Noboru"
140
+ },
141
+ "episodes": [
142
+ { "number": 1, "url": "..." }
143
+ ]
144
+ }
145
+ }
146
+ ```
147
+ </details>
148
+
149
+ <details>
150
+ <summary><b>GET /api/anime/:slug/:episode</b></summary>
151
+
152
+ ```json
153
+ {
154
+ "success": true,
155
+ "data": {
156
+ "episode": 1,
157
+ "servers": [
158
+ { "id": "kuramadrive", "label": "Kuramadrive s1" },
159
+ { "id": "mega", "label": "MEGA" }
160
+ ],
161
+ "selectedServer": "kuramadrive"
162
+ }
163
+ }
164
+ ```
165
+ </details>
166
+
167
+ ## Fitur
168
+
169
+ - **9 REST Endpoints** — list, detail, episode, search, status, sources, health, source check, cache clear
170
+ - **Auto-fallback** — source utama timeout/403 → auto coba alternatif (v17, v18, v19...)
171
+ - **Auto-update Source** — tiap 6 jam cek + rotate URL source secara otomatis
172
+ - **Quicksearch** — pakai AJAX endpoint untuk hasil search instan & akurat
173
+ - **SQLite Cache** — cache response per endpoint (30m–2h TTL)
174
+ - **Rate Limiting** — 60 req/menit general, 20 search, 30 episode
175
+ - **Browser Farm** — ekstrak streaming URL via headless Playwright
176
+ - **Docker Ready** — Dockerfile + docker-compose.yml
177
+
178
+ ## Konfigurasi
179
+
180
+ Copy `.env.example` → `.env`:
181
+
182
+ | Variable | Default | Deskripsi |
183
+ |----------|---------|-----------|
184
+ | `PORT` | `3000` | Port server |
185
+ | `CACHE_TTL_ANIME_LIST` | `1800` | Cache TTL list (detik) |
186
+ | `CACHE_TTL_ANIME_DETAIL` | `3600` | Cache TTL detail |
187
+ | `CACHE_TTL_EPISODE` | `7200` | Cache TTL episode |
188
+ | `BROWSER_FARM_URL` | - | URL Browser Farm service |
189
+ | `BROWSER_FARM_KEY` | - | API key Browser Farm |
190
+ | `SOURCE_AUTO_UPDATE_INTERVAL` | `6` | Interval cek source (jam) |
191
+
192
+ ## Auto-Fallback & Auto-Update
193
+
194
+ Sistem otomatis:
195
+ 1. Coba URL utama → alternatif → derived URLs (v17, v18, v19...)
196
+ 2. Tiap 6 jam: cek health semua URL
197
+ 3. Primary down → rotate ke alternatif yang hidup
198
+ 4. Update `data/sources.json` otomatis
199
+ 5. Semua log ke SQLite
200
+
201
+ Manual trigger: `POST /api/sources/check`
202
+
203
+ ## Cache Strategy
204
+
205
+ | Endpoint | TTL |
206
+ |----------|-----|
207
+ | Anime list | 30 menit |
208
+ | Anime detail | 1 jam |
209
+ | Episode stream | 2 jam |
210
+ | Source status | 5 menit |
211
+ | Search | 30 menit |
212
+
213
+ Clear: `POST /api/cache/clear`
214
+
215
+ ## Struktur Project
216
+
217
+ ```
218
+ kuramanime-api/
219
+ ├── src/
220
+ │ ├── index.js # Express app + middleware
221
+ │ ├── config/
222
+ │ │ ├── index.js # Env config
223
+ │ │ └── sources.js # URL source manager
224
+ │ ├── routes/
225
+ │ │ ├── anime.js # Anime endpoints
226
+ │ │ └── status.js # Status, sources, cache
227
+ │ ├── scrapers/
228
+ │ │ ├── cheerio.js # Static scraper (list, detail, search)
229
+ │ │ └── browser.js # Browser scraper (streaming URL)
230
+ │ ├── services/
231
+ │ │ ├── browserFarm.js # Browser Farm REST client
232
+ │ │ └── sourceUpdater.js # Auto source rotate
233
+ │ └── db/
234
+ │ └── index.js # SQLite (cache + source log)
235
+ ├── data/
236
+ │ └── sources.json # URL source (runtime)
237
+ ├── Dockerfile
238
+ ├── docker-compose.yml
239
+ └── README.md
240
+ ```
241
+
242
+ ## Streaming Servers
243
+
244
+ | Server ID | Deskripsi |
245
+ |-----------|-----------|
246
+ | `kuramadrive` | Default (normal, iklan banner) |
247
+ | `filelions` | FileLions (kencang, iklan popup) |
248
+ | `filemoon` | FileMoon (kencang, iklan popup) |
249
+ | `mega` | MEGA (kencang, iklan banner) |
250
+ | `streamwish` | StreamWish (kencang, iklan popup) |
251
+ | `vidguard` | VidGuard (kencang, iklan popup) |
252
+
253
+ ## Tech Stack
254
+
255
+ - **Node.js 20+** + **Express** — REST framework
256
+ - **Cheerio** — HTML parsing (list, detail, search)
257
+ - **Browser Farm** (Playwright) — dynamic JS scraping
258
+ - **better-sqlite3** — SQLite database
259
+ - **express-rate-limit** — rate limiting
260
+
261
+ ## License
262
+
263
+ MIT © 2026 mmoaa
package/api/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Vercel Serverless Entry Point
3
+ *
4
+ * Adaptasi Express app untuk Vercel serverless:
5
+ * - SQLite disabled (filesystem ephemeral)
6
+ * - node-cron disabled (no long-running process)
7
+ * - Rate limiting stays (per-instance, acceptable for low-traffic)
8
+ * - Source URLs loaded from data/sources.json (static)
9
+ */
10
+
11
+ // Override before requiring modules
12
+ process.env.VERCEL = '1';
13
+ process.env.NODE_ENV = 'production';
14
+ process.env.DISABLE_SQLITE = '1'; // Signal to db module
15
+
16
+ const app = require('../src/index');
17
+
18
+ // Vercel serverless export
19
+ module.exports = app;
package/bin/cli.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Kuramanime API CLI — one command to start the server.
4
+ * Usage:
5
+ * kuramanime-api → start server (port from PORT env or 3000)
6
+ * kuramanime-api --port 8080 → start on custom port
7
+ * kuramanime-api --help → show help
8
+ */
9
+
10
+ const { spawn } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+
14
+ const args = process.argv.slice(2);
15
+ const help = args.includes('--help') || args.includes('-h');
16
+
17
+ if (help) {
18
+ console.log(`
19
+ 📦 Kuramanime API v${require('../package.json').version}
20
+
21
+ kuramanime-api Start server on PORT env or 3000
22
+ kuramanime-api --port 8080 Start on custom port
23
+ kuramanime-api --help Show this help
24
+
25
+ Environment:
26
+ PORT=3000 Server port
27
+ BROWSER_FARM_URL Browser Farm endpoint
28
+ BROWSER_FARM_KEY Browser Farm API key
29
+
30
+ Deploy to Vercel:
31
+ Just push to GitHub and connect to Vercel — vercel.json is included.
32
+ `);
33
+ process.exit(0);
34
+ }
35
+
36
+ // Parse --port
37
+ let port = process.env.PORT || 3000;
38
+ for (let i = 0; i < args.length; i++) {
39
+ if (args[i] === '--port' && args[i + 1]) {
40
+ port = args[i + 1];
41
+ i++;
42
+ }
43
+ }
44
+
45
+ // Ensure data directory
46
+ const dataDir = path.join(__dirname, '..', 'data');
47
+ if (!fs.existsSync(dataDir)) {
48
+ fs.mkdirSync(dataDir, { recursive: true });
49
+ }
50
+
51
+ // Start the server (inherit stdio so logs show)
52
+ console.log(`🎬 Starting Kuramanime API on port ${port}...`);
53
+ const child = spawn('node', [path.join(__dirname, '..', 'src', 'index.js')], {
54
+ env: { ...process.env, PORT: port },
55
+ stdio: 'inherit',
56
+ });
57
+
58
+ child.on('close', (code) => {
59
+ process.exit(code);
60
+ });
61
+
62
+ process.on('SIGINT', () => child.kill());
63
+ process.on('SIGTERM', () => child.kill());
@@ -0,0 +1,10 @@
1
+ {
2
+ "primary": "https://v18.kuramanime.ing",
3
+ "alternatives": [
4
+ "https://v17.kuramanime.ing",
5
+ "https://v19.kuramanime.ing",
6
+ "https://kuramanime.ing"
7
+ ],
8
+ "lastUpdated": "2026-07-05T17:20:00.000Z",
9
+ "updatedBy": "init"
10
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "kuramanime-api",
3
+ "version": "1.0.0",
4
+ "description": "REST API for scraping anime data from Kuramanime \u2014 portal streaming anime sub Indo",
5
+ "keywords": [
6
+ "anime",
7
+ "kuramanime",
8
+ "sub-indo",
9
+ "scraper",
10
+ "rest-api",
11
+ "express"
12
+ ],
13
+ "homepage": "https://github.com/mocasus/kuramanime-api#readme",
14
+ "bugs": "https://github.com/mocasus/kuramanime-api/issues",
15
+ "license": "MIT",
16
+ "author": "mmoaa",
17
+ "main": "src/index.js",
18
+ "bin": {
19
+ "kuramanime-api": "bin/cli.js"
20
+ },
21
+ "files": [
22
+ "bin/",
23
+ "src/",
24
+ "api/",
25
+ "data/sources.json",
26
+ "vercel.json",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/mocasus/kuramanime-api.git"
33
+ },
34
+ "scripts": {
35
+ "start": "node src/index.js",
36
+ "dev": "node --watch src/index.js",
37
+ "vercel:dev": "vercel dev",
38
+ "docker:build": "docker build -t kuramanime-api .",
39
+ "docker:run": "docker run -p 3000:3000 -v $(pwd)/data:/app/data kuramanime-api"
40
+ },
41
+ "dependencies": {
42
+ "better-sqlite3": "^11.0.0",
43
+ "cheerio": "^1.0.0",
44
+ "cors": "^2.8.5",
45
+ "dotenv": "^16.4.5",
46
+ "express": "^4.21.0",
47
+ "express-rate-limit": "^7.4.0",
48
+ "node-cron": "^3.0.3",
49
+ "uuid": "^10.0.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ }
54
+ }
@@ -0,0 +1,19 @@
1
+ require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '.env') });
2
+
3
+ module.exports = {
4
+ port: parseInt(process.env.PORT || '3000', 10),
5
+ nodeEnv: process.env.NODE_ENV || 'development',
6
+
7
+ cache: {
8
+ animeList: parseInt(process.env.CACHE_TTL_ANIME_LIST || '1800', 10),
9
+ animeDetail: parseInt(process.env.CACHE_TTL_ANIME_DETAIL || '3600', 10),
10
+ episodeStream: parseInt(process.env.CACHE_TTL_EPISODE || '7200', 10),
11
+ },
12
+
13
+ browserFarm: {
14
+ baseUrl: process.env.BROWSER_FARM_URL || 'https://cool-flying-lifetime-dream.trycloudflare.com',
15
+ apiKey: process.env.BROWSER_FARM_KEY || 'fox-browser-farm-2026',
16
+ },
17
+
18
+ autoUpdateIntervalHours: parseInt(process.env.SOURCE_AUTO_UPDATE_INTERVAL || '6', 10),
19
+ };
@@ -0,0 +1,83 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const SOURCES_FILE = path.join(__dirname, '..', '..', 'data', 'sources.json');
5
+
6
+ const DEFAULT_SOURCES = {
7
+ primary: 'https://v18.kuramanime.ing',
8
+ alternatives: [
9
+ 'https://v17.kuramanime.ing',
10
+ 'https://v19.kuramanime.ing',
11
+ 'https://kuramanime.ing',
12
+ ],
13
+ lastUpdated: null,
14
+ updatedBy: null,
15
+ };
16
+
17
+ function loadSources() {
18
+ try {
19
+ const raw = fs.readFileSync(SOURCES_FILE, 'utf-8');
20
+ const parsed = JSON.parse(raw);
21
+ if (parsed.primary) return parsed;
22
+ } catch (_) { /* ignore, use defaults */ }
23
+ return { ...DEFAULT_SOURCES };
24
+ }
25
+
26
+ function saveSources(sources) {
27
+ fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true });
28
+ fs.writeFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
29
+ return sources;
30
+ }
31
+
32
+ function getAllUrls() {
33
+ const s = loadSources();
34
+ return [s.primary, ...s.alternatives].filter(Boolean);
35
+ }
36
+
37
+ function getPrimary() {
38
+ return loadSources().primary;
39
+ }
40
+
41
+ function getAll() {
42
+ return loadSources();
43
+ }
44
+
45
+ function setPrimary(url) {
46
+ const s = loadSources();
47
+ s.primary = url;
48
+ s.lastUpdated = new Date().toISOString();
49
+ s.updatedBy = 'auto-check';
50
+ return saveSources(s);
51
+ }
52
+
53
+ function addAlternative(url) {
54
+ const s = loadSources();
55
+ if (!s.alternatives.includes(url) && url !== s.primary) {
56
+ s.alternatives.push(url);
57
+ s.lastUpdated = new Date().toISOString();
58
+ s.updatedBy = 'auto-check';
59
+ return saveSources(s);
60
+ }
61
+ return s;
62
+ }
63
+
64
+ function validateUrl(url) {
65
+ try {
66
+ const u = new URL(url);
67
+ return u.protocol === 'https:' || u.protocol === 'http:';
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ module.exports = {
74
+ getAllUrls,
75
+ getPrimary,
76
+ getAll,
77
+ setPrimary,
78
+ addAlternative,
79
+ validateUrl,
80
+ loadSources,
81
+ saveSources,
82
+ DEFAULT_SOURCES,
83
+ };
@@ -0,0 +1,93 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ const DB_PATH = path.join(__dirname, '..', '..', 'data', 'kuramanime.db');
5
+ const isDisabled = !!process.env.DISABLE_SQLITE;
6
+
7
+ let db;
8
+
9
+ function getDb() {
10
+ if (isDisabled) return null;
11
+ if (!db) {
12
+ const Database = require('better-sqlite3');
13
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
14
+ db = new Database(DB_PATH);
15
+ db.pragma('journal_mode = WAL');
16
+ db.pragma('busy_timeout = 5000');
17
+ migrate(db);
18
+ }
19
+ return db;
20
+ }
21
+
22
+ function migrate(database) {
23
+ if (!database) return;
24
+ database.exec(`
25
+ CREATE TABLE IF NOT EXISTS cache (
26
+ key TEXT PRIMARY KEY,
27
+ value TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ ttl INTEGER NOT NULL
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_cache_created ON cache(created_at);
32
+ CREATE TABLE IF NOT EXISTS source_log (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ url TEXT NOT NULL,
35
+ status TEXT NOT NULL,
36
+ checked_at INTEGER NOT NULL
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_source_log_url ON source_log(url);
39
+ `);
40
+ }
41
+
42
+ function cacheGet(key) {
43
+ const d = getDb();
44
+ if (!d) return null;
45
+ const row = d.prepare('SELECT value, created_at, ttl FROM cache WHERE key = ?').get(key);
46
+ if (!row) return null;
47
+ if (Date.now() - row.created_at > row.ttl * 1000) {
48
+ d.prepare('DELETE FROM cache WHERE key = ?').run(key);
49
+ return null;
50
+ }
51
+ return JSON.parse(row.value);
52
+ }
53
+
54
+ function cacheSet(key, value, ttlSeconds = 1800) {
55
+ const d = getDb();
56
+ if (!d) return;
57
+ d.prepare(
58
+ 'INSERT OR REPLACE INTO cache (key, value, created_at, ttl) VALUES (?, ?, ?, ?)'
59
+ ).run(key, JSON.stringify(value), Date.now(), ttlSeconds);
60
+ }
61
+
62
+ function cacheClear(pattern) {
63
+ const d = getDb();
64
+ if (!d) return;
65
+ if (pattern) {
66
+ d.prepare('DELETE FROM cache WHERE key LIKE ?').run(`%${pattern}%`);
67
+ } else {
68
+ d.prepare('DELETE FROM cache').run();
69
+ }
70
+ }
71
+
72
+ function logSource(url, status) {
73
+ const d = getDb();
74
+ if (!d) return;
75
+ d.prepare('INSERT INTO source_log (url, status, checked_at) VALUES (?, ?, ?)').run(
76
+ url, status, Date.now()
77
+ );
78
+ }
79
+
80
+ function getSourceLogs(limit = 20) {
81
+ const d = getDb();
82
+ if (!d) return [];
83
+ return d.prepare('SELECT * FROM source_log ORDER BY checked_at DESC LIMIT ?').all(limit);
84
+ }
85
+
86
+ module.exports = {
87
+ getDb,
88
+ cacheGet,
89
+ cacheSet,
90
+ cacheClear,
91
+ logSource,
92
+ getSourceLogs,
93
+ };