Triage security alerts (#1903)

* fix(cors): use ACAO: * for bootstrap to fix CF cache origin pinning

CF ignores Vary: Origin and pins the first request's ACAO header on the
cached response. Preview deployments from *.vercel.app got ACAO: worldmonitor.app
from CF's cache, blocking CORS. Bootstrap data is fully public (world events,
market prices, seismic data) so ACAO: * is safe and allows CF to cache one
entry valid for all origins. isDisallowedOrigin() still gates non-cache paths.

* chore: finish security triage

* fix(aviation): update isArray callback signature for fast-xml-parser 5.5.x

fast-xml-parser bumped from 5.4.2 to 5.5.7 changed the isArray callback's
second parameter type from string to unknown. Guard with typeof check before
calling .test() to satisfy the new type contract.

* docs: fix MD032 blank lines around lists in tradingview-screener-integration

* fix(security): address code review findings from PR #1903

- api/_json-response.js: add recursion depth limit (20) to sanitizeJsonValue
  and strip Error.cause chain alongside stack/stackTrace
- scripts/ais-relay.cjs: extract WORLD_BANK_COUNTRY_ALLOWLIST to module level
  to eliminate duplicate; clamp years param to [1,30] to prevent unbounded
  World Bank date ranges
- src-tauri/sidecar/local-api-server.mjs: use JSON.stringify for vq value
  in inline JS, consistent with safeVideoId/safeOrigin handling
- src/services/story-share.ts: simplify sanitizeStoryType to use typed array
  instead of repeated as-casts

* fix(desktop): use parent window origin for YouTube embed postMessage

Sidecar youtube-embed route was targeting the iframe's own localhost origin
for all window.parent.postMessage calls, so browsers dropped yt-ready/
yt-state/yt-error on Tauri builds where the parent is tauri://localhost or
asset://localhost. LiveNewsPanel and LiveWebcamsPanel already pass
parentOrigin=window.location.origin in the embed URL; the sidecar now reads,
validates, and uses it as the postMessage target for all player event
messages. The YT API playerVars origin/widget_referrer continue to use the
sidecar's own localhost origin which YouTube requires.

Also restore World Bank relay to a generic proxy: replace TECH_INDICATORS
membership check with a format-only regex so any valid indicator code
(NY.GDP.MKTP.CD etc.) is accepted, not just the 16 tech-sector codes.
This commit is contained in:
Elie Habib
2026-03-20 12:37:24 +04:00
committed by GitHub
parent 608eb42e4b
commit 483d859ceb
24 changed files with 1131 additions and 80 deletions

View File

@@ -5,6 +5,9 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
biome:
runs-on: ubuntu-latest

View File

@@ -6,6 +6,9 @@ on:
- '**/*.md'
- '.markdownlint-cli2.jsonc'
permissions:
contents: read
jobs:
markdown:
# No secrets needed — run for all PRs including forks

View File

@@ -9,6 +9,9 @@ on:
- 'Makefile'
- '.github/workflows/proto-check.yml'
permissions:
contents: read
jobs:
proto-freshness:
if: github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -5,6 +5,9 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-latest

View File

@@ -5,6 +5,9 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
typecheck:
# No secrets needed — run for all PRs including forks

View File

