Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The app ships multiple variants with different panel/layer configurations:
- `finance`: Financial markets focus
- `commodity`: Commodity markets focus
- `happy`: Positive news only
- `sports`: Sports news, fixtures, tables, and match stats

Variant is set via `VITE_VARIANT` env var. Config lives in `src/config/variants/`.

Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ World Monitor is a real-time OSINT dashboard built with **Vanilla TypeScript** (

### Variant System

The codebase produces three app variants from the same source, each targeting a different audience:
The codebase produces six app variants from the same source, each targeting a different audience:

| Variant | Command | Focus |
|---|---|---|
| `full` | `npm run dev` | Geopolitics, military, conflicts, infrastructure |
| `tech` | `npm run dev:tech` | Startups, AI/ML, cloud, cybersecurity |
| `finance` | `npm run dev:finance` | Markets, trading, central banks, commodities |
| `commodity` | `npm run dev:commodity` | Mining, energy, metals, supply chains |
| `happy` | `npm run dev:happy` | Positive news, progress, conservation |
| `sports` | `npm run dev:sports` | Sports news, fixtures, tables, live stats |

Variants share all code but differ in default panels, map layers, and RSS feeds. Variant configs live in `src/config/variants/`.

Expand Down
202 changes: 202 additions & 0 deletions api/_sports-data-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
const RAW_SPORTS_DATA_PROVIDERS = Object.freeze({
thesportsdb: {
baseUrl: 'https://www.thesportsdb.com/api/v1/json/123',
endpointTtls: Object.freeze({
'/all_leagues.php': 6 * 60 * 60,
'/lookupleague.php': 60 * 60,
'/search_all_seasons.php': 60 * 60,
'/lookuptable.php': 10 * 60,
'/eventslast.php': 10 * 60,
'/eventsnext.php': 5 * 60,
'/eventsday.php': 5 * 60,
'/lookupevent.php': 2 * 60,
'/lookupeventstats.php': 10 * 60,
'/searchvenues.php': 6 * 60 * 60,
'/searchplayers.php': 30 * 60,
'/lookupplayer.php': 60 * 60,
}),
allowedParams: Object.freeze({
'/all_leagues.php': [],
'/lookupleague.php': ['id'],
'/search_all_seasons.php': ['id'],
'/lookuptable.php': ['l', 's'],
'/eventslast.php': ['id'],
'/eventsnext.php': ['id'],
'/eventsday.php': ['d', 's'],
'/lookupevent.php': ['id'],
'/lookupeventstats.php': ['id'],
'/searchvenues.php': ['v'],
'/searchplayers.php': ['p'],
'/lookupplayer.php': ['id'],
}),
},
espn: {
baseUrl: 'https://www.espn.com',
endpointTtls: Object.freeze({
'/nba/standings': 5 * 60,
}),
allowedParams: Object.freeze({
'/nba/standings': [],
}),
},
espnsite: {
baseUrl: 'https://site.api.espn.com/apis/site/v2/sports',
endpointTtls: Object.freeze({
'/soccer/eng.1/scoreboard': 5 * 60,
'/soccer/eng.1/summary': 2 * 60,
'/soccer/uefa.champions/scoreboard': 5 * 60,
'/soccer/uefa.champions/summary': 2 * 60,
'/soccer/fifa.world/scoreboard': 10 * 60,
'/soccer/fifa.world/summary': 2 * 60,
'/soccer/uefa.euro/scoreboard': 10 * 60,
'/soccer/uefa.euro/summary': 2 * 60,
'/soccer/conmebol.america/scoreboard': 10 * 60,
'/soccer/conmebol.america/summary': 2 * 60,
'/soccer/conmebol.libertadores/scoreboard': 5 * 60,
'/soccer/conmebol.libertadores/summary': 2 * 60,
'/soccer/esp.1/scoreboard': 5 * 60,
'/soccer/esp.1/summary': 2 * 60,
'/soccer/ger.1/scoreboard': 5 * 60,
'/soccer/ger.1/summary': 2 * 60,
'/soccer/ita.1/scoreboard': 5 * 60,
'/soccer/ita.1/summary': 2 * 60,
'/soccer/fra.1/scoreboard': 5 * 60,
'/soccer/fra.1/summary': 2 * 60,
'/soccer/ned.1/scoreboard': 5 * 60,
'/soccer/ned.1/summary': 2 * 60,
'/soccer/por.1/scoreboard': 5 * 60,
'/soccer/por.1/summary': 2 * 60,
'/soccer/usa.1/scoreboard': 5 * 60,
'/soccer/usa.1/summary': 2 * 60,
'/soccer/mex.1/scoreboard': 5 * 60,
'/soccer/mex.1/summary': 2 * 60,
'/soccer/eng.2/scoreboard': 5 * 60,
'/soccer/eng.2/summary': 2 * 60,
'/soccer/eng.3/scoreboard': 5 * 60,
'/soccer/eng.3/summary': 2 * 60,
'/soccer/sco.1/scoreboard': 5 * 60,
'/soccer/sco.1/summary': 2 * 60,
'/soccer/arg.1/scoreboard': 5 * 60,
'/soccer/arg.1/summary': 2 * 60,
'/basketball/nba/standings': 5 * 60,
'/basketball/nba/scoreboard': 2 * 60,
'/basketball/nba/summary': 90,
'/hockey/nhl/scoreboard': 2 * 60,
'/hockey/nhl/summary': 2 * 60,
'/baseball/mlb/scoreboard': 2 * 60,
'/baseball/mlb/summary': 2 * 60,
'/football/nfl/scoreboard': 2 * 60,
'/football/nfl/summary': 2 * 60,
}),
allowedParams: Object.freeze({
'/soccer/eng.1/scoreboard': ['dates'],
'/soccer/eng.1/summary': ['event'],
'/soccer/uefa.champions/scoreboard': ['dates'],
'/soccer/uefa.champions/summary': ['event'],
'/soccer/fifa.world/scoreboard': ['dates'],
'/soccer/fifa.world/summary': ['event'],
'/soccer/uefa.euro/scoreboard': ['dates'],
'/soccer/uefa.euro/summary': ['event'],
'/soccer/conmebol.america/scoreboard': ['dates'],
'/soccer/conmebol.america/summary': ['event'],
'/soccer/conmebol.libertadores/scoreboard': ['dates'],
'/soccer/conmebol.libertadores/summary': ['event'],
'/soccer/esp.1/scoreboard': ['dates'],
'/soccer/esp.1/summary': ['event'],
'/soccer/ger.1/scoreboard': ['dates'],
'/soccer/ger.1/summary': ['event'],
'/soccer/ita.1/scoreboard': ['dates'],
'/soccer/ita.1/summary': ['event'],
'/soccer/fra.1/scoreboard': ['dates'],
'/soccer/fra.1/summary': ['event'],
'/soccer/ned.1/scoreboard': ['dates'],
'/soccer/ned.1/summary': ['event'],
'/soccer/por.1/scoreboard': ['dates'],
'/soccer/por.1/summary': ['event'],
'/soccer/usa.1/scoreboard': ['dates'],
'/soccer/usa.1/summary': ['event'],
'/soccer/mex.1/scoreboard': ['dates'],
'/soccer/mex.1/summary': ['event'],
'/soccer/eng.2/scoreboard': ['dates'],
'/soccer/eng.2/summary': ['event'],
'/soccer/eng.3/scoreboard': ['dates'],
'/soccer/eng.3/summary': ['event'],
'/soccer/sco.1/scoreboard': ['dates'],
'/soccer/sco.1/summary': ['event'],
'/soccer/arg.1/scoreboard': ['dates'],
'/soccer/arg.1/summary': ['event'],
'/basketball/nba/standings': [],
'/basketball/nba/scoreboard': ['dates'],
'/basketball/nba/summary': ['event'],
'/hockey/nhl/scoreboard': ['dates'],
'/hockey/nhl/summary': ['event'],
'/baseball/mlb/scoreboard': ['dates'],
'/baseball/mlb/summary': ['event'],
'/football/nfl/scoreboard': ['dates'],
'/football/nfl/summary': ['event'],
}),
},
jolpica: {
baseUrl: 'https://api.jolpi.ca',
endpointTtls: Object.freeze({
'/ergast/f1/current/driverStandings.json': 5 * 60,
'/ergast/f1/current/constructorStandings.json': 5 * 60,
'/ergast/f1/current/last/results.json': 5 * 60,
'/ergast/f1/current/next.json': 30 * 60,
}),
allowedParams: Object.freeze({
'/ergast/f1/current/driverStandings.json': [],
'/ergast/f1/current/constructorStandings.json': [],
'/ergast/f1/current/last/results.json': [],
'/ergast/f1/current/next.json': [],
}),
},
openf1: {
baseUrl: 'https://api.openf1.org',
endpointTtls: Object.freeze({
'/v1/drivers': 6 * 60 * 60,
}),
allowedParams: Object.freeze({
'/v1/drivers': ['session_key'],
}),
},
});

const PROVIDER_KEYS = Object.freeze(Object.keys(RAW_SPORTS_DATA_PROVIDERS));
const PROVIDER_KEY_SET = new Set(PROVIDER_KEYS);

function createProviderConfig(rawProvider) {
const endpointTtls = Object.freeze({ ...rawProvider.endpointTtls });
const endpointPaths = Object.keys(endpointTtls);
const allowedParamsEntries = Object.entries(rawProvider.allowedParams);
const allowedPaths = allowedParamsEntries.map(([path]) => path);

if (allowedPaths.length !== endpointPaths.length || !endpointPaths.every((path) => allowedPaths.includes(path))) {
throw new Error('Sports proxy config mismatch between endpoint TTLs and allowed parameter paths');
}

const allowedParams = Object.freeze(Object.fromEntries(
allowedParamsEntries.map(([path, params]) => [path, new Set(params)]),
));

return Object.freeze({
baseUrl: rawProvider.baseUrl,
endpointTtls,
endpoints: new Set(endpointPaths),
allowedParams,
});
}

export function createSportsDataProviders() {
return Object.freeze(Object.fromEntries(
Object.entries(RAW_SPORTS_DATA_PROVIDERS).map(([providerKey, provider]) => [providerKey, createProviderConfig(provider)]),
));
}

export function isSportsProvider(providerKey) {
return typeof providerKey === 'string' && PROVIDER_KEY_SET.has(providerKey);
}

export function getSportsProviderKeys() {
return [...PROVIDER_KEYS];
}
2 changes: 1 addition & 1 deletion api/enrichment/company.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async function fetchSECData(companyName) {
);
if (!res.ok) return null;
const data = await res.json();
if (!data.hits || !data.hits.hits || data.hits.hits.length === 0) return null;
if (!data.hits?.hits || data.hits.hits.length === 0) return null;
return {
totalFilings: data.hits.total?.value || 0,
recentFilings: data.hits.hits.slice(0, 5).map((h) => ({
Expand Down
27 changes: 13 additions & 14 deletions api/mcp-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,17 @@ class SseSession {
let buf = '';
let eventType = '';
const reader = this._reader;
const self = this;

(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// Stream closed — if endpoint never arrived, reject so connect() throws
if (!self._endpointUrl) {
self._endpointDeferred.reject(new Error('SSE stream closed before endpoint event'));
if (!this._endpointUrl) {
this._endpointDeferred.reject(new Error('SSE stream closed before endpoint event'));
}
for (const [, d] of self._pending) d.reject(new Error('SSE stream closed'));
for (const [, d] of this._pending) d.reject(new Error('SSE stream closed'));
break;
}
buf += dec.decode(value, { stream: true });
Expand All @@ -209,27 +208,27 @@ class SseSession {
// to prevent SSRF: a malicious server could emit an RFC1918 address.
let resolved;
try {
resolved = new URL(data.startsWith('http') ? data : data, self._sseUrl);
resolved = new URL(data.startsWith('http') ? data : data, this._sseUrl);
} catch {
self._endpointDeferred.reject(new Error('SSE endpoint event contains invalid URL'));
this._endpointDeferred.reject(new Error('SSE endpoint event contains invalid URL'));
return;
}
if (resolved.protocol !== 'https:' && resolved.protocol !== 'http:') {
self._endpointDeferred.reject(new Error('SSE endpoint protocol not allowed'));
this._endpointDeferred.reject(new Error('SSE endpoint protocol not allowed'));
return;
}
if (BLOCKED_HOST_PATTERNS.some(p => p.test(resolved.hostname))) {
self._endpointDeferred.reject(new Error('SSE endpoint host is blocked'));
this._endpointDeferred.reject(new Error('SSE endpoint host is blocked'));
return;
}
self._endpointUrl = resolved.toString();
self._endpointDeferred.resolve();
this._endpointUrl = resolved.toString();
this._endpointDeferred.resolve();
} else {
try {
const msg = JSON.parse(data);
if (msg.id !== undefined) {
const d = self._pending.get(msg.id);
if (d) { self._pending.delete(msg.id); d.resolve(msg); }
const d = this._pending.get(msg.id);
if (d) { this._pending.delete(msg.id); d.resolve(msg); }
}
} catch { /* skip non-JSON data lines */ }
}
Expand All @@ -238,8 +237,8 @@ class SseSession {
}
}
} catch (err) {
self._endpointDeferred.reject(err);
for (const [, d] of self._pending) d.reject(new Error('SSE stream closed'));
this._endpointDeferred.reject(err);
for (const [, d] of this._pending) d.reject(new Error('SSE stream closed'));
}
})();
}
Expand Down
Loading
Loading