mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Cost/traffic hardening, runtime fallback controls, and PostHog removal (#638)
- Remove PostHog analytics runtime and configuration - Add API rate limiting (api/_rate-limit.js) - Harden traffic controls across edge functions - Add runtime fallback controls and data-loader improvements - Add military base data scripts (fetch-mirta-bases, fetch-osm-bases) - Gitignore large raw data files - Settings playground prototypes
This commit is contained in:
@@ -147,10 +147,6 @@ VITE_WS_API_URL=
|
||||
# Client-side Sentry DSN (optional). Leave empty to disable error reporting.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# PostHog product analytics (optional). Leave empty to disable analytics.
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=
|
||||
|
||||
# Map interaction mode:
|
||||
# - "flat" keeps pitch/rotation disabled (2D interaction)
|
||||
# - "3d" enables pitch/rotation interactions (default)
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,3 +38,5 @@ scripts/data/osm-military-processed.json
|
||||
scripts/data/military-bases-final.json
|
||||
scripts/data/dedup-dropped-pairs.json
|
||||
scripts/data/gpsjam-latest.json
|
||||
scripts/data/mirta-raw.geojson
|
||||
scripts/data/osm-military-raw.json
|
||||
|
||||
22
README.md
22
README.md
@@ -850,28 +850,6 @@ With Ollama or LM Studio configured, AI summarization runs entirely on local har
|
||||
|
||||
The desktop readiness framework (`desktop-readiness.ts`) catalogs each feature's locality class — `fully-local` (no API required), `api-key` (degrades gracefully without keys), or `cloud-fallback` (proxy available) — enabling clear communication about what works offline.
|
||||
|
||||
### Product Analytics
|
||||
|
||||
World Monitor includes privacy-first product analytics via PostHog to understand usage patterns and improve the dashboard. The implementation enforces strict data safety at multiple levels:
|
||||
|
||||
**Typed event allowlists** — every analytics event has a schema defining exactly which properties are permitted. Unlisted properties are silently dropped before transmission. This prevents accidental inclusion of sensitive data in analytics payloads, even if a developer passes extra fields.
|
||||
|
||||
**API key stripping** — a `sanitize_properties` callback runs on every outgoing event. Any string value matching common API key prefixes (`sk-`, `gsk_`, `or-`, `Bearer `) is replaced with `[REDACTED]` before it leaves the browser. This is defense-in-depth: even if a key somehow ends up in an event payload, it never reaches the analytics backend.
|
||||
|
||||
**No session recordings, no autocapture** — PostHog's session replay and automatic DOM event capture are explicitly disabled. Only explicitly instrumented events are tracked.
|
||||
|
||||
**Pseudonymous identity** — each installation generates a random UUID stored in localStorage. There is no user login, no email collection, and no cross-device tracking. The UUID is purely pseudonymous — it enables session attribution without identifying individuals.
|
||||
|
||||
**Ad-blocker bypass** — on the web, PostHog traffic is routed through a reverse proxy on the app's own domain (`/ingest`) rather than directly to PostHog's servers. This prevents ad blockers from silently dropping analytics requests, ensuring usage data is representative. Desktop builds use PostHog's direct endpoint since ad blockers aren't a factor in native apps.
|
||||
|
||||
**Offline event queue** — the desktop app may launch without network connectivity. Events captured while offline are queued in localStorage (capped at 200 entries) and flushed to PostHog when connectivity is restored. A `window.online` listener triggers automatic flush on reconnection.
|
||||
|
||||
**Super properties** — every event automatically carries platform context: variant (world/tech/finance), app version, platform (web/desktop), screen dimensions, viewport size, device pixel ratio, browser language, and desktop OS/arch. This enables segmentation without per-event instrumentation.
|
||||
|
||||
30+ typed events cover core user interactions: app load timing, panel views, LLM summary generation (provider, model, cache status), API key configuration snapshots, map layer toggles, variant switches, country brief opens, theme changes, language changes, search usage, panel resizing, webcam selections, and auto-update interactions.
|
||||
|
||||
Analytics is entirely opt-out by omitting the `VITE_POSTHOG_KEY` environment variable. When the key is absent, all analytics functions are no-ops with zero runtime overhead.
|
||||
|
||||
### Responsive Layout System
|
||||
|
||||
The dashboard adapts to four screen categories without JavaScript layout computation — all breakpoints are CSS-only:
|
||||
|
||||
@@ -132,7 +132,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/economic/v1/get-macro-signals': 'medium',
|
||||
'/api/prediction/v1/list-prediction-markets': 'medium',
|
||||
'/api/supply-chain/v1/get-chokepoint-status': 'medium',
|
||||
'/api/news/v1/list-feed-digest': 'medium',
|
||||
'/api/news/v1/list-feed-digest': 'slow',
|
||||
};
|
||||
|
||||
const serverOptions: ServerOptions = { onError: mapErrorToResponse };
|
||||
|
||||
58
api/_rate-limit.js
Normal file
58
api/_rate-limit.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Ratelimit } from '@upstash/ratelimit';
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
let ratelimit = null;
|
||||
|
||||
function getRatelimit() {
|
||||
if (ratelimit) return ratelimit;
|
||||
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
if (!url || !token) return null;
|
||||
|
||||
ratelimit = new Ratelimit({
|
||||
redis: new Redis({ url, token }),
|
||||
limiter: Ratelimit.slidingWindow(300, '60 s'),
|
||||
prefix: 'rl',
|
||||
analytics: false,
|
||||
});
|
||||
|
||||
return ratelimit;
|
||||
}
|
||||
|
||||
function getClientIp(request) {
|
||||
return (
|
||||
request.headers.get('x-real-ip') ||
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
'0.0.0.0'
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkRateLimit(request, corsHeaders) {
|
||||
const rl = getRatelimit();
|
||||
if (!rl) return null;
|
||||
|
||||
const ip = getClientIp(request);
|
||||
try {
|
||||
const { success, limit, reset } = await rl.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-RateLimit-Limit': String(limit),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': String(reset),
|
||||
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { validateApiKey } from './_api-key.js';
|
||||
import { checkRateLimit } from './_rate-limit.js';
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
@@ -49,6 +51,17 @@ export default async function handler(req) {
|
||||
});
|
||||
}
|
||||
|
||||
const keyCheck = validateApiKey(req);
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitResponse = await checkRateLimit(req, corsHeaders);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
|
||||
const relayBaseUrl = getRelayBaseUrl();
|
||||
if (!relayBaseUrl) {
|
||||
return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), {
|
||||
@@ -65,9 +78,13 @@ export default async function handler(req) {
|
||||
}, 12000);
|
||||
|
||||
const body = await response.text();
|
||||
const isSuccess = response.status >= 200 && response.status < 300;
|
||||
const headers = {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
'Cache-Control': response.headers.get('cache-control') || 'no-cache',
|
||||
'Cache-Control': isSuccess
|
||||
? 'public, max-age=60, s-maxage=180, stale-while-revalidate=300, stale-if-error=900'
|
||||
: 'public, max-age=10, s-maxage=30, stale-while-revalidate=120',
|
||||
...(isSuccess && { 'CDN-Cache-Control': 'public, s-maxage=180, stale-while-revalidate=300, stale-if-error=900' }),
|
||||
...corsHeaders,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { validateApiKey } from './_api-key.js';
|
||||
import { checkRateLimit } from './_rate-limit.js';
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
@@ -49,6 +51,17 @@ export default async function handler(req) {
|
||||
});
|
||||
}
|
||||
|
||||
const keyCheck = validateApiKey(req);
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitResponse = await checkRateLimit(req, corsHeaders);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
|
||||
const relayBaseUrl = getRelayBaseUrl();
|
||||
if (!relayBaseUrl) {
|
||||
return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), {
|
||||
@@ -65,9 +78,13 @@ export default async function handler(req) {
|
||||
}, 15000);
|
||||
|
||||
const body = await response.text();
|
||||
const isSuccess = response.status >= 200 && response.status < 300;
|
||||
const headers = {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=120, stale-while-revalidate=60',
|
||||
'Cache-Control': isSuccess
|
||||
? 'public, max-age=120, s-maxage=300, stale-while-revalidate=900, stale-if-error=1800'
|
||||
: 'public, max-age=10, s-maxage=30, stale-while-revalidate=120',
|
||||
...(isSuccess && { 'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=900, stale-if-error=1800' }),
|
||||
...corsHeaders,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Non-sebuf: returns XML/HTML, stays as standalone Vercel function
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { validateApiKey } from './_api-key.js';
|
||||
import { checkRateLimit } from './_rate-limit.js';
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
@@ -328,10 +330,34 @@ const ALLOWED_DOMAINS = [
|
||||
export default async function handler(req) {
|
||||
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
|
||||
|
||||
if (isDisallowedOrigin(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const keyCheck = validateApiKey(req);
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitResponse = await checkRateLimit(req, corsHeaders);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
|
||||
const requestUrl = new URL(req.url);
|
||||
const feedUrl = requestUrl.searchParams.get('url');
|
||||
@@ -415,9 +441,9 @@ export default async function handler(req) {
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/xml',
|
||||
'Cache-Control': isSuccess
|
||||
? 'public, max-age=120, s-maxage=300, stale-while-revalidate=600'
|
||||
: 'public, max-age=10, s-maxage=30, stale-while-revalidate=60',
|
||||
...(isSuccess && { 'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' }),
|
||||
? 'public, max-age=180, s-maxage=900, stale-while-revalidate=1800, stale-if-error=3600'
|
||||
: 'public, max-age=15, s-maxage=60, stale-while-revalidate=120',
|
||||
...(isSuccess && { 'CDN-Cache-Control': 'public, s-maxage=900, stale-while-revalidate=1800, stale-if-error=3600' }),
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
|
||||
1
intelhq
Submodule
1
intelhq
Submodule
Submodule intelhq added at 4034b55d68
@@ -22,10 +22,6 @@ const SOCIAL_IMAGE_UA =
|
||||
export default function middleware(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.hostname === 'api.worldmonitor.app') {
|
||||
return;
|
||||
}
|
||||
|
||||
const ua = request.headers.get('user-agent') ?? '';
|
||||
const path = url.pathname;
|
||||
|
||||
|
||||
327
package-lock.json
generated
327
package-lock.json
generated
@@ -29,7 +29,6 @@
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"onnxruntime-web": "^1.23.2",
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.356.1",
|
||||
"telegram": "^2.26.22",
|
||||
"topojson-client": "^3.1.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -3379,252 +3378,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
|
||||
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
|
||||
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.208.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-exporter-base": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
|
||||
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
|
||||
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0",
|
||||
"@opentelemetry/sdk-metrics": "2.2.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.2.0",
|
||||
"protobufjs": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
|
||||
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
|
||||
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
|
||||
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
|
||||
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
|
||||
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
@@ -3651,21 +3404,6 @@
|
||||
"@webcomponents/shadycss": "^1.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz",
|
||||
"integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.356.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.356.1.tgz",
|
||||
"integrity": "sha512-miIUjs4LiBDMOxKkC87HEJLIih0pNGMAjxx+mW4X7jLpN41n0PLMW7swRE6uuxcMV0z3H6MllRSCYmsokkyfuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@probe.gl/env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz",
|
||||
@@ -6619,17 +6357,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
|
||||
@@ -6672,6 +6399,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -7592,15 +7320,6 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -9346,6 +9065,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
@@ -10886,6 +10606,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -11119,33 +10840,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.356.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.356.1.tgz",
|
||||
"integrity": "sha512-4EQliSyTp3j/xOaWpZmu7fk1b4S+J3qy4JOu5Xy3/MYFxv1SlAylgifRdCbXZxCQWb6PViaNvwRf4EmburgfWA==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.208.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
"@opentelemetry/sdk-logs": "^0.208.0",
|
||||
"@posthog/core": "1.23.1",
|
||||
"@posthog/types": "1.356.1",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.28.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
@@ -11342,12 +11036,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/query-selector-shadow-dom": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
|
||||
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -11935,6 +11623,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -11947,6 +11636,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -13701,12 +13391,6 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
@@ -13768,6 +13452,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
"maplibre-gl": "^5.16.0",
|
||||
"onnxruntime-web": "^1.23.2",
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.356.1",
|
||||
"telegram": "^2.26.22",
|
||||
"topojson-client": "^3.1.0",
|
||||
"ws": "^8.19.0",
|
||||
|
||||
457
playground-settings-A-sidebar.html
Normal file
457
playground-settings-A-sidebar.html
Normal file
@@ -0,0 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings Option A — Sidebar + Content Panel</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#1a1c1e;--surface:#2a2d31;--surface-hover:#32363b;
|
||||
--text:#e8eaed;--text-sec:#9aa0a6;--accent:#60a5fa;
|
||||
--green:#34d399;--yellow:#fbbf24;--red:#ef4444;--blue:#60a5fa;
|
||||
--border:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.14);
|
||||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:'SF Mono',Monaco,'Cascadia Code',monospace;
|
||||
--sidebar-w:220px;
|
||||
}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;-webkit-font-smoothing:antialiased}
|
||||
|
||||
/* Shell */
|
||||
.shell{display:flex;flex-direction:column;height:100vh;max-width:980px;max-height:760px;margin:auto;border:1px solid var(--border-strong);border-radius:10px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.5)}
|
||||
|
||||
/* Header */
|
||||
.header{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border)}
|
||||
.header-title{font-size:15px;font-weight:700;display:flex;align-items:center;gap:8px}
|
||||
.header-title svg{opacity:0.7}
|
||||
.header-badge{font-size:11px;color:var(--text-sec);background:rgba(255,255,255,0.06);padding:3px 10px;border-radius:12px}
|
||||
|
||||
/* Main layout */
|
||||
.main{display:flex;flex:1;min-height:0}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||||
.sidebar-search{padding:12px 14px 8px}
|
||||
.sidebar-search input{width:100%;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:7px 10px 7px 30px;font:inherit;font-size:12px;outline:none;transition:border-color .15s;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='%239aa0a6' viewBox='0 0 24 24'%3E%3Cpath d='M15.5 14h-.79l-.28-.27A6.47 6.47 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:8px center}
|
||||
.sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.sidebar-search input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
|
||||
.sidebar-nav{flex:1;overflow-y:auto;padding:4px 8px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent}
|
||||
.sidebar-sep{height:1px;background:var(--border);margin:6px 8px}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:6px;cursor:pointer;transition:background .12s;font-size:13px;color:var(--text-sec);position:relative;user-select:none}
|
||||
.nav-item:hover{background:rgba(255,255,255,0.04);color:var(--text)}
|
||||
.nav-item.active{background:rgba(96,165,250,0.1);color:var(--text);font-weight:600}
|
||||
.nav-item.active::before{content:'';position:absolute;left:0;top:8px;bottom:8px;width:3px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.nav-icon{width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:15px;opacity:0.8;flex-shrink:0}
|
||||
.nav-label{flex:1}
|
||||
.nav-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.nav-dot.green{background:var(--green)}
|
||||
.nav-dot.yellow{background:var(--yellow)}
|
||||
.nav-dot.blue{background:var(--blue)}
|
||||
.nav-count{font-size:11px;color:var(--text-sec);opacity:0.6}
|
||||
|
||||
/* Content */
|
||||
.content{flex:1;overflow-y:auto;padding:24px 28px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent}
|
||||
.content::-webkit-scrollbar{width:6px}
|
||||
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
|
||||
|
||||
/* Overview */
|
||||
.overview{display:flex;flex-direction:column;gap:20px}
|
||||
.ov-top{display:flex;gap:24px;align-items:flex-start}
|
||||
.ov-ring-wrap{display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.ov-ring{position:relative;width:120px;height:120px}
|
||||
.ov-ring svg{transform:rotate(-90deg)}
|
||||
.ov-ring-bg{fill:none;stroke:rgba(255,255,255,0.06);stroke-width:8}
|
||||
.ov-ring-fg{fill:none;stroke:var(--green);stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset .6s ease}
|
||||
.ov-ring-text{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.ov-ring-num{font-size:28px;font-weight:700;color:var(--text)}
|
||||
.ov-ring-label{font-size:11px;color:var(--text-sec)}
|
||||
.ov-info{flex:1}
|
||||
.ov-info h2{font-size:18px;font-weight:700;margin-bottom:6px}
|
||||
.ov-info p{font-size:13px;color:var(--text-sec);line-height:1.5;margin-bottom:12px}
|
||||
.ov-cats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.ov-cat{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:border-color .15s}
|
||||
.ov-cat:hover{border-color:var(--border-strong)}
|
||||
.ov-cat-icon{font-size:16px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.ov-cat-info{flex:1}
|
||||
.ov-cat-name{font-size:12px;font-weight:600}
|
||||
.ov-cat-stat{font-size:11px;color:var(--text-sec)}
|
||||
.ov-cat-dot{width:6px;height:6px;border-radius:50%}
|
||||
|
||||
/* License section in overview */
|
||||
.ov-license{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px 20px}
|
||||
.ov-license h3{font-size:14px;font-weight:600;margin-bottom:4px}
|
||||
.ov-license p{font-size:12px;color:var(--text-sec);margin-bottom:10px}
|
||||
.ov-license-row{display:flex;gap:10px}
|
||||
.ov-license-row input{flex:1;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:8px 12px;font:inherit;font-size:13px;font-family:var(--mono);outline:none}
|
||||
.ov-license-row input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.ov-license-row input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
|
||||
/* Section header */
|
||||
.section-head{margin-bottom:16px}
|
||||
.section-head h2{font-size:17px;font-weight:700;margin-bottom:2px}
|
||||
.section-head p{font-size:12px;color:var(--text-sec)}
|
||||
|
||||
/* Feature cards */
|
||||
.features{display:flex;flex-direction:column;gap:8px}
|
||||
.feat{background:var(--surface);border:1px solid var(--border);border-radius:8px;border-left:3px solid var(--border-strong);transition:border-color .2s,box-shadow .2s;overflow:hidden}
|
||||
.feat.ready{border-left-color:var(--green)}
|
||||
.feat.needs{border-left-color:var(--yellow)}
|
||||
.feat.staged{border-left-color:var(--blue)}
|
||||
.feat-header{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;user-select:none}
|
||||
.feat-toggle{position:relative;width:36px;height:20px;background:rgba(255,255,255,0.1);border-radius:10px;flex-shrink:0;cursor:pointer;transition:background .2s}
|
||||
.feat-toggle.on{background:var(--accent)}
|
||||
.feat-toggle::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,0.3)}
|
||||
.feat-toggle.on::after{transform:translateX(16px)}
|
||||
.feat-info{flex:1;min-width:0}
|
||||
.feat-name{font-size:13px;font-weight:600}
|
||||
.feat-desc{font-size:11px;color:var(--text-sec);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.feat-pill{font-size:10px;font-weight:600;padding:3px 10px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
|
||||
.feat-pill.ok{color:var(--green);background:rgba(52,211,153,0.12)}
|
||||
.feat-pill.warn{color:var(--yellow);background:rgba(251,191,36,0.12)}
|
||||
.feat-pill.staged{color:var(--blue);background:rgba(96,165,250,0.12)}
|
||||
.feat-chevron{color:var(--text-sec);font-size:12px;transition:transform .2s;flex-shrink:0}
|
||||
.feat.expanded .feat-chevron{transform:rotate(180deg)}
|
||||
|
||||
.feat-body{max-height:0;overflow:hidden;transition:max-height .25s ease}
|
||||
.feat.expanded .feat-body{max-height:300px}
|
||||
.feat-body-inner{padding:0 16px 14px;border-top:1px solid var(--border)}
|
||||
.feat-body-inner{padding-top:12px}
|
||||
.key-row{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}
|
||||
.key-label{display:flex;align-items:center;justify-content:space-between}
|
||||
.key-label code{font-size:11px;color:var(--text-sec);font-family:var(--mono)}
|
||||
.key-label a{font-size:11px;color:var(--accent);text-decoration:none;font-weight:600}
|
||||
.key-label a:hover{text-decoration:underline}
|
||||
.key-input{width:100%;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:8px 12px;font:inherit;font-size:12px;font-family:var(--mono);outline:none;transition:border-color .15s}
|
||||
.key-input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.key-input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
.feat-fallback{font-size:11px;color:var(--yellow);margin-top:6px;font-style:italic}
|
||||
|
||||
/* Debug section */
|
||||
.debug-btns{display:flex;gap:10px;margin-bottom:16px}
|
||||
.debug-btns button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text);font:inherit;font-size:13px;padding:8px 16px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||
.debug-btns button:hover{background:var(--surface-hover)}
|
||||
.debug-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px}
|
||||
.debug-card h3{font-size:14px;font-weight:600;margin-bottom:10px}
|
||||
.diag-row{display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||
.diag-row label{font-size:13px;color:var(--text-sec);display:flex;align-items:center;gap:6px;cursor:pointer}
|
||||
.diag-row input[type=checkbox]{accent-color:var(--accent)}
|
||||
.traffic-placeholder{font-size:12px;color:var(--text-sec);font-style:italic;padding:16px 0}
|
||||
|
||||
/* Footer */
|
||||
.footer{display:flex;justify-content:flex-end;gap:10px;padding:14px 24px;border-top:1px solid var(--border);background:var(--surface);flex-shrink:0}
|
||||
.btn{font:inherit;font-size:13px;font-weight:600;padding:8px 24px;border-radius:6px;cursor:pointer;min-width:80px;text-align:center;transition:background .15s,border-color .15s,transform .1s;letter-spacing:.01em}
|
||||
.btn:active{transform:scale(0.98)}
|
||||
.btn-secondary{background:transparent;border:1px solid var(--border-strong);color:var(--text-sec)}
|
||||
.btn-secondary:hover{background:rgba(255,255,255,0.04);color:var(--text)}
|
||||
.btn-primary{background:var(--accent);border:1px solid var(--accent);color:#fff}
|
||||
.btn-primary:hover{background:#5294e8}
|
||||
|
||||
/* Label tag */
|
||||
.option-label{position:fixed;top:12px;right:12px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;padding:4px 12px;border-radius:6px;letter-spacing:.05em;z-index:999;text-transform:uppercase}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="option-label">Option A — Sidebar</div>
|
||||
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<div class="header-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
World Monitor Settings
|
||||
</div>
|
||||
<span class="header-badge">v2.5.19</span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-search">
|
||||
<input type="text" placeholder="Search settings..." id="searchInput">
|
||||
</div>
|
||||
<div class="sidebar-nav" id="sidebarNav"></div>
|
||||
</div>
|
||||
<div class="content" id="contentArea"></div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
<button class="btn btn-primary">Save & Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CATEGORIES = [
|
||||
{ id:'overview', icon:'📊', label:'Overview', dot:null },
|
||||
{ id:'sep1', sep:true },
|
||||
{ id:'ai', icon:'🤖', label:'AI Models', dot:'green', count:'3/3',
|
||||
features:[
|
||||
{ name:'Ollama Local LLM', desc:'Local summarization via OpenAI-compatible endpoint', keys:['OLLAMA_API_URL','OLLAMA_MODEL'], status:'ready', on:true, fallback:'Falls back to Groq, then OpenRouter, then local browser model.', signup:'https://ollama.com/download' },
|
||||
{ name:'Groq Summarization', desc:'Primary fast LLM provider for AI summaries', keys:['GROQ_API_KEY'], status:'ready', on:true, fallback:'Falls back to OpenRouter, then local browser model.', signup:'https://console.groq.com/keys' },
|
||||
{ name:'OpenRouter LLM', desc:'Secondary LLM provider for AI summary fallback', keys:['OPENROUTER_API_KEY'], status:'ready', on:true, fallback:'Falls back to local browser model only.', signup:'https://openrouter.ai/settings/keys' },
|
||||
]},
|
||||
{ id:'data', icon:'📈', label:'Data Sources', dot:'green', count:'3/3',
|
||||
features:[
|
||||
{ name:'FRED Economic Data', desc:'Macro indicators from Federal Reserve Economic Data', keys:['FRED_API_KEY'], status:'ready', on:true, fallback:'Economic panel remains available with non-FRED metrics.', signup:'https://fred.stlouisfed.org/docs/api/api_key.html' },
|
||||
{ name:'EIA Oil Analytics', desc:'US Energy Information Administration oil metrics', keys:['EIA_API_KEY'], status:'ready', on:true, fallback:'Oil analytics cards show disabled state.', signup:'https://www.eia.gov/opendata/register.php' },
|
||||
{ name:'WTO Trade Policy', desc:'Trade restrictions, tariff trends, barriers and flows', keys:['WTO_API_KEY'], status:'ready', on:true, fallback:'Trade policy panel shows disabled state.', signup:'https://apiportal.wto.org/' },
|
||||
]},
|
||||
{ id:'security', icon:'🛡️', label:'Security Intel', dot:'yellow', count:'2/4',
|
||||
features:[
|
||||
{ name:'Cloudflare Outage Radar', desc:'Internet outages from Cloudflare Radar annotations', keys:['CLOUDFLARE_API_TOKEN'], status:'ready', on:true, fallback:'Outage layer is disabled.', signup:'https://dash.cloudflare.com/profile/api-tokens' },
|
||||
{ name:'abuse.ch Cyber IOC', desc:'URLhaus and ThreatFox IOC ingestion for cyber layer', keys:['URLHAUS_AUTH_KEY'], status:'needs', on:true, fallback:'URLhaus/ThreatFox IOC ingestion is disabled.', signup:'https://auth.abuse.ch/' },
|
||||
{ name:'AlienVault OTX Intel', desc:'OTX IOC ingestion for cyber threat enrichment', keys:['OTX_API_KEY'], status:'ready', on:true, fallback:'OTX IOC enrichment is disabled.', signup:'https://otx.alienvault.com/' },
|
||||
{ name:'AbuseIPDB Threat Intel', desc:'AbuseIPDB reputation enrichment for cyber layer', keys:['ABUSEIPDB_API_KEY'], status:'needs', on:false, fallback:'AbuseIPDB enrichment is disabled.', signup:'https://www.abuseipdb.com/login' },
|
||||
]},
|
||||
{ id:'tracking', icon:'✈️', label:'Tracking', dot:'yellow', count:'2/4',
|
||||
features:[
|
||||
{ name:'AIS Vessel Tracking', desc:'Live vessel ingestion via AISStream WebSocket', keys:['AISSTREAM_API_KEY'], status:'ready', on:true, fallback:'AIS layer is disabled.', signup:'https://aisstream.io/authenticate' },
|
||||
{ name:'OpenSky Military Flights', desc:'OAuth credentials for military flight data', keys:['OPENSKY_CLIENT_ID','OPENSKY_CLIENT_SECRET'], status:'needs', on:true, fallback:'Military flights fall back to limited data.', signup:'https://opensky-network.org/login?view=registration' },
|
||||
{ name:'Wingbits Aircraft Data', desc:'Military flight operator/aircraft enrichment', keys:['WINGBITS_API_KEY'], status:'needs', on:false, fallback:'Flight map uses heuristic-only classification.', signup:'https://wingbits.com/register' },
|
||||
{ name:'NASA FIRMS Fire Data', desc:'Fire satellite data from FIRMS', keys:['NASA_FIRMS_API_KEY'], status:'ready', on:true, fallback:'FIRMS fire layer uses public VIIRS feed.', signup:'https://firms.modaps.eosdis.nasa.gov/api/area/' },
|
||||
]},
|
||||
{ id:'markets', icon:'💹', label:'Markets', dot:'green', count:'2/2',
|
||||
features:[
|
||||
{ name:'Finnhub Market Data', desc:'Real-time stock quotes and market data', keys:['FINNHUB_API_KEY'], status:'ready', on:true, fallback:'Stock ticker uses limited free data.', signup:'https://finnhub.io/register' },
|
||||
{ name:'ACLED Conflicts', desc:'Conflict and protest event feeds', keys:['ACLED_ACCESS_TOKEN'], status:'ready', on:true, fallback:'Conflict/protest overlays are hidden.', signup:'https://developer.acleddata.com/' },
|
||||
]},
|
||||
{ id:'sep2', sep:true },
|
||||
{ id:'debug', icon:'🔧', label:'Debug & Logs', dot:null },
|
||||
];
|
||||
|
||||
function getReadyCount() {
|
||||
let ready=0, total=0;
|
||||
CATEGORIES.forEach(c => {
|
||||
if (!c.features) return;
|
||||
c.features.forEach(f => { total++; if (f.status==='ready') ready++; });
|
||||
});
|
||||
return { ready, total };
|
||||
}
|
||||
|
||||
function renderSidebar(activeId) {
|
||||
const nav = document.getElementById('sidebarNav');
|
||||
nav.innerHTML = CATEGORIES.map(c => {
|
||||
if (c.sep) return '<div class="sidebar-sep"></div>';
|
||||
const active = c.id===activeId ? ' active' : '';
|
||||
const dot = c.dot ? `<span class="nav-dot ${c.dot}"></span>` : '';
|
||||
const count = c.count ? `<span class="nav-count">${c.count}</span>` : '';
|
||||
return `<div class="nav-item${active}" data-nav="${c.id}">
|
||||
<span class="nav-icon">${c.icon}</span>
|
||||
<span class="nav-label">${c.label}</span>
|
||||
${count}${dot}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
nav.querySelectorAll('.nav-item').forEach(el => {
|
||||
el.addEventListener('click', () => renderSection(el.dataset.nav));
|
||||
});
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const { ready, total } = getReadyCount();
|
||||
const pct = ready/total;
|
||||
const circ = 2*Math.PI*46;
|
||||
const offset = circ*(1-pct);
|
||||
|
||||
let catCards = CATEGORIES.filter(c=>c.features).map(c => {
|
||||
const r = c.features.filter(f=>f.status==='ready').length;
|
||||
const t = c.features.length;
|
||||
const dot = r===t?'green':'yellow';
|
||||
return `<div class="ov-cat" data-nav="${c.id}">
|
||||
<span class="ov-cat-icon">${c.icon}</span>
|
||||
<div class="ov-cat-info">
|
||||
<div class="ov-cat-name">${c.label}</div>
|
||||
<div class="ov-cat-stat">${r}/${t} ready</div>
|
||||
</div>
|
||||
<span class="ov-cat-dot nav-dot ${dot}"></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="overview">
|
||||
<div class="ov-top">
|
||||
<div class="ov-ring-wrap">
|
||||
<div class="ov-ring">
|
||||
<svg width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle class="ov-ring-bg" cx="60" cy="60" r="46" />
|
||||
<circle class="ov-ring-fg" cx="60" cy="60" r="46"
|
||||
stroke-dasharray="${circ}" stroke-dashoffset="${offset}" />
|
||||
</svg>
|
||||
<div class="ov-ring-text">
|
||||
<span class="ov-ring-num">${ready}/${total}</span>
|
||||
<span class="ov-ring-label">features ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-info">
|
||||
<h2>Welcome to World Monitor</h2>
|
||||
<p>Configure API keys to unlock real-time data feeds, AI summaries, and intelligence layers. Features without keys gracefully degrade to public data.</p>
|
||||
<div class="ov-cats">${catCards}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-license">
|
||||
<h3>World Monitor API Key</h3>
|
||||
<p>Enter your license key for full access, or use BYOK (Bring Your Own Keys) below.</p>
|
||||
<div class="ov-license-row">
|
||||
<input type="password" placeholder="wm_live_..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFeatureSection(cat) {
|
||||
const features = cat.features.map((f,i) => {
|
||||
const pillCls = f.status==='ready'?'ok':f.status==='staged'?'staged':'warn';
|
||||
const pillText = f.status==='ready'?'Ready':f.status==='staged'?'Staged':'Needs Keys';
|
||||
const toggleCls = f.on?'on':'';
|
||||
const statCls = f.status==='ready'?'ready':f.status==='staged'?'staged':'needs';
|
||||
const keys = f.keys.map(k => `
|
||||
<div class="key-row">
|
||||
<div class="key-label">
|
||||
<code>${k}</code>
|
||||
<a href="#" onclick="return false">Get key →</a>
|
||||
</div>
|
||||
<input type="password" class="key-input" placeholder="${f.status==='ready'?'••••••••••••':'Enter API key...'}" ${f.status==='ready'?'value="••••••••••••"':''}>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `<div class="feat ${statCls}" data-feat="${i}">
|
||||
<div class="feat-header">
|
||||
<div class="feat-toggle ${toggleCls}" data-toggle="${i}"></div>
|
||||
<div class="feat-info">
|
||||
<div class="feat-name">${f.name}</div>
|
||||
<div class="feat-desc">${f.desc}</div>
|
||||
</div>
|
||||
<span class="feat-pill ${pillCls}">${pillText}</span>
|
||||
<span class="feat-chevron">▼</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<div class="feat-body-inner">
|
||||
${keys}
|
||||
${f.status!=='ready'?`<div class="feat-fallback">${f.fallback}</div>`:''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="section-head">
|
||||
<h2>${cat.icon} ${cat.label}</h2>
|
||||
<p>${cat.features.length} features · ${cat.features.filter(f=>f.status==='ready').length} configured</p>
|
||||
</div>
|
||||
<div class="features">${features}</div>`;
|
||||
}
|
||||
|
||||
function renderDebug() {
|
||||
return `<div class="section-head">
|
||||
<h2>🔧 Debug & Logs</h2>
|
||||
<p>Diagnostics and API traffic monitoring</p>
|
||||
</div>
|
||||
<div class="debug-btns">
|
||||
<button>Open Logs Folder</button>
|
||||
<button>Open API Log</button>
|
||||
</div>
|
||||
<div class="debug-card">
|
||||
<h3>Diagnostics</h3>
|
||||
<div class="diag-row"><label><input type="checkbox"> Verbose Sidecar Log</label></div>
|
||||
<div class="diag-row"><label><input type="checkbox"> Frontend Fetch Debug</label></div>
|
||||
<div style="margin-top:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-sec)">API Traffic</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<label style="font-size:12px;color:var(--text-sec);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" checked style="accent-color:var(--accent)"> Auto</label>
|
||||
<button style="background:var(--surface);border:1px solid var(--border-strong);color:var(--text-sec);font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer">Refresh</button>
|
||||
<button style="background:var(--surface);border:1px solid var(--border-strong);color:var(--text-sec);font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-placeholder">No traffic recorded yet.</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSection(id) {
|
||||
renderSidebar(id);
|
||||
const area = document.getElementById('contentArea');
|
||||
|
||||
if (id==='overview') {
|
||||
area.innerHTML = renderOverview();
|
||||
area.querySelectorAll('[data-nav]').forEach(el => {
|
||||
el.addEventListener('click', () => renderSection(el.dataset.nav));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (id==='debug') {
|
||||
area.innerHTML = renderDebug();
|
||||
return;
|
||||
}
|
||||
|
||||
const cat = CATEGORIES.find(c=>c.id===id);
|
||||
if (!cat || !cat.features) return;
|
||||
area.innerHTML = renderFeatureSection(cat);
|
||||
|
||||
// Toggle expand
|
||||
area.querySelectorAll('.feat-header').forEach(hdr => {
|
||||
hdr.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.feat-toggle')) return;
|
||||
const feat = hdr.closest('.feat');
|
||||
feat.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle on/off
|
||||
area.querySelectorAll('.feat-toggle').forEach(tog => {
|
||||
tog.addEventListener('click', () => tog.classList.toggle('on'));
|
||||
});
|
||||
}
|
||||
|
||||
// Search
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
const q = e.target.value.toLowerCase().trim();
|
||||
if (!q) { renderSection('overview'); return; }
|
||||
// Find matching features
|
||||
const matches = [];
|
||||
CATEGORIES.forEach(c => {
|
||||
if (!c.features) return;
|
||||
c.features.forEach(f => {
|
||||
if (f.name.toLowerCase().includes(q) || f.desc.toLowerCase().includes(q) || f.keys.some(k=>k.toLowerCase().includes(q))) {
|
||||
matches.push({...f, category: c.label, catIcon: c.icon});
|
||||
}
|
||||
});
|
||||
});
|
||||
const area = document.getElementById('contentArea');
|
||||
if (matches.length===0) {
|
||||
area.innerHTML = `<div style="text-align:center;padding:60px 0;color:var(--text-sec)">No features matching "${e.target.value}"</div>`;
|
||||
return;
|
||||
}
|
||||
area.innerHTML = `<div class="section-head"><h2>Search Results</h2><p>${matches.length} feature${matches.length>1?'s':''} found</p></div>
|
||||
<div class="features">${matches.map((f,i) => {
|
||||
const pillCls = f.status==='ready'?'ok':'warn';
|
||||
const pillText = f.status==='ready'?'Ready':'Needs Keys';
|
||||
return `<div class="feat ${f.status==='ready'?'ready':'needs'}" data-feat="${i}">
|
||||
<div class="feat-header">
|
||||
<div class="feat-toggle ${f.on?'on':''}"></div>
|
||||
<div class="feat-info">
|
||||
<div class="feat-name">${f.catIcon} ${f.name}</div>
|
||||
<div class="feat-desc">${f.desc}</div>
|
||||
</div>
|
||||
<span class="feat-pill ${pillCls}">${pillText}</span>
|
||||
<span class="feat-chevron">▼</span>
|
||||
</div>
|
||||
<div class="feat-body"><div class="feat-body-inner">
|
||||
${f.keys.map(k=>`<div class="key-row"><div class="key-label"><code>${k}</code><a href="#" onclick="return false">Get key →</a></div><input type="password" class="key-input" placeholder="Enter API key..."></div>`).join('')}
|
||||
</div></div>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
|
||||
area.querySelectorAll('.feat-header').forEach(hdr => {
|
||||
hdr.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.feat-toggle')) return;
|
||||
hdr.closest('.feat').classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
area.querySelectorAll('.feat-toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
|
||||
});
|
||||
|
||||
renderSection('overview');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
330
playground-settings-B-accordion.html
Normal file
330
playground-settings-B-accordion.html
Normal file
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings Option B — Accordion Sections</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#1a1c1e;--surface:#2a2d31;--surface-hover:#32363b;
|
||||
--text:#e8eaed;--text-sec:#9aa0a6;--accent:#60a5fa;
|
||||
--green:#34d399;--yellow:#fbbf24;--red:#ef4444;--blue:#60a5fa;
|
||||
--border:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.14);
|
||||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:'SF Mono',Monaco,'Cascadia Code',monospace;
|
||||
}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;-webkit-font-smoothing:antialiased}
|
||||
|
||||
.shell{display:flex;flex-direction:column;height:100vh;max-width:980px;max-height:760px;margin:auto;border:1px solid var(--border-strong);border-radius:10px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.5)}
|
||||
|
||||
/* Sticky header */
|
||||
.top-bar{display:flex;align-items:center;gap:16px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.top-bar-title{font-size:15px;font-weight:700;display:flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
.top-bar-title svg{opacity:0.7}
|
||||
.top-search{flex:1;max-width:320px;position:relative}
|
||||
.top-search input{width:100%;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:7px 10px 7px 30px;font:inherit;font-size:12px;outline:none;transition:border-color .15s;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='%239aa0a6' viewBox='0 0 24 24'%3E%3Cpath d='M15.5 14h-.79l-.28-.27A6.47 6.47 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:8px center}
|
||||
.top-search input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.top-search input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
|
||||
/* Progress chip */
|
||||
.progress-chip{display:flex;align-items:center;gap:8px;margin-left:auto;white-space:nowrap}
|
||||
.progress-bar-bg{width:100px;height:6px;background:rgba(255,255,255,0.06);border-radius:3px;overflow:hidden}
|
||||
.progress-bar-fg{height:100%;background:var(--green);border-radius:3px;transition:width .4s ease}
|
||||
.progress-label{font-size:12px;font-weight:600;color:var(--green)}
|
||||
|
||||
/* Scroll area */
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:20px 24px 80px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent}
|
||||
.scroll-area::-webkit-scrollbar{width:6px}
|
||||
.scroll-area::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
|
||||
|
||||
/* License card */
|
||||
.license-card{background:linear-gradient(135deg,rgba(52,211,153,0.06),rgba(96,165,250,0.06));border:1px solid rgba(52,211,153,0.15);border-radius:12px;padding:20px 24px;margin-bottom:20px}
|
||||
.license-card h2{font-size:18px;font-weight:700;margin-bottom:4px}
|
||||
.license-card p{font-size:13px;color:var(--text-sec);margin-bottom:12px;line-height:1.5}
|
||||
.license-row{display:flex;gap:10px}
|
||||
.license-row input{flex:1;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:8px 12px;font:inherit;font-size:13px;font-family:var(--mono);outline:none}
|
||||
.license-row input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.license-row input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
.license-badge{font-size:10px;font-weight:600;padding:3px 10px;border-radius:10px;text-transform:uppercase;color:var(--yellow);background:rgba(251,191,36,0.12);align-self:center;white-space:nowrap}
|
||||
|
||||
/* Accordion section */
|
||||
.accordion{margin-bottom:8px;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface);transition:border-color .2s}
|
||||
.accordion.open{border-color:var(--border-strong)}
|
||||
.acc-header{display:flex;align-items:center;gap:12px;padding:16px 20px;cursor:pointer;user-select:none;transition:background .12s}
|
||||
.acc-header:hover{background:rgba(255,255,255,0.02)}
|
||||
.acc-icon{font-size:20px;width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.04);border-radius:8px;flex-shrink:0}
|
||||
.acc-info{flex:1}
|
||||
.acc-title{font-size:14px;font-weight:700}
|
||||
.acc-subtitle{font-size:12px;color:var(--text-sec);margin-top:1px}
|
||||
.acc-stat{display:flex;align-items:center;gap:8px}
|
||||
.acc-stat-bar{width:48px;height:4px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}
|
||||
.acc-stat-fill{height:100%;border-radius:2px;transition:width .3s}
|
||||
.acc-stat-fill.full{background:var(--green)}
|
||||
.acc-stat-fill.partial{background:var(--yellow)}
|
||||
.acc-count{font-size:11px;font-weight:600;color:var(--text-sec);white-space:nowrap}
|
||||
.acc-chevron{font-size:12px;color:var(--text-sec);transition:transform .2s;margin-left:4px}
|
||||
.accordion.open .acc-chevron{transform:rotate(180deg)}
|
||||
|
||||
.acc-body{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.accordion.open .acc-body{max-height:2000px}
|
||||
.acc-body-inner{padding:0 20px 16px}
|
||||
|
||||
/* Feature row inside accordion */
|
||||
.feat-row{display:flex;flex-direction:column;gap:0;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;overflow:hidden;transition:border-color .15s}
|
||||
.feat-row.ready{border-left:3px solid var(--green)}
|
||||
.feat-row.needs{border-left:3px solid var(--yellow)}
|
||||
.feat-row.staged{border-left:3px solid var(--blue)}
|
||||
|
||||
.feat-top{display:flex;align-items:center;gap:12px;padding:12px 14px;cursor:pointer;transition:background .1s}
|
||||
.feat-top:hover{background:rgba(255,255,255,0.02)}
|
||||
.toggle{position:relative;width:34px;height:18px;background:rgba(255,255,255,0.1);border-radius:9px;flex-shrink:0;cursor:pointer;transition:background .2s}
|
||||
.toggle.on{background:var(--accent)}
|
||||
.toggle::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;background:#fff;border-radius:50%;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,0.3)}
|
||||
.toggle.on::after{transform:translateX(16px)}
|
||||
.feat-name{flex:1;font-size:13px;font-weight:600}
|
||||
.pill{font-size:10px;font-weight:600;padding:2px 8px;border-radius:8px;text-transform:uppercase;letter-spacing:.04em}
|
||||
.pill.ok{color:var(--green);background:rgba(52,211,153,0.12)}
|
||||
.pill.warn{color:var(--yellow);background:rgba(251,191,36,0.12)}
|
||||
.pill.staged{color:var(--blue);background:rgba(96,165,250,0.12)}
|
||||
.feat-expand-icon{font-size:10px;color:var(--text-sec);transition:transform .15s}
|
||||
.feat-row.expanded .feat-expand-icon{transform:rotate(180deg)}
|
||||
|
||||
.feat-detail{max-height:0;overflow:hidden;transition:max-height .2s ease}
|
||||
.feat-row.expanded .feat-detail{max-height:300px}
|
||||
.feat-detail-inner{padding:8px 14px 14px;border-top:1px solid var(--border)}
|
||||
.feat-desc-text{font-size:12px;color:var(--text-sec);margin-bottom:10px;line-height:1.4}
|
||||
.key-group{margin-bottom:6px}
|
||||
.key-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
||||
.key-header code{font-size:11px;color:var(--text-sec);font-family:var(--mono)}
|
||||
.key-header a{font-size:11px;color:var(--accent);text-decoration:none;font-weight:600}
|
||||
.key-header a:hover{text-decoration:underline}
|
||||
.key-input{width:100%;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:7px 10px;font:inherit;font-size:12px;font-family:var(--mono);outline:none;transition:border-color .15s}
|
||||
.key-input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.key-input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
.feat-fallback{font-size:11px;color:var(--yellow);margin-top:6px;font-style:italic}
|
||||
|
||||
/* Debug section */
|
||||
.debug-section{margin-top:8px}
|
||||
.debug-btns{display:flex;gap:10px;margin-bottom:12px}
|
||||
.debug-btns button{background:var(--surface-hover);border:1px solid var(--border-strong);color:var(--text);font:inherit;font-size:12px;padding:7px 14px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||
.debug-btns button:hover{background:rgba(255,255,255,0.08)}
|
||||
.debug-card{background:rgba(0,0,0,0.15);border:1px solid var(--border);border-radius:8px;padding:14px 16px}
|
||||
.debug-card h3{font-size:13px;font-weight:600;margin-bottom:8px}
|
||||
.diag-check{display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:12px;color:var(--text-sec);cursor:pointer}
|
||||
.diag-check input{accent-color:var(--accent)}
|
||||
|
||||
/* FAB save button */
|
||||
.fab{position:fixed;bottom:24px;right:24px;display:flex;align-items:center;gap:8px;background:var(--accent);color:#fff;font-family:var(--font);font-size:14px;font-weight:700;padding:12px 28px;border:none;border-radius:12px;cursor:pointer;box-shadow:0 4px 20px rgba(96,165,250,0.4);transition:transform .15s,box-shadow .15s;z-index:100}
|
||||
.fab:hover{transform:translateY(-2px);box-shadow:0 6px 28px rgba(96,165,250,0.5)}
|
||||
.fab:active{transform:scale(0.97)}
|
||||
.fab svg{width:18px;height:18px}
|
||||
|
||||
/* Cancel link */
|
||||
.cancel-link{position:fixed;bottom:30px;right:160px;color:var(--text-sec);font-size:13px;cursor:pointer;z-index:100;text-decoration:underline;text-underline-offset:2px}
|
||||
.cancel-link:hover{color:var(--text)}
|
||||
|
||||
/* Label */
|
||||
.option-label{position:fixed;top:12px;right:12px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;padding:4px 12px;border-radius:6px;letter-spacing:.05em;z-index:999;text-transform:uppercase}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="option-label">Option B — Accordion</div>
|
||||
|
||||
<div class="shell">
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
Settings
|
||||
</div>
|
||||
<div class="top-search">
|
||||
<input type="text" placeholder="Search features..." id="searchInput">
|
||||
</div>
|
||||
<div class="progress-chip">
|
||||
<div class="progress-bar-bg"><div class="progress-bar-fg" id="progressBar" style="width:71%"></div></div>
|
||||
<span class="progress-label" id="progressLabel">12/17</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-area" id="scrollArea">
|
||||
<!-- Content rendered by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="cancel-link">Cancel</span>
|
||||
<button class="fab">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 13l4 4L19 7"/></svg>
|
||||
Save & Close
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const SECTIONS = [
|
||||
{ id:'license', type:'license' },
|
||||
{ id:'ai', icon:'🤖', title:'AI Models', desc:'LLM providers for intelligent summaries',
|
||||
features:[
|
||||
{ name:'Ollama Local LLM', desc:'Local summarization via OpenAI-compatible endpoint (Ollama or LM Studio)', keys:['OLLAMA_API_URL','OLLAMA_MODEL'], status:'ready', on:true, fallback:'Falls back to Groq → OpenRouter → browser model', signup:'https://ollama.com/download' },
|
||||
{ name:'Groq Summarization', desc:'Primary fast LLM provider for AI summary generation', keys:['GROQ_API_KEY'], status:'ready', on:true, fallback:'Falls back to OpenRouter → browser model', signup:'https://console.groq.com/keys' },
|
||||
{ name:'OpenRouter LLM', desc:'Secondary LLM provider for AI summary fallback', keys:['OPENROUTER_API_KEY'], status:'ready', on:true, fallback:'Falls back to local browser model only', signup:'https://openrouter.ai/settings/keys' },
|
||||
]},
|
||||
{ id:'data', icon:'📈', title:'Economic Data', desc:'Macro indicators and trade intelligence',
|
||||
features:[
|
||||
{ name:'FRED Economic Data', desc:'Federal Reserve macro indicators (GDP, CPI, unemployment)', keys:['FRED_API_KEY'], status:'ready', on:true, fallback:'Economic panel uses non-FRED metrics', signup:'https://fred.stlouisfed.org/docs/api/api_key.html' },
|
||||
{ name:'EIA Oil Analytics', desc:'US Energy Information Administration oil production & pricing', keys:['EIA_API_KEY'], status:'ready', on:true, fallback:'Oil analytics cards show disabled state', signup:'https://www.eia.gov/opendata/register.php' },
|
||||
{ name:'WTO Trade Policy', desc:'Tariff trends, trade barriers, and global trade flows', keys:['WTO_API_KEY'], status:'ready', on:true, fallback:'Trade policy panel shows disabled state', signup:'https://apiportal.wto.org/' },
|
||||
]},
|
||||
{ id:'security', icon:'🛡️', title:'Security Intel', desc:'Cyber threat feeds and internet monitoring',
|
||||
features:[
|
||||
{ name:'Cloudflare Outage Radar', desc:'Internet outages from Cloudflare Radar annotations API', keys:['CLOUDFLARE_API_TOKEN'], status:'ready', on:true, fallback:'Outage layer disabled', signup:'https://dash.cloudflare.com/profile/api-tokens' },
|
||||
{ name:'abuse.ch Cyber IOC', desc:'URLhaus and ThreatFox IOC ingestion for cyber threat layer', keys:['URLHAUS_AUTH_KEY'], status:'needs', on:true, fallback:'URLhaus/ThreatFox IOC ingestion disabled', signup:'https://auth.abuse.ch/' },
|
||||
{ name:'AlienVault OTX Intel', desc:'OTX IOC ingestion for cyber threat enrichment', keys:['OTX_API_KEY'], status:'ready', on:true, fallback:'OTX enrichment disabled', signup:'https://otx.alienvault.com/' },
|
||||
{ name:'AbuseIPDB Reputation', desc:'IP reputation enrichment for the cyber threat layer', keys:['ABUSEIPDB_API_KEY'], status:'needs', on:false, fallback:'AbuseIPDB enrichment disabled', signup:'https://www.abuseipdb.com/login' },
|
||||
]},
|
||||
{ id:'tracking', icon:'✈️', title:'Tracking & Flights', desc:'Real-time vessel, aircraft, and satellite monitoring',
|
||||
features:[
|
||||
{ name:'AIS Vessel Tracking', desc:'Live vessel ingestion via AISStream WebSocket feed', keys:['AISSTREAM_API_KEY'], status:'ready', on:true, fallback:'AIS layer disabled', signup:'https://aisstream.io/authenticate' },
|
||||
{ name:'OpenSky Military Flights', desc:'OAuth credentials for military flight data', keys:['OPENSKY_CLIENT_ID','OPENSKY_CLIENT_SECRET'], status:'needs', on:true, fallback:'Military flights fall back to limited data', signup:'https://opensky-network.org' },
|
||||
{ name:'Wingbits Aircraft Data', desc:'Operator/aircraft type enrichment metadata', keys:['WINGBITS_API_KEY'], status:'needs', on:false, fallback:'Heuristic-only classification', signup:'https://wingbits.com/register' },
|
||||
{ name:'NASA FIRMS Fire Data', desc:'FIRMS satellite fire detection data', keys:['NASA_FIRMS_API_KEY'], status:'ready', on:true, fallback:'Uses public VIIRS feed', signup:'https://firms.modaps.eosdis.nasa.gov/api/area/' },
|
||||
]},
|
||||
{ id:'markets', icon:'💹', title:'Markets & Conflicts', desc:'Financial markets and geopolitical event data',
|
||||
features:[
|
||||
{ name:'Finnhub Market Data', desc:'Real-time stock quotes, market data, and economic calendars', keys:['FINNHUB_API_KEY'], status:'ready', on:true, fallback:'Stock ticker uses limited free data', signup:'https://finnhub.io/register' },
|
||||
{ name:'ACLED Conflicts & Protests', desc:'Armed conflict and political violence event feeds', keys:['ACLED_ACCESS_TOKEN'], status:'ready', on:true, fallback:'Conflict/protest overlays hidden', signup:'https://developer.acleddata.com/' },
|
||||
]},
|
||||
{ id:'supply', icon:'🚢', title:'Supply Chain', desc:'Shipping, chokepoints, and critical minerals',
|
||||
features:[
|
||||
{ name:'Supply Chain Intelligence', desc:'Baltic Dry Index via FRED, chokepoints and minerals from public data', keys:['FRED_API_KEY'], status:'ready', on:true, fallback:'Chokepoints and minerals always available', signup:'https://fred.stlouisfed.org/docs/api/api_key.html' },
|
||||
]},
|
||||
{ id:'debug', type:'debug' },
|
||||
];
|
||||
|
||||
function render(filter='') {
|
||||
const area = document.getElementById('scrollArea');
|
||||
const q = filter.toLowerCase();
|
||||
let html = '';
|
||||
|
||||
for (const sec of SECTIONS) {
|
||||
if (sec.type==='license' && !q) {
|
||||
html += `<div class="license-card">
|
||||
<h2>World Monitor</h2>
|
||||
<p>Enter your license key for full cloud API access, or configure individual keys below (BYOK).</p>
|
||||
<div class="license-row">
|
||||
<input type="password" placeholder="wm_live_..." autocomplete="off">
|
||||
<span class="license-badge">Not Set</span>
|
||||
</div>
|
||||
</div>`;
|
||||
continue;
|
||||
}
|
||||
if (sec.type==='debug' && !q) {
|
||||
html += `<div class="accordion open debug-section" data-acc="debug">
|
||||
<div class="acc-header" data-acc-toggle="debug">
|
||||
<span class="acc-icon">🔧</span>
|
||||
<div class="acc-info"><div class="acc-title">Debug & Logs</div><div class="acc-subtitle">Diagnostics and traffic monitoring</div></div>
|
||||
<span class="acc-chevron">▼</span>
|
||||
</div>
|
||||
<div class="acc-body"><div class="acc-body-inner">
|
||||
<div class="debug-btns"><button>Open Logs Folder</button><button>Open API Log</button></div>
|
||||
<div class="debug-card">
|
||||
<h3>Diagnostics</h3>
|
||||
<label class="diag-check"><input type="checkbox"> Verbose Sidecar Log</label>
|
||||
<label class="diag-check"><input type="checkbox"> Frontend Fetch Debug</label>
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--text-sec);font-style:italic">No API traffic recorded yet.</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>`;
|
||||
continue;
|
||||
}
|
||||
if (sec.type) continue;
|
||||
|
||||
let features = sec.features;
|
||||
if (q) {
|
||||
features = features.filter(f =>
|
||||
f.name.toLowerCase().includes(q) || f.desc.toLowerCase().includes(q) || f.keys.some(k=>k.toLowerCase().includes(q))
|
||||
);
|
||||
if (features.length===0) continue;
|
||||
}
|
||||
|
||||
const ready = features.filter(f=>f.status==='ready').length;
|
||||
const total = features.length;
|
||||
const pct = (ready/total)*100;
|
||||
const fillClass = ready===total?'full':'partial';
|
||||
|
||||
html += `<div class="accordion${!q?' open':' open'}" data-acc="${sec.id}">
|
||||
<div class="acc-header" data-acc-toggle="${sec.id}">
|
||||
<span class="acc-icon">${sec.icon}</span>
|
||||
<div class="acc-info">
|
||||
<div class="acc-title">${sec.title}</div>
|
||||
<div class="acc-subtitle">${sec.desc}</div>
|
||||
</div>
|
||||
<div class="acc-stat">
|
||||
<div class="acc-stat-bar"><div class="acc-stat-fill ${fillClass}" style="width:${pct}%"></div></div>
|
||||
<span class="acc-count">${ready}/${total}</span>
|
||||
</div>
|
||||
<span class="acc-chevron">▼</span>
|
||||
</div>
|
||||
<div class="acc-body"><div class="acc-body-inner">
|
||||
${features.map(f => {
|
||||
const cls = f.status==='ready'?'ready':f.status==='staged'?'staged':'needs';
|
||||
const pillCls = f.status==='ready'?'ok':f.status==='staged'?'staged':'warn';
|
||||
const pillText = f.status==='ready'?'Ready':f.status==='staged'?'Staged':'Needs Keys';
|
||||
return `<div class="feat-row ${cls}">
|
||||
<div class="feat-top">
|
||||
<div class="toggle ${f.on?'on':''}" data-t></div>
|
||||
<span class="feat-name">${f.name}</span>
|
||||
<span class="pill ${pillCls}">${pillText}</span>
|
||||
<span class="feat-expand-icon">▼</span>
|
||||
</div>
|
||||
<div class="feat-detail"><div class="feat-detail-inner">
|
||||
<div class="feat-desc-text">${f.desc}</div>
|
||||
${f.keys.map(k=>`<div class="key-group">
|
||||
<div class="key-header"><code>${k}</code><a href="#" onclick="return false">Get key →</a></div>
|
||||
<input type="password" class="key-input" placeholder="${f.status==='ready'?'••••••••••••':'Enter API key...'}" ${f.status==='ready'?'value="••••••••••••"':''}>
|
||||
</div>`).join('')}
|
||||
${f.status!=='ready'?`<div class="feat-fallback">${f.fallback}</div>`:''}
|
||||
</div></div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (q && html==='') {
|
||||
html = `<div style="text-align:center;padding:60px 0;color:var(--text-sec)">No features matching "${filter}"</div>`;
|
||||
}
|
||||
|
||||
area.innerHTML = html;
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
// Accordion toggle
|
||||
document.querySelectorAll('.acc-header').forEach(hdr => {
|
||||
hdr.addEventListener('click', () => {
|
||||
hdr.closest('.accordion').classList.toggle('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Feature expand
|
||||
document.querySelectorAll('.feat-top').forEach(top => {
|
||||
top.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-t]')) return;
|
||||
top.closest('.feat-row').classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
|
||||
// Toggles
|
||||
document.querySelectorAll('[data-t]').forEach(t => {
|
||||
t.addEventListener('click', () => t.classList.toggle('on'));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
render(e.target.value.trim());
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
321
playground-settings-C-cards.html
Normal file
321
playground-settings-C-cards.html
Normal file
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings Option C — Dashboard Card Grid</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#1a1c1e;--surface:#2a2d31;--surface-hover:#32363b;
|
||||
--text:#e8eaed;--text-sec:#9aa0a6;--accent:#60a5fa;
|
||||
--green:#34d399;--yellow:#fbbf24;--red:#ef4444;--blue:#60a5fa;
|
||||
--border:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.14);
|
||||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:'SF Mono',Monaco,'Cascadia Code',monospace;
|
||||
}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;-webkit-font-smoothing:antialiased}
|
||||
|
||||
.shell{display:flex;flex-direction:column;height:100vh;max-width:980px;max-height:760px;margin:auto;border:1px solid var(--border-strong);border-radius:10px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.5)}
|
||||
|
||||
/* Top bar */
|
||||
.top-bar{display:flex;align-items:center;gap:16px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.top-title{font-size:15px;font-weight:700;display:flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
.top-title svg{opacity:0.7}
|
||||
|
||||
/* Big progress bar */
|
||||
.top-progress{flex:1;display:flex;align-items:center;gap:12px}
|
||||
.tp-bar{flex:1;height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden;max-width:400px}
|
||||
.tp-fill{height:100%;border-radius:4px;transition:width .5s ease}
|
||||
.tp-label{font-size:13px;font-weight:700;white-space:nowrap}
|
||||
.tp-sub{font-size:11px;color:var(--text-sec);white-space:nowrap}
|
||||
|
||||
/* Tab row for switching between Features / Debug */
|
||||
.tab-row{display:flex;gap:0;padding:0 24px;background:var(--surface);border-bottom:1px solid var(--border)}
|
||||
.tab-btn{background:none;border:none;border-bottom:2px solid transparent;color:var(--text-sec);font:inherit;font-size:13px;font-weight:500;padding:10px 18px;cursor:pointer;transition:color .15s,border-color .15s}
|
||||
.tab-btn:hover{color:var(--text)}
|
||||
.tab-btn.active{color:var(--text);font-weight:600;border-bottom-color:var(--accent)}
|
||||
|
||||
/* Scroll area */
|
||||
.scroll-area{flex:1;overflow-y:auto;padding:20px 24px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent}
|
||||
.scroll-area::-webkit-scrollbar{width:6px}
|
||||
.scroll-area::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
|
||||
|
||||
/* Category header */
|
||||
.cat-head{display:flex;align-items:center;gap:8px;margin-top:16px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
|
||||
.cat-head:first-child{margin-top:0}
|
||||
.cat-icon{font-size:16px}
|
||||
.cat-title{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--text-sec)}
|
||||
.cat-count{font-size:11px;color:var(--text-sec);margin-left:auto;opacity:0.6}
|
||||
|
||||
/* Card grid */
|
||||
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px;margin-bottom:8px}
|
||||
|
||||
/* Cards */
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s,box-shadow .15s,transform .1s;position:relative;overflow:hidden}
|
||||
.card:hover{border-color:var(--border-strong);box-shadow:0 2px 12px rgba(0,0,0,0.2)}
|
||||
.card.expanded{grid-column:1/-1;cursor:default}
|
||||
.card.expanded:hover{transform:none}
|
||||
|
||||
.card-top{display:flex;align-items:flex-start;gap:10px}
|
||||
.card-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-top:4px}
|
||||
.card-dot.green{background:var(--green);box-shadow:0 0 6px rgba(52,211,153,0.3)}
|
||||
.card-dot.yellow{background:var(--yellow);box-shadow:0 0 6px rgba(251,191,36,0.3)}
|
||||
.card-dot.blue{background:var(--blue);box-shadow:0 0 6px rgba(96,165,250,0.3)}
|
||||
.card-info{flex:1;min-width:0}
|
||||
.card-name{font-size:13px;font-weight:600;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card-desc-mini{font-size:11px;color:var(--text-sec);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
||||
.card-toggle{position:relative;width:32px;height:18px;background:rgba(255,255,255,0.1);border-radius:9px;flex-shrink:0;cursor:pointer;transition:background .2s}
|
||||
.card-toggle.on{background:var(--accent)}
|
||||
.card-toggle::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;background:#fff;border-radius:50%;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,0.3)}
|
||||
.card-toggle.on::after{transform:translateX(14px)}
|
||||
|
||||
/* Expanded card */
|
||||
.card-expanded-body{display:none;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
|
||||
.card.expanded .card-expanded-body{display:block}
|
||||
.card.expanded .card-desc-mini{display:none}
|
||||
|
||||
.exp-desc{font-size:12px;color:var(--text-sec);line-height:1.5;margin-bottom:12px}
|
||||
.exp-keys{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
@media(max-width:600px){.exp-keys{grid-template-columns:1fr}}
|
||||
.exp-key{display:flex;flex-direction:column;gap:4px}
|
||||
.exp-key-header{display:flex;align-items:center;justify-content:space-between}
|
||||
.exp-key-header code{font-size:11px;color:var(--text-sec);font-family:var(--mono)}
|
||||
.exp-key-header a{font-size:11px;color:var(--accent);text-decoration:none;font-weight:600}
|
||||
.exp-key-header a:hover{text-decoration:underline}
|
||||
.exp-input{width:100%;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:7px 10px;font:inherit;font-size:12px;font-family:var(--mono);outline:none;transition:border-color .15s}
|
||||
.exp-input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.exp-input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
.exp-pill{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:8px;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
|
||||
.exp-pill.ok{color:var(--green);background:rgba(52,211,153,0.12)}
|
||||
.exp-pill.warn{color:var(--yellow);background:rgba(251,191,36,0.12)}
|
||||
.exp-fallback{font-size:11px;color:var(--yellow);margin-top:10px;font-style:italic}
|
||||
.exp-close{position:absolute;top:10px;right:12px;background:none;border:none;color:var(--text-sec);cursor:pointer;font-size:18px;line-height:1;padding:4px;transition:color .1s}
|
||||
.exp-close:hover{color:var(--text)}
|
||||
|
||||
/* License card */
|
||||
.license-card{background:linear-gradient(135deg,rgba(52,211,153,0.06),rgba(96,165,250,0.06));border:1px solid rgba(52,211,153,0.15);border-radius:10px;padding:16px 20px;margin-bottom:16px}
|
||||
.license-card h3{font-size:14px;font-weight:700;margin-bottom:4px}
|
||||
.license-card p{font-size:12px;color:var(--text-sec);margin-bottom:10px;line-height:1.4}
|
||||
.license-row{display:flex;gap:10px}
|
||||
.license-row input{flex:1;background:rgba(0,0,0,0.25);border:1px solid var(--border-strong);border-radius:6px;color:var(--text);padding:7px 10px;font:inherit;font-size:12px;font-family:var(--mono);outline:none}
|
||||
.license-row input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(96,165,250,0.15)}
|
||||
.license-row input::placeholder{color:rgba(255,255,255,0.2)}
|
||||
.license-badge{font-size:10px;font-weight:600;padding:3px 10px;border-radius:10px;text-transform:uppercase;color:var(--yellow);background:rgba(251,191,36,0.12);align-self:center;white-space:nowrap}
|
||||
|
||||
/* Debug panel */
|
||||
.debug-panel{padding:4px 0}
|
||||
.debug-btns{display:flex;gap:10px;margin-bottom:12px}
|
||||
.debug-btns button{background:var(--surface-hover);border:1px solid var(--border-strong);color:var(--text);font:inherit;font-size:12px;padding:7px 14px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||
.debug-btns button:hover{background:rgba(255,255,255,0.08)}
|
||||
.debug-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px}
|
||||
.debug-card h3{font-size:13px;font-weight:600;margin-bottom:8px}
|
||||
.diag-check{display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:12px;color:var(--text-sec);cursor:pointer}
|
||||
.diag-check input{accent-color:var(--accent)}
|
||||
.traffic-empty{font-size:12px;color:var(--text-sec);font-style:italic;margin-top:10px}
|
||||
|
||||
/* Footer */
|
||||
.footer{display:flex;justify-content:flex-end;gap:10px;padding:12px 24px;border-top:1px solid var(--border);background:var(--surface);flex-shrink:0}
|
||||
.btn{font:inherit;font-size:13px;font-weight:600;padding:8px 24px;border-radius:6px;cursor:pointer;min-width:80px;text-align:center;transition:background .15s,transform .1s;letter-spacing:.01em}
|
||||
.btn:active{transform:scale(0.98)}
|
||||
.btn-sec{background:transparent;border:1px solid var(--border-strong);color:var(--text-sec)}
|
||||
.btn-sec:hover{background:rgba(255,255,255,0.04);color:var(--text)}
|
||||
.btn-pri{background:var(--accent);border:1px solid var(--accent);color:#fff}
|
||||
.btn-pri:hover{background:#5294e8}
|
||||
|
||||
/* Label */
|
||||
.option-label{position:fixed;top:12px;right:12px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;padding:4px 12px;border-radius:6px;letter-spacing:.05em;z-index:999;text-transform:uppercase}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="option-label">Option C — Card Grid</div>
|
||||
|
||||
<div class="shell">
|
||||
<div class="top-bar">
|
||||
<div class="top-title">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
World Monitor
|
||||
</div>
|
||||
<div class="top-progress">
|
||||
<div class="tp-bar"><div class="tp-fill" id="tpFill" style="width:71%;background:linear-gradient(90deg,var(--green),var(--blue))"></div></div>
|
||||
<span class="tp-label" id="tpLabel" style="color:var(--green)">12/17</span>
|
||||
<span class="tp-sub">features configured</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-row">
|
||||
<button class="tab-btn active" data-tab="features">Features</button>
|
||||
<button class="tab-btn" data-tab="debug">Debug & Logs</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll-area" id="scrollArea"></div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="btn btn-sec">Cancel</button>
|
||||
<button class="btn btn-pri">Save & Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CATEGORIES = [
|
||||
{ id:'ai', icon:'🤖', title:'AI Models',
|
||||
features:[
|
||||
{ name:'Ollama Local LLM', desc:'Local summarization via OpenAI-compatible endpoint (Ollama or LM Studio, desktop-first).', keys:['OLLAMA_API_URL','OLLAMA_MODEL'], status:'ready', on:true, fallback:'Falls back to Groq → OpenRouter → browser model.', signup:'https://ollama.com/download' },
|
||||
{ name:'Groq Summarization', desc:'Primary fast LLM provider used for AI summary generation.', keys:['GROQ_API_KEY'], status:'ready', on:true, fallback:'Falls back to OpenRouter → browser model.', signup:'https://console.groq.com/keys' },
|
||||
{ name:'OpenRouter LLM', desc:'Secondary LLM provider for AI summary fallback.', keys:['OPENROUTER_API_KEY'], status:'ready', on:true, fallback:'Falls back to local browser model only.', signup:'https://openrouter.ai/settings/keys' },
|
||||
]},
|
||||
{ id:'data', icon:'📈', title:'Economic Data',
|
||||
features:[
|
||||
{ name:'FRED Economic', desc:'Macro indicators from Federal Reserve Economic Data.', keys:['FRED_API_KEY'], status:'ready', on:true, fallback:'Economic panel uses non-FRED metrics.', signup:'https://fred.stlouisfed.org/docs/api/api_key.html' },
|
||||
{ name:'EIA Oil Analytics', desc:'US Energy Information Administration oil metrics.', keys:['EIA_API_KEY'], status:'ready', on:true, fallback:'Oil analytics cards disabled.', signup:'https://www.eia.gov/opendata/register.php' },
|
||||
{ name:'WTO Trade Policy', desc:'Trade restrictions, tariff trends, barriers and flows.', keys:['WTO_API_KEY'], status:'ready', on:true, fallback:'Trade policy panel disabled.', signup:'https://apiportal.wto.org/' },
|
||||
]},
|
||||
{ id:'security', icon:'🛡️', title:'Security Intel',
|
||||
features:[
|
||||
{ name:'Cloudflare Radar', desc:'Internet outages from Cloudflare Radar annotations API.', keys:['CLOUDFLARE_API_TOKEN'], status:'ready', on:true, fallback:'Outage layer disabled.', signup:'https://dash.cloudflare.com/profile/api-tokens' },
|
||||
{ name:'abuse.ch IOC Feeds', desc:'URLhaus and ThreatFox IOC ingestion for cyber threat layer.', keys:['URLHAUS_AUTH_KEY'], status:'needs', on:true, fallback:'URLhaus/ThreatFox ingestion disabled.', signup:'https://auth.abuse.ch/' },
|
||||
{ name:'AlienVault OTX', desc:'OTX IOC ingestion for cyber threat enrichment.', keys:['OTX_API_KEY'], status:'ready', on:true, fallback:'OTX enrichment disabled.', signup:'https://otx.alienvault.com/' },
|
||||
{ name:'AbuseIPDB', desc:'IP reputation enrichment for cyber threat layer.', keys:['ABUSEIPDB_API_KEY'], status:'needs', on:false, fallback:'AbuseIPDB enrichment disabled.', signup:'https://www.abuseipdb.com/login' },
|
||||
]},
|
||||
{ id:'tracking', icon:'✈️', title:'Tracking & Flights',
|
||||
features:[
|
||||
{ name:'AIS Vessels', desc:'Live vessel ingestion via AISStream WebSocket.', keys:['AISSTREAM_API_KEY'], status:'ready', on:true, fallback:'AIS layer disabled.', signup:'https://aisstream.io/authenticate' },
|
||||
{ name:'OpenSky Military', desc:'OpenSky OAuth for military flight data.', keys:['OPENSKY_CLIENT_ID','OPENSKY_CLIENT_SECRET'], status:'needs', on:true, fallback:'Military flights limited data.', signup:'https://opensky-network.org' },
|
||||
{ name:'Wingbits Aircraft', desc:'Operator/aircraft enrichment metadata.', keys:['WINGBITS_API_KEY'], status:'needs', on:false, fallback:'Heuristic-only classification.', signup:'https://wingbits.com/register' },
|
||||
{ name:'NASA FIRMS Fire', desc:'FIRMS satellite fire detection data.', keys:['NASA_FIRMS_API_KEY'], status:'ready', on:true, fallback:'Uses public VIIRS feed.', signup:'https://firms.modaps.eosdis.nasa.gov/api/area/' },
|
||||
]},
|
||||
{ id:'markets', icon:'💹', title:'Markets & Conflicts',
|
||||
features:[
|
||||
{ name:'Finnhub Markets', desc:'Real-time stock quotes and market data.', keys:['FINNHUB_API_KEY'], status:'ready', on:true, fallback:'Limited free data.', signup:'https://finnhub.io/register' },
|
||||
{ name:'ACLED Conflicts', desc:'Armed conflict and protest event feeds.', keys:['ACLED_ACCESS_TOKEN'], status:'ready', on:true, fallback:'Overlays hidden.', signup:'https://developer.acleddata.com/' },
|
||||
]},
|
||||
{ id:'supply', icon:'🚢', title:'Supply Chain',
|
||||
features:[
|
||||
{ name:'Supply Chain Intel', desc:'Baltic Dry Index, chokepoints, and critical minerals.', keys:['FRED_API_KEY'], status:'ready', on:true, fallback:'Chokepoints/minerals always available.', signup:'https://fred.stlouisfed.org/docs/api/api_key.html' },
|
||||
]},
|
||||
];
|
||||
|
||||
let expandedCard = null;
|
||||
|
||||
function renderFeatures() {
|
||||
const area = document.getElementById('scrollArea');
|
||||
let html = `<div class="license-card">
|
||||
<h3>License Key</h3>
|
||||
<p>Enter your World Monitor key for full cloud access, or use BYOK below.</p>
|
||||
<div class="license-row">
|
||||
<input type="password" placeholder="wm_live_..." autocomplete="off">
|
||||
<span class="license-badge">Not Set</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
for (const cat of CATEGORIES) {
|
||||
const ready = cat.features.filter(f=>f.status==='ready').length;
|
||||
html += `<div class="cat-head">
|
||||
<span class="cat-icon">${cat.icon}</span>
|
||||
<span class="cat-title">${cat.title}</span>
|
||||
<span class="cat-count">${ready}/${cat.features.length} ready</span>
|
||||
</div>`;
|
||||
|
||||
html += `<div class="card-grid" data-cat="${cat.id}">`;
|
||||
for (let i=0; i<cat.features.length; i++) {
|
||||
const f = cat.features[i];
|
||||
const fid = `${cat.id}-${i}`;
|
||||
const isExpanded = expandedCard===fid;
|
||||
const dotColor = f.status==='ready'?'green':f.status==='staged'?'blue':'yellow';
|
||||
const pillCls = f.status==='ready'?'ok':f.status==='staged'?'staged':'warn';
|
||||
const pillText = f.status==='ready'?'Ready':f.status==='staged'?'Staged':'Needs Keys';
|
||||
|
||||
html += `<div class="card${isExpanded?' expanded':''}" data-card="${fid}">
|
||||
${isExpanded?'<button class="exp-close" data-close>×</button>':''}
|
||||
<div class="card-top">
|
||||
<span class="card-dot ${dotColor}"></span>
|
||||
<div class="card-info">
|
||||
<div class="card-name">${f.name}</div>
|
||||
<span class="card-desc-mini">${f.desc}</span>
|
||||
</div>
|
||||
<div class="card-toggle ${f.on?'on':''}" data-tog="${fid}"></div>
|
||||
</div>
|
||||
<div class="card-expanded-body">
|
||||
<span class="exp-pill ${pillCls}">${pillText}</span>
|
||||
<div class="exp-desc">${f.desc}</div>
|
||||
<div class="exp-keys">
|
||||
${f.keys.map(k=>`<div class="exp-key">
|
||||
<div class="exp-key-header"><code>${k}</code><a href="#" onclick="return false">Get key →</a></div>
|
||||
<input type="password" class="exp-input" placeholder="${f.status==='ready'?'••••••••••••':'Enter API key...'}" ${f.status==='ready'?'value="••••••••••••"':''} onclick="event.stopPropagation()">
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${f.status!=='ready'?`<div class="exp-fallback">${f.fallback}</div>`:''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
area.innerHTML = html;
|
||||
attachCardListeners();
|
||||
}
|
||||
|
||||
function renderDebug() {
|
||||
const area = document.getElementById('scrollArea');
|
||||
area.innerHTML = `<div class="debug-panel">
|
||||
<div class="debug-btns"><button>Open Logs Folder</button><button>Open API Log</button></div>
|
||||
<div class="debug-card">
|
||||
<h3>Diagnostics</h3>
|
||||
<label class="diag-check"><input type="checkbox"> Verbose Sidecar Log</label>
|
||||
<label class="diag-check"><input type="checkbox"> Frontend Fetch Debug</label>
|
||||
<div style="margin-top:14px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-sec)">API Traffic</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<label style="font-size:12px;color:var(--text-sec);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" checked style="accent-color:var(--accent)"> Auto</label>
|
||||
<button style="background:var(--surface-hover);border:1px solid var(--border-strong);color:var(--text-sec);font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer">Refresh</button>
|
||||
<button style="background:var(--surface-hover);border:1px solid var(--border-strong);color:var(--text-sec);font-size:11px;padding:3px 10px;border-radius:4px;cursor:pointer">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-empty">No API traffic recorded yet.</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function attachCardListeners() {
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-tog]') || e.target.closest('.exp-input') || e.target.closest('.exp-key-header')) return;
|
||||
if (e.target.closest('[data-close]')) {
|
||||
expandedCard = null;
|
||||
renderFeatures();
|
||||
return;
|
||||
}
|
||||
const fid = card.dataset.card;
|
||||
if (expandedCard===fid) return; // already expanded, don't collapse on body click
|
||||
expandedCard = fid;
|
||||
renderFeatures();
|
||||
// Scroll expanded card into view
|
||||
setTimeout(() => {
|
||||
document.querySelector('.card.expanded')?.scrollIntoView({ behavior:'smooth', block:'nearest' });
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-tog]').forEach(t => {
|
||||
t.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
t.classList.toggle('on');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (btn.dataset.tab==='features') renderFeatures();
|
||||
else renderDebug();
|
||||
});
|
||||
});
|
||||
|
||||
renderFeatures();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
304
scripts/fetch-mirta-bases.mjs
Normal file
304
scripts/fetch-mirta-bases.mjs
Normal file
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch MIRTA (Military Installations, Ranges and Training Areas) dataset
|
||||
* from the US Army Corps of Engineers ArcGIS FeatureServer.
|
||||
*
|
||||
* Source: https://geospatial-usace.opendata.arcgis.com/maps/fc0f38c5a19a46dbacd92f2fb823ef8c
|
||||
* API: https://services7.arcgis.com/n1YM8pTrFmm7L4hs/arcgis/rest/services/mirta/FeatureServer
|
||||
*
|
||||
* Layers:
|
||||
* 0 = DoD Sites - Point (737 features)
|
||||
* 1 = DoD Sites - Boundary (825 features, polygons)
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = resolve(__dirname, 'data');
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const BASE = 'https://services7.arcgis.com/n1YM8pTrFmm7L4hs/arcgis/rest/services/mirta/FeatureServer';
|
||||
const PAGE_SIZE = 1000; // server maxRecordCount
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch / component mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
const BRANCH_MAP = {
|
||||
usa: 'Army',
|
||||
usar: 'Army Reserve',
|
||||
armynationalguard: 'Army National Guard',
|
||||
usaf: 'Air Force',
|
||||
afr: 'Air Force Reserve',
|
||||
airnationalguard: 'Air National Guard',
|
||||
usmc: 'Marine Corps',
|
||||
usmcr: 'Marine Corps Reserve',
|
||||
usn: 'Navy',
|
||||
usnr: 'Navy Reserve',
|
||||
whs: 'Washington Headquarters Services',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
const COMPONENT_MAP = {
|
||||
usa: 'Active',
|
||||
usar: 'Reserve',
|
||||
armynationalguard: 'National Guard',
|
||||
usaf: 'Active',
|
||||
afr: 'Reserve',
|
||||
airnationalguard: 'National Guard',
|
||||
usmc: 'Active',
|
||||
usmcr: 'Reserve',
|
||||
usn: 'Active',
|
||||
usnr: 'Reserve',
|
||||
whs: 'Active',
|
||||
other: 'Unknown',
|
||||
};
|
||||
|
||||
const STATUS_MAP = {
|
||||
act: 'Active',
|
||||
clsd: 'Closed',
|
||||
semi: 'Semi-Active',
|
||||
care: 'Caretaker',
|
||||
excs: 'Excess',
|
||||
};
|
||||
|
||||
const STATE_MAP = {
|
||||
al: 'Alabama', ak: 'Alaska', az: 'Arizona', ar: 'Arkansas', ca: 'California',
|
||||
co: 'Colorado', ct: 'Connecticut', de: 'Delaware', fl: 'Florida', ga: 'Georgia',
|
||||
hi: 'Hawaii', id: 'Idaho', il: 'Illinois', in: 'Indiana', ia: 'Iowa',
|
||||
ks: 'Kansas', ky: 'Kentucky', la: 'Louisiana', me: 'Maine', md: 'Maryland',
|
||||
ma: 'Massachusetts', mi: 'Michigan', mn: 'Minnesota', ms: 'Mississippi',
|
||||
mo: 'Missouri', mt: 'Montana', ne: 'Nebraska', nv: 'Nevada', nh: 'New Hampshire',
|
||||
nj: 'New Jersey', nm: 'New Mexico', ny: 'New York', nc: 'North Carolina',
|
||||
nd: 'North Dakota', oh: 'Ohio', ok: 'Oklahoma', or: 'Oregon', pa: 'Pennsylvania',
|
||||
ri: 'Rhode Island', sc: 'South Carolina', sd: 'South Dakota', tn: 'Tennessee',
|
||||
tx: 'Texas', ut: 'Utah', vt: 'Vermont', va: 'Virginia', wa: 'Washington',
|
||||
wv: 'West Virginia', wi: 'Wisconsin', wy: 'Wyoming', dc: 'District of Columbia',
|
||||
pr: 'Puerto Rico', gu: 'Guam', vi: 'Virgin Islands', as: 'American Samoa',
|
||||
mp: 'Northern Mariana Islands',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paginated ArcGIS fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchAllFeatures(layerIndex) {
|
||||
let offset = 0;
|
||||
let page = 0;
|
||||
const allFeatures = [];
|
||||
let exceeded = true;
|
||||
|
||||
while (exceeded) {
|
||||
page++;
|
||||
const params = new URLSearchParams({
|
||||
where: '1=1',
|
||||
outFields: '*',
|
||||
f: 'geojson',
|
||||
resultRecordCount: String(PAGE_SIZE),
|
||||
resultOffset: String(offset),
|
||||
});
|
||||
const url = `${BASE}/${layerIndex}/query?${params}`;
|
||||
console.log(` Page ${page}: offset=${offset} ...`);
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
|
||||
const json = await resp.json();
|
||||
|
||||
const features = json.features || [];
|
||||
allFeatures.push(...features);
|
||||
console.log(` Page ${page}: got ${features.length} features (total so far: ${allFeatures.length})`);
|
||||
|
||||
exceeded = json.properties?.exceededTransferLimit === true;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Centroid of a polygon (simple average of all coordinates)
|
||||
// ---------------------------------------------------------------------------
|
||||
function centroid(geometry) {
|
||||
if (!geometry) return { lat: null, lon: null };
|
||||
|
||||
if (geometry.type === 'Point') {
|
||||
return { lon: geometry.coordinates[0], lat: geometry.coordinates[1] };
|
||||
}
|
||||
|
||||
let rings;
|
||||
if (geometry.type === 'Polygon') {
|
||||
rings = geometry.coordinates;
|
||||
} else if (geometry.type === 'MultiPolygon') {
|
||||
rings = geometry.coordinates.flat();
|
||||
} else {
|
||||
return { lat: null, lon: null };
|
||||
}
|
||||
|
||||
let sumLon = 0, sumLat = 0, count = 0;
|
||||
for (const ring of rings) {
|
||||
for (const [lon, lat] of ring) {
|
||||
sumLon += lon;
|
||||
sumLat += lat;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0
|
||||
? { lon: +(sumLon / count).toFixed(6), lat: +(sumLat / count).toFixed(6) }
|
||||
: { lat: null, lon: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process features into clean records
|
||||
// ---------------------------------------------------------------------------
|
||||
function processFeature(feature) {
|
||||
const p = feature.properties || {};
|
||||
const comp = (p.SITEREPORTINGCOMPONENT || '').toLowerCase().trim();
|
||||
const statusRaw = (p.SITEOPERATIONALSTATUS || '').toLowerCase().trim();
|
||||
const stateRaw = (p.STATENAMECODE || '').toLowerCase().trim();
|
||||
const { lat, lon } = centroid(feature.geometry);
|
||||
|
||||
return {
|
||||
name: (p.SITENAME || p.FEATURENAME || '').trim(),
|
||||
branch: BRANCH_MAP[comp] || comp || 'Unknown',
|
||||
status: STATUS_MAP[statusRaw] || statusRaw || 'Unknown',
|
||||
state: STATE_MAP[stateRaw] || stateRaw.toUpperCase() || 'Unknown',
|
||||
lat,
|
||||
lon,
|
||||
kind: (p.FEATUREDESCRIPTION && p.FEATUREDESCRIPTION !== 'na')
|
||||
? p.FEATUREDESCRIPTION
|
||||
: 'Installation',
|
||||
component: COMPONENT_MAP[comp] || 'Unknown',
|
||||
jointBase: p.ISJOINTBASE === 'yes',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log('=== MIRTA Dataset Fetcher ===\n');
|
||||
|
||||
// ---------- Points layer (layer 0) ----------
|
||||
console.log('[1/4] Fetching Points layer (layer 0)...');
|
||||
const pointsGeoJson = await fetchAllFeatures(0);
|
||||
console.log(` Total point features: ${pointsGeoJson.features.length}\n`);
|
||||
|
||||
// ---------- Boundary layer (layer 1) ----------
|
||||
console.log('[2/4] Fetching Boundary layer (layer 1)...');
|
||||
const boundaryGeoJson = await fetchAllFeatures(1);
|
||||
console.log(` Total boundary features: ${boundaryGeoJson.features.length}\n`);
|
||||
|
||||
// ---------- Save raw GeoJSON ----------
|
||||
console.log('[3/4] Saving raw GeoJSON...');
|
||||
|
||||
const combinedRaw = {
|
||||
type: 'FeatureCollection',
|
||||
metadata: {
|
||||
source: 'MIRTA - Military Installations, Ranges and Training Areas',
|
||||
url: 'https://geospatial-usace.opendata.arcgis.com/maps/fc0f38c5a19a46dbacd92f2fb823ef8c',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
pointFeatures: pointsGeoJson.features.length,
|
||||
boundaryFeatures: boundaryGeoJson.features.length,
|
||||
},
|
||||
features: [
|
||||
...pointsGeoJson.features,
|
||||
...boundaryGeoJson.features,
|
||||
],
|
||||
};
|
||||
|
||||
const rawPath = resolve(DATA_DIR, 'mirta-raw.geojson');
|
||||
writeFileSync(rawPath, JSON.stringify(combinedRaw, null, 2));
|
||||
const rawSizeMB = (Buffer.byteLength(JSON.stringify(combinedRaw)) / 1024 / 1024).toFixed(2);
|
||||
console.log(` Saved ${rawPath} (${rawSizeMB} MB)\n`);
|
||||
|
||||
// ---------- Process into clean records ----------
|
||||
console.log('[4/4] Processing into clean records...');
|
||||
|
||||
// Use points layer as primary (has exact coordinates).
|
||||
// Supplement with boundary-only entries (those not in points).
|
||||
const pointNames = new Set(
|
||||
pointsGeoJson.features.map(f => (f.properties?.SITENAME || '').toLowerCase().trim())
|
||||
);
|
||||
|
||||
const processed = [];
|
||||
|
||||
for (const f of pointsGeoJson.features) {
|
||||
processed.push(processFeature(f));
|
||||
}
|
||||
|
||||
let boundaryOnly = 0;
|
||||
for (const f of boundaryGeoJson.features) {
|
||||
const name = (f.properties?.SITENAME || '').toLowerCase().trim();
|
||||
if (!pointNames.has(name)) {
|
||||
processed.push(processFeature(f));
|
||||
boundaryOnly++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
processed.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const output = {
|
||||
metadata: {
|
||||
source: 'MIRTA - Military Installations, Ranges and Training Areas',
|
||||
url: 'https://geospatial-usace.opendata.arcgis.com/maps/fc0f38c5a19a46dbacd92f2fb823ef8c',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
totalInstallations: processed.length,
|
||||
fromPoints: pointsGeoJson.features.length,
|
||||
fromBoundariesOnly: boundaryOnly,
|
||||
},
|
||||
installations: processed,
|
||||
};
|
||||
|
||||
const processedPath = resolve(DATA_DIR, 'mirta-processed.json');
|
||||
writeFileSync(processedPath, JSON.stringify(output, null, 2));
|
||||
const procSizeMB = (Buffer.byteLength(JSON.stringify(output)) / 1024 / 1024).toFixed(2);
|
||||
console.log(` Saved ${processedPath} (${procSizeMB} MB)\n`);
|
||||
|
||||
// ---------- Summary ----------
|
||||
console.log('=== Summary ===');
|
||||
console.log(`Total installations: ${processed.length}`);
|
||||
console.log(` From points layer: ${pointsGeoJson.features.length}`);
|
||||
console.log(` From boundaries only: ${boundaryOnly}`);
|
||||
|
||||
// Branch breakdown
|
||||
const branchCounts = {};
|
||||
const statusCounts = {};
|
||||
const componentCounts = {};
|
||||
for (const inst of processed) {
|
||||
branchCounts[inst.branch] = (branchCounts[inst.branch] || 0) + 1;
|
||||
statusCounts[inst.status] = (statusCounts[inst.status] || 0) + 1;
|
||||
componentCounts[inst.component] = (componentCounts[inst.component] || 0) + 1;
|
||||
}
|
||||
|
||||
console.log('\nBy branch:');
|
||||
for (const [k, v] of Object.entries(branchCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
|
||||
console.log('\nBy status:');
|
||||
for (const [k, v] of Object.entries(statusCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
|
||||
console.log('\nBy component:');
|
||||
for (const [k, v] of Object.entries(componentCounts).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
|
||||
console.log('\nSample entries:');
|
||||
const samples = [processed[0], processed[Math.floor(processed.length / 3)], processed[Math.floor(processed.length * 2 / 3)], processed[processed.length - 1]];
|
||||
for (const s of samples) {
|
||||
console.log(` ${s.name} | ${s.branch} | ${s.status} | ${s.state} | (${s.lat}, ${s.lon})`);
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
164
scripts/fetch-osm-bases.mjs
Normal file
164
scripts/fetch-osm-bases.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, 'data');
|
||||
const RAW_PATH = join(DATA_DIR, 'osm-military-raw.json');
|
||||
const PROCESSED_PATH = join(DATA_DIR, 'osm-military-processed.json');
|
||||
|
||||
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||
const OVERPASS_QUERY = `
|
||||
[out:json][timeout:300];
|
||||
(
|
||||
node["military"]["name"];
|
||||
way["military"]["name"];
|
||||
relation["military"]["name"];
|
||||
);
|
||||
out center tags;
|
||||
`.trim();
|
||||
|
||||
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
console.log(`Created directory: ${DATA_DIR}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOverpassData() {
|
||||
console.log('Querying Overpass API for military features with names...');
|
||||
console.log(`Query:\n${OVERPASS_QUERY}\n`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(OVERPASS_QUERY)}`,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Overpass API returned ${res.status}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
console.log('Response received, reading body...');
|
||||
const json = await res.json();
|
||||
return json;
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Overpass API request timed out after 5 minutes');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function processFeatures(raw) {
|
||||
const elements = raw.elements || [];
|
||||
console.log(`Raw elements count: ${elements.length}`);
|
||||
|
||||
const processed = elements.map((el) => {
|
||||
const tags = el.tags || {};
|
||||
|
||||
// Coordinates: nodes have lat/lon directly; ways/relations use center
|
||||
const lat = el.lat ?? el.center?.lat ?? null;
|
||||
const lon = el.lon ?? el.center?.lon ?? null;
|
||||
|
||||
const typePrefix = el.type; // node, way, relation
|
||||
const osmId = `${typePrefix}/${el.id}`;
|
||||
|
||||
const name = tags['name:en'] || tags.name || '';
|
||||
const country = tags['addr:country'] || '';
|
||||
const kind = tags.military || '';
|
||||
const operator = tags.operator || '';
|
||||
const description = tags.description || '';
|
||||
const militaryBranch = tags.military_branch || '';
|
||||
|
||||
return {
|
||||
osm_id: osmId,
|
||||
name,
|
||||
country,
|
||||
kind,
|
||||
lat,
|
||||
lon,
|
||||
operator,
|
||||
description,
|
||||
military_branch: militaryBranch,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out entries without coordinates
|
||||
const withCoords = processed.filter((f) => f.lat != null && f.lon != null);
|
||||
const skipped = processed.length - withCoords.length;
|
||||
if (skipped > 0) {
|
||||
console.log(`Skipped ${skipped} features without coordinates`);
|
||||
}
|
||||
|
||||
return withCoords;
|
||||
}
|
||||
|
||||
function printSummary(features) {
|
||||
console.log(`\n--- Summary ---`);
|
||||
console.log(`Total processed features: ${features.length}`);
|
||||
|
||||
// Count by kind
|
||||
const kindCounts = {};
|
||||
for (const f of features) {
|
||||
kindCounts[f.kind] = (kindCounts[f.kind] || 0) + 1;
|
||||
}
|
||||
console.log('\nBy military tag value:');
|
||||
const sorted = Object.entries(kindCounts).sort((a, b) => b[1] - a[1]);
|
||||
for (const [kind, count] of sorted) {
|
||||
console.log(` ${kind}: ${count}`);
|
||||
}
|
||||
|
||||
// Count with country
|
||||
const withCountry = features.filter((f) => f.country).length;
|
||||
console.log(`\nFeatures with country tag: ${withCountry}`);
|
||||
|
||||
// Sample entries
|
||||
console.log('\nSample entries (first 5):');
|
||||
for (const f of features.slice(0, 5)) {
|
||||
console.log(` ${f.osm_id} | ${f.name} | ${f.kind} | ${f.lat?.toFixed(4)},${f.lon?.toFixed(4)} | ${f.country || '(no country)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const start = Date.now();
|
||||
ensureDataDir();
|
||||
|
||||
const raw = await fetchOverpassData();
|
||||
|
||||
// Save raw
|
||||
console.log(`Saving raw response to ${RAW_PATH}...`);
|
||||
writeFileSync(RAW_PATH, JSON.stringify(raw, null, 2));
|
||||
console.log('Raw data saved.');
|
||||
|
||||
// Process
|
||||
const features = processFeatures(raw);
|
||||
|
||||
// Save processed
|
||||
console.log(`Saving processed data to ${PROCESSED_PATH}...`);
|
||||
writeFileSync(PROCESSED_PATH, JSON.stringify(features, null, 2));
|
||||
console.log('Processed data saved.');
|
||||
|
||||
printSummary(features);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\nDone in ${elapsed}s`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
46
scripts/need-work.csv
Normal file
46
scripts/need-work.csv
Normal file
@@ -0,0 +1,46 @@
|
||||
#,Variant,Category,Feed Name,Status,Newest Date,Error,URL,Replacement,Notes
|
||||
32,full,europe,Corriere della Sera,DEAD,,HTTP 404,https://xml2.corriereobjects.it/rss/incipit.xml,https://www.corriere.it/rss/homepage.xml,Direct RSS 69 items verified
|
||||
36,full,europe,De Telegraaf,DEAD,,HTTP 403,https://www.telegraaf.nl/rss,"https://news.google.com/rss/search?q=site:telegraaf.nl+when:1d&hl=nl&gl=NL&ceid=NL:nl",Direct feed blocks crawlers; Google News 100 items
|
||||
38,full,europe,Dagens Nyheter,DEAD,,HTTP 404,https://www.dn.se/rss/senaste-nytt/,https://www.dn.se/rss/,Drop /senaste-nytt/ suffix; 116 items verified
|
||||
65,full,middleeast,L'Orient-Le Jour,DEAD,,HTTP 403,https://www.lorientlejour.com/rss,"https://news.google.com/rss/search?q=site:lorientlejour.com+when:1d&hl=fr&gl=LB&ceid=LB:fr",Direct 403; Google News 74 items French
|
||||
132,full,latam,O Globo,DEAD,,HTTP 404,https://oglobo.globo.com/rss/top_noticias/,"https://news.google.com/rss/search?q=site:oglobo.globo.com+when:1d&hl=pt-BR&gl=BR&ceid=BR:pt-419",Direct RSS empty shell; Google News 100 items
|
||||
133,full,latam,Folha de S.Paulo,DEAD,,fetch failed,https://feeds.folha.uol.com.br/emcimadahora/rss091.xml,KEEP,Transient failure; 100 items on retest
|
||||
136,full,latam,El Universal,DEAD,,HTTP 404,https://www.eluniversal.com.mx/rss.xml,"https://news.google.com/rss/search?q=site:eluniversal.com.mx+when:1d&hl=es-419&gl=MX&ceid=MX:es-419",All direct paths 404; Google News 100 items
|
||||
139,full,latam,Animal Político,DEAD,,HTTP 404,https://animalpolitico.com/feed/,"https://news.google.com/rss/search?q=site:animalpolitico.com+when:1d&hl=es-419&gl=MX&ceid=MX:es-419",Direct 404; Google News 98 items
|
||||
140,full,latam,Proceso,DEAD,,HTTP 404,https://www.proceso.com.mx/feed/,"https://news.google.com/rss/search?q=site:proceso.com.mx+when:1d&hl=es-419&gl=MX&ceid=MX:es-419",Direct 404; Google News 100 items
|
||||
141,full,latam,Milenio,DEAD,,HTTP 404,https://www.milenio.com/rss,"https://news.google.com/rss/search?q=site:milenio.com+when:1d&hl=es-419&gl=MX&ceid=MX:es-419",All direct paths 404; Google News 100 items
|
||||
161,full,asia,Bangkok Post,DEAD,,HTTP 451,https://www.bangkokpost.com/rss,"https://news.google.com/rss/search?q=site:bangkokpost.com+when:1d&hl=en-US&gl=US&ceid=US:en",Geo-blocked 451; Google News 42 items
|
||||
377,happy,science,ScienceDaily,DEAD,,Timeout,https://www.sciencedaily.com/rss/top.xml,https://www.sciencedaily.com/rss/all.xml,top.xml empty; all.xml has 40 items verified
|
||||
388,intel,inspiring,Breaking Defense,DEAD,,HTTP 403,https://breakingdefense.com/feed/,KEEP,Works with proper User-Agent; 15 items verified
|
||||
402,intel,inspiring,RAND,DEAD,,HTTP 404,https://www.rand.org/rss/all.xml,"https://news.google.com/rss/search?q=site:rand.org+when:7d&hl=en-US&gl=US&ceid=US:en",Direct 403; Google News 50 items
|
||||
406,intel,inspiring,NTI,DEAD,,HTTP 403,https://www.nti.org/rss/,"https://news.google.com/rss/search?q=site:nti.org+when:30d&hl=en-US&gl=US&ceid=US:en",Direct feed empty; Google News 30d window 27 items
|
||||
415,intel,inspiring,Bellingcat,DEAD,,fetch failed,https://www.bellingcat.com/feed/,"https://news.google.com/rss/search?q=site:bellingcat.com+when:30d&hl=en-US&gl=US&ceid=US:en",SSL handshake fails; Google News 30d 19 items (low pub freq)
|
||||
23,full,europe,DW News [es],EMPTY,,No dates found,https://rss.dw.com/xml/rss-es-all,"https://news.google.com/rss/search?q=site:dw.com/es&hl=es-419&gl=MX&ceid=MX:es-419",DW deprecated es RSS endpoint; Google News 100 items
|
||||
28,full,europe,Bild,EMPTY,,No dates found,https://www.bild.de/feed/alles.xml,KEEP (parser fix),Feed works; dates use CET/CEST timezone abbreviation not RFC 2822
|
||||
110,full,crisis,CrisisWatch,EMPTY,,No dates found,https://www.crisisgroup.org/rss,KEEP (parser fix),"Feed works; Drupal date format: ""Wednesday, February 25, 2026 - 21:07"""
|
||||
111,full,crisis,IAEA,EMPTY,,No dates found,https://www.iaea.org/feeds/topnews,KEEP (parser fix),"Feed works; 2-digit year: ""Thu, 26 Feb 26"" needs expansion to 2026"
|
||||
116,full,africa,News24,EMPTY,,No dates found,https://feeds.capi24.com/v1/Search/articles/news24/Africa/rss,https://feeds.news24.com/articles/news24/TopStories/rss,Old CAPI feed empty; new URL 20 items verified
|
||||
157,full,asia,India News Network,EMPTY,,No dates found,https://www.indianewsnetwork.com/rss.en.diplomacy.xml,"https://news.google.com/rss/search?q=India+diplomacy+foreign+policy+news&hl=en&gl=US&ceid=US:en",Original feed has zero date fields in any item
|
||||
162,full,asia,Thai PBS,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:thaipbsworld.com+when:2d&hl=th&gl=TH&ceid=TH:th","https://news.google.com/rss/search?q=Thai+PBS+World+news&hl=en&gl=US&ceid=US:en",Site moved to world.thaipbs.or.th no RSS; sparse results consider REMOVE
|
||||
163,full,asia,VnExpress,EMPTY,,No dates found,https://vnexpress.net/rss,https://vnexpress.net/rss/tin-moi-nhat.rss,Bare /rss is HTML index; correct endpoint is /rss/tin-moi-nhat.rss 55 items
|
||||
164,full,asia,Tuoi Tre News,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:tuoitrenews.vn+when:2d&hl=vi&gl=VN&ceid=VN:vi",https://tuoitrenews.vn/rss,Direct RSS works 50 items; Google News was stale
|
||||
231,tech,regionalStartups,Disrupt Africa,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:disrupt-africa.com+when:7d&hl=en-US&gl=US&ceid=US:en",REMOVE,Last post Jan 2024; site inactive; no Google News results
|
||||
237,tech,github,GitHub Trending,EMPTY,,No dates found,https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml,KEEP (parser fix),Feed works with current items; parser may not handle its date format
|
||||
268,tech,thinktanks,MIT Tech Policy,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:techpolicypress.org+when:14d&hl=en-US&gl=US&ceid=US:en","https://news.google.com/rss/search?q=%22Tech+Policy+Press%22&hl=en&gl=US&ceid=US:en",Domain DNS fails; search by name returns 100 items
|
||||
270,tech,thinktanks,AI Now Institute,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:ainowinstitute.org+when:14d&hl=en-US&gl=US&ceid=US:en","https://news.google.com/rss/search?q=%22AI+Now+Institute%22&hl=en&gl=US&ceid=US:en",SSL issue on direct; Google News 59 items (infrequent publisher)
|
||||
279,tech,thinktanks,DigiChina,EMPTY,,No dates found,"https://news.google.com/rss/search?q=site:digichina.stanford.edu+when:14d&hl=en-US&gl=US&ceid=US:en","https://news.google.com/rss/search?q=DigiChina+Stanford+China+technology&hl=en&gl=US&ceid=US:en",WordPress RSS empty; Google News 20 items to Jul 2025
|
||||
306,tech,podcasts,20VC Episodes,EMPTY,,No dates found,"https://news.google.com/rss/search?q=""20+Minute+VC""+Harry+Stebbings+when:14d&hl=en-US&gl=US&ceid=US:en",https://rss.libsyn.com/shows/61840/destinations/240976.xml,Official podcast RSS via Apple; 1423 episodes current
|
||||
310,tech,podcasts,Pivot Podcast,EMPTY,,No dates found,"https://news.google.com/rss/search?q=""Pivot+podcast""+(Kara+Swisher+OR+Scott+Galloway)+when:14d&hl=en-US&gl=US&ceid=US:en",https://feeds.megaphone.fm/pivot,Megaphone RSS; 750 episodes current
|
||||
315,tech,podcasts,Startup Podcasts,EMPTY,,No dates found,"https://news.google.com/rss/search?q=(""Masters+of+Scale""+OR+""The+Pitch+podcast""+OR+""startup+podcast"")+episode+when:14d&hl=en-US&gl=US&ceid=US:en",https://rss.art19.com/masters-of-scale,"Masters of Scale RSS 670 eps; ""The Pitch"" feeds 404 — drop it"
|
||||
379,happy,science,Live Science,EMPTY,,No dates found,https://www.livescience.com/feeds/all,https://www.livescience.com/feeds.xml,/feeds/all redirects to /feeds.xml; 20+ items current
|
||||
383,happy,science,Greater Good (Berkeley),EMPTY,,No dates found,https://greatergood.berkeley.edu/rss,https://greatergood.berkeley.edu/site/rss/articles,/rss is 404; correct path /site/rss/articles 50 items; uses dc:date
|
||||
398,intel,inspiring,CSIS,EMPTY,,No dates found,https://www.csis.org/analysis?type=analysis,"https://news.google.com/rss/search?q=site:csis.org&hl=en&gl=US&ceid=US:en",Not an RSS URL (HTML); all RSS paths 403; Google News 100 items
|
||||
403,intel,inspiring,Brookings,EMPTY,,No dates found,https://www.brookings.edu/feed/,"https://news.google.com/rss/search?q=site:brookings.edu&hl=en&gl=US&ceid=US:en",WordPress feed bot-blocked; Google News 100 items
|
||||
404,intel,inspiring,Carnegie,EMPTY,,No dates found,https://carnegieendowment.org/rss/,"https://news.google.com/rss/search?q=site:carnegieendowment.org&hl=en&gl=US&ceid=US:en",Next.js site returns HTML for RSS paths; Google News 100 items
|
||||
5,full,politics,CNN World,STALE,18/09/2023,Stale,http://rss.cnn.com/rss/cnn_world.rss,"https://news.google.com/rss/search?q=site:cnn.com+world+news+when:1d&hl=en-US&gl=US&ceid=US:en",rss.cnn.com SSL failures; use Google News proxy for CNN world
|
||||
43,full,europe,TVN24,STALE,01/04/2025,Stale,https://tvn24.pl/najwazniejsze.xml,https://tvn24.pl/swiat.xml,najwazniejsze.xml stale; swiat.xml (world) 30 items current
|
||||
73,full,ai,VentureBeat AI,STALE,22/01/2026,Stale,https://venturebeat.com/category/ai/feed/,KEEP,Borderline stale; 308 redirect issue; sparse 7-item feed by design
|
||||
84,full,gov,Pentagon,STALE,23/01/2026,Stale,"https://news.google.com/rss/search?q=site:defense.gov+OR+Pentagon&hl=en-US&gl=US&ceid=US:en",KEEP,Borderline; defense.gov low recent output; Google News proxy working
|
||||
94,full,layoffs,Layoffs.fyi,STALE,29/12/2020,Stale,https://layoffs.fyi/feed/,"https://news.google.com/rss/search?q=tech+company+layoffs+announced&hl=en&gl=US&ceid=US:en",Feed abandoned Dec 2020; Google News 100 items
|
||||
396,intel,inspiring,Oryx OSINT,STALE,07/12/2024,Stale,https://www.oryxspioenkop.com/feeds/posts/default?alt=rss,KEEP,Publishes infrequently by design (detailed equipment loss lists)
|
||||
405,intel,inspiring,FAS,STALE,14/02/2023,Stale,https://fas.org/feed/,"https://news.google.com/rss/search?q=site:fas.org+nuclear+weapons+security&hl=en&gl=US&ceid=US:en",RSS broken (1 item from 2023); Google News proxy available
|
||||
|
421
scripts/rss-feeds-report.csv
Normal file
421
scripts/rss-feeds-report.csv
Normal file
@@ -0,0 +1,421 @@
|
||||
#,Variant,Category,Feed Name,Status,Newest Date,Error,URL
|
||||
1,full,politics,"BBC World",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/news/world/rss.xml"
|
||||
2,full,politics,"Guardian World",OK,2026-02-26,"OK","https://www.theguardian.com/world/rss"
|
||||
3,full,politics,"AP News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:apnews.com&hl=en-US&gl=US&ceid=US:en"
|
||||
4,full,politics,"Reuters World",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+world&hl=en-US&gl=US&ceid=US:en"
|
||||
5,full,politics,"CNN World",STALE,2023-09-18,"Stale","http://rss.cnn.com/rss/cnn_world.rss"
|
||||
6,full,us,"NPR News",OK,2026-02-26,"OK","https://feeds.npr.org/1001/rss.xml"
|
||||
7,full,us,"Politico",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:politico.com+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
8,full,europe,"France 24 [en]",OK,2026-02-26,"OK","https://www.france24.com/en/rss"
|
||||
9,full,europe,"France 24 [fr]",OK,2026-02-26,"OK","https://www.france24.com/fr/rss"
|
||||
10,full,europe,"France 24 [es]",OK,2026-02-26,"OK","https://www.france24.com/es/rss"
|
||||
11,full,europe,"France 24 [ar]",OK,2026-02-26,"OK","https://www.france24.com/ar/rss"
|
||||
12,full,europe,"EuroNews [en]",OK,2026-02-26,"OK","https://www.euronews.com/rss?format=xml"
|
||||
13,full,europe,"EuroNews [fr]",OK,2026-02-26,"OK","https://fr.euronews.com/rss?format=xml"
|
||||
14,full,europe,"EuroNews [de]",OK,2026-02-26,"OK","https://de.euronews.com/rss?format=xml"
|
||||
15,full,europe,"EuroNews [it]",OK,2026-02-26,"OK","https://it.euronews.com/rss?format=xml"
|
||||
16,full,europe,"EuroNews [es]",OK,2026-02-26,"OK","https://es.euronews.com/rss?format=xml"
|
||||
17,full,europe,"EuroNews [pt]",OK,2026-02-26,"OK","https://pt.euronews.com/rss?format=xml"
|
||||
18,full,europe,"EuroNews [ru]",OK,2026-02-26,"OK","https://ru.euronews.com/rss?format=xml"
|
||||
19,full,europe,"Le Monde [en]",OK,2026-02-26,"OK","https://www.lemonde.fr/en/rss/une.xml"
|
||||
20,full,europe,"Le Monde [fr]",OK,2026-02-26,"OK","https://www.lemonde.fr/rss/une.xml"
|
||||
21,full,europe,"DW News [en]",OK,2026-02-26,"OK","https://rss.dw.com/xml/rss-en-all"
|
||||
22,full,europe,"DW News [de]",OK,2026-02-26,"OK","https://rss.dw.com/xml/rss-de-all"
|
||||
23,full,europe,"DW News [es]",EMPTY,,"No dates found","https://rss.dw.com/xml/rss-es-all"
|
||||
24,full,europe,"El País",OK,2026-02-26,"OK","https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/portada"
|
||||
25,full,europe,"El Mundo",OK,2026-02-26,"OK","https://e00-elmundo.uecdn.es/elmundo/rss/portada.xml"
|
||||
26,full,europe,"BBC Mundo",OK,2026-02-26,"OK","https://www.bbc.com/mundo/index.xml"
|
||||
27,full,europe,"Tagesschau",OK,2026-02-26,"OK","https://www.tagesschau.de/xml/rss2/"
|
||||
28,full,europe,"Bild",EMPTY,,"No dates found","https://www.bild.de/feed/alles.xml"
|
||||
29,full,europe,"Der Spiegel",OK,2026-02-26,"OK","https://www.spiegel.de/schlagzeilen/tops/index.rss"
|
||||
30,full,europe,"Die Zeit",OK,2026-02-26,"OK","https://newsfeed.zeit.de/index"
|
||||
31,full,europe,"ANSA",OK,2026-02-26,"OK","https://www.ansa.it/sito/notizie/topnews/topnews_rss.xml"
|
||||
32,full,europe,"Corriere della Sera",DEAD,,"HTTP 404","https://xml2.corriereobjects.it/rss/incipit.xml"
|
||||
33,full,europe,"Repubblica",OK,2026-02-26,"OK","https://www.repubblica.it/rss/homepage/rss2.0.xml"
|
||||
34,full,europe,"NOS Nieuws",OK,2026-02-26,"OK","https://feeds.nos.nl/nosnieuwsalgemeen"
|
||||
35,full,europe,"NRC",OK,2026-02-26,"OK","https://www.nrc.nl/rss/"
|
||||
36,full,europe,"De Telegraaf",DEAD,,"HTTP 403","https://www.telegraaf.nl/rss"
|
||||
37,full,europe,"SVT Nyheter",OK,2026-02-26,"OK","https://www.svt.se/nyheter/rss.xml"
|
||||
38,full,europe,"Dagens Nyheter",DEAD,,"HTTP 404","https://www.dn.se/rss/senaste-nytt/"
|
||||
39,full,europe,"Svenska Dagbladet",OK,2026-02-26,"OK","https://www.svd.se/feed/articles.rss"
|
||||
40,full,europe,"BBC Turkce",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/turkce/rss.xml"
|
||||
41,full,europe,"DW Turkish",OK,2026-02-26,"OK","https://rss.dw.com/xml/rss-tur-all"
|
||||
42,full,europe,"Hurriyet",OK,2026-02-26,"OK","https://www.hurriyet.com.tr/rss/anasayfa"
|
||||
43,full,europe,"TVN24",STALE,2025-04-01,"Stale","https://tvn24.pl/najwazniejsze.xml"
|
||||
44,full,europe,"Polsat News",OK,2026-02-26,"OK","https://www.polsatnews.pl/rss/wszystkie.xml"
|
||||
45,full,europe,"Rzeczpospolita",OK,2026-02-26,"OK","https://www.rp.pl/rss_main"
|
||||
46,full,europe,"Kathimerini",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:kathimerini.gr+when:2d&hl=el&gl=GR&ceid=GR:el"
|
||||
47,full,europe,"Naftemporiki",OK,2026-02-26,"OK","https://www.naftemporiki.gr/feed/"
|
||||
48,full,europe,"in.gr",OK,2026-02-26,"OK","https://www.in.gr/feed/"
|
||||
49,full,europe,"iefimerida",OK,2026-02-26,"OK","https://www.iefimerida.gr/rss.xml"
|
||||
50,full,europe,"Proto Thema",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:protothema.gr+when:2d&hl=el&gl=GR&ceid=GR:el"
|
||||
51,full,europe,"BBC Russian",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/russian/rss.xml"
|
||||
52,full,europe,"Meduza",OK,2026-02-26,"OK","https://meduza.io/rss/all"
|
||||
53,full,europe,"Novaya Gazeta Europe",OK,2026-02-26,"OK","https://novayagazeta.eu/feed/rss"
|
||||
54,full,europe,"TASS",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:tass.com+OR+TASS+Russia+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
55,full,europe,"Kyiv Independent",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:kyivindependent.com+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
56,full,europe,"Moscow Times",OK,2026-02-26,"OK","https://www.themoscowtimes.com/rss/news"
|
||||
57,full,middleeast,"BBC Middle East",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/news/world/middle_east/rss.xml"
|
||||
58,full,middleeast,"Al Jazeera [en]",OK,2026-02-26,"OK","https://www.aljazeera.com/xml/rss/all.xml"
|
||||
59,full,middleeast,"Al Jazeera [ar]",OK,2026-02-26,"OK","https://www.aljazeera.net/aljazeerarss/a7c186be-1adb-4b11-a982-4783e765316e/4e17ecdc-8fb9-40de-a5d6-d00f72384a51"
|
||||
60,full,middleeast,"Al Arabiya",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:english.alarabiya.net+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
61,full,middleeast,"Guardian ME",OK,2026-02-26,"OK","https://www.theguardian.com/world/middleeast/rss"
|
||||
62,full,middleeast,"BBC Persian",OK,2026-02-26,"OK","http://feeds.bbci.co.uk/persian/tv-and-radio-37434376/rss.xml"
|
||||
63,full,middleeast,"Iran International",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:iranintl.com+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
64,full,middleeast,"Fars News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:farsnews.ir+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
65,full,middleeast,"L\",DEAD,,"HTTP 403","https://www.lorientlejour.com/rss"
|
||||
66,full,middleeast,"Haaretz",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:haaretz.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
67,full,middleeast,"Arab News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:arabnews.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
68,full,tech,"Hacker News",OK,2026-02-26,"OK","https://hnrss.org/frontpage"
|
||||
69,full,tech,"Ars Technica",OK,2026-02-26,"OK","https://feeds.arstechnica.com/arstechnica/technology-lab"
|
||||
70,full,tech,"The Verge",OK,2026-02-26,"OK","https://www.theverge.com/rss/index.xml"
|
||||
71,full,tech,"MIT Tech Review",OK,2026-02-26,"OK","https://www.technologyreview.com/feed/"
|
||||
72,full,ai,"AI News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(OpenAI+OR+Anthropic+OR+Google+AI+OR+""large+language+model""+OR+ChatGPT)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
73,full,ai,"VentureBeat AI",STALE,2026-01-22,"Stale","https://venturebeat.com/category/ai/feed/"
|
||||
74,full,ai,"The Verge AI",OK,2026-02-26,"OK","https://www.theverge.com/rss/ai-artificial-intelligence/index.xml"
|
||||
75,full,ai,"MIT Tech Review",OK,2026-02-26,"OK","https://www.technologyreview.com/topic/artificial-intelligence/feed"
|
||||
76,full,ai,"ArXiv AI",OK,2026-02-26,"OK","https://export.arxiv.org/rss/cs.AI"
|
||||
77,full,finance,"CNBC",OK,2026-02-26,"OK","https://www.cnbc.com/id/100003114/device/rss/rss.html"
|
||||
78,full,finance,"MarketWatch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:marketwatch.com+markets+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
79,full,finance,"Yahoo Finance",OK,2026-02-25,"OK","https://finance.yahoo.com/news/rssindex"
|
||||
80,full,finance,"Financial Times",OK,2026-02-26,"OK","https://www.ft.com/rss/home"
|
||||
81,full,finance,"Reuters Business",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+business+markets&hl=en-US&gl=US&ceid=US:en"
|
||||
82,full,gov,"White House",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:whitehouse.gov&hl=en-US&gl=US&ceid=US:en"
|
||||
83,full,gov,"State Dept",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:state.gov+OR+""State+Department""&hl=en-US&gl=US&ceid=US:en"
|
||||
84,full,gov,"Pentagon",STALE,2026-01-23,"Stale","https://news.google.com/rss/search?q=site:defense.gov+OR+Pentagon&hl=en-US&gl=US&ceid=US:en"
|
||||
85,full,gov,"Treasury",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:treasury.gov+OR+""Treasury+Department""&hl=en-US&gl=US&ceid=US:en"
|
||||
86,full,gov,"DOJ",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:justice.gov+OR+""Justice+Department""+DOJ&hl=en-US&gl=US&ceid=US:en"
|
||||
87,full,gov,"Federal Reserve",OK,2026-02-24,"OK","https://www.federalreserve.gov/feeds/press_all.xml"
|
||||
88,full,gov,"SEC",OK,2026-02-26,"OK","https://www.sec.gov/news/pressreleases.rss"
|
||||
89,full,gov,"CDC",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:cdc.gov+OR+CDC+health&hl=en-US&gl=US&ceid=US:en"
|
||||
90,full,gov,"FEMA",OK,2026-02-21,"OK","https://news.google.com/rss/search?q=site:fema.gov+OR+FEMA+emergency&hl=en-US&gl=US&ceid=US:en"
|
||||
91,full,gov,"DHS",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:dhs.gov+OR+""Homeland+Security""&hl=en-US&gl=US&ceid=US:en"
|
||||
92,full,gov,"UN News",OK,2026-02-26,"OK","https://news.un.org/feed/subscribe/en/news/all/rss.xml"
|
||||
93,full,gov,"CISA",OK,2026-02-26,"OK","https://www.cisa.gov/cybersecurity-advisories/all.xml"
|
||||
94,full,layoffs,"Layoffs.fyi",STALE,2020-12-29,"Stale","https://layoffs.fyi/feed/"
|
||||
95,full,layoffs,"TechCrunch Layoffs",OK,2026-02-26,"OK","https://techcrunch.com/tag/layoffs/feed/"
|
||||
96,full,layoffs,"Layoffs News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(layoffs+OR+""job+cuts""+OR+""workforce+reduction"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
97,full,thinktanks,"Foreign Policy",OK,2026-02-26,"OK","https://foreignpolicy.com/feed/"
|
||||
98,full,thinktanks,"Atlantic Council",OK,2026-02-26,"OK","https://www.atlanticcouncil.org/feed/"
|
||||
99,full,thinktanks,"Foreign Affairs",OK,2026-02-26,"OK","https://www.foreignaffairs.com/rss.xml"
|
||||
100,full,thinktanks,"CSIS",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:csis.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
101,full,thinktanks,"RAND",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:rand.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
102,full,thinktanks,"Brookings",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:brookings.edu+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
103,full,thinktanks,"Carnegie",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:carnegieendowment.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
104,full,thinktanks,"War on the Rocks",OK,2026-02-26,"OK","https://warontherocks.com/feed"
|
||||
105,full,thinktanks,"AEI",OK,2026-02-26,"OK","https://www.aei.org/feed/"
|
||||
106,full,thinktanks,"Responsible Statecraft",OK,2026-02-26,"OK","https://responsiblestatecraft.org/feed/"
|
||||
107,full,thinktanks,"RUSI",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:rusi.org+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
108,full,thinktanks,"FPRI",OK,2026-02-26,"OK","https://www.fpri.org/feed/"
|
||||
109,full,thinktanks,"Jamestown",OK,2026-02-25,"OK","https://jamestown.org/feed/"
|
||||
110,full,crisis,"CrisisWatch",EMPTY,,"No dates found","https://www.crisisgroup.org/rss"
|
||||
111,full,crisis,"IAEA",EMPTY,,"No dates found","https://www.iaea.org/feeds/topnews"
|
||||
112,full,crisis,"WHO",OK,2026-02-25,"OK","https://www.who.int/rss-feeds/news-english.xml"
|
||||
113,full,crisis,"UNHCR",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:unhcr.org+OR+UNHCR+refugees+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
114,full,africa,"Africa News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Africa+OR+Nigeria+OR+Kenya+OR+""South+Africa""+OR+Ethiopia)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
115,full,africa,"Sahel Crisis",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Sahel+OR+Mali+OR+Niger+OR+""Burkina+Faso""+OR+Wagner)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
116,full,africa,"News24",EMPTY,,"No dates found","https://feeds.capi24.com/v1/Search/articles/news24/Africa/rss"
|
||||
117,full,africa,"BBC Africa",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/news/world/africa/rss.xml"
|
||||
118,full,africa,"Jeune Afrique",OK,2026-02-26,"OK","https://www.jeuneafrique.com/feed/"
|
||||
119,full,africa,"Africanews [en]",OK,2026-02-26,"OK","https://www.africanews.com/feed/rss"
|
||||
120,full,africa,"Africanews [fr]",OK,2026-02-26,"OK","https://fr.africanews.com/feed/rss"
|
||||
121,full,africa,"BBC Afrique",OK,2026-02-26,"OK","https://www.bbc.com/afrique/index.xml"
|
||||
122,full,africa,"Premium Times",OK,2026-02-26,"OK","https://www.premiumtimesng.com/feed"
|
||||
123,full,africa,"Vanguard Nigeria",OK,2026-02-26,"OK","https://www.vanguardngr.com/feed/"
|
||||
124,full,africa,"Channels TV",OK,2026-02-26,"OK","https://www.channelstv.com/feed/"
|
||||
125,full,africa,"Daily Trust",OK,2026-02-26,"OK","https://dailytrust.com/feed/"
|
||||
126,full,africa,"ThisDay",OK,2026-02-26,"OK","https://www.thisdaylive.com/feed"
|
||||
127,full,latam,"Latin America",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Brazil+OR+Mexico+OR+Argentina+OR+Venezuela+OR+Colombia)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
128,full,latam,"BBC Latin America",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/news/world/latin_america/rss.xml"
|
||||
129,full,latam,"Reuters LatAm",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+(Brazil+OR+Mexico+OR+Argentina)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
130,full,latam,"Guardian Americas",OK,2026-02-26,"OK","https://www.theguardian.com/world/americas/rss"
|
||||
131,full,latam,"Clarín",OK,2026-02-26,"OK","https://www.clarin.com/rss/lo-ultimo/"
|
||||
132,full,latam,"O Globo",DEAD,,"HTTP 404","https://oglobo.globo.com/rss/top_noticias/"
|
||||
133,full,latam,"Folha de S.Paulo",DEAD,,"fetch failed","https://feeds.folha.uol.com.br/emcimadahora/rss091.xml"
|
||||
134,full,latam,"Brasil Paralelo",OK,2026-02-26,"OK","https://www.brasilparalelo.com.br/noticias/rss.xml"
|
||||
135,full,latam,"El Tiempo",OK,2026-02-26,"OK","https://www.eltiempo.com/rss/mundo_latinoamerica.xml"
|
||||
136,full,latam,"El Universal",DEAD,,"HTTP 404","https://www.eluniversal.com.mx/rss.xml"
|
||||
137,full,latam,"La Silla Vacía",OK,2026-02-26,"OK","https://www.lasillavacia.com/rss"
|
||||
138,full,latam,"Mexico News Daily",OK,2026-02-26,"OK","https://mexiconewsdaily.com/feed/"
|
||||
139,full,latam,"Animal Político",DEAD,,"HTTP 404","https://animalpolitico.com/feed/"
|
||||
140,full,latam,"Proceso",DEAD,,"HTTP 404","https://www.proceso.com.mx/feed/"
|
||||
141,full,latam,"Milenio",DEAD,,"HTTP 404","https://www.milenio.com/rss"
|
||||
142,full,latam,"Mexico Security",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Mexico+cartel+OR+Mexico+violence+OR+Mexico+troops+OR+narco+Mexico)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
143,full,latam,"AP Mexico",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:apnews.com+Mexico+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
144,full,latam,"InSight Crime",OK,2026-02-26,"OK","https://insightcrime.org/feed/"
|
||||
145,full,latam,"France 24 LatAm",OK,2026-02-26,"OK","https://www.france24.com/en/americas/rss"
|
||||
146,full,asia,"Asia News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(China+OR+Japan+OR+Korea+OR+India+OR+ASEAN)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
147,full,asia,"BBC Asia",OK,2026-02-26,"OK","https://feeds.bbci.co.uk/news/world/asia/rss.xml"
|
||||
148,full,asia,"The Diplomat",OK,2026-02-26,"OK","https://thediplomat.com/feed/"
|
||||
149,full,asia,"South China Morning Post",OK,2026-02-26,"OK","https://www.scmp.com/rss/91/feed/"
|
||||
150,full,asia,"Reuters Asia",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+(China+OR+Japan+OR+Taiwan+OR+Korea)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
151,full,asia,"Xinhua",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:xinhuanet.com+OR+Xinhua+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
152,full,asia,"Japan Today",OK,2026-02-26,"OK","https://japantoday.com/feed/atom"
|
||||
153,full,asia,"Nikkei Asia",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:asia.nikkei.com+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
154,full,asia,"Asahi Shimbun",OK,2026-02-26,"OK","https://www.asahi.com/rss/asahi/newsheadlines.rdf"
|
||||
155,full,asia,"The Hindu",OK,2026-02-26,"OK","https://www.thehindu.com/news/national/feeder/default.rss"
|
||||
156,full,asia,"Indian Express",OK,2026-02-26,"OK","https://indianexpress.com/section/india/feed/"
|
||||
157,full,asia,"India News Network",EMPTY,,"No dates found","https://www.indianewsnetwork.com/rss.en.diplomacy.xml"
|
||||
158,full,asia,"CNA",OK,2026-02-26,"OK","https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml"
|
||||
159,full,asia,"MIIT (China)",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:miit.gov.cn+when:7d&hl=zh-CN&gl=CN&ceid=CN:zh-Hans"
|
||||
160,full,asia,"MOFCOM (China)",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:mofcom.gov.cn+when:7d&hl=zh-CN&gl=CN&ceid=CN:zh-Hans"
|
||||
161,full,asia,"Bangkok Post",DEAD,,"HTTP 451","https://www.bangkokpost.com/rss"
|
||||
162,full,asia,"Thai PBS",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:thaipbsworld.com+when:2d&hl=th&gl=TH&ceid=TH:th"
|
||||
163,full,asia,"VnExpress",EMPTY,,"No dates found","https://vnexpress.net/rss"
|
||||
164,full,asia,"Tuoi Tre News",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:tuoitrenews.vn+when:2d&hl=vi&gl=VN&ceid=VN:vi"
|
||||
165,full,asia,"ABC News Australia",OK,2026-02-26,"OK","https://www.abc.net.au/news/feed/2942460/rss.xml"
|
||||
166,full,asia,"Guardian Australia",OK,2026-02-26,"OK","https://www.theguardian.com/australia-news/rss"
|
||||
167,full,asia,"Island Times (Palau)",OK,2026-02-24,"OK","https://islandtimes.org/feed/"
|
||||
168,full,energy,"Oil & Gas",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(oil+price+OR+OPEC+OR+""natural+gas""+OR+pipeline+OR+LNG)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
169,full,energy,"Nuclear Energy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""nuclear+energy""+OR+""nuclear+power""+OR+uranium+OR+IAEA)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
170,full,energy,"Reuters Energy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+(oil+OR+gas+OR+energy+OR+OPEC)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
171,full,energy,"Mining & Resources",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(lithium+OR+""rare+earth""+OR+cobalt+OR+mining)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
172,tech,tech,"TechCrunch",OK,2026-02-26,"OK","https://techcrunch.com/feed/"
|
||||
173,tech,tech,"ZDNet",OK,2026-02-26,"OK","https://www.zdnet.com/news/rss.xml"
|
||||
174,tech,tech,"TechMeme",OK,2026-02-26,"OK","https://www.techmeme.com/feed.xml"
|
||||
175,tech,tech,"Engadget",OK,2026-02-26,"OK","https://www.engadget.com/rss.xml"
|
||||
176,tech,tech,"Fast Company",OK,2026-02-26,"OK","https://feeds.feedburner.com/fastcompany/headlines"
|
||||
177,tech,ai,"AI News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(OpenAI+OR+Anthropic+OR+Google+AI+OR+""large+language+model""+OR+ChatGPT+OR+Claude+OR+""AI+model"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
178,tech,ai,"MIT Tech Review AI",OK,2026-02-26,"OK","https://www.technologyreview.com/topic/artificial-intelligence/feed"
|
||||
179,tech,ai,"MIT Research",OK,2026-02-26,"OK","https://news.mit.edu/rss/research"
|
||||
180,tech,ai,"ArXiv ML",OK,2026-02-26,"OK","https://export.arxiv.org/rss/cs.LG"
|
||||
181,tech,ai,"AI Weekly",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""artificial+intelligence""+OR+""machine+learning""+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
182,tech,ai,"Anthropic News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Anthropic+Claude+AI+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
183,tech,ai,"OpenAI News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=OpenAI+ChatGPT+GPT-4+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
184,tech,startups,"TechCrunch Startups",OK,2026-02-26,"OK","https://techcrunch.com/category/startups/feed/"
|
||||
185,tech,startups,"VentureBeat",OK,2026-02-26,"OK","https://venturebeat.com/feed/"
|
||||
186,tech,startups,"Crunchbase News",OK,2026-02-26,"OK","https://news.crunchbase.com/feed/"
|
||||
187,tech,startups,"SaaStr",OK,2026-02-26,"OK","https://www.saastr.com/feed/"
|
||||
188,tech,startups,"AngelList News",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:angellist.com+OR+""AngelList""+funding+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
189,tech,startups,"TechCrunch Venture",OK,2026-02-26,"OK","https://techcrunch.com/category/venture/feed/"
|
||||
190,tech,startups,"The Information",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:theinformation.com+startup+OR+funding+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
191,tech,startups,"Fortune Term Sheet",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""Term+Sheet""+venture+capital+OR+startup+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
192,tech,startups,"PitchBook News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:pitchbook.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
193,tech,startups,"CB Insights",OK,2026-02-18,"OK","https://www.cbinsights.com/research/feed/"
|
||||
194,tech,vcblogs,"Y Combinator Blog",OK,2026-02-05,"OK","https://www.ycombinator.com/blog/rss/"
|
||||
195,tech,vcblogs,"a16z Blog",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:a16z.com+OR+""Andreessen+Horowitz""+blog+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
196,tech,vcblogs,"Sequoia Blog",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:sequoiacap.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
197,tech,vcblogs,"Paul Graham Essays",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=""Paul+Graham""+essay+OR+blog+when:30d&hl=en-US&gl=US&ceid=US:en"
|
||||
198,tech,vcblogs,"VC Insights",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""venture+capital""+insights+OR+""VC+trends""+OR+""startup+advice"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
199,tech,vcblogs,"Lenny\",OK,2026-02-26,"OK","https://www.lennysnewsletter.com/feed"
|
||||
200,tech,vcblogs,"Stratechery",OK,2026-02-26,"OK","https://stratechery.com/feed/"
|
||||
201,tech,regionalStartups,"EU Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:eu-startups.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
202,tech,regionalStartups,"Tech.eu",OK,2026-02-26,"OK","https://tech.eu/feed/"
|
||||
203,tech,regionalStartups,"Sifted (Europe)",OK,2026-02-26,"OK","https://sifted.eu/feed"
|
||||
204,tech,regionalStartups,"The Next Web",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:thenextweb.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
205,tech,regionalStartups,"Tech in Asia",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:techinasia.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
206,tech,regionalStartups,"KrASIA",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:kr-asia.com+OR+KrASIA+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
207,tech,regionalStartups,"SEA Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Singapore+OR+Indonesia+OR+Vietnam+OR+Thailand+OR+Malaysia)+startup+funding+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
208,tech,regionalStartups,"Asia VC News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Southeast+Asia""+OR+ASEAN)+venture+capital+OR+funding+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
209,tech,regionalStartups,"China Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=China+startup+funding+OR+""Chinese+startup""+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
210,tech,regionalStartups,"36Kr English",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:36kr.com+OR+""36Kr""+startup+china+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
211,tech,regionalStartups,"China Tech Giants",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Alibaba+OR+Tencent+OR+ByteDance+OR+Baidu+OR+JD.com+OR+Xiaomi+OR+Huawei)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
212,tech,regionalStartups,"Japan Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Japan+startup+funding+OR+""Japanese+startup""+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
213,tech,regionalStartups,"Japan Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Japan+startup+OR+Japan+tech+OR+SoftBank+OR+Rakuten+OR+Sony)+funding+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
214,tech,regionalStartups,"Nikkei Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:asia.nikkei.com+technology+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
215,tech,regionalStartups,"Korea Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Korea+startup+OR+Korean+tech+OR+Samsung+OR+Kakao+OR+Naver+OR+Coupang)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
216,tech,regionalStartups,"Korea Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Korea+startup+funding+OR+""Korean+unicorn""+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
217,tech,regionalStartups,"Inc42 (India)",OK,2026-02-26,"OK","https://inc42.com/feed/"
|
||||
218,tech,regionalStartups,"YourStory",OK,2026-02-26,"OK","https://yourstory.com/feed"
|
||||
219,tech,regionalStartups,"India Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=India+startup+funding+OR+""Indian+startup""+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
220,tech,regionalStartups,"India Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Flipkart+OR+Razorpay+OR+Zerodha+OR+Zomato+OR+Paytm+OR+PhonePe)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
221,tech,regionalStartups,"SEA Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Grab+OR+GoTo+OR+Sea+Limited+OR+Shopee+OR+Tokopedia)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
222,tech,regionalStartups,"Vietnam Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Vietnam+startup+OR+Vietnam+tech+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
223,tech,regionalStartups,"Indonesia Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Indonesia+startup+OR+Indonesia+tech+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
224,tech,regionalStartups,"Taiwan Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Taiwan+startup+OR+TSMC+OR+MediaTek+OR+Foxconn)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
225,tech,regionalStartups,"LAVCA (LATAM)",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=site:lavca.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
226,tech,regionalStartups,"LATAM Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Latin+America""+startup+OR+LATAM+funding)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
227,tech,regionalStartups,"Startups LATAM",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(startup+Brazil+OR+startup+Mexico+OR+startup+Argentina+OR+startup+Colombia+OR+startup+Chile)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
228,tech,regionalStartups,"Brazil Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Nubank+OR+iFood+OR+Mercado+Libre+OR+Rappi+OR+VTEX)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
229,tech,regionalStartups,"FinTech LATAM",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=fintech+(Brazil+OR+Mexico+OR+Argentina+OR+""Latin+America"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
230,tech,regionalStartups,"TechCabal (Africa)",OK,2026-02-26,"OK","https://techcabal.com/feed/"
|
||||
231,tech,regionalStartups,"Disrupt Africa",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:disrupt-africa.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
232,tech,regionalStartups,"Africa Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Africa+startup+funding+OR+""African+startup""+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
233,tech,regionalStartups,"Africa Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Flutterwave+OR+Paystack+OR+Jumia+OR+Andela+OR+""Africa+startup"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
234,tech,regionalStartups,"MENA Startups",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(MENA+startup+OR+""Middle+East""+funding+OR+Gulf+startup)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
235,tech,regionalStartups,"MENA Tech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(UAE+startup+OR+Saudi+tech+OR+Dubai+startup+OR+NEOM+tech)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
236,tech,github,"GitHub Blog",OK,2026-02-24,"OK","https://github.blog/feed/"
|
||||
237,tech,github,"GitHub Trending",EMPTY,,"No dates found","https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml"
|
||||
238,tech,github,"Show HN",OK,2026-02-26,"OK","https://hnrss.org/show"
|
||||
239,tech,github,"YC Launches",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Y+Combinator""+OR+""YC+launch""+OR+""YC+W25""+OR+""YC+S25"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
240,tech,github,"Dev Events",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""developer+conference""+OR+""tech+summit""+OR+""devcon""+OR+""developer+event"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
241,tech,github,"Open Source News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""open+source""+project+release+OR+launch+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
242,tech,ipo,"IPO News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(IPO+OR+""initial+public+offering""+OR+SPAC)+tech+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
243,tech,ipo,"Renaissance IPO",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:renaissancecapital.com+IPO+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
244,tech,ipo,"Tech IPO News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=tech+IPO+OR+""tech+company""+IPO+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
245,tech,funding,"SEC Filings",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=(S-1+OR+""IPO+filing""+OR+""SEC+filing"")+startup+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
246,tech,funding,"VC News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Series+A""+OR+""Series+B""+OR+""Series+C""+OR+""funding+round""+OR+""venture+capital"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
247,tech,funding,"Seed & Pre-Seed",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""seed+round""+OR+""pre-seed""+OR+""angel+round""+OR+""seed+funding"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
248,tech,funding,"Startup Funding",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""startup+funding""+OR+""raised+funding""+OR+""raised+$""+OR+""funding+announced"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
249,tech,producthunt,"Product Hunt",OK,2026-02-26,"OK","https://www.producthunt.com/feed"
|
||||
250,tech,outages,"AWS Status",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=AWS+outage+OR+""Amazon+Web+Services""+down+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
251,tech,outages,"Cloud Outages",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Azure+OR+GCP+OR+Cloudflare+OR+Slack+OR+GitHub)+outage+OR+down+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
252,tech,security,"Krebs Security",OK,2026-02-20,"OK","https://krebsonsecurity.com/feed/"
|
||||
253,tech,security,"The Hacker News",OK,2026-02-26,"OK","https://feeds.feedburner.com/TheHackersNews"
|
||||
254,tech,security,"Dark Reading",OK,2026-02-26,"OK","https://www.darkreading.com/rss.xml"
|
||||
255,tech,security,"Schneier",OK,2026-02-26,"OK","https://www.schneier.com/feed/"
|
||||
256,tech,policy,"Politico Tech",OK,2026-02-26,"OK","https://rss.politico.com/technology.xml"
|
||||
257,tech,policy,"AI Regulation",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=AI+regulation+OR+""artificial+intelligence""+law+OR+policy+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
258,tech,policy,"Tech Antitrust",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=tech+antitrust+OR+FTC+Google+OR+FTC+Apple+OR+FTC+Amazon+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
259,tech,policy,"EFF News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:eff.org+OR+""Electronic+Frontier+Foundation""+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
260,tech,policy,"EU Digital Policy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Digital+Services+Act""+OR+""Digital+Markets+Act""+OR+""EU+AI+Act""+OR+""GDPR"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
261,tech,policy,"Euractiv Digital",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:euractiv.com+digital+OR+tech+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
262,tech,policy,"EU Commission Digital",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:ec.europa.eu+digital+OR+technology+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
263,tech,policy,"China Tech Policy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(China+tech+regulation+OR+China+AI+policy+OR+MIIT+technology)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
264,tech,policy,"UK Tech Policy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(UK+AI+safety+OR+""Online+Safety+Bill""+OR+UK+tech+regulation)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
265,tech,policy,"India Tech Policy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(India+tech+regulation+OR+India+data+protection+OR+India+AI+policy)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
266,tech,thinktanks,"Brookings Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:brookings.edu+technology+OR+AI+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
267,tech,thinktanks,"CSIS Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:csis.org+technology+OR+AI+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
268,tech,thinktanks,"MIT Tech Policy",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:techpolicypress.org+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
269,tech,thinktanks,"Stanford HAI",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:hai.stanford.edu+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
270,tech,thinktanks,"AI Now Institute",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:ainowinstitute.org+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
271,tech,thinktanks,"OECD Digital",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:oecd.org+digital+OR+AI+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
272,tech,thinktanks,"EU Tech Policy",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""EU+tech+policy""+OR+""European+digital""+OR+Bruegel+tech)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
273,tech,thinktanks,"Chatham House Tech",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:chathamhouse.org+technology+OR+AI+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
274,tech,thinktanks,"ISEAS (Singapore)",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:iseas.edu.sg+technology+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
275,tech,thinktanks,"ORF Tech (India)",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(India+tech+policy+OR+ORF+technology+OR+""Observer+Research+Foundation""+tech)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
276,tech,thinktanks,"RIETI (Japan)",OK,2026-02-19,"OK","https://news.google.com/rss/search?q=site:rieti.go.jp+technology+when:30d&hl=en-US&gl=US&ceid=US:en"
|
||||
277,tech,thinktanks,"Asia Pacific Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Asia+Pacific""+tech+policy+OR+""Lowy+Institute""+technology)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
278,tech,thinktanks,"China Tech Analysis",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""China+tech+strategy""+OR+""Chinese+AI""+OR+""China+semiconductor"")+analysis+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
279,tech,thinktanks,"DigiChina",EMPTY,,"No dates found","https://news.google.com/rss/search?q=site:digichina.stanford.edu+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
280,tech,finance,"CNBC Tech",OK,2026-02-26,"OK","https://www.cnbc.com/id/19854910/device/rss/rss.html"
|
||||
281,tech,finance,"MarketWatch Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:marketwatch.com+technology+markets+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
282,tech,finance,"Yahoo Finance",OK,2026-02-25,"OK","https://finance.yahoo.com/rss/topstories"
|
||||
283,tech,finance,"Seeking Alpha Tech",OK,2026-02-26,"OK","https://seekingalpha.com/market_currents.xml"
|
||||
284,tech,hardware,"Tom's Hardware",OK,2026-02-26,"OK","https://www.tomshardware.com/feeds/all"
|
||||
285,tech,hardware,"SemiAnalysis",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:semianalysis.com+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
286,tech,hardware,"Semiconductor News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=semiconductor+OR+chip+OR+TSMC+OR+NVIDIA+OR+Intel+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
287,tech,cloud,"InfoQ",OK,2026-02-26,"OK","https://feed.infoq.com/"
|
||||
288,tech,cloud,"The New Stack",OK,2026-02-26,"OK","https://thenewstack.io/feed/"
|
||||
289,tech,cloud,"DevOps.com",OK,2026-02-26,"OK","https://devops.com/feed/"
|
||||
290,tech,dev,"Dev.to",OK,2026-02-26,"OK","https://dev.to/feed"
|
||||
291,tech,dev,"Lobsters",OK,2026-02-26,"OK","https://lobste.rs/rss"
|
||||
292,tech,dev,"Changelog",OK,2026-02-23,"OK","https://changelog.com/feed"
|
||||
293,tech,layoffs,"Layoffs.fyi",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=tech+layoffs+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
294,tech,unicorns,"Unicorn News",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=(""unicorn+startup""+OR+""unicorn+valuation""+OR+""$1+billion+valuation"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
295,tech,unicorns,"CB Insights Unicorn",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=site:cbinsights.com+unicorn+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
296,tech,unicorns,"Decacorn News",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=(""decacorn""+OR+""$10+billion+valuation""+OR+""$10B+valuation"")+startup+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
297,tech,unicorns,"New Unicorns",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""becomes+unicorn""+OR+""joins+unicorn""+OR+""reaches+unicorn""+OR+""achieved+unicorn"")+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
298,tech,accelerators,"Techstars News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=Techstars+accelerator+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
299,tech,accelerators,"500 Global News",OK,2026-02-19,"OK","https://news.google.com/rss/search?q=""500+Global""+OR+""500+Startups""+accelerator+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
300,tech,accelerators,"Demo Day News",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=(""demo+day""+OR+""YC+batch""+OR+""accelerator+batch"")+startup+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
301,tech,accelerators,"Startup School",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""Startup+School""+OR+""YC+Startup+School""+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
302,tech,podcasts,"Acquired Episodes",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=""Acquired+podcast""+episode+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
303,tech,podcasts,"All-In Podcast",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=""All-In+podcast""+(Chamath+OR+Sacks+OR+Friedberg)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
304,tech,podcasts,"a16z Insights",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""a16z""+OR+""Andreessen+Horowitz"")+podcast+OR+interview+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
305,tech,podcasts,"TWIST Episodes",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=""This+Week+in+Startups""+Jason+Calacanis+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
306,tech,podcasts,"20VC Episodes",EMPTY,,"No dates found","https://news.google.com/rss/search?q=""20+Minute+VC""+Harry+Stebbings+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
307,tech,podcasts,"Lex Fridman Tech",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=(""Lex+Fridman""+interview)+(AI+OR+tech+OR+startup+OR+CEO)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
308,tech,podcasts,"Verge Shows",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Vergecast""+OR+""Decoder+podcast""+Verge)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
309,tech,podcasts,"Hard Fork (NYT)",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""Hard+Fork""+podcast+NYT+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
310,tech,podcasts,"Pivot Podcast",EMPTY,,"No dates found","https://news.google.com/rss/search?q=""Pivot+podcast""+(Kara+Swisher+OR+Scott+Galloway)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
311,tech,podcasts,"Tech Newsletters",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=(""Benedict+Evans""+OR+""Pragmatic+Engineer""+OR+Stratechery)+tech+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
312,tech,podcasts,"AI Podcasts",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""AI+podcast""+OR+""artificial+intelligence+podcast"")+episode+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
313,tech,podcasts,"AI Interviews",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(NVIDIA+OR+OpenAI+OR+Anthropic+OR+DeepMind)+interview+OR+podcast+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
314,tech,podcasts,"How I Built This",OK,2026-02-23,"OK","https://news.google.com/rss/search?q=""How+I+Built+This""+Guy+Raz+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
315,tech,podcasts,"Startup Podcasts",EMPTY,,"No dates found","https://news.google.com/rss/search?q=(""Masters+of+Scale""+OR+""The+Pitch+podcast""+OR+""startup+podcast"")+episode+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
316,finance,markets,"Seeking Alpha",OK,2026-02-26,"OK","https://seekingalpha.com/market_currents.xml"
|
||||
317,finance,markets,"Reuters Markets",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:reuters.com+markets+stocks+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
318,finance,markets,"Bloomberg Markets",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:bloomberg.com+markets+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
319,finance,markets,"Investing.com News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:investing.com+markets+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
320,finance,forex,"Forex News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""forex""+OR+""currency""+OR+""FX+market"")+trading+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
321,finance,forex,"Dollar Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""dollar+index""+OR+DXY+OR+""US+dollar""+OR+""euro+dollar"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
322,finance,forex,"Central Bank Rates",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""central+bank""+OR+""interest+rate""+OR+""rate+decision""+OR+""monetary+policy"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
323,finance,bonds,"Bond Market",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""bond+market""+OR+""treasury+yields""+OR+""bond+yields""+OR+""fixed+income"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
324,finance,bonds,"Treasury Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""US+Treasury""+OR+""Treasury+auction""+OR+""10-year+yield""+OR+""2-year+yield"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
325,finance,bonds,"Corporate Bonds",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""corporate+bond""+OR+""high+yield""+OR+""investment+grade""+OR+""credit+spread"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
326,finance,commodities,"Oil & Gas",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(oil+price+OR+OPEC+OR+""natural+gas""+OR+""crude+oil""+OR+WTI+OR+Brent)+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
327,finance,commodities,"Gold & Metals",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(gold+price+OR+silver+price+OR+copper+OR+platinum+OR+""precious+metals"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
328,finance,commodities,"Agriculture",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(wheat+OR+corn+OR+soybeans+OR+coffee+OR+sugar)+price+OR+commodity+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
329,finance,commodities,"Commodity Trading",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""commodity+trading""+OR+""futures+market""+OR+CME+OR+NYMEX+OR+COMEX)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
330,finance,crypto,"CoinDesk",OK,2026-02-26,"OK","https://www.coindesk.com/arc/outboundfeeds/rss/"
|
||||
331,finance,crypto,"Cointelegraph",OK,2026-02-26,"OK","https://cointelegraph.com/rss"
|
||||
332,finance,crypto,"The Block",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:theblock.co+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
333,finance,crypto,"Crypto News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(bitcoin+OR+ethereum+OR+crypto+OR+""digital+assets"")+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
334,finance,crypto,"DeFi News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(DeFi+OR+""decentralized+finance""+OR+DEX+OR+""yield+farming"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
335,finance,centralbanks,"ECB Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""European+Central+Bank""+OR+ECB+OR+Lagarde)+monetary+policy+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
336,finance,centralbanks,"BoJ Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Bank+of+Japan""+OR+BoJ)+monetary+policy+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
337,finance,centralbanks,"BoE Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Bank+of+England""+OR+BoE)+monetary+policy+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
338,finance,centralbanks,"PBoC Watch",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""People%27s+Bank+of+China""+OR+PBoC+OR+PBOC)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
339,finance,centralbanks,"Global Central Banks",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""rate+hike""+OR+""rate+cut""+OR+""interest+rate+decision"")+central+bank+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
340,finance,economic,"Economic Data",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(CPI+OR+inflation+OR+GDP+OR+""jobs+report""+OR+""nonfarm+payrolls""+OR+PMI)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
341,finance,economic,"Trade & Tariffs",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(tariff+OR+""trade+war""+OR+""trade+deficit""+OR+sanctions)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
342,finance,economic,"Housing Market",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""housing+market""+OR+""home+prices""+OR+""mortgage+rates""+OR+REIT)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
343,finance,ipo,"IPO News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(IPO+OR+""initial+public+offering""+OR+SPAC+OR+""direct+listing"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
344,finance,ipo,"Earnings Reports",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""earnings+report""+OR+""quarterly+earnings""+OR+""revenue+beat""+OR+""earnings+miss"")+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
345,finance,ipo,"M&A News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""merger""+OR+""acquisition""+OR+""takeover+bid""+OR+""buyout"")+billion+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
346,finance,derivatives,"Options Market",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""options+market""+OR+""options+trading""+OR+""put+call+ratio""+OR+VIX)+when:2d&hl=en-US&gl=US&ceid=US:en"
|
||||
347,finance,derivatives,"Futures Trading",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""futures+trading""+OR+""S%26P+500+futures""+OR+""Nasdaq+futures"")+when:1d&hl=en-US&gl=US&ceid=US:en"
|
||||
348,finance,fintech,"Fintech News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(fintech+OR+""payment+technology""+OR+""neobank""+OR+""digital+banking"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
349,finance,fintech,"Trading Tech",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""algorithmic+trading""+OR+""trading+platform""+OR+""quantitative+finance"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
350,finance,fintech,"Blockchain Finance",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""blockchain+finance""+OR+""tokenization""+OR+""digital+securities""+OR+CBDC)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
351,finance,regulation,"Financial Regulation",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(SEC+OR+CFTC+OR+FINRA+OR+FCA)+regulation+OR+enforcement+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
352,finance,regulation,"Banking Rules",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(Basel+OR+""capital+requirements""+OR+""banking+regulation"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
353,finance,regulation,"Crypto Regulation",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(crypto+regulation+OR+""digital+asset""+regulation+OR+""stablecoin""+regulation)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
354,finance,institutional,"Hedge Fund News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""hedge+fund""+OR+""Bridgewater""+OR+""Citadel""+OR+""Renaissance"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
355,finance,institutional,"Private Equity",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""private+equity""+OR+Blackstone+OR+KKR+OR+Apollo+OR+Carlyle)+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
356,finance,institutional,"Sovereign Wealth",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""sovereign+wealth+fund""+OR+""pension+fund""+OR+""institutional+investor"")+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
357,finance,analysis,"Market Outlook",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""market+outlook""+OR+""stock+market+forecast""+OR+""bull+market""+OR+""bear+market"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
358,finance,analysis,"Risk & Volatility",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(VIX+OR+""market+volatility""+OR+""risk+off""+OR+""market+correction"")+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
359,finance,analysis,"Bank Research",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Goldman+Sachs""+OR+""JPMorgan""+OR+""Morgan+Stanley"")+forecast+OR+outlook+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
360,finance,gccNews,"Arabian Business",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:arabianbusiness.com+(Saudi+Arabia+OR+UAE+OR+GCC)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
361,finance,gccNews,"The National",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:thenationalnews.com+(Abu+Dhabi+OR+UAE+OR+Saudi)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
362,finance,gccNews,"Arab News",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:arabnews.com+(Saudi+Arabia+OR+investment+OR+infrastructure)+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
363,finance,gccNews,"Gulf FDI",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(PIF+OR+""DP+World""+OR+Mubadala+OR+ADNOC+OR+Masdar+OR+""ACWA+Power"")+infrastructure+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
364,finance,gccNews,"Gulf Investments",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=(""Saudi+Arabia""+OR+""UAE""+OR+""Abu+Dhabi"")+investment+infrastructure+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
365,finance,gccNews,"Vision 2030",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=""Vision+2030""+(project+OR+investment+OR+announced)+when:14d&hl=en-US&gl=US&ceid=US:en"
|
||||
366,happy,positive,"Good News Network",OK,2026-02-26,"OK","https://www.goodnewsnetwork.org/feed/"
|
||||
367,happy,positive,"Positive.News",OK,2026-02-26,"OK","https://www.positive.news/feed/"
|
||||
368,happy,positive,"Reasons to be Cheerful",OK,2026-02-26,"OK","https://reasonstobecheerful.world/feed/"
|
||||
369,happy,positive,"Optimist Daily",OK,2026-02-26,"OK","https://www.optimistdaily.com/feed/"
|
||||
370,happy,positive,"Upworthy",OK,2026-02-26,"OK","https://www.upworthy.com/feed/"
|
||||
371,happy,positive,"DailyGood",OK,2026-02-23,"OK","https://www.dailygood.org/feed"
|
||||
372,happy,positive,"Good Good Good",OK,2026-02-26,"OK","https://www.goodgoodgood.co/articles/rss.xml"
|
||||
373,happy,positive,"GOOD Magazine",OK,2026-02-26,"OK","https://www.good.is/feed/"
|
||||
374,happy,positive,"Sunny Skyz",OK,2026-02-26,"OK","https://www.sunnyskyz.com/rss_tebow.php"
|
||||
375,happy,positive,"The Better India",OK,2026-02-26,"OK","https://thebetterindia.com/feed/"
|
||||
376,happy,science,"GNN Science",OK,2026-02-26,"OK","https://www.goodnewsnetwork.org/category/news/science/feed/"
|
||||
377,happy,science,"ScienceDaily",DEAD,,"Timeout","https://www.sciencedaily.com/rss/top.xml"
|
||||
378,happy,science,"Nature News",OK,2026-02-26,"OK","https://feeds.nature.com/nature/rss/current"
|
||||
379,happy,science,"Live Science",EMPTY,,"No dates found","https://www.livescience.com/feeds/all"
|
||||
380,happy,science,"New Scientist",OK,2026-02-26,"OK","https://www.newscientist.com/feed/home/"
|
||||
381,happy,science,"Singularity Hub",OK,2026-02-24,"OK","https://singularityhub.com/feed/"
|
||||
382,happy,science,"Human Progress",OK,2026-02-26,"OK","https://humanprogress.org/feed/"
|
||||
383,happy,science,"Greater Good (Berkeley)",EMPTY,,"No dates found","https://greatergood.berkeley.edu/rss"
|
||||
384,happy,nature,"GNN Animals",OK,2026-02-26,"OK","https://www.goodnewsnetwork.org/category/news/animals/feed/"
|
||||
385,happy,health,"GNN Health",OK,2026-02-25,"OK","https://www.goodnewsnetwork.org/category/news/health/feed/"
|
||||
386,happy,inspiring,"GNN Heroes",OK,2026-02-25,"OK","https://www.goodnewsnetwork.org/category/news/inspiring/feed/"
|
||||
387,intel,inspiring,"Defense One",OK,2026-02-26,"OK","https://www.defenseone.com/rss/all/"
|
||||
388,intel,inspiring,"Breaking Defense",DEAD,,"HTTP 403","https://breakingdefense.com/feed/"
|
||||
389,intel,inspiring,"The War Zone",OK,2026-02-26,"OK","https://www.twz.com/feed"
|
||||
390,intel,inspiring,"Defense News",OK,2026-02-26,"OK","https://www.defensenews.com/arc/outboundfeeds/rss/?outputType=xml"
|
||||
391,intel,inspiring,"Janes",OK,2026-02-25,"OK","https://news.google.com/rss/search?q=site:janes.com+when:3d&hl=en-US&gl=US&ceid=US:en"
|
||||
392,intel,inspiring,"Military Times",OK,2026-02-26,"OK","https://www.militarytimes.com/arc/outboundfeeds/rss/?outputType=xml"
|
||||
393,intel,inspiring,"Task & Purpose",OK,2026-02-26,"OK","https://taskandpurpose.com/feed/"
|
||||
394,intel,inspiring,"USNI News",OK,2026-02-26,"OK","https://news.usni.org/feed"
|
||||
395,intel,inspiring,"gCaptain",OK,2026-02-26,"OK","https://gcaptain.com/feed/"
|
||||
396,intel,inspiring,"Oryx OSINT",STALE,2024-12-07,"Stale","https://www.oryxspioenkop.com/feeds/posts/default?alt=rss"
|
||||
397,intel,inspiring,"UK MOD",OK,2026-02-26,"OK","https://www.gov.uk/government/organisations/ministry-of-defence.atom"
|
||||
398,intel,inspiring,"CSIS",EMPTY,,"No dates found","https://www.csis.org/analysis?type=analysis"
|
||||
399,intel,inspiring,"Chatham House",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:chathamhouse.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
400,intel,inspiring,"ECFR",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:ecfr.eu+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
401,intel,inspiring,"Middle East Institute",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:mei.edu+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
402,intel,inspiring,"RAND",DEAD,,"HTTP 404","https://www.rand.org/rss/all.xml"
|
||||
403,intel,inspiring,"Brookings",EMPTY,,"No dates found","https://www.brookings.edu/feed/"
|
||||
404,intel,inspiring,"Carnegie",EMPTY,,"No dates found","https://carnegieendowment.org/rss/"
|
||||
405,intel,inspiring,"FAS",STALE,2023-02-14,"Stale","https://fas.org/feed/"
|
||||
406,intel,inspiring,"NTI",DEAD,,"HTTP 403","https://www.nti.org/rss/"
|
||||
407,intel,inspiring,"RUSI",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:rusi.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
408,intel,inspiring,"Wilson Center",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:wilsoncenter.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
409,intel,inspiring,"GMF",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:gmfus.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
410,intel,inspiring,"Stimson Center",OK,2026-02-26,"OK","https://www.stimson.org/feed/"
|
||||
411,intel,inspiring,"CNAS",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:cnas.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
412,intel,inspiring,"Lowy Institute",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:lowyinstitute.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
413,intel,inspiring,"Arms Control Assn",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:armscontrol.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
414,intel,inspiring,"Bulletin of Atomic Scientists",OK,2026-02-26,"OK","https://news.google.com/rss/search?q=site:thebulletin.org+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
415,intel,inspiring,"Bellingcat",DEAD,,"fetch failed","https://www.bellingcat.com/feed/"
|
||||
416,intel,inspiring,"Ransomware.live",OK,2026-02-26,"OK","https://www.ransomware.live/rss.xml"
|
||||
417,intel,inspiring,"FAO News",OK,2026-02-25,"OK","https://www.fao.org/feeds/fao-newsroom-rss"
|
||||
418,intel,inspiring,"FAO GIEWS",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=site:fao.org+GIEWS+food+security+when:30d&hl=en-US&gl=US&ceid=US:en"
|
||||
419,intel,inspiring,"EU ISS",OK,2026-02-24,"OK","https://news.google.com/rss/search?q=site:iss.europa.eu+when:7d&hl=en-US&gl=US&ceid=US:en"
|
||||
420,tech,vcblogs,"FwdStart Newsletter",SKIP,,"Local endpoint","/api/fwdstart"
|
||||
|
@@ -188,7 +188,7 @@ export async function listFeedDigest(
|
||||
const digestCacheKey = `news:digest:v1:${variant}:${lang}`;
|
||||
|
||||
try {
|
||||
const cached = await cachedFetchJson<ListFeedDigestResponse>(digestCacheKey, 300, async () => {
|
||||
const cached = await cachedFetchJson<ListFeedDigestResponse>(digestCacheKey, 900, async () => {
|
||||
return buildDigest(variant, lang);
|
||||
});
|
||||
return cached ?? { categories: {}, feedStatuses: {}, generatedAt: new Date().toISOString() };
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
|
||||
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -77,7 +77,7 @@ import { fetchTelegramFeed } from '@/services/telegram-intel';
|
||||
import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
|
||||
import { enrichEventsWithExposure } from '@/services/population-exposure';
|
||||
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
|
||||
import { isFeatureAvailable } from '@/services/runtime-config';
|
||||
import { isFeatureAvailable, isFeatureEnabled } from '@/services/runtime-config';
|
||||
import { getAiFlowSettings } from '@/services/ai-flow-settings';
|
||||
import { t, getCurrentLanguage } from '@/services/i18n';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
@@ -175,6 +175,12 @@ export class DataLoaderManager implements AppModule {
|
||||
public updateSearchIndex: () => void = () => {};
|
||||
|
||||
private digestBreaker = { state: 'closed' as 'closed' | 'open' | 'half-open', failures: 0, cooldownUntil: 0 };
|
||||
private readonly digestRequestTimeoutMs = 8000;
|
||||
private readonly digestBreakerCooldownMs = 5 * 60 * 1000;
|
||||
private readonly persistedDigestMaxAgeMs = 6 * 60 * 60 * 1000;
|
||||
private readonly perFeedFallbackCategoryFeedLimit = 3;
|
||||
private readonly perFeedFallbackIntelFeedLimit = 6;
|
||||
private readonly perFeedFallbackBatchSize = 2;
|
||||
private lastGoodDigest: ListFeedDigestResponse | null = null;
|
||||
|
||||
constructor(ctx: AppContext, callbacks: DataLoaderCallbacks) {
|
||||
@@ -201,7 +207,7 @@ export class DataLoaderManager implements AppModule {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/news/v1/list-feed-digest?variant=${SITE_VARIANT}&lang=${getCurrentLanguage()}`,
|
||||
{ signal: AbortSignal.timeout(3000) },
|
||||
{ signal: AbortSignal.timeout(this.digestRequestTimeoutMs) },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json() as ListFeedDigestResponse;
|
||||
@@ -216,7 +222,7 @@ export class DataLoaderManager implements AppModule {
|
||||
this.digestBreaker.failures++;
|
||||
if (this.digestBreaker.failures >= 2) {
|
||||
this.digestBreaker.state = 'open';
|
||||
this.digestBreaker.cooldownUntil = now + 60_000;
|
||||
this.digestBreaker.cooldownUntil = now + this.digestBreakerCooldownMs;
|
||||
}
|
||||
return this.lastGoodDigest ?? await this.loadPersistedDigest();
|
||||
}
|
||||
@@ -230,12 +236,27 @@ export class DataLoaderManager implements AppModule {
|
||||
try {
|
||||
const envelope = await getPersistentCache<ListFeedDigestResponse>('digest:last-good');
|
||||
if (!envelope) return null;
|
||||
if (Date.now() - envelope.updatedAt > 30 * 60 * 1000) return null;
|
||||
if (Date.now() - envelope.updatedAt > this.persistedDigestMaxAgeMs) return null;
|
||||
this.lastGoodDigest = envelope.data;
|
||||
return envelope.data;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
private isPerFeedFallbackEnabled(): boolean {
|
||||
return isFeatureEnabled('newsPerFeedFallback');
|
||||
}
|
||||
|
||||
private getStaleNewsItems(category: string): NewsItem[] {
|
||||
const staleItems = this.ctx.newsByCategory[category];
|
||||
if (!Array.isArray(staleItems) || staleItems.length === 0) return [];
|
||||
return [...staleItems].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
||||
}
|
||||
|
||||
private selectLimitedFeeds<T>(feeds: T[], maxFeeds: number): T[] {
|
||||
if (feeds.length <= maxFeeds) return feeds;
|
||||
return feeds.slice(0, maxFeeds);
|
||||
}
|
||||
|
||||
private shouldShowIntelligenceNotifications(): boolean {
|
||||
return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled();
|
||||
}
|
||||
@@ -540,10 +561,10 @@ export class DataLoaderManager implements AppModule {
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const enabledNames = new Set(enabledFeeds.map(f => f.name));
|
||||
|
||||
// Digest branch: server already aggregated feeds — map proto items to client types
|
||||
if (digest?.categories && category in digest.categories) {
|
||||
const enabledNames = new Set(enabledFeeds.map(f => f.name));
|
||||
let items = (digest.categories[category]?.items ?? [])
|
||||
.map(protoItemToNewsItem)
|
||||
.filter(i => enabledNames.has(i.source));
|
||||
@@ -584,7 +605,7 @@ export class DataLoaderManager implements AppModule {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Per-feed fallback: fetch each feed individually (first load or digest unavailable)
|
||||
// Fallback path when digest is unavailable: stale-first, then limited per-feed fan-out.
|
||||
const renderIntervalMs = 100;
|
||||
let lastRenderTime = 0;
|
||||
let renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -618,7 +639,36 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
};
|
||||
|
||||
const items = await fetchCategoryFeeds(enabledFeeds, {
|
||||
const staleItems = this.getStaleNewsItems(category).filter(i => enabledNames.has(i.source));
|
||||
if (staleItems.length > 0) {
|
||||
console.warn(`[News] Digest missing for "${category}", serving stale headlines (${staleItems.length})`);
|
||||
this.renderNewsForCategory(category, staleItems);
|
||||
this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), {
|
||||
status: 'ok',
|
||||
itemCount: staleItems.length,
|
||||
});
|
||||
return staleItems;
|
||||
}
|
||||
|
||||
if (!this.isPerFeedFallbackEnabled()) {
|
||||
console.warn(`[News] Digest missing for "${category}", limited per-feed fallback disabled`);
|
||||
this.renderNewsForCategory(category, []);
|
||||
this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), {
|
||||
status: 'error',
|
||||
errorMessage: 'Digest unavailable',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const fallbackFeeds = this.selectLimitedFeeds(enabledFeeds, this.perFeedFallbackCategoryFeedLimit);
|
||||
if (fallbackFeeds.length < enabledFeeds.length) {
|
||||
console.warn(`[News] Digest missing for "${category}", using limited per-feed fallback (${fallbackFeeds.length}/${enabledFeeds.length} feeds)`);
|
||||
} else {
|
||||
console.warn(`[News] Digest missing for "${category}", using per-feed fallback (${fallbackFeeds.length} feeds)`);
|
||||
}
|
||||
|
||||
const items = await fetchCategoryFeeds(fallbackFeeds, {
|
||||
batchSize: this.perFeedFallbackBatchSize,
|
||||
onBatch: (partialItems) => {
|
||||
scheduleRender(partialItems);
|
||||
this.flashMapForNews(partialItems);
|
||||
@@ -636,7 +686,7 @@ export class DataLoaderManager implements AppModule {
|
||||
|
||||
if (items.length === 0) {
|
||||
const failures = getFeedFailures();
|
||||
const failedFeeds = enabledFeeds.filter(f => failures.has(f.name));
|
||||
const failedFeeds = fallbackFeeds.filter(f => failures.has(f.name));
|
||||
if (failedFeeds.length > 0) {
|
||||
const names = failedFeeds.map(f => f.name).join(', ');
|
||||
panel.showError(`${t('common.noNewsAvailable')} (${names} failed)`);
|
||||
@@ -714,6 +764,7 @@ export class DataLoaderManager implements AppModule {
|
||||
|
||||
if (SITE_VARIANT === 'full') {
|
||||
const enabledIntelSources = INTEL_SOURCES.filter(f => !this.ctx.disabledSources.has(f.name));
|
||||
const enabledIntelNames = new Set(enabledIntelSources.map(f => f.name));
|
||||
const intelPanel = this.ctx.newsPanels['intel'];
|
||||
if (enabledIntelSources.length === 0) {
|
||||
delete this.ctx.newsByCategory['intel'];
|
||||
@@ -721,10 +772,9 @@ export class DataLoaderManager implements AppModule {
|
||||
this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: 0 });
|
||||
} else if (digest?.categories && 'intel' in digest.categories) {
|
||||
// Digest branch for intel
|
||||
const enabledNames = new Set(enabledIntelSources.map(f => f.name));
|
||||
const intel = (digest.categories['intel']?.items ?? [])
|
||||
.map(protoItemToNewsItem)
|
||||
.filter(i => enabledNames.has(i.source));
|
||||
.filter(i => enabledIntelNames.has(i.source));
|
||||
checkBatchForBreakingAlerts(intel);
|
||||
this.renderNewsForCategory('intel', intel);
|
||||
if (intelPanel) {
|
||||
@@ -738,24 +788,50 @@ export class DataLoaderManager implements AppModule {
|
||||
collectedNews.push(...intel);
|
||||
this.flashMapForNews(intel);
|
||||
} else {
|
||||
const intelResult = await Promise.allSettled([fetchCategoryFeeds(enabledIntelSources)]);
|
||||
if (intelResult[0]?.status === 'fulfilled') {
|
||||
const intel = intelResult[0].value;
|
||||
checkBatchForBreakingAlerts(intel);
|
||||
this.renderNewsForCategory('intel', intel);
|
||||
const staleIntel = this.getStaleNewsItems('intel').filter(i => enabledIntelNames.has(i.source));
|
||||
if (staleIntel.length > 0) {
|
||||
console.warn(`[News] Intel digest missing, serving stale headlines (${staleIntel.length})`);
|
||||
this.renderNewsForCategory('intel', staleIntel);
|
||||
if (intelPanel) {
|
||||
try {
|
||||
const baseline = await updateBaseline('news:intel', intel.length);
|
||||
const deviation = calculateDeviation(intel.length, baseline);
|
||||
const baseline = await updateBaseline('news:intel', staleIntel.length);
|
||||
const deviation = calculateDeviation(staleIntel.length, baseline);
|
||||
intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level);
|
||||
} catch (e) { console.warn('[Baseline] news:intel write failed:', e); }
|
||||
}
|
||||
this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: intel.length });
|
||||
collectedNews.push(...intel);
|
||||
this.flashMapForNews(intel);
|
||||
} else {
|
||||
this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: staleIntel.length });
|
||||
collectedNews.push(...staleIntel);
|
||||
} else if (!this.isPerFeedFallbackEnabled()) {
|
||||
console.warn('[News] Intel digest missing, limited per-feed fallback disabled');
|
||||
delete this.ctx.newsByCategory['intel'];
|
||||
console.error('[App] Intel feed failed:', intelResult[0]?.reason);
|
||||
this.ctx.statusPanel?.updateFeed('Intel', { status: 'error', errorMessage: 'Digest unavailable' });
|
||||
} else {
|
||||
const fallbackIntelFeeds = this.selectLimitedFeeds(enabledIntelSources, this.perFeedFallbackIntelFeedLimit);
|
||||
if (fallbackIntelFeeds.length < enabledIntelSources.length) {
|
||||
console.warn(`[News] Intel digest missing, using limited per-feed fallback (${fallbackIntelFeeds.length}/${enabledIntelSources.length} feeds)`);
|
||||
}
|
||||
|
||||
const intelResult = await Promise.allSettled([
|
||||
fetchCategoryFeeds(fallbackIntelFeeds, { batchSize: this.perFeedFallbackBatchSize }),
|
||||
]);
|
||||
if (intelResult[0]?.status === 'fulfilled') {
|
||||
const intel = intelResult[0].value;
|
||||
checkBatchForBreakingAlerts(intel);
|
||||
this.renderNewsForCategory('intel', intel);
|
||||
if (intelPanel) {
|
||||
try {
|
||||
const baseline = await updateBaseline('news:intel', intel.length);
|
||||
const deviation = calculateDeviation(intel.length, baseline);
|
||||
intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level);
|
||||
} catch (e) { console.warn('[Baseline] news:intel write failed:', e); }
|
||||
}
|
||||
this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: intel.length });
|
||||
collectedNews.push(...intel);
|
||||
this.flashMapForNews(intel);
|
||||
} else {
|
||||
delete this.ctx.newsByCategory['intel'];
|
||||
console.error('[App] Intel feed failed:', intelResult[0]?.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export { AI_DATA_CENTERS } from '../ai-datacenters';
|
||||
|
||||
// Refresh intervals - shared across all variants
|
||||
export const REFRESH_INTERVALS = {
|
||||
feeds: 7 * 60 * 1000,
|
||||
feeds: 15 * 60 * 1000,
|
||||
markets: 8 * 60 * 1000,
|
||||
crypto: 8 * 60 * 1000,
|
||||
predictions: 10 * 60 * 1000,
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -155,8 +155,6 @@ Sentry.init({
|
||||
if (frames.length > 0 && frames.every(f => /^blob:/.test(f.filename ?? ''))) return null;
|
||||
// Suppress YouTube IFrame widget API internal errors
|
||||
if (frames.some(f => /www-widgetapi\.js/.test(f.filename ?? ''))) return null;
|
||||
// Suppress Sentry SDK internal crashes (logs.js)
|
||||
if (frames.some(f => /\/ingest\/static\/logs\.js/.test(f.filename ?? ''))) return null;
|
||||
return event;
|
||||
},
|
||||
});
|
||||
@@ -170,7 +168,6 @@ import { debugGetCells, getCellCount } from '@/services/geo-convergence';
|
||||
import { initMetaTags } from '@/services/meta-tags';
|
||||
import { installRuntimeFetchPatch, installWebApiRedirect } from '@/services/runtime';
|
||||
import { loadDesktopSecrets } from '@/services/runtime-config';
|
||||
import { initAnalytics, trackApiKeysSnapshot } from '@/services/analytics';
|
||||
import { applyStoredTheme } from '@/utils/theme-manager';
|
||||
import { SITE_VARIANT } from '@/config/variant';
|
||||
import { clearChunkReloadGuard, installChunkReloadGuard } from '@/bootstrap/chunk-reload';
|
||||
@@ -181,9 +178,6 @@ const chunkReloadStorageKey = installChunkReloadGuard(__APP_VERSION__);
|
||||
// Initialize Vercel Analytics
|
||||
inject();
|
||||
|
||||
// Initialize PostHog product analytics
|
||||
void initAnalytics();
|
||||
|
||||
// Initialize dynamic meta tags for sharing
|
||||
initMetaTags();
|
||||
|
||||
@@ -191,10 +185,7 @@ initMetaTags();
|
||||
installRuntimeFetchPatch();
|
||||
// In web production, route RPC calls through api.worldmonitor.app (Cloudflare edge).
|
||||
installWebApiRedirect();
|
||||
loadDesktopSecrets().then(async () => {
|
||||
await initAnalytics();
|
||||
trackApiKeysSnapshot();
|
||||
}).catch(() => {});
|
||||
loadDesktopSecrets().catch(() => {});
|
||||
|
||||
// Apply stored theme preference before app initialization (safety net for inline script)
|
||||
applyStoredTheme();
|
||||
|
||||
@@ -1,386 +1,123 @@
|
||||
/**
|
||||
* PostHog Analytics Service
|
||||
* Analytics facade.
|
||||
*
|
||||
* Always active when VITE_POSTHOG_KEY is set. No consent gate.
|
||||
* All exports are no-ops when the key is absent (dev/local).
|
||||
*
|
||||
* Data safety:
|
||||
* - Typed allowlists per event — unlisted properties silently dropped
|
||||
* - sanitize_properties callback strips strings matching API key prefixes
|
||||
* - No session recordings, no autocapture
|
||||
* - distinct_id is a random UUID — pseudonymous, not identifiable
|
||||
* PostHog has been removed from the application.
|
||||
* Vercel Analytics remains initialized in src/main.ts.
|
||||
* Event-level helpers are kept as no-ops to preserve existing call sites.
|
||||
*/
|
||||
|
||||
import { isDesktopRuntime } from './runtime';
|
||||
import { getRuntimeConfigSnapshot, type RuntimeSecretKey } from './runtime-config';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { isMobileDevice } from '@/utils';
|
||||
import { invokeTauri } from './tauri-bridge';
|
||||
|
||||
// ── Installation identity ──
|
||||
|
||||
function getOrCreateInstallationId(): string {
|
||||
const STORAGE_KEY = 'wm-installation-id';
|
||||
let id = localStorage.getItem(STORAGE_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Stable property name map for secret keys ──
|
||||
|
||||
const SECRET_ANALYTICS_NAMES: Record<RuntimeSecretKey, string> = {
|
||||
GROQ_API_KEY: 'groq',
|
||||
OPENROUTER_API_KEY: 'openrouter',
|
||||
FRED_API_KEY: 'fred',
|
||||
EIA_API_KEY: 'eia',
|
||||
CLOUDFLARE_API_TOKEN: 'cloudflare',
|
||||
ACLED_ACCESS_TOKEN: 'acled',
|
||||
URLHAUS_AUTH_KEY: 'urlhaus',
|
||||
OTX_API_KEY: 'otx',
|
||||
ABUSEIPDB_API_KEY: 'abuseipdb',
|
||||
WINGBITS_API_KEY: 'wingbits',
|
||||
WS_RELAY_URL: 'ws_relay',
|
||||
VITE_OPENSKY_RELAY_URL: 'opensky_relay',
|
||||
OPENSKY_CLIENT_ID: 'opensky',
|
||||
OPENSKY_CLIENT_SECRET: 'opensky_secret',
|
||||
AISSTREAM_API_KEY: 'aisstream',
|
||||
FINNHUB_API_KEY: 'finnhub',
|
||||
NASA_FIRMS_API_KEY: 'nasa_firms',
|
||||
UC_DP_KEY: 'uc_dp',
|
||||
OLLAMA_API_URL: 'ollama_url',
|
||||
OLLAMA_MODEL: 'ollama_model',
|
||||
WORLDMONITOR_API_KEY: 'worldmonitor',
|
||||
WTO_API_KEY: 'wto',
|
||||
AVIATIONSTACK_API: 'aviationstack',
|
||||
ICAO_API_KEY: 'icao',
|
||||
};
|
||||
|
||||
// ── Typed event schemas (allowlisted properties per event) ──
|
||||
|
||||
const HAS_KEYS = Object.values(SECRET_ANALYTICS_NAMES).map(n => `has_${n}`);
|
||||
|
||||
const EVENT_SCHEMAS: Record<string, Set<string>> = {
|
||||
// Phase 1 — core events
|
||||
wm_app_loaded: new Set(['load_time_ms', 'panel_count']),
|
||||
wm_panel_viewed: new Set(['panel_id']),
|
||||
wm_summary_generated: new Set(['provider', 'model', 'cached']),
|
||||
wm_summary_failed: new Set(['last_provider']),
|
||||
wm_api_keys_configured: new Set([
|
||||
'total_keys_configured', 'total_features_enabled', 'enabled_features',
|
||||
'ollama_model', 'platform',
|
||||
...HAS_KEYS,
|
||||
]),
|
||||
// Phase 2 — plan-specified events
|
||||
wm_panel_resized: new Set(['panel_id', 'new_span']),
|
||||
wm_variant_switched: new Set(['from', 'to']),
|
||||
wm_map_layer_toggled: new Set(['layer_id', 'enabled', 'source']),
|
||||
wm_country_brief_opened: new Set(['country_code']),
|
||||
wm_theme_changed: new Set(['theme']),
|
||||
wm_language_changed: new Set(['language']),
|
||||
wm_feature_toggled: new Set(['feature_id', 'enabled']),
|
||||
wm_search_used: new Set(['query_length', 'result_count']),
|
||||
// Phase 2 — additional interaction events
|
||||
wm_map_view_changed: new Set(['view']),
|
||||
wm_country_selected: new Set(['country_code', 'country_name', 'source']),
|
||||
wm_search_result_selected: new Set(['result_type']),
|
||||
wm_panel_toggled: new Set(['panel_id', 'enabled']),
|
||||
wm_finding_clicked: new Set(['finding_id', 'finding_source', 'finding_type', 'priority']),
|
||||
wm_update_shown: new Set(['current_version', 'remote_version']),
|
||||
wm_update_clicked: new Set(['target_version']),
|
||||
wm_update_dismissed: new Set(['target_version']),
|
||||
wm_critical_banner_action: new Set(['action', 'theater_id']),
|
||||
wm_download_clicked: new Set(['platform']),
|
||||
wm_download_banner_dismissed: new Set([]),
|
||||
wm_webcam_selected: new Set(['webcam_id', 'city', 'view_mode']),
|
||||
wm_webcam_region_filtered: new Set(['region']),
|
||||
wm_deeplink_opened: new Set(['deeplink_type', 'target']),
|
||||
};
|
||||
|
||||
function sanitizeProps(event: string, raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const allowed = EVENT_SCHEMAS[event];
|
||||
if (!allowed) return {};
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (allowed.has(k)) safe[k] = v;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
// ── Defense-in-depth: strip values that look like API keys ──
|
||||
|
||||
const API_KEY_PREFIXES = /^(sk-|gsk_|or-|Bearer )/;
|
||||
|
||||
function deepStripSecrets(props: Record<string, unknown>): Record<string, unknown> {
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (typeof v === 'string' && API_KEY_PREFIXES.test(v)) {
|
||||
cleaned[k] = '[REDACTED]';
|
||||
} else {
|
||||
cleaned[k] = v;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ── PostHog instance management ──
|
||||
|
||||
type PostHogInstance = {
|
||||
init: (key: string, config: Record<string, unknown>) => void;
|
||||
register: (props: Record<string, unknown>) => void;
|
||||
capture: (event: string, props?: Record<string, unknown>, options?: { transport?: 'XHR' | 'sendBeacon' }) => void;
|
||||
};
|
||||
|
||||
let posthogInstance: PostHogInstance | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY as string | undefined;
|
||||
const POSTHOG_HOST = isDesktopRuntime()
|
||||
? ((import.meta.env.VITE_POSTHOG_HOST as string | undefined) || 'https://us.i.posthog.com')
|
||||
: '/ingest'; // Reverse proxy through own domain to bypass ad blockers
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (!POSTHOG_KEY) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
const mod = await import('posthog-js');
|
||||
const posthog = mod.default;
|
||||
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
ui_host: 'https://us.posthog.com',
|
||||
persistence: 'localStorage',
|
||||
autocapture: false,
|
||||
capture_pageview: false, // Manual capture below — auto-capture silently fails with bootstrap + SPA
|
||||
capture_pageleave: true,
|
||||
disable_session_recording: true,
|
||||
bootstrap: { distinctID: getOrCreateInstallationId() },
|
||||
sanitize_properties: (props: Record<string, unknown>) => deepStripSecrets(props),
|
||||
});
|
||||
|
||||
// Register super properties (attached to every event)
|
||||
const superProps: Record<string, unknown> = {
|
||||
platform: isDesktopRuntime() ? 'desktop' : 'web',
|
||||
variant: SITE_VARIANT,
|
||||
app_version: __APP_VERSION__,
|
||||
is_mobile: isMobileDevice(),
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
viewport_width: innerWidth,
|
||||
viewport_height: innerHeight,
|
||||
is_big_screen: screen.width >= 2560 || screen.height >= 1440,
|
||||
is_tv_mode: screen.width >= 3840,
|
||||
device_pixel_ratio: devicePixelRatio,
|
||||
browser_language: navigator.language,
|
||||
local_hour: new Date().getHours(),
|
||||
local_day: new Date().getDay(),
|
||||
};
|
||||
|
||||
// Desktop additionally registers OS and arch
|
||||
if (isDesktopRuntime()) {
|
||||
try {
|
||||
const info = await invokeTauri<{ os: string; arch: string }>('get_desktop_runtime_info');
|
||||
superProps.desktop_os = info.os;
|
||||
superProps.desktop_arch = info.arch;
|
||||
} catch {
|
||||
// Tauri bridge may not be available yet
|
||||
}
|
||||
}
|
||||
|
||||
posthog.register(superProps);
|
||||
posthogInstance = posthog as unknown as PostHogInstance;
|
||||
|
||||
// Fire $pageview manually after full init — auto capture_pageview: true
|
||||
// fires during init() before super props are registered, and silently
|
||||
// fails with bootstrap + SPA setups (posthog-js #386).
|
||||
posthog.capture('$pageview');
|
||||
|
||||
// Flush any events queued while offline (desktop)
|
||||
flushOfflineQueue();
|
||||
|
||||
// Re-flush when coming back online
|
||||
if (isDesktopRuntime()) {
|
||||
window.addEventListener('online', () => flushOfflineQueue());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Analytics] Failed to initialize PostHog:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
// ── Offline event queue (desktop) ──
|
||||
|
||||
const OFFLINE_QUEUE_KEY = 'wm-analytics-offline-queue';
|
||||
const OFFLINE_QUEUE_CAP = 200;
|
||||
|
||||
function enqueueOffline(name: string, props: Record<string, unknown>): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
|
||||
const queue: Array<{ name: string; props: Record<string, unknown>; ts: number }> = raw ? JSON.parse(raw) : [];
|
||||
queue.push({ name, props, ts: Date.now() });
|
||||
if (queue.length > OFFLINE_QUEUE_CAP) queue.splice(0, queue.length - OFFLINE_QUEUE_CAP);
|
||||
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
||||
} catch { /* localStorage full or unavailable */ }
|
||||
export function trackEvent(_name: string, _props?: Record<string, unknown>): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
function flushOfflineQueue(): void {
|
||||
if (!posthogInstance) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
|
||||
if (!raw) return;
|
||||
const queue: Array<{ name: string; props: Record<string, unknown> }> = JSON.parse(raw);
|
||||
localStorage.removeItem(OFFLINE_QUEUE_KEY);
|
||||
for (const { name, props } of queue) {
|
||||
posthogInstance.capture(name, props);
|
||||
}
|
||||
} catch { /* corrupt queue, discard */ }
|
||||
export function trackEventBeforeUnload(_name: string, _props?: Record<string, unknown>): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, props?: Record<string, unknown>): void {
|
||||
const safeProps = props ? sanitizeProps(name, props) : {};
|
||||
if (!posthogInstance) {
|
||||
if (isDesktopRuntime() && POSTHOG_KEY) enqueueOffline(name, safeProps);
|
||||
return;
|
||||
}
|
||||
posthogInstance.capture(name, safeProps);
|
||||
}
|
||||
|
||||
/** Use sendBeacon transport for events fired just before page reload. */
|
||||
export function trackEventBeforeUnload(name: string, props?: Record<string, unknown>): void {
|
||||
if (!posthogInstance) return;
|
||||
const safeProps = props ? sanitizeProps(name, props) : {};
|
||||
posthogInstance.capture(name, safeProps, { transport: 'sendBeacon' });
|
||||
}
|
||||
|
||||
export function trackPanelView(panelId: string): void {
|
||||
trackEvent('wm_panel_viewed', { panel_id: panelId });
|
||||
export function trackPanelView(_panelId: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackApiKeysSnapshot(): void {
|
||||
const config = getRuntimeConfigSnapshot();
|
||||
const presence: Record<string, boolean> = {};
|
||||
for (const [internalKey, analyticsName] of Object.entries(SECRET_ANALYTICS_NAMES)) {
|
||||
const state = config.secrets[internalKey as RuntimeSecretKey];
|
||||
presence[`has_${analyticsName}`] = Boolean(state?.value);
|
||||
}
|
||||
|
||||
const enabledFeatures = Object.entries(config.featureToggles)
|
||||
.filter(([, v]) => v).map(([k]) => k);
|
||||
|
||||
trackEvent('wm_api_keys_configured', {
|
||||
platform: isDesktopRuntime() ? 'desktop' : 'web',
|
||||
total_keys_configured: Object.values(presence).filter(Boolean).length,
|
||||
...presence,
|
||||
enabled_features: enabledFeatures,
|
||||
total_features_enabled: enabledFeatures.length,
|
||||
ollama_model: config.secrets.OLLAMA_MODEL?.value || 'none',
|
||||
});
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackLLMUsage(provider: string, model: string, cached: boolean): void {
|
||||
trackEvent('wm_summary_generated', { provider, model, cached });
|
||||
export function trackLLMUsage(_provider: string, _model: string, _cached: boolean): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackLLMFailure(lastProvider: string): void {
|
||||
trackEvent('wm_summary_failed', { last_provider: lastProvider });
|
||||
export function trackLLMFailure(_lastProvider: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
// ── Phase 2 helpers (plan-specified events) ──
|
||||
|
||||
export function trackPanelResized(panelId: string, newSpan: number): void {
|
||||
trackEvent('wm_panel_resized', { panel_id: panelId, new_span: newSpan });
|
||||
export function trackPanelResized(_panelId: string, _newSpan: number): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackVariantSwitch(from: string, to: string): void {
|
||||
trackEventBeforeUnload('wm_variant_switched', { from, to });
|
||||
export function trackVariantSwitch(_from: string, _to: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackMapLayerToggle(layerId: string, enabled: boolean, source: 'user' | 'programmatic'): void {
|
||||
trackEvent('wm_map_layer_toggled', { layer_id: layerId, enabled, source });
|
||||
export function trackMapLayerToggle(_layerId: string, _enabled: boolean, _source: 'user' | 'programmatic'): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackCountryBriefOpened(countryCode: string): void {
|
||||
trackEvent('wm_country_brief_opened', { country_code: countryCode });
|
||||
export function trackCountryBriefOpened(_countryCode: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackThemeChanged(theme: string): void {
|
||||
trackEventBeforeUnload('wm_theme_changed', { theme });
|
||||
export function trackThemeChanged(_theme: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackLanguageChange(language: string): void {
|
||||
trackEventBeforeUnload('wm_language_changed', { language });
|
||||
export function trackLanguageChange(_language: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackFeatureToggle(featureId: string, enabled: boolean): void {
|
||||
trackEvent('wm_feature_toggled', { feature_id: featureId, enabled });
|
||||
export function trackFeatureToggle(_featureId: string, _enabled: boolean): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackSearchUsed(queryLength: number, resultCount: number): void {
|
||||
trackEvent('wm_search_used', { query_length: queryLength, result_count: resultCount });
|
||||
export function trackSearchUsed(_queryLength: number, _resultCount: number): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
// ── Phase 2 helpers (additional interaction events) ──
|
||||
|
||||
export function trackMapViewChange(view: string): void {
|
||||
trackEvent('wm_map_view_changed', { view });
|
||||
export function trackMapViewChange(_view: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackCountrySelected(code: string, name: string, source: string): void {
|
||||
trackEvent('wm_country_selected', { country_code: code, country_name: name, source });
|
||||
export function trackCountrySelected(_code: string, _name: string, _source: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackSearchResultSelected(resultType: string): void {
|
||||
trackEvent('wm_search_result_selected', { result_type: resultType });
|
||||
export function trackSearchResultSelected(_resultType: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackPanelToggled(panelId: string, enabled: boolean): void {
|
||||
trackEvent('wm_panel_toggled', { panel_id: panelId, enabled });
|
||||
export function trackPanelToggled(_panelId: string, _enabled: boolean): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackFindingClicked(id: string, source: string, type: string, priority: string): void {
|
||||
trackEvent('wm_finding_clicked', { finding_id: id, finding_source: source, finding_type: type, priority });
|
||||
export function trackFindingClicked(_id: string, _source: string, _type: string, _priority: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackUpdateShown(current: string, remote: string): void {
|
||||
trackEvent('wm_update_shown', { current_version: current, remote_version: remote });
|
||||
export function trackUpdateShown(_current: string, _remote: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackUpdateClicked(version: string): void {
|
||||
trackEvent('wm_update_clicked', { target_version: version });
|
||||
export function trackUpdateClicked(_version: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackUpdateDismissed(version: string): void {
|
||||
trackEvent('wm_update_dismissed', { target_version: version });
|
||||
export function trackUpdateDismissed(_version: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackCriticalBannerAction(action: string, theaterId: string): void {
|
||||
trackEvent('wm_critical_banner_action', { action, theater_id: theaterId });
|
||||
export function trackCriticalBannerAction(_action: string, _theaterId: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackDownloadClicked(platform: string): void {
|
||||
trackEvent('wm_download_clicked', { platform });
|
||||
export function trackDownloadClicked(_platform: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackDownloadBannerDismissed(): void {
|
||||
trackEvent('wm_download_banner_dismissed');
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackWebcamSelected(webcamId: string, city: string, viewMode: string): void {
|
||||
trackEvent('wm_webcam_selected', { webcam_id: webcamId, city, view_mode: viewMode });
|
||||
export function trackWebcamSelected(_webcamId: string, _city: string, _viewMode: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackWebcamRegionFiltered(region: string): void {
|
||||
trackEvent('wm_webcam_region_filtered', { region });
|
||||
export function trackWebcamRegionFiltered(_region: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
export function trackDeeplinkOpened(type: string, target: string): void {
|
||||
trackEvent('wm_deeplink_opened', { deeplink_type: type, target });
|
||||
export function trackDeeplinkOpened(_type: string, _target: string): void {
|
||||
// Intentionally no-op.
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const MAX_CACHE_ENTRIES = 100;
|
||||
const FEED_SCOPE_SEPARATOR = '::';
|
||||
const feedFailures = new Map<string, { count: number; cooldownUntil: number }>();
|
||||
const feedCache = new Map<string, { items: NewsItem[]; timestamp: number }>();
|
||||
const CACHE_TTL = 10 * 60 * 1000;
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
function toSerializable(items: NewsItem[]): Array<Omit<NewsItem, 'pubDate'> & { pubDate: string }> {
|
||||
return items.map(item => ({ ...item, pubDate: item.pubDate.toISOString() }));
|
||||
|
||||
@@ -45,6 +45,7 @@ export type RuntimeFeatureId =
|
||||
| 'aiOllama'
|
||||
| 'wtoTrade'
|
||||
| 'supplyChain'
|
||||
| 'newsPerFeedFallback'
|
||||
| 'aviationStack'
|
||||
| 'icaoNotams';
|
||||
|
||||
@@ -93,6 +94,7 @@ const defaultToggles: Record<RuntimeFeatureId, boolean> = {
|
||||
aiOllama: true,
|
||||
wtoTrade: true,
|
||||
supplyChain: true,
|
||||
newsPerFeedFallback: false,
|
||||
aviationStack: true,
|
||||
icaoNotams: true,
|
||||
};
|
||||
@@ -219,6 +221,13 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
|
||||
requiredSecrets: ['FRED_API_KEY'],
|
||||
fallback: 'Chokepoints and minerals always available; shipping requires FRED key.',
|
||||
},
|
||||
{
|
||||
id: 'newsPerFeedFallback',
|
||||
name: 'News per-feed fallback',
|
||||
description: 'If digest aggregation is unavailable, use stale headlines first and optionally fetch a limited feed subset.',
|
||||
requiredSecrets: [],
|
||||
fallback: 'Stale headlines remain available; limited per-feed fallback is disabled.',
|
||||
},
|
||||
{
|
||||
id: 'aviationStack',
|
||||
name: 'AviationStack flight delays',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const WS_API_URL = import.meta.env.VITE_WS_API_URL || '';
|
||||
const KEYED_CLOUD_API_PATTERN = /^\/api\/(?:[^/]+\/v1\/|bootstrap(?:\?|$)|rss-proxy(?:\?|$)|polymarket(?:\?|$)|ais-snapshot(?:\?|$))/;
|
||||
|
||||
const DEFAULT_REMOTE_HOSTS: Record<string, string> = {
|
||||
tech: WS_API_URL,
|
||||
@@ -323,7 +324,7 @@ export function installRuntimeFetchPatch(): void {
|
||||
const cloudUrl = `${getRemoteApiBaseUrl()}${target}`;
|
||||
if (debug) console.log(`[fetch] cloud fallback → ${cloudUrl}`);
|
||||
const cloudHeaders = new Headers(init?.headers);
|
||||
if (/^\/api\/[^/]+\/v1\//.test(target)) {
|
||||
if (KEYED_CLOUD_API_PATTERN.test(target)) {
|
||||
const { getRuntimeConfigSnapshot } = await import('@/services/runtime-config');
|
||||
const wmKeyValue = getRuntimeConfigSnapshot().secrets['WORLDMONITOR_API_KEY']?.value;
|
||||
if (wmKeyValue) {
|
||||
@@ -385,7 +386,12 @@ export function installRuntimeFetchPatch(): void {
|
||||
(window as unknown as Record<string, unknown>).__wmFetchPatched = true;
|
||||
}
|
||||
|
||||
const WEB_RPC_PATTERN = /^\/api\/[^/]+\/v1\//;
|
||||
const WEB_REDIRECT_PATHS = [
|
||||
/^\/api\/[^/]+\/v1\//,
|
||||
/^\/api\/rss-proxy(?:\?|$)/,
|
||||
/^\/api\/polymarket(?:\?|$)/,
|
||||
/^\/api\/ais-snapshot(?:\?|$)/,
|
||||
];
|
||||
const ALLOWED_REDIRECT_HOSTS = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*worldmonitor\.app(:\d+)?$/;
|
||||
|
||||
function isAllowedRedirectTarget(url: string): boolean {
|
||||
@@ -408,18 +414,37 @@ export function installWebApiRedirect(): void {
|
||||
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
const API_BASE = WS_API_URL;
|
||||
const shouldRedirectPath = (pathWithQuery: string): boolean => WEB_REDIRECT_PATHS.some((pattern) => pattern.test(pathWithQuery));
|
||||
const shouldFallbackToOrigin = (status: number): boolean => status === 404 || status === 405 || status === 501;
|
||||
const fetchWithRedirectFallback = async (
|
||||
redirectedInput: RequestInfo | URL,
|
||||
originalInput: RequestInfo | URL,
|
||||
originalInit?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const redirectedResponse = await nativeFetch(redirectedInput, originalInit);
|
||||
if (!shouldFallbackToOrigin(redirectedResponse.status)) return redirectedResponse;
|
||||
return nativeFetch(originalInput, originalInit);
|
||||
} catch {
|
||||
return nativeFetch(originalInput, originalInit);
|
||||
}
|
||||
};
|
||||
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
if (typeof input === 'string' && WEB_RPC_PATTERN.test(input)) {
|
||||
return nativeFetch(`${API_BASE}${input}`, init);
|
||||
if (typeof input === 'string' && shouldRedirectPath(input)) {
|
||||
return fetchWithRedirectFallback(`${API_BASE}${input}`, input, init);
|
||||
}
|
||||
if (input instanceof URL && input.origin === window.location.origin && WEB_RPC_PATTERN.test(input.pathname)) {
|
||||
return nativeFetch(new URL(`${API_BASE}${input.pathname}${input.search}`), init);
|
||||
if (input instanceof URL && input.origin === window.location.origin && shouldRedirectPath(`${input.pathname}${input.search}`)) {
|
||||
return fetchWithRedirectFallback(new URL(`${API_BASE}${input.pathname}${input.search}`), input, init);
|
||||
}
|
||||
if (input instanceof Request) {
|
||||
const u = new URL(input.url);
|
||||
if (u.origin === window.location.origin && WEB_RPC_PATTERN.test(u.pathname)) {
|
||||
return nativeFetch(new Request(`${API_BASE}${u.pathname}${u.search}`, input), init);
|
||||
if (u.origin === window.location.origin && shouldRedirectPath(`${u.pathname}${u.search}`)) {
|
||||
return fetchWithRedirectFallback(
|
||||
new Request(`${API_BASE}${u.pathname}${u.search}`, input),
|
||||
input.clone(),
|
||||
init,
|
||||
);
|
||||
}
|
||||
}
|
||||
return nativeFetch(input, init);
|
||||
|
||||
@@ -90,6 +90,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
|
||||
{
|
||||
id: 'tracking',
|
||||
label: 'Tracking & Sensing',
|
||||
features: ['aisRelay', 'openskyRelay', 'wingbitsEnrichment', 'nasaFirms', 'aviationStack', 'icaoNotams'],
|
||||
features: ['aisRelay', 'openskyRelay', 'wingbitsEnrichment', 'nasaFirms', 'aviationStack', 'icaoNotams', 'newsPerFeedFallback'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"ignoreCommand": "if [ -z \"$VERCEL_GIT_PREVIOUS_SHA\" ]; then exit 1; fi; git cat-file -e $VERCEL_GIT_PREVIOUS_SHA 2>/dev/null || exit 1; git diff --quiet $VERCEL_GIT_PREVIOUS_SHA HEAD -- ':!*.md' ':!.planning' ':!docs/' ':!e2e/' ':!scripts/' ':!.github/'",
|
||||
"rewrites": [
|
||||
{ "source": "/ingest/static/:path(.*)", "destination": "https://us-assets.i.posthog.com/static/:path" },
|
||||
{ "source": "/ingest/:path(.*)", "destination": "https://us.i.posthog.com/:path" }
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
@@ -13,7 +9,7 @@
|
||||
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
|
||||
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
|
||||
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com; frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'" }
|
||||
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com; frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -732,18 +732,6 @@ export default defineConfig({
|
||||
handler: 'NetworkOnly',
|
||||
method: 'POST',
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
||||
sameOrigin && /^\/ingest\//.test(url.pathname),
|
||||
handler: 'NetworkOnly',
|
||||
method: 'GET',
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
||||
sameOrigin && /^\/ingest\//.test(url.pathname),
|
||||
handler: 'NetworkOnly',
|
||||
method: 'POST',
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
||||
sameOrigin && /^\/rss\//.test(url.pathname),
|
||||
|
||||
BIN
web-app-overview.jpg
Normal file
BIN
web-app-overview.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Reference in New Issue
Block a user