@@ -1,5 +1,28 @@
function sanitizeJsonValue(value, depth = 0) {
if (depth > 20) return '[truncated]';
if (value instanceof Error) {
return { error: value.message };
}
if (Array.isArray(value)) {
return value.map(item => sanitizeJsonValue(item, depth + 1));
}
if (value && typeof value === 'object') {
const clone = {};
for (const [key, nested] of Object.entries(value)) {
if (key === 'stack' || key === 'stackTrace' || key === 'cause') continue;
clone[key] = sanitizeJsonValue(nested, depth + 1);
}
return clone;
}
return value;
}
export function jsonResponse(body, status, headers = {}) {
return new Response(JSON.stringify(body), {
return new Response(JSON.stringify(sanitizeJsonValue(body)), {
status,
headers: {
'Content-Type': 'application/json',

View File

@@ -57,6 +57,14 @@ function isAllowedDomain(hostname) {
return ALLOWED_DOMAINS.includes(hostname) || ALLOWED_DOMAINS.includes(bare) || ALLOWED_DOMAINS.includes(withWww);
}
function isGoogleNewsFeedUrl(feedUrl) {
try {
return new URL(feedUrl).hostname === 'news.google.com';
} catch {
return false;
}
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
@@ -99,7 +107,7 @@ export default async function handler(req) {
const isRelayOnly = RELAY_ONLY_DOMAINS.has(hostname);
// Google News is slow - use longer timeout
const isGoogleNews = feedUrl.includes('news.google.com');
const isGoogleNews = isGoogleNewsFeedUrl(feedUrl);
const timeout = isGoogleNews ? 20000 : 12000;
const fetchDirect = async () => {

View File

@@ -20,7 +20,9 @@
"node": ">=22.12.0"
}
},
"fxp-builder": {},
"fxp-builder": {
"extraneous": true
},
"node_modules/@astrojs/compiler": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.0.tgz",
@@ -2370,9 +2372,9 @@
}
},
"node_modules/fast-xml-builder": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz",
"integrity": "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -2381,13 +2383,13 @@
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.2"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.0.tgz",
"integrity": "sha512-KFu/DWExDkbufaVw5RE4q/p175xcNr6XyHEem4vO7mAQ1PqEIw+vKy0VHaU3n6+nRtgDqo8SwQuXm4TRHRhQvA==",
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
"funding": [
{
"type": "github",
@@ -2396,18 +2398,14 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "file:../../fxp-builder",
"path-expression-matcher": "^1.1.2",
"strnum": "^2.1.2"
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fast-xml-parser/node_modules/fast-xml-builder": {
"resolved": "fxp-builder",
"link": true
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3926,9 +3924,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz",
"integrity": "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"funding": [
{
"type": "github",

View File

@@ -0,0 +1,910 @@
# TradingView Screener Integration Guide
**Purpose:** Reference document for extending WorldMonitor's finance hub with TradingView Screener data.
---
## Table of Contents
1. [Overview](#overview)
2. [Libraries](#libraries)
3. [What Data Is Available](#what-data-is-available)
4. [Architecture Fit (Gold Standard)](#architecture-fit-gold-standard)
5. [Integration Patterns](#integration-patterns)
6. [Panel Extensions](#panel-extensions)
7. [Field Reference](#field-reference)
8. [Query Cookbook](#query-cookbook)
9. [Rate Limiting & Production Notes](#rate-limiting--production-notes)
10. [Implementation Checklist](#implementation-checklist)
---
## Overview
TradingView exposes an undocumented but stable `/screener` API at `https://scanner.tradingview.com/{market}/scan`. Two open-source libraries wrap it:
| Library | Language | Repo |
|---------|----------|------|
| `tradingview-screener` | Python | `../TradingView-Screener/` |
| `tradingview-screener-ts` | TypeScript | https://github.com/Anny26022/TradingView-Screener-ts |
Both provide:
- 3,000+ fields across stocks, crypto, forex, futures, bonds
- SQL-like `Query` builder with filter, sort, paginate
- 87 markets/exchanges (geographic + asset class)
- Timeframe variants per field (1m → 1M)
- No API key required for delayed/public data
The TypeScript library is the one relevant for WorldMonitor. It has full parity with the Python version (41/41 operations pass) plus a self-hosted REST server option.
---
## Libraries
### TypeScript: `tradingview-screener-ts`
```bash
npm install tradingview-screener-ts
```
```typescript
import { Query, col, And, Or } from 'tradingview-screener-ts'
const [total, rows] = await new Query()
.set_markets('crypto')
.select('name', 'close', 'volume', 'market_cap_basic', 'change')
.order_by('market_cap_basic', false)
.limit(50)
.get_scanner_data()
// rows: [{ ticker: 'BINANCE:BTCUSDT', name: 'Bitcoin', close: 62000, ... }]
```
**Key types:**
```typescript
interface ScreenerRowDict { s: string; d: unknown[] }
interface ScreenerDict { totalCount: number; data: ScreenerRowDict[] | null }
// get_scanner_data returns:
[number, Record<string, unknown>[]]
// [totalCount, [{ ticker, col1, col2, ... }, ...]]
```
**Timeframe variants:** append `|{tf}` to any field name.
```typescript
'close' // daily
'close|1' // 1-minute
'close|5' // 5-minute
'close|60' // 1-hour
'close|240' // 4-hour
'close|1W' // weekly
'close|1M' // monthly
```
**All filter methods on `col()`:**
```typescript
col('close').gt(100) col('close').ge(100)
col('close').lt(100) col('close').le(100)
col('close').eq(100) col('close').ne(100)
col('close').between(10, 50) col('close').not_between(10, 50)
col('type').isin(['stock']) col('type').not_in(['etf'])
col('tags').has(['value']) col('tags').has_none_of(['etf'])
col('close').crosses(col('EMA20'))
col('close').crosses_above(col('EMA20'))
col('close').crosses_below(col('EMA20'))
col('close').above_pct(col('SMA200'), 0.05)
col('close').below_pct(col('SMA200'), 0.05)
col('name').like('Apple%') col('name').not_like('Apple%')
col('eps').empty() col('eps').not_empty()
col('date').in_day_range(0, 0) col('date').in_week_range(0, 2)
```
---
## What Data Is Available
### By Asset Class
| Market Key | Fields | With Timeframes | Notes |
|------------|--------|-----------------|-------|
| `america` | 1,003 | 3,514 | US stocks |
| `crypto` | 525 | 3,094 | BTC/ETH/etc. |
| `forex` | 439 | 2,950 | FX pairs |
| `cfd` | 439 | 2,950 | CFDs |
| `futures` | 394 | 394 | Commodities, index futures |
| `bonds` | 153 | 180 | Government/corporate bonds |
| `coin` | 518 | 3,029 | Spot crypto |
### Data Categories Available
| Category | Example Fields |
|----------|---------------|
| **Price / OHLCV** | `close`, `open`, `high`, `low`, `volume` |
| **Change** | `change` (%), `change_abs` ($), `change_from_open`, `gap` |
| **Market cap** | `market_cap_basic` |
| **Technicals** | `RSI`, `MACD.macd`, `MACD.signal`, `MACD.hist`, `BB.upper`, `BB.lower`, `BB.mid` |
| **Moving averages** | `EMA5`, `EMA20`, `EMA50`, `EMA100`, `EMA200`, `SMA20`, `SMA50`, `SMA200` |
| **Volume analysis** | `relative_volume_10d_calc`, `Value.Traded`, `average_volume_10d_calc` |
| **Fundamentals** | `price_earnings_ttm`, `earnings_per_share_basic_ttm`, `dividend_yield_recent`, `book_value_per_share` |
| **52-week** | `price_52_week_high`, `price_52_week_low`, `High.All`, `Low.All` |
| **VWAP** | `VWAP` |
| **Classification** | `type`, `typespecs`, `sector`, `industry`, `country`, `exchange`, `currency` |
| **Status** | `active_symbol`, `is_primary`, `update_mode` |
| **Indices** | `index` (which indices the stock belongs to) |
| **Analyst ratings** | `Recommend.All`, `Recommend.MA`, `Recommend.Other` |
| **Beta/risk** | `beta_1_year` |
| **Pre/post market** | `premarket_change`, `premarket_volume`, `postmarket_change` |
| **Earnings** | `earnings_release_next_trading_date_fq`, `earnings_per_share_forecast_next_fq` |
| **Crypto-specific** | `24h_vol_change`, `circulating_supply`, `total_supply`, `24h_close_change` |
---
## Architecture Fit (Gold Standard)
WorldMonitor's data flow: `Railway seeds Redis → Vercel reads Redis → Frontend RPC`
TradingView Screener calls belong in the **Railway AIS relay** (`scripts/ais-relay.cjs`), alongside existing CoinGecko/Yahoo/Finnhub calls.
```
Railway ais-relay.cjs
└── seedTvStockScreener() → market:tv-screener:stocks:v1 (TTL 5m)
└── seedTvCryptoScreener() → market:tv-screener:crypto:v1 (TTL 5m)
└── seedTvForexScreener() → market:tv-screener:forex:v1 (TTL 5m)
└── seedTvTechnicals() → market:tv-technicals:v1 (TTL 5m)
└── seedTvEarningsCalendar() → market:tv-earnings:v1 (TTL 1h)
└── seedTvSectorSummary() → market:tv-sectors:v1 (TTL 5m)
Vercel RPC handlers (read-only from Redis):
└── list-tv-stock-screener.ts
└── list-tv-crypto-screener.ts
└── list-tv-forex-screener.ts
└── get-tv-technicals.ts
└── list-tv-earnings.ts
└── list-tv-sectors.ts
Frontend → circuit breaker → RPC → Redis
```
**No TradingView calls from Vercel edge.** All upstream calls are Railway-side.
The TS library (`tradingview-screener-ts`) runs inside the Railway relay Node.js process.
---
## Integration Patterns
### Seed Script Pattern (matches existing `seed-crypto-quotes.mjs`)
```javascript
// scripts/seed-tv-stock-screener.mjs
import { Query, col } from 'tradingview-screener-ts';
import { runSeed, CHROME_UA } from './_seed-utils.mjs';
const TV_KEY = 'market:tv-screener:stocks:v1';
const CACHE_TTL = 300; // 5 minutes
async function seedTvStockScreener() {
const [total, rows] = await new Query()
.select(
'name', 'close', 'change', 'volume', 'market_cap_basic',
'relative_volume_10d_calc', 'RSI', 'sector', 'country'
)
.where(
col('market_cap_basic').gt(1_000_000_000),
col('active_symbol').eq(true),
col('is_primary').eq(true)
)
.order_by('Value.Traded', false)
.limit(100)
.get_scanner_data();
if (!rows.length) throw new Error('TradingView returned no stock data');
await runSeed(TV_KEY, { rows, total }, CACHE_TTL);
}
seedTvStockScreener().catch(err => {
console.error('FATAL:', err.message);
process.exit(1);
});
```
### Relay Integration Pattern
```javascript
// In scripts/ais-relay.cjs — add to seedAllMarketData():
const { Query, col } = require('tradingview-screener-ts');
async function seedTvStockScreener() {
try {
const [total, rows] = await new Query()
.select('name', 'close', 'change', 'volume', 'market_cap_basic', 'RSI', 'sector')
.where(col('market_cap_basic').gt(1_000_000_000), col('is_primary').eq(true))
.order_by('Value.Traded', false)
.limit(100)
.get_scanner_data();
if (rows.length > 0) {
await redisSet('market:tv-screener:stocks:v1', JSON.stringify({ rows, total }), 'EX', 300);
console.log(`[TV Screener] Seeded ${rows.length} stocks`);
}
} catch (err) {
console.error('[TV Screener] Failed:', err.message);
}
}
```
### Server Handler Pattern (read-only from Redis)
```typescript
// server/worldmonitor/market/v1/list-tv-stock-screener.ts
import { getCachedJson } from '@/_shared/redis';
import type { ListTvStockScreenerRequest, ListTvStockScreenerResponse } from '@generated/...';
const CACHE_KEY = 'market:tv-screener:stocks:v1';
export async function listTvStockScreener(
_req: ListTvStockScreenerRequest
): Promise<ListTvStockScreenerResponse> {
const data = await getCachedJson<{ rows: TvStockRow[]; total: number }>(CACHE_KEY, true);
return { stocks: data?.rows ?? [], total: data?.total ?? 0 };
}
```
---
## Panel Extensions
### 1. Stock Screener Panel
**New panel idea:** Filterable table of top stocks by market cap, volume, RSI.
**Data needed:**
```typescript
new Query()
.select('name', 'close', 'change', 'change_abs', 'volume',
'market_cap_basic', 'RSI', 'relative_volume_10d_calc',
'sector', 'country', 'exchange')
.where(
col('market_cap_basic').gt(1_000_000_000), // >$1B market cap
col('is_primary').eq(true),
col('active_symbol').eq(true)
)
.order_by('Value.Traded', false)
.limit(100)
```
**Fields per row:**
- Ticker, Name, Price, Change %, Volume, Market Cap, RSI, Rel. Volume, Sector
---
### 2. Enhanced Crypto Panel
**Upgrade existing CryptoPanel** with TradingView data (richer than CoinGecko):
```typescript
new Query()
.set_markets('crypto')
.select('name', 'close', 'change', 'change|1W', 'volume',
'market_cap_basic', 'RSI', 'Recommend.All',
'relative_volume_10d_calc', '24h_vol_change')
.order_by('market_cap_basic', false)
.limit(50)
```
**New fields vs current:** Analyst recommendation, multi-timeframe change, RSI, relative volume.
---
### 3. Sector Performance Panel
**Real-time sector heatmap** (US stock sectors + international):
```typescript
// One query per sector, or use sector field + aggregate
new Query()
.select('sector', 'change')
.where(
col('market_cap_basic').gt(500_000_000),
col('type').isin(['stock']),
col('exchange').not_in(['OTC']),
col('is_primary').eq(true)
)
.order_by('Value.Traded', false)
.limit(2000)
// Then group by 'sector' and average 'change' on the Railway side
```
**Sectors available:** Technology, Healthcare, Financials, Consumer Cyclical, Industrials, Communication Services, Consumer Defensive, Energy, Basic Materials, Real Estate, Utilities.
---
### 4. Forex Panel
**New panel:** Major currency pairs with 24h change.
```typescript
new Query()
.set_markets('forex')
.select('name', 'close', 'change', 'change_abs', 'volume',
'RSI', 'MACD.macd', 'EMA20', 'BB.upper', 'BB.lower')
.set_tickers(
'FX:EURUSD', 'FX:GBPUSD', 'FX:USDJPY', 'FX:USDCHF',
'FX:AUDUSD', 'FX:USDCAD', 'FX:NZDUSD', 'FX:USDHKD',
'FX:USDCNH', 'FX:EURGBP'
)
.get_scanner_data()
```
---
### 5. Commodity Futures Panel
**Upgrade existing CommoditiesPanel** with futures data:
```typescript
new Query()
.set_markets('futures')
.select('name', 'close', 'change', 'change_abs', 'volume',
'open', 'high', 'low', 'price_52_week_high', 'price_52_week_low')
.set_tickers(
'NYMEX:CL1!', // WTI Crude Oil
'NYMEX:NG1!', // Natural Gas
'COMEX:GC1!', // Gold
'COMEX:SI1!', // Silver
'CBOT:ZW1!', // Wheat
'CBOT:ZC1!', // Corn
'CBOT:ZS1!', // Soybeans
'NYMEX:HO1!', // Heating Oil
'NYMEX:RB1!', // RBOB Gasoline
'COMEX:HG1!', // Copper
'COMEX:PL1!', // Platinum
'COMEX:PA1!', // Palladium
)
.get_scanner_data()
```
---
### 6. Technical Signals Panel
**New panel:** Stocks/crypto with notable technical setups.
```typescript
// Golden cross: EMA50 crossed above EMA200
new Query()
.select('name', 'close', 'change', 'EMA50', 'EMA200', 'volume', 'RSI')
.where(
col('EMA50').crosses_above(col('EMA200')),
col('volume').gt(500_000),
col('market_cap_basic').gt(500_000_000)
)
.limit(20)
.get_scanner_data()
// Oversold (RSI < 30) with positive change
new Query()
.select('name', 'close', 'change', 'RSI', 'volume', 'market_cap_basic')
.where(
col('RSI').between(20, 30),
col('change').gt(0),
col('volume').gt(1_000_000)
)
.limit(20)
.get_scanner_data()
// Strong buy recommendations
new Query()
.select('name', 'close', 'Recommend.All', 'RSI', 'MACD.macd')
.where(col('Recommend.All').between(0.5, 1.0))
.order_by('Recommend.All', false)
.limit(20)
.get_scanner_data()
```
---
### 7. Earnings Calendar Panel
**New panel:** Upcoming earnings dates.
```typescript
new Query()
.select('name', 'close', 'change', 'market_cap_basic',
'earnings_release_next_trading_date_fq',
'earnings_per_share_forecast_next_fq',
'earnings_per_share_basic_ttm')
.where(
col('earnings_release_next_trading_date_fq').in_day_range(0, 7),
col('market_cap_basic').gt(1_000_000_000)
)
.order_by('market_cap_basic', false)
.limit(50)
.get_scanner_data()
```
---
### 8. Relative Strength / Hot Sectors
**Ideas for the AI Forecasts panel or new "Market Pulse" panel:**
```typescript
// Top movers today
new Query()
.select('name', 'close', 'change', 'volume', 'relative_volume_10d_calc', 'sector')
.where(
col('change').gt(5), // up 5%+
col('volume').gt(1_000_000),
col('relative_volume_10d_calc').gt(2) // 2x normal volume
)
.order_by('change', false)
.limit(20)
.get_scanner_data()
// Pre-market movers
new Query()
.select('name', 'close', 'premarket_change', 'premarket_volume', 'market_cap_basic')
.where(
col('premarket_change').not_empty(),
col('premarket_volume').gt(100_000),
col('market_cap_basic').gt(500_000_000)
)
.order_by('premarket_change', false)
.limit(20)
.get_scanner_data()
```
---
### 9. Bond / Yield Panel
**New panel:** Government bond yields.
```typescript
new Query()
.set_markets('bonds')
.select('name', 'close', 'change', 'yield', 'duration', 'country')
.set_tickers(
'TVC:US10Y', // US 10-year yield
'TVC:US02Y', // US 2-year yield
'TVC:US30Y', // US 30-year yield
'TVC:DE10Y', // German 10Y
'TVC:GB10Y', // UK 10Y
'TVC:JP10Y', // Japan 10Y
'TVC:IT10Y', // Italy 10Y
'TVC:CN10Y', // China 10Y
)
.get_scanner_data()
```
---
## Field Reference
### Price & Volume
| Field | Description | Type |
|-------|-------------|------|
| `close` | Current/closing price | price |
| `open` | Opening price | price |
| `high` | Day high | price |
| `low` | Day low | price |
| `volume` | Volume | number |
| `Value.Traded` | Dollar volume traded | fundamental_price |
| `average_volume_10d_calc` | 10-day avg volume | number |
| `relative_volume_10d_calc` | Relative volume vs 10d avg | number |
### Change
| Field | Description | Type |
|-------|-------------|------|
| `change` | % change today | percent |
| `change_abs` | Absolute $ change | price |
| `change_from_open` | % change from open | percent |
| `gap` | Gap % from prev close | percent |
| `premarket_change` | Pre-market % change | percent |
| `postmarket_change` | After-hours % change | percent |
### Technicals
| Field | Description | Type |
|-------|-------------|------|
| `RSI` | RSI(14) | number |
| `RSI[1]` | RSI prev bar | number |
| `MACD.macd` | MACD line | number |
| `MACD.signal` | Signal line | number |
| `MACD.hist` | Histogram | number |
| `BB.upper` | Bollinger upper | price |
| `BB.lower` | Bollinger lower | price |
| `BB.mid` | Bollinger mid (SMA20) | price |
| `VWAP` | VWAP | price |
| `stochastic_k` | Stochastic %K | number |
| `stochastic_d` | Stochastic %D | number |
| `ATR` | Average True Range | number |
| `Recommend.All` | Combined TV rating (-1 to 1) | number |
| `Recommend.MA` | MA recommendation | number |
| `Recommend.Other` | Oscillator recommendation | number |
### Moving Averages
| Field | Description |
|-------|-------------|
| `EMA5`, `EMA10`, `EMA20` | Exponential MAs |
| `EMA50`, `EMA100`, `EMA200` | Longer-term EMAs |
| `SMA5`, `SMA10`, `SMA20` | Simple MAs |
| `SMA50`, `SMA100`, `SMA200` | Longer-term SMAs |
| `HullMA9` | Hull MA |
| `VWMA` | Volume-weighted MA |
### Fundamentals (Stocks)
| Field | Description | Type |
|-------|-------------|------|
| `market_cap_basic` | Market cap | fundamental_price |
| `price_earnings_ttm` | P/E ratio | number |
| `earnings_per_share_basic_ttm` | EPS (TTM) | fundamental_price |
| `dividend_yield_recent` | Dividend yield % | percent |
| `dividends_yield_current` | Current dividend yield | percent |
| `book_value_per_share` | Book value/share | fundamental_price |
| `price_book_fq` | Price/Book | number |
| `price_sales_current` | Price/Sales | number |
| `debt_to_equity` | Debt/Equity ratio | number |
| `return_on_equity` | ROE % | percent |
| `gross_margin` | Gross margin % | percent |
| `net_income_margin` | Net margin % | percent |
| `beta_1_year` | Beta vs market | number |
| `price_52_week_high` | 52-week high | price |
| `price_52_week_low` | 52-week low | price |
| `number_of_employees` | Headcount | number |
| `sector` | Sector classification | text |
| `industry` | Industry | text |
### Crypto-Specific
| Field | Description | Type |
|-------|-------------|------|
| `market_cap_basic` | Market cap | fundamental_price |
| `24h_vol_change` | 24h volume change % | percent |
| `circulating_supply` | Circulating supply | number |
| `total_supply` | Total supply | number |
| `24h_close_change` | 24h close change % | percent |
### Classification
| Field | Description |
|-------|-------------|
| `name` | Ticker symbol |
| `description` | Company/asset name |
| `type` | `stock`, `fund`, `dr`, `bond`, `crypto` |
| `typespecs` | Sub-type array: `['common']`, `['etf']`, `['etn']` |
| `exchange` | Exchange: `NASDAQ`, `NYSE`, `BINANCE`, etc. |
| `country` | Country code |
| `currency` | Quote currency |
| `update_mode` | `streaming`, `delayed_streaming_900` |
| `is_primary` | Primary listing (not ADR/secondary) |
| `active_symbol` | Traded today |
---
## Query Cookbook
### Top 50 Most Traded US Stocks
```typescript
const [total, rows] = await new Query()
.select('name', 'description', 'close', 'change', 'volume', 'market_cap_basic', 'sector')
.where(
col('is_primary').eq(true),
col('active_symbol').eq(true),
col('exchange').not_in(['OTC'])
)
.order_by('Value.Traded', false)
.limit(50)
.get_scanner_data()
```
### Top Crypto by Market Cap
```typescript
const [total, rows] = await new Query()
.set_markets('crypto')
.select('name', 'close', 'change', 'market_cap_basic', 'volume', 'RSI')
.order_by('market_cap_basic', false)
.limit(50)
.get_scanner_data()
```
### S&P 500 Components
```typescript
const [total, rows] = await new Query()
.set_index('SP;SPX')
.select('name', 'close', 'change', 'volume', 'market_cap_basic', 'sector', 'RSI')
.order_by('market_cap_basic', false)
.limit(505)
.get_scanner_data()
```
### Sector Performance (US)
```typescript
// Fetch all large-cap stocks with sector; aggregate by sector on Railway side
const [total, rows] = await new Query()
.select('sector', 'change', 'market_cap_basic')
.where(
col('market_cap_basic').gt(500_000_000),
col('type').isin(['stock']),
col('is_primary').eq(true),
col('exchange').not_in(['OTC']),
col('sector').not_empty()
)
.limit(2000)
.get_scanner_data()
// Group and average on Railway:
const sectorChange = Object.entries(
rows.reduce((acc, r) => {
const s = r.sector as string
if (!acc[s]) acc[s] = { sum: 0, count: 0 }
acc[s].sum += (r.change as number) ?? 0
acc[s].count++
return acc
}, {} as Record<string, { sum: number; count: number }>)
).map(([name, { sum, count }]) => ({ name, change: sum / count }))
```
### Forex Major Pairs
```typescript
const [total, rows] = await new Query()
.set_markets('forex')
.set_tickers(
'FX:EURUSD', 'FX:GBPUSD', 'FX:USDJPY', 'FX:USDCHF',
'FX:AUDUSD', 'FX:USDCAD', 'FX:NZDUSD', 'FX:EURGBP',
'FX:EURJPY', 'FX:GBPJPY'
)
.select('name', 'close', 'change', 'change_abs', 'high', 'low', 'RSI')
.get_scanner_data()
```
### Commodity Futures
```typescript
const [total, rows] = await new Query()
.set_markets('futures')
.set_tickers(
'NYMEX:CL1!', 'NYMEX:NG1!', 'COMEX:GC1!', 'COMEX:SI1!',
'CBOT:ZW1!', 'CBOT:ZC1!', 'CBOT:ZS1!', 'COMEX:HG1!'
)
.select('name', 'close', 'change', 'high', 'low', 'volume')
.get_scanner_data()
```
### Multi-Timeframe Screener (1D + 1W change)
```typescript
const [total, rows] = await new Query()
.select('name', 'close', 'change', 'change|1W', 'change|1M',
'RSI', 'RSI|1W', 'volume', 'market_cap_basic')
.where(col('market_cap_basic').gt(1_000_000_000))
.order_by('Value.Traded', false)
.limit(50)
.get_scanner_data()
```
### Upcoming Earnings (7 Days)
```typescript
const [total, rows] = await new Query()
.select('name', 'close', 'change', 'market_cap_basic',
'earnings_release_next_trading_date_fq',
'earnings_per_share_forecast_next_fq')
.where(
col('earnings_release_next_trading_date_fq').in_day_range(0, 7),
col('market_cap_basic').gt(1_000_000_000)
)
.order_by('market_cap_basic', false)
.limit(50)
.get_scanner_data()
```
### Technical Breakouts
```typescript
// Price above 52-week high
const [total, rows] = await new Query()
.select('name', 'close', 'change', 'volume', 'price_52_week_high')
.where(
col('close').above_pct(col('price_52_week_high'), 1.0), // AT or above 52wk high
col('volume').gt(1_000_000)
)
.order_by('change', false)
.limit(20)
.get_scanner_data()
// Golden cross (EMA50 > EMA200)
const [total2, rows2] = await new Query()
.select('name', 'close', 'change', 'EMA50', 'EMA200', 'volume')
.where(
col('EMA50').crosses_above(col('EMA200')),
col('volume').gt(500_000)
)
.limit(20)
.get_scanner_data()
```
### Oversold / Strong Buy Setups
```typescript
// RSI oversold with positive change
const [total, rows] = await new Query()
.select('name', 'close', 'change', 'RSI', 'Recommend.All')
.where(
col('RSI').between(20, 35),
col('change').gt(0),
col('volume').gt(500_000),
col('market_cap_basic').gt(500_000_000)
)
.limit(20)
.get_scanner_data()
```
---
## Rate Limiting & Production Notes
### TradingView API Behavior
- **No documented rate limits.** In practice, bans are possible with high-frequency polling.
- **Auth:** Unauthenticated requests get delayed data (15-min for some exchanges). Pass a valid TradingView session cookie for real-time.
- **Max limit:** Up to 100,000 rows technically possible but not recommended.
- **No retry logic built-in** — implement your own for 429/5xx.
### WorldMonitor-Specific Recommendations
| Concern | Recommendation |
|---------|---------------|
| **Polling interval** | 5 min (matches existing `MARKET_SEED_INTERVAL_MS = 300_000`) |
| **TTL** | 5 min for price data, 1h for earnings/fundamentals |
| **Per-call limits** | 100 rows for screener, 500 for index components |
| **CoinGecko overlap** | Keep CoinGecko for crypto token panels (richer DeFi/AI/Other data); TradingView for top-N crypto |
| **Finnhub/Yahoo overlap** | TradingView can supplement stock quotes; keep Finnhub/Yahoo as primary for existing panels |
| **Railway concurrency** | Run TV calls sequentially inside `seedAllMarketData()`, not `Promise.all` |
| **Circuit breaker** | Add a `tvScreenerBreaker` alongside existing stock/crypto breakers |
| **No auth initially** | Delayed data is fine for WorldMonitor's use case |
| **User-Agent** | Library automatically mimics Chrome headers; do not override |
### Sample Relay Integration Timing
Assuming existing relay runs `seedAllMarketData()` every 5 min with 3 existing CoinGecko calls:
| Seed Function | Avg Duration | CoinGecko? |
|---------------|-------------|------------|
| `seedCryptoQuotes` | ~1s | Yes |
| `seedStablecoins` | ~1s | Yes |
| `seedCryptoSectors` | ~1.5s | Yes |
| `seedTokenPanels` | ~1.5s | Yes |
| `seedTvStocks` (new) | ~0.5s | No (TradingView) |
| `seedTvCrypto` (new) | ~0.5s | No (TradingView) |
| `seedTvForex` (new) | ~0.5s | No (TradingView) |
TradingView calls are independent of CoinGecko rate limits. Each call completes in 200-800ms.
---
## Implementation Checklist
### Phase A: Stock Screener Panel
- [ ] `npm install tradingview-screener-ts` in Railway relay package
- [ ] Add `seedTvStockScreener()` to `scripts/ais-relay.cjs`
- [ ] Add `market:tv-screener:stocks:v1` to `server/_shared/cache-keys.ts`
- [ ] Create `proto/worldmonitor/market/v1/list_tv_stock_screener.proto`
- [ ] Create `server/worldmonitor/market/v1/list-tv-stock-screener.ts`
- [ ] Register in `handler.ts` and `service.proto`
- [ ] Run `buf generate`
- [ ] Add `tvStocksBreaker` to `src/services/market/index.ts`
- [ ] Create `TvStockScreenerPanel` in `src/components/MarketPanel.ts`
- [ ] Register panel in `src/config/panels.ts`
- [ ] Wire in `panel-layout.ts` and `data-loader.ts`
- [ ] Add to `api/bootstrap.js` and `cache-keys.ts` BOOTSTRAP_TIERS
- [ ] Add cache tier to `server/gateway.ts`
- [ ] Sync `scripts/shared/` if needed
- [ ] `npm run typecheck && npm run test:data` → all pass
### Phase B: Sector Performance Upgrade
- [ ] Add `seedTvSectorSummary()` to relay (aggregates by sector server-side)
- [ ] Upgrade existing `HeatmapPanel` to use TradingView sector data
- [ ] Keep existing `get-sector-summary` handler as fallback
### Phase C: Forex Panel
- [ ] Add `seedTvForexPairs()` to relay
- [ ] New `ForexPanel` component
- [ ] Register in `FINANCE_PANELS`
### Phase D: Enhanced Commodity Futures
- [ ] Add `seedTvCommodityFutures()` (replaces/supplements Yahoo commodities)
- [ ] Upgrade `CommoditiesPanel` rendering with futures data
### Phase E: Earnings Calendar Panel
- [ ] Add `seedTvEarningsCalendar()` (TTL 1h, not 5min)
- [ ] New `EarningsCalendarPanel` component
- [ ] Show next 7 days of earnings for large-cap stocks
---
## Appendix: Useful TradingView Market Identifiers
### Major US Indices (for `set_index()`)
| Symbol | Index |
|--------|-------|
| `SP;SPX` | S&P 500 |
| `DJ;DJI` | Dow Jones |
| `NASDAQ;NDX` | Nasdaq 100 |
| `SP;MID` | S&P MidCap 400 |
| `SP;SML` | S&P SmallCap 600 |
| `RUSSELL;RUT` | Russell 2000 |
### Futures Tickers
| Ticker | Commodity |
|--------|-----------|
| `NYMEX:CL1!` | WTI Crude Oil |
| `NYMEX:NG1!` | Natural Gas |
| `COMEX:GC1!` | Gold |
| `COMEX:SI1!` | Silver |
| `COMEX:HG1!` | Copper |
| `COMEX:PL1!` | Platinum |
| `CBOT:ZW1!` | Wheat |
| `CBOT:ZC1!` | Corn |
| `CBOT:ZS1!` | Soybeans |
| `CME:ES1!` | S&P 500 E-mini |
| `CME:NQ1!` | Nasdaq E-mini |
| `CME:RTY1!` | Russell 2000 E-mini |
| `EUREX:FDAX1!` | DAX Futures |
| `SGX:CN1!` | CSI 300 Futures |
### Government Bonds / Yields
| Ticker | Description |
|--------|-------------|
| `TVC:US02Y` | US 2-Year Yield |
| `TVC:US10Y` | US 10-Year Yield |
| `TVC:US30Y` | US 30-Year Yield |
| `TVC:DE10Y` | German 10-Year Bund |
| `TVC:GB10Y` | UK Gilt 10-Year |
| `TVC:JP10Y` | Japan JGB 10-Year |
| `TVC:IT10Y` | Italy BTP 10-Year |
| `TVC:FR10Y` | France OAT 10-Year |
| `TVC:CN10Y` | China 10-Year |
### Forex Pairs
| Ticker | Pair |
|--------|------|
| `FX:EURUSD` | EUR/USD |
| `FX:GBPUSD` | GBP/USD |
| `FX:USDJPY` | USD/JPY |
| `FX:USDCHF` | USD/CHF |
| `FX:AUDUSD` | AUD/USD |
| `FX:USDCAD` | USD/CAD |
| `FX:NZDUSD` | NZD/USD |
| `FX:USDCNH` | USD/CNH (offshore RMB) |
| `FX:USDINR` | USD/INR |
| `FX:USDBRL` | USD/BRL |
| `FX:USDTRY` | USD/TRY |
| `FX:USDRUB` | USD/RUB |
| `FX:USDZAR` | USD/ZAR |
---
*Document generated 2026-03-20. TradingView API is undocumented and subject to change. Field availability and market identifiers should be verified against the library's live metadata endpoint before production use: `GET /api/v1/metadata/fields?universe={market}`*

65
package-lock.json generated
View File

@@ -13397,9 +13397,9 @@
}
},
"node_modules/devalue": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"license": "MIT",
"peer": true
},
@@ -14169,21 +14169,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz",
"integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -14192,8 +14180,24 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -18518,6 +18522,21 @@
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -21002,9 +21021,9 @@
"peer": true
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz",
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
"funding": [
{
"type": "github",

View File

@@ -15,7 +15,8 @@ export function htmlToPlainText(html) {
return String(html ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*?)?>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')

View File

@@ -276,6 +276,22 @@ function safeEnd(res, statusCode, headers, body) {
}
}
const WORLD_BANK_COUNTRY_ALLOWLIST = new Set([
'USA','CHN','JPN','DEU','KOR','GBR','IND','ISR','SGP','TWN',
'FRA','CAN','SWE','NLD','CHE','FIN','IRL','AUS','BRA','IDN',
'ARE','SAU','QAT','BHR','EGY','TUR','MYS','THA','VNM','PHL',
'ESP','ITA','POL','CZE','DNK','NOR','AUT','BEL','PRT','EST',
'MEX','ARG','CHL','COL','ZAF','NGA','KEN',
]);
function normalizeWorldBankCountryCodes(rawValue) {
const parts = String(rawValue || '')
.split(/[;,]/g)
.map((part) => part.trim().toUpperCase())
.filter((part) => /^[A-Z]{3}$/.test(part) && WORLD_BANK_COUNTRY_ALLOWLIST.has(part));
return parts.length > 0 ? parts.join(';') : null;
}
function _acceptsEncoding(header, encoding) {
if (!header) return false;
const tokens = header.split(',');
@@ -6307,25 +6323,11 @@ function handleWorldBankRequest(req, res) {
}, body);
}
const indicator = wbParams.get('indicator');
if (!indicator) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Missing indicator parameter' }));
}
const country = wbParams.get('country');
const countries = wbParams.get('countries');
const years = parseInt(wbParams.get('years') || '5', 10);
const countryList = country || (countries ? countries.split(',').join(';') : [
'USA','CHN','JPN','DEU','KOR','GBR','IND','ISR','SGP','TWN',
'FRA','CAN','SWE','NLD','CHE','FIN','IRL','AUS','BRA','IDN',
'ARE','SAU','QAT','BHR','EGY','TUR','MYS','THA','VNM','PHL',
'ESP','ITA','POL','CZE','DNK','NOR','AUT','BEL','PRT','EST',
'MEX','ARG','CHL','COL','ZAF','NGA','KEN',
].join(';'));
const currentYear = new Date().getFullYear();
const startYear = currentYear - years;
const TECH_INDICATORS = {
'IT.NET.USER.ZS': 'Internet Users (% of population)',
'IT.CEL.SETS.P2': 'Mobile Subscriptions (per 100 people)',
@@ -6345,6 +6347,21 @@ function handleWorldBankRequest(req, res) {
'NE.EXP.GNFS.ZS': 'Exports of Goods & Services (% of GDP)',
};
const indicator = wbParams.get('indicator');
// Validate World Bank indicator code format (e.g. IT.NET.USER.ZS, NY.GDP.MKTP.CD).
// Accept any code with 2-6 dot-separated alphanumeric segments; this allows callers
// to request indicators beyond the TECH_INDICATORS display-name map.
if (!indicator || !/^[A-Z0-9]{2,10}(\.[A-Z0-9]{2,10}){1,5}$/.test(indicator)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Invalid indicator parameter' }));
}
const countryList = normalizeWorldBankCountryCodes(country)
|| normalizeWorldBankCountryCodes(countries)
|| [...WORLD_BANK_COUNTRY_ALLOWLIST].join(';');
const startYear = currentYear - Math.min(Math.max(1, years), 30);
const wbUrl = `https://api.worldbank.org/v2/country/${countryList}/indicator/${encodeURIComponent(indicator)}?format=json&date=${startYear}:${currentYear}&per_page=1000`;
console.log('[Relay] World Bank request (MISS):', indicator);

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { loadEnvFile, maskToken, runSeed, CHROME_UA, sleep } from './_seed-utils.mjs';
import { loadEnvFile, runSeed, CHROME_UA, sleep } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
@@ -112,7 +112,7 @@ async function main() {
process.exit(0);
}
console.log(` FIRMS key: ${maskToken(apiKey)}`);
console.log(' FIRMS key configured');
await runSeed('wildfire', 'fires', CANONICAL_KEY, () => fetchAllRegions(apiKey), {
validateFn: (data) => Array.isArray(data?.fireDetections) && data.fireDetections.length > 0,

View File

@@ -5742,7 +5742,7 @@ function validatePerspectives(items, predictions) {
if (typeof item.index !== 'number' || item.index < 0 || item.index >= predictions.length) return false;
for (const key of ['strategic', 'regional', 'contrarian']) {
if (typeof item[key] !== 'string') return false;
item[key] = item[key].replace(/<[^>]*>/g, '').trim().slice(0, 300);
item[key] = sanitizeForPrompt(item[key]).slice(0, 300);
if (item[key].length < 20) return false;
}
return true;
@@ -5755,7 +5755,7 @@ function validateCaseNarratives(items, predictions) {
if (typeof item.index !== 'number' || item.index < 0 || item.index >= predictions.length) return false;
for (const key of ['baseCase', 'escalatoryCase', 'contrarianCase']) {
if (typeof item[key] !== 'string') return false;
item[key] = item[key].replace(/<[^>]*>/g, '').trim().slice(0, 500);
item[key] = sanitizeForPrompt(item[key]).slice(0, 500);
if (item[key].length < 20) return false;
}
return true;
@@ -5814,7 +5814,7 @@ function validateScenarios(scenarios, predictions) {
console.warn(` [LLM] Scenario ${s.index} rejected: no evidence reference`);
return false;
}
s.scenario = s.scenario.replace(/<[^>]*>/g, '').slice(0, 500);
s.scenario = sanitizeForPrompt(s.scenario).slice(0, 500);
return true;
});
}

View File

@@ -32,9 +32,9 @@ const NOTAM_RESTRICTION_QCODES = new Set(['RA', 'RO']);
export const xmlParser = new XMLParser({
ignoreAttributes: true,
isArray: (_name: string, jpath: string) => {
isArray: (_name: string, jpath: unknown) => {
// Force arrays for list items regardless of count to prevent single-item-as-object bug
return /\.(Ground_Delay|Ground_Stop|Delay|Airport)$/.test(jpath);
return typeof jpath === 'string' && /\.(Ground_Delay|Ground_Stop|Delay|Airport)$/.test(jpath);
},
});

View File

@@ -10,7 +10,14 @@ import { fetchWithTimeout } from './_fetch-with-timeout';
* Rejects on non-2xx status.
*/
export async function fetchJSON(url: string, timeout = 8000): Promise<any> {
if (url.includes('yahoo.com')) await yahooGate();
try {
const parsed = new URL(url);
if (parsed.hostname === 'yahoo.com' || parsed.hostname.endsWith('.yahoo.com')) {
await yahooGate();
}
} catch {
// Let fetchWithTimeout surface invalid URLs.
}
const res = await fetchWithTimeout(url, { headers: { 'User-Agent': CHROME_UA } }, timeout);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();

2
src-tauri/Cargo.lock generated
View File

@@ -5422,7 +5422,7 @@ dependencies = [
[[package]]
name = "world-monitor"
version = "2.6.1"
version = "2.6.5"
dependencies = [
"getrandom 0.2.17",
"keyring",

View File

@@ -1111,7 +1111,18 @@ async function dispatch(requestUrl, req, routes, context) {
const mute = requestUrl.searchParams.get('mute') === '0' ? '0' : '1';
const vq = ['small','medium','large','hd720','hd1080'].includes(requestUrl.searchParams.get('vq') || '') ? requestUrl.searchParams.get('vq') : '';
const origin = `http://localhost:${context.port}`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="strict-origin-when-cross-origin"><style>html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}#player{width:100%;height:100%}#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;pointer-events:none;background:rgba(0,0,0,0.15)}#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}#play-overlay.hidden{display:none}</style></head><body><div id="player"></div><div id="play-overlay" class="hidden"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div><script>function tryStorageAccess(){if(document.requestStorageAccess){document.requestStorageAccess().catch(function(){})}}tryStorageAccess();var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';document.head.appendChild(tag);var player,overlay=document.getElementById('play-overlay'),started=false,muteSyncId,retryTimers=[];var obs=new MutationObserver(function(muts){for(var i=0;i<muts.length;i++){var nodes=muts[i].addedNodes;for(var j=0;j<nodes.length;j++){if(nodes[j].tagName==='IFRAME'){var a=nodes[j].getAttribute('allow')||'';if(a.indexOf('autoplay')===-1){nodes[j].setAttribute('allow','autoplay; encrypted-media; picture-in-picture; storage-access'+(a?'; '+a:''));console.log('[yt-embed] patched iframe allow=autoplay+storage-access')}obs.disconnect();return}}}});obs.observe(document.getElementById('player'),{childList:true,subtree:true});function hideOverlay(){overlay.classList.add('hidden')}function readMuted(){if(!player)return null;if(typeof player.isMuted==='function')return player.isMuted();if(typeof player.getVolume==='function')return player.getVolume()===0;return null}function stopMuteSync(){if(muteSyncId){clearInterval(muteSyncId);muteSyncId=null}}function startMuteSync(){if(muteSyncId)return;var last=readMuted();if(last!==null)window.parent.postMessage({type:'yt-mute-state',muted:last},'*');muteSyncId=setInterval(function(){var m=readMuted();if(m!==null&&m!==last){last=m;window.parent.postMessage({type:'yt-mute-state',muted:m},'*')}},500)}function tryAutoplay(){if(!player||!player.playVideo)return;try{player.mute();player.playVideo();console.log('[yt-embed] tryAutoplay: mute+play')}catch(e){}}function onYouTubeIframeAPIReady(){player=new YT.Player('player',{videoId:'${videoId}',host:'https://www.youtube.com',playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:'${origin}',widget_referrer:'${origin}'},events:{onReady:function(){console.log('[yt-embed] onReady');window.parent.postMessage({type:'yt-ready'},'*');${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality('${vq}');` : ''}if(${autoplay}===1){tryAutoplay();retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},500));retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},1500));retryTimers.push(setTimeout(function(){if(!started){console.log('[yt-embed] autoplay failed after retries');window.parent.postMessage({type:'yt-autoplay-failed'},'*')}},2500))}startMuteSync()},onError:function(e){console.log('[yt-embed] error code='+e.data);stopMuteSync();window.parent.postMessage({type:'yt-error',code:e.data},'*')},onStateChange:function(e){window.parent.postMessage({type:'yt-state',state:e.data},'*');if(e.data===1||e.data===3){hideOverlay();started=true;retryTimers.forEach(clearTimeout);retryTimers=[]}}}})}setTimeout(function(){if(!started)overlay.classList.remove('hidden')},4000);window.addEventListener('message',function(e){if(!player||!player.getPlayerState)return;var m=e.data;if(!m||!m.type)return;switch(m.type){case'play':player.playVideo();break;case'pause':player.pauseVideo();break;case'mute':player.mute();break;case'unmute':player.unMute();break;case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break}});window.addEventListener('beforeunload',function(){stopMuteSync();obs.disconnect();retryTimers.forEach(clearTimeout)})<\/script></body></html>`;
// parentOrigin is the actual parent window origin (tauri://localhost, asset://localhost, etc.)
// passed by the frontend so window.parent.postMessage reaches it. Only accept known desktop
// schemes; fall back to '*' if absent or unrecognised.
const rawParentOrigin = requestUrl.searchParams.get('parentOrigin') || '';
const isAllowedParentOrigin = /^(tauri|asset):\/\/localhost$/.test(rawParentOrigin)
|| /^https?:\/\/localhost(:\d{1,5})?$/.test(rawParentOrigin)
|| /^https?:\/\/[\w-]+\.tauri\.localhost(:\d{1,5})?$/.test(rawParentOrigin);
const parentOrigin = isAllowedParentOrigin ? rawParentOrigin : '*';
const safeVideoId = JSON.stringify(String(videoId));
const safeOrigin = JSON.stringify(origin);
const safeParentOrigin = JSON.stringify(parentOrigin);
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="strict-origin-when-cross-origin"><style>html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}#player{width:100%;height:100%}#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;pointer-events:none;background:rgba(0,0,0,0.15)}#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}#play-overlay.hidden{display:none}</style></head><body><div id="player"></div><div id="play-overlay" class="hidden"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div><script>function tryStorageAccess(){if(document.requestStorageAccess){document.requestStorageAccess().catch(function(){})}}tryStorageAccess();var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';document.head.appendChild(tag);var player,overlay=document.getElementById('play-overlay'),started=false,muteSyncId,retryTimers=[];var obs=new MutationObserver(function(muts){for(var i=0;i<muts.length;i++){var nodes=muts[i].addedNodes;for(var j=0;j<nodes.length;j++){if(nodes[j].tagName==='IFRAME'){var a=nodes[j].getAttribute('allow')||'';if(a.indexOf('autoplay')===-1){nodes[j].setAttribute('allow','autoplay; encrypted-media; picture-in-picture; storage-access'+(a?'; '+a:''));console.log('[yt-embed] patched iframe allow=autoplay+storage-access')}obs.disconnect();return}}}});obs.observe(document.getElementById('player'),{childList:true,subtree:true});function hideOverlay(){overlay.classList.add('hidden')}function readMuted(){if(!player)return null;if(typeof player.isMuted==='function')return player.isMuted();if(typeof player.getVolume==='function')return player.getVolume()===0;return null}function stopMuteSync(){if(muteSyncId){clearInterval(muteSyncId);muteSyncId=null}}function startMuteSync(){if(muteSyncId)return;var last=readMuted();if(last!==null)window.parent.postMessage({type:'yt-mute-state',muted:last},${safeParentOrigin});muteSyncId=setInterval(function(){var m=readMuted();if(m!==null&&m!==last){last=m;window.parent.postMessage({type:'yt-mute-state',muted:m},${safeParentOrigin})}},500)}function tryAutoplay(){if(!player||!player.playVideo)return;try{player.mute();player.playVideo();console.log('[yt-embed] tryAutoplay: mute+play')}catch(e){}}function onYouTubeIframeAPIReady(){player=new YT.Player('player',{videoId:${safeVideoId},host:'https://www.youtube.com',playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:${safeOrigin},widget_referrer:${safeOrigin}},events:{onReady:function(){console.log('[yt-embed] onReady');window.parent.postMessage({type:'yt-ready'},${safeParentOrigin});${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality(${JSON.stringify(vq)});` : ''}if(${autoplay}===1){tryAutoplay();retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},500));retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},1500));retryTimers.push(setTimeout(function(){if(!started){console.log('[yt-embed] autoplay failed after retries');window.parent.postMessage({type:'yt-autoplay-failed'},${safeParentOrigin})}},2500))}startMuteSync()},onError:function(e){console.log('[yt-embed] error code='+e.data);stopMuteSync();window.parent.postMessage({type:'yt-error',code:e.data},${safeParentOrigin})},onStateChange:function(e){window.parent.postMessage({type:'yt-state',state:e.data},${safeParentOrigin});if(e.data===1||e.data===3){hideOverlay();started=true;retryTimers.forEach(clearTimeout);retryTimers=[]}}}})}setTimeout(function(){if(!started)overlay.classList.remove('hidden')},4000);window.addEventListener('message',function(e){if(!player||!player.getPlayerState)return;var m=e.data;if(!m||!m.type)return;switch(m.type){case'play':player.playVideo();break;case'pause':player.pauseVideo();break;case'mute':player.mute();break;case'unmute':player.unMute();break;case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break}});window.addEventListener('beforeunload',function(){stopMuteSync();obs.disconnect();retryTimers.forEach(clearTimeout)})<\/script></body></html>`;
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'permissions-policy': 'autoplay=*, encrypted-media=*, storage-access=(self "https://www.youtube.com")', ...makeCorsHeaders(req) } });
}

View File

@@ -831,7 +831,14 @@ export class EventHandlerManager implements AppModule {
}
const target = options.href || VARIANT_META[variant]?.url;
if (target) window.location.href = target;
if (!target) return;
try {
const parsed = new URL(target, window.location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
window.location.href = parsed.toString();
} catch {
return;
}
}
toggleFullscreen(): void {

View File

@@ -327,8 +327,8 @@ export class AirlineIntelPanel extends Panel {
private renderPrices(): void {
const provider = this.pricesProvider;
const providerBadge = provider === 'travelpayouts_data'
? `<span class="tp-badge">${t('components.airlineIntel.cachedInsight')} · Travelpayouts</span>`
: `<span class="demo-badge">${t('components.airlineIntel.demoMode')}</span>`;
? `<span class="tp-badge">${escapeHtml(t('components.airlineIntel.cachedInsight'))} · Travelpayouts</span>`
: `<span class="demo-badge">${escapeHtml(t('components.airlineIntel.demoMode'))}</span>`;
const searchForm = `
<div class="price-controls" style="display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;align-items:center">

View File

@@ -646,7 +646,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
shareBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a2 2 0 002 2h12a2 2 0 002-2v-7"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
shareBtn.addEventListener('click', () => {
if (!this.currentCode || !this.currentName) return;
const url = `${window.location.origin}/?c=${this.currentCode}`;
const url = `${window.location.origin}/?c=${encodeURIComponent(this.currentCode)}`;
navigator.clipboard.writeText(url).then(() => {
const orig = shareBtn.innerHTML;
shareBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';

View File

@@ -1338,7 +1338,15 @@ export class LiveNewsPanel extends Panel {
const storageObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLIFrameElement && node.src.includes('youtube.com')) {
if (node instanceof HTMLIFrameElement) {
let isYouTube = false;
try {
const parsed = new URL(node.src);
isYouTube = parsed.hostname === 'youtube.com' || parsed.hostname.endsWith('.youtube.com');
} catch {
isYouTube = false;
}
if (!isYouTube) continue;
const cur = node.getAttribute('allow') || '';
if (!cur.includes('storage-access')) {
node.setAttribute('allow', cur ? `${cur}; storage-access` : 'storage-access');

View File

@@ -2,6 +2,19 @@ import type { StoryData } from './story-data';
import { toFlagEmoji } from '@/utils/country-flag';
import { getCanonicalApiOrigin } from '@/services/runtime';
const VALID_STORY_TYPES = ['ciianalysis', 'convergence', 'brief'] as const;
type StoryType = typeof VALID_STORY_TYPES[number];
function sanitizeCountryCode(value: string): string {
const code = String(value || '').trim().toUpperCase();
return /^[A-Z]{2}$/.test(code) ? code : '';
}
function sanitizeStoryType(value: string | null): StoryType {
const t = String(value || '').trim().toLowerCase();
return (VALID_STORY_TYPES as readonly string[]).includes(t) ? (t as StoryType) : 'ciianalysis';
}
// Deep link generator for story sharing
export function generateStoryDeepLink(
countryCode: string,
@@ -9,13 +22,15 @@ export function generateStoryDeepLink(
score?: number,
level?: string
): string {
const safeCountryCode = sanitizeCountryCode(countryCode);
const safeType = sanitizeStoryType(type);
const params = new URLSearchParams({
c: countryCode,
t: type,
c: safeCountryCode,
t: safeType,
ts: Date.now().toString()
});
if (score !== undefined) params.set('s', String(score));
if (level) params.set('l', level);
if (level) params.set('l', String(level).trim().slice(0, 32));
return `${getCanonicalApiOrigin()}/api/story?${params.toString()}`;
}
@@ -23,9 +38,11 @@ export function generateStoryDeepLink(
export function parseStoryParams(url: URL): { countryCode: string; type: string } | null {
const countryCode = url.searchParams.get('c');
if (!countryCode) return null;
const safeCountryCode = sanitizeCountryCode(countryCode);
if (!safeCountryCode) return null;
return {
countryCode,
type: url.searchParams.get('t') || 'ciianalysis'
countryCode: safeCountryCode,
type: sanitizeStoryType(url.searchParams.get('t'))
};
}
@@ -94,7 +111,7 @@ export function getShareUrls(data: StoryData): Record<string, string> {
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
reddit: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(`${data.countryName} Intelligence Brief - World Monitor`)}`,
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
whatsapp: `https://wa.me/?text=${encodeURIComponent(shareTexts.whatsapp(data).replace('\n', '%0A'))}`,
telegram: `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(shareTexts.telegram(data).replace('\n', '%0A'))}`,
whatsapp: `https://wa.me/?text=${encodeURIComponent(shareTexts.whatsapp(data))}`,
telegram: `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(shareTexts.telegram(data))}`,
};
}

View File

@@ -125,6 +125,10 @@ function getModelConfig(modelId: string): ModelConfig | undefined {
return MODEL_CONFIGS.find(m => m.id === modelId);
}
function isSupportedModelId(modelId: string): boolean {
return !!getModelConfig(modelId);
}
async function loadModel(modelId: string): Promise<void> {
if (loadedPipelines.has(modelId)) return;
@@ -190,6 +194,9 @@ async function embedTexts(texts: string[]): Promise<number[][]> {
}
async function summarizeTexts(texts: string[], modelId = 'summarization'): Promise<string[]> {
if (!isSupportedModelId(modelId)) {
throw new Error(`Unknown model: ${modelId}`);
}
await loadModel(modelId);
const pipe = loadedPipelines.get(modelId)!;
@@ -337,6 +344,9 @@ self.onmessage = async (event: MessageEvent<MLWorkerMessage>) => {
}
case 'load-model': {
if (!isSupportedModelId(message.modelId)) {
throw new Error(`Unknown model: ${message.modelId}`);
}
await loadModel(message.modelId);
self.postMessage({
type: 'model-loaded',