feat: add daily gas prices (AAA), electricity rates, natgas + dashboard deltas
- Replace EIA weekly gasoline with AAA daily scraper (gasprices.aaa.com) - urllib-based fetch (Cloudflare blocks httpx), cached 30min - 5 grades: regular, mid-grade, premium, diesel, E85 - Day-over-day, week, month, year-over-year deltas - 51 state-level regular prices - Add EIA electricity retail rates (residential/commercial/industrial) - Add EIA residential natural gas prices (monthly) - Dashboard: all energy sections show change indicators (DoD/WoW/MoM) - Dashboard: vector panel moved to right:360px (was covering zoom controls) - Dashboard: fix pre-existing num() → fmtNum() bug in BTC technicals - 3 new tools: intel_gas_prices, intel_residential_natgas, intel_electricity_rates - CLI: gas-prices, natgas, electricity commands - Collector: 3 new source entries - Tests: 8 new tests (AAA parse, electricity, natgas, edge cases)
This commit is contained in:
@@ -232,6 +232,101 @@ def energy(ctx: click.Context) -> None:
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command("gas-prices")
|
||||
@click.pass_context
|
||||
def gas_prices(ctx: click.Context) -> None:
|
||||
"""US retail gasoline & diesel prices (AAA, daily)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(economic.fetch_gas_prices(f))
|
||||
|
||||
if ctx.obj.get("json") or "error" in data:
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
prices = data.get("prices", {})
|
||||
table = Table(title="US Gas Prices — Today (AAA)", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Grade", style="bold")
|
||||
table.add_column("$/gallon", justify="right")
|
||||
table.add_column("DoD", justify="right")
|
||||
table.add_column("WoW", justify="right")
|
||||
|
||||
grade_labels = {
|
||||
"regular": "Regular",
|
||||
"mid_grade": "Mid-Grade",
|
||||
"premium": "Premium",
|
||||
"diesel": "Diesel",
|
||||
}
|
||||
for grade, label in grade_labels.items():
|
||||
info = prices.get(grade)
|
||||
if info and isinstance(info, dict):
|
||||
price = info.get("price_per_gallon", 0)
|
||||
dod = info.get("change_pct")
|
||||
wow = info.get("week_ago_pct")
|
||||
dod_str = (
|
||||
f"[{'green' if dod >= 0 else 'red'}]{dod:+.2f}%[/]"
|
||||
if dod is not None
|
||||
else "—"
|
||||
)
|
||||
wow_str = (
|
||||
f"[{'green' if wow >= 0 else 'red'}]{wow:+.2f}%[/]"
|
||||
if wow is not None
|
||||
else "—"
|
||||
)
|
||||
table.add_row(label, f"${price:.3f}", dod_str, wow_str)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command("natgas")
|
||||
@click.pass_context
|
||||
def natgas(ctx: click.Context) -> None:
|
||||
"""US residential natural gas prices (EIA)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(economic.fetch_residential_natgas_prices(f))
|
||||
|
||||
if ctx.obj.get("json") or "error" in data:
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
prices = data.get("prices", [])
|
||||
table = Table(title="US Residential Natural Gas Prices", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Period", style="bold")
|
||||
table.add_column("$/MCF", justify="right")
|
||||
|
||||
for entry in prices:
|
||||
table.add_row(str(entry.get("period", "")), f"${entry.get('price', '?'):.2f}")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command("electricity")
|
||||
@click.option("--state", "-s", default=None, help="2-letter state code (e.g., CA, TX)")
|
||||
@click.pass_context
|
||||
def electricity(ctx: click.Context, state: str | None) -> None:
|
||||
"""US electricity retail rates (EIA)."""
|
||||
f = _get_fetcher()
|
||||
data = _run(economic.fetch_electricity_rates(f, state=state))
|
||||
|
||||
if ctx.obj.get("json") or "error" in data:
|
||||
_print_json(data)
|
||||
return
|
||||
|
||||
rates = data.get("rates", {})
|
||||
label = data.get("state", "US")
|
||||
table = Table(title=f"Electricity Rates — {label}", box=box.SIMPLE_HEAVY)
|
||||
table.add_column("Sector", style="bold")
|
||||
table.add_column("cents/kWh", justify="right")
|
||||
table.add_column("Period")
|
||||
|
||||
for sector in ("residential", "commercial", "industrial", "all_sectors"):
|
||||
info = rates.get(sector)
|
||||
if info and isinstance(info, dict):
|
||||
table.add_row(
|
||||
sector.replace("_", " ").title(),
|
||||
f"{info.get('price_cents_kwh', '?'):.2f}",
|
||||
str(info.get("period", "")),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("series_id")
|
||||
@click.option("--limit", "-n", default=30, help="Number of observations")
|
||||
|
||||
@@ -46,8 +46,11 @@ SOURCES = [
|
||||
("etf_flows", "sources.markets", "fetch_etf_flows", {}),
|
||||
("commodity_quotes", "sources.markets", "fetch_commodity_quotes", {}),
|
||||
("btc_technicals", "sources.markets", "fetch_btc_technicals", {}),
|
||||
# Economic (3)
|
||||
# Economic (6)
|
||||
("energy_prices", "sources.economic", "fetch_energy_prices", {}),
|
||||
("gas_prices", "sources.economic", "fetch_gas_prices", {}),
|
||||
("residential_natgas", "sources.economic", "fetch_residential_natgas_prices", {}),
|
||||
("electricity_rates", "sources.economic", "fetch_electricity_rates", {}),
|
||||
("central_bank_rates", "sources.central_banks", "fetch_central_bank_rates", {}),
|
||||
# Natural Disasters (2)
|
||||
("earthquakes", "sources.seismology", "fetch_earthquakes", {}),
|
||||
@@ -115,7 +118,13 @@ DOMAIN_GROUPS = {
|
||||
"commodity_quotes",
|
||||
"btc_technicals",
|
||||
],
|
||||
"economic": ["energy_prices", "central_bank_rates"],
|
||||
"economic": [
|
||||
"energy_prices",
|
||||
"gas_prices",
|
||||
"residential_natgas",
|
||||
"electricity_rates",
|
||||
"central_bank_rates",
|
||||
],
|
||||
"natural": ["earthquakes", "wildfires"],
|
||||
"conflict": ["acled_events", "ucdp_events", "displacement"],
|
||||
"military": ["military_flights"],
|
||||
|
||||
@@ -127,6 +127,9 @@ async def _fetch_overview() -> dict:
|
||||
"airport_delays": aviation.fetch_airport_delays(fetcher),
|
||||
"climate_anomalies": climate.fetch_climate_anomalies(fetcher),
|
||||
"energy_prices": economic.fetch_energy_prices(fetcher),
|
||||
"gas_prices": economic.fetch_gas_prices(fetcher),
|
||||
"residential_natgas": economic.fetch_residential_natgas_prices(fetcher),
|
||||
"electricity_rates": economic.fetch_electricity_rates(fetcher),
|
||||
"stablecoin_status": markets.fetch_stablecoin_status(fetcher),
|
||||
"etf_flows": markets.fetch_etf_flows(fetcher),
|
||||
"acled_events": conflict.fetch_acled_events(fetcher),
|
||||
|
||||
@@ -327,14 +327,19 @@ a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* ═══════════════ VECTOR INTELLIGENCE PANEL ═══════════════ */
|
||||
#vectorPanel {
|
||||
position: fixed; bottom: 44px; left: 10px; z-index: 40;
|
||||
position: fixed; bottom: 44px; right: 360px; z-index: 40;
|
||||
width: 310px; max-height: 420px;
|
||||
overflow-y: auto; overflow-x: hidden;
|
||||
padding: 12px 14px;
|
||||
transition: transform 0.4s var(--ease), opacity 0.3s;
|
||||
}
|
||||
#vectorPanel.hidden { transform: translateY(20px); opacity: 0; pointer-events: none; }
|
||||
.vec-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
#vectorPanel.collapsed { max-height: 36px; overflow: hidden; }
|
||||
#vectorPanel.collapsed .vec-body { display: none; }
|
||||
.vec-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; cursor: pointer; user-select: none; }
|
||||
.vec-header:hover .vec-title { color: var(--bright); }
|
||||
.vec-toggle { font-size: 0.7rem; color: var(--dim); transition: transform 0.25s var(--ease); }
|
||||
#vectorPanel.collapsed .vec-toggle { transform: rotate(-90deg); }
|
||||
.vec-title { font-size: 0.62rem; font-weight: 600; letter-spacing: 2.5px; color: var(--accent); text-transform: uppercase; }
|
||||
.vec-stat { font-family: var(--mono); font-size: 0.6rem; color: var(--dim); }
|
||||
.vec-stat .v { color: var(--bright); font-weight: 600; }
|
||||
@@ -783,16 +788,21 @@ a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
<!-- VECTOR INTELLIGENCE PANEL -->
|
||||
<div id="vectorPanel" class="glass hidden">
|
||||
<div class="vec-header">
|
||||
<div class="vec-header" id="vecHeaderToggle">
|
||||
<span class="vec-title">Vector Intelligence</span>
|
||||
<span class="vec-stat" id="vecPointCount"></span>
|
||||
<span style="display:flex;align-items:center;gap:6px">
|
||||
<span class="vec-stat" id="vecPointCount"></span>
|
||||
<span class="vec-toggle">▼</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="vecContent"><div class="vec-unavail">Connecting...</div></div>
|
||||
<div class="vec-search-wrap">
|
||||
<input type="text" class="vec-search-input" id="vecSearchInput" placeholder="Semantic search..." autocomplete="off">
|
||||
<button class="vec-search-btn" id="vecSearchBtn">Search</button>
|
||||
<div class="vec-body">
|
||||
<div id="vecContent"><div class="vec-unavail">Connecting...</div></div>
|
||||
<div class="vec-search-wrap">
|
||||
<input type="text" class="vec-search-input" id="vecSearchInput" placeholder="Semantic search..." autocomplete="off">
|
||||
<button class="vec-search-btn" id="vecSearchBtn">Search</button>
|
||||
</div>
|
||||
<div class="vec-results" id="vecResults"></div>
|
||||
</div>
|
||||
<div class="vec-results" id="vecResults"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -2248,10 +2258,10 @@ function updateDrawer(data) {
|
||||
var ep = data.energy_prices;
|
||||
var eRows = [];
|
||||
if (ep.oil) {
|
||||
if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date});
|
||||
if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date});
|
||||
if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date, unit: '$/bbl'});
|
||||
if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date, unit: '$/bbl'});
|
||||
}
|
||||
if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Natural Gas', price: ep.natural_gas.price, date: ep.natural_gas.date});
|
||||
if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Nat Gas Futures', price: ep.natural_gas.price, date: ep.natural_gas.date, unit: '$/MMBtu'});
|
||||
if (eRows.length) {
|
||||
h += '<div class="sub">Energy</div><table class="dtable"><thead><tr><th>Commodity</th><th>Price</th><th>Date</th></tr></thead><tbody>';
|
||||
eRows.forEach(function(item) {
|
||||
@@ -2260,6 +2270,59 @@ function updateDrawer(data) {
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
// US Retail Gas Prices (AAA daily)
|
||||
if (data.gas_prices && !data.gas_prices.error) {
|
||||
var gp = data.gas_prices;
|
||||
var prices = gp.prices || {};
|
||||
var grades = ['regular', 'mid_grade', 'premium', 'diesel'];
|
||||
var gradeLabels = {regular: 'Regular', mid_grade: 'Mid-Grade', premium: 'Premium', diesel: 'Diesel'};
|
||||
var gRows = [];
|
||||
grades.forEach(function(g) {
|
||||
var p = prices[g];
|
||||
if (p && p.price_per_gallon != null) gRows.push({name: gradeLabels[g] || g, price: p.price_per_gallon, change: p.change, change_pct: p.change_pct, week_ago_pct: p.week_ago_pct, month_ago_pct: p.month_ago_pct});
|
||||
});
|
||||
if (gRows.length) {
|
||||
h += '<div class="sub">US Gas Prices <span class="dim" style="font-size:0.55rem">today via AAA</span></div>';
|
||||
h += '<table class="dtable"><thead><tr><th>Grade</th><th>$/gal</th><th>DoD</th><th>WoW</th></tr></thead><tbody>';
|
||||
gRows.forEach(function(item) {
|
||||
var dod = item.change_pct != null ? '<span class="' + cls(item.change_pct) + '">' + fmtPct(item.change_pct) + '%</span>' : '<span class="dim">\u2014</span>';
|
||||
var wow = item.week_ago_pct != null ? '<span class="' + cls(item.week_ago_pct) + '">' + fmtPct(item.week_ago_pct) + '%</span>' : '<span class="dim">\u2014</span>';
|
||||
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">$' + fmtNum(item.price, 3) + '</td><td>' + dod + '</td><td>' + wow + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
// Residential Natural Gas
|
||||
if (data.residential_natgas && !data.residential_natgas.error) {
|
||||
var rng = data.residential_natgas;
|
||||
var ngPrices = rng.prices || [];
|
||||
if (ngPrices.length) {
|
||||
var ngFirst = ngPrices[0];
|
||||
var ngChg = ngFirst.change_pct != null ? ' <span class="' + cls(ngFirst.change_pct) + '" style="font-size:0.6rem">' + fmtPct(ngFirst.change_pct) + '% MoM</span>' : '';
|
||||
h += '<div class="sub">Residential Nat Gas <span class="dim" style="font-size:0.55rem">' + esc(ngFirst.period || '') + '</span></div>';
|
||||
h += '<div class="mini-row"><div class="mini-box"><div class="v bright">$' + fmtNum(ngFirst.price) + '</div><div class="l">$/MCF' + ngChg + '</div></div></div>';
|
||||
}
|
||||
}
|
||||
// Electricity Rates
|
||||
if (data.electricity_rates && !data.electricity_rates.error) {
|
||||
var er = data.electricity_rates;
|
||||
var rates = er.rates || {};
|
||||
var sectors = ['residential', 'commercial', 'industrial'];
|
||||
var eRateRows = [];
|
||||
sectors.forEach(function(s) {
|
||||
var r = rates[s];
|
||||
if (r && r.price_cents_kwh != null) eRateRows.push({name: s.charAt(0).toUpperCase() + s.slice(1), price: r.price_cents_kwh, period: r.period, change: r.change, change_pct: r.change_pct});
|
||||
});
|
||||
if (eRateRows.length) {
|
||||
h += '<div class="sub">Electricity (\u00A2/kWh) — ' + esc(er.state || 'US') + ' <span class="dim" style="font-size:0.55rem">' + esc(eRateRows[0].period || '') + '</span></div>';
|
||||
h += '<table class="dtable"><thead><tr><th>Sector</th><th>\u00A2/kWh</th><th>MoM</th></tr></thead><tbody>';
|
||||
eRateRows.forEach(function(item) {
|
||||
var chg = item.change_pct != null ? '<span class="' + cls(item.change_pct) + '">' + fmtPct(item.change_pct) + '%</span>' : '<span class="dim">\u2014</span>';
|
||||
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">' + fmtNum(item.price) + '\u00A2</td><td>' + chg + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
if (data.etf_flows && !data.etf_flows.error) {
|
||||
var etfs = data.etf_flows.etfs || data.etf_flows.data || [];
|
||||
if (etfs.length) {
|
||||
@@ -2803,12 +2866,12 @@ function updateDrawer(data) {
|
||||
var crossCls = bt.cross_signal === 'golden_cross' ? 'good' : bt.cross_signal === 'death_cross' ? 'crit' : 'dim';
|
||||
var crossLabel = bt.cross_signal === 'golden_cross' ? 'GOLDEN CROSS' : bt.cross_signal === 'death_cross' ? 'DEATH CROSS' : 'NEUTRAL';
|
||||
h += '<div class="mini-boxes" style="margin:6px 0">';
|
||||
h += '<div class="mini-box"><div class="v">$' + num(bt.price) + '</div><div class="l">BTC Price</div></div>';
|
||||
h += '<div class="mini-box"><div class="v">$' + fmtNum(bt.price) + '</div><div class="l">BTC Price</div></div>';
|
||||
h += '<div class="mini-box"><div class="v">' + (bt.mayer_multiple || '—') + '</div><div class="l">Mayer Multiple</div></div>';
|
||||
h += '<div class="mini-box"><div class="v ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></div>';
|
||||
h += '</div>';
|
||||
h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + num(bt.sma_50) + '</span></div>';
|
||||
if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + num(bt.sma_200) + '</span></div>';
|
||||
h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + fmtNum(bt.sma_50) + '</span></div>';
|
||||
if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + fmtNum(bt.sma_200) + '</span></div>';
|
||||
if (bt.change_7d_pct != null) {
|
||||
var c7 = bt.change_7d_pct >= 0 ? 'good' : 'crit';
|
||||
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">7d</span> <span class="' + c7 + '">' + (bt.change_7d_pct > 0 ? '+' : '') + bt.change_7d_pct + '%</span>';
|
||||
@@ -3235,6 +3298,14 @@ function doVecSearch() {
|
||||
});
|
||||
}
|
||||
|
||||
$('#vecHeaderToggle').addEventListener('click', function() {
|
||||
var panel = $('#vectorPanel');
|
||||
panel.classList.toggle('collapsed');
|
||||
try { localStorage.setItem('phoenix-vec-collapsed', panel.classList.contains('collapsed') ? '1' : ''); } catch(e) {}
|
||||
});
|
||||
// Restore collapsed state
|
||||
try { if (localStorage.getItem('phoenix-vec-collapsed') === '1') { $('#vectorPanel').classList.add('collapsed'); } } catch(e) {}
|
||||
|
||||
$('#vecSearchBtn').addEventListener('click', doVecSearch);
|
||||
$('#vecSearchInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doVecSearch(); }
|
||||
|
||||
@@ -167,10 +167,33 @@ TOOLS: list[Tool] = [
|
||||
description="Get commodity futures quotes: gold, silver, crude oil (WTI & Brent), natural gas, corn, wheat, soybeans from Yahoo Finance.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
# --- Economic (3 tools) ---
|
||||
# --- Economic (6 tools) ---
|
||||
Tool(
|
||||
name="intel_gas_prices",
|
||||
description="Get today's US retail gasoline and diesel prices ($/gallon) from AAA — daily national averages for regular, mid-grade, premium, diesel, E85. Includes day-over-day, week, month, year deltas plus per-state prices. No API key required.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="intel_residential_natgas",
|
||||
description="Get US residential natural gas prices ($/thousand cubic feet) — monthly average. Requires EIA_API_KEY.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="intel_electricity_rates",
|
||||
description="Get US electricity retail rates (cents/kWh) by sector (residential, commercial, industrial). Optionally filter by state. Requires EIA_API_KEY.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "2-letter US state code (e.g., 'CA', 'TX'). Defaults to nationwide.",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="intel_energy_prices",
|
||||
description="Get crude oil (Brent, WTI) and natural gas prices from EIA. Requires EIA_API_KEY.",
|
||||
description="Get crude oil (Brent, WTI) and natural gas futures prices from EIA. Requires EIA_API_KEY.",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
@@ -1769,6 +1792,15 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
||||
return await markets.fetch_commodity_quotes(fetcher)
|
||||
|
||||
# Economic
|
||||
case "intel_gas_prices":
|
||||
return await economic.fetch_gas_prices(fetcher)
|
||||
case "intel_residential_natgas":
|
||||
return await economic.fetch_residential_natgas_prices(fetcher)
|
||||
case "intel_electricity_rates":
|
||||
return await economic.fetch_electricity_rates(
|
||||
fetcher,
|
||||
state=arguments.get("state"),
|
||||
)
|
||||
case "intel_energy_prices":
|
||||
return await economic.fetch_energy_prices(fetcher)
|
||||
case "intel_fred_series":
|
||||
|
||||
@@ -85,9 +85,7 @@ async def fetch_energy_prices(
|
||||
# --- Parse oil prices ---------------------------------------------------
|
||||
if oil_data:
|
||||
try:
|
||||
records = (
|
||||
oil_data.get("response", {}).get("data", [])
|
||||
)
|
||||
records = oil_data.get("response", {}).get("data", [])
|
||||
for rec in records:
|
||||
product = rec.get("product")
|
||||
value = rec.get("value")
|
||||
@@ -105,9 +103,7 @@ async def fetch_energy_prices(
|
||||
# --- Parse natural gas price --------------------------------------------
|
||||
if gas_data:
|
||||
try:
|
||||
records = (
|
||||
gas_data.get("response", {}).get("data", [])
|
||||
)
|
||||
records = gas_data.get("response", {}).get("data", [])
|
||||
if records:
|
||||
rec = records[0]
|
||||
value = rec.get("value")
|
||||
@@ -123,6 +119,366 @@ async def fetch_energy_prices(
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AAA: US retail gasoline & diesel prices (daily, via gasprices.aaa.com)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AAA_URL = "https://gasprices.aaa.com/"
|
||||
|
||||
# Row labels in AAA's price table → our key names
|
||||
_AAA_ROW_LABELS = {
|
||||
"Current Avg.": "today",
|
||||
"Yesterday Avg.": "yesterday",
|
||||
"Week Ago Avg.": "week_ago",
|
||||
"Month Ago Avg.": "month_ago",
|
||||
"Year Ago Avg.": "year_ago",
|
||||
}
|
||||
|
||||
# Column order in the AAA table (indices 1..5 after the row label)
|
||||
_AAA_GRADES = ["regular", "mid_grade", "premium", "diesel", "e85"]
|
||||
|
||||
|
||||
def _parse_aaa_html(html: str) -> dict:
|
||||
"""Extract national gas prices from AAA's HTML price table.
|
||||
|
||||
Returns dict with per-grade prices, yesterday delta, and week/month/year
|
||||
comparisons. Also extracts per-state prices from the ``iwmparam`` JS var.
|
||||
"""
|
||||
import re
|
||||
|
||||
result: dict = {"prices": {}, "state_prices": []}
|
||||
|
||||
# --- National price table ------------------------------------------------
|
||||
# Table has rows: Current Avg., Yesterday Avg., Week Ago Avg., ...
|
||||
# Each row: label, Regular, Mid-Grade, Premium, Diesel, E85
|
||||
table_match = re.search(
|
||||
r"<table[^>]*>.*?Regular.*?</table>", html, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
if table_match:
|
||||
table_html = table_match.group(0)
|
||||
rows = re.findall(
|
||||
r"<tr[^>]*>\s*<td[^>]*>([^<]+)</td>\s*"
|
||||
r"<td[^>]*>\$?([\d.]+)</td>\s*"
|
||||
r"<td[^>]*>\$?([\d.]+)</td>\s*"
|
||||
r"<td[^>]*>\$?([\d.]+)</td>\s*"
|
||||
r"<td[^>]*>\$?([\d.]+)</td>\s*"
|
||||
r"<td[^>]*>\$?([\d.]+)</td>",
|
||||
table_html,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
time_rows: dict[str, dict[str, float]] = {}
|
||||
for row in rows:
|
||||
label = row[0].strip()
|
||||
key = _AAA_ROW_LABELS.get(label)
|
||||
if key is None:
|
||||
continue
|
||||
time_rows[key] = {
|
||||
grade: float(row[i + 1]) for i, grade in enumerate(_AAA_GRADES)
|
||||
}
|
||||
|
||||
today = time_rows.get("today", {})
|
||||
yesterday = time_rows.get("yesterday", {})
|
||||
|
||||
for grade in _AAA_GRADES:
|
||||
cur = today.get(grade)
|
||||
if cur is None:
|
||||
continue
|
||||
entry: dict = {
|
||||
"price_per_gallon": cur,
|
||||
"unit": "$/gallon",
|
||||
}
|
||||
prev = yesterday.get(grade)
|
||||
if prev is not None and prev != 0:
|
||||
delta = cur - prev
|
||||
entry["change"] = round(delta, 3)
|
||||
entry["change_pct"] = round(delta / prev * 100, 2)
|
||||
|
||||
# Week/month/year comparisons
|
||||
for period_key, period_label in [
|
||||
("week_ago", "week_ago"),
|
||||
("month_ago", "month_ago"),
|
||||
("year_ago", "year_ago"),
|
||||
]:
|
||||
comp = time_rows.get(period_key, {}).get(grade)
|
||||
if comp is not None and comp != 0:
|
||||
entry[period_label] = comp
|
||||
entry[f"{period_label}_pct"] = round((cur - comp) / comp * 100, 2)
|
||||
|
||||
result["prices"][grade] = entry
|
||||
|
||||
# --- State prices from iwmparam ------------------------------------------
|
||||
iwm = re.search(r'iwmparam\[0\]\.placestxt\s*=\s*"([^"]+)"', html)
|
||||
if iwm:
|
||||
parts = iwm.group(1).split(",")
|
||||
# Format: STATE,Name,$price,link,color;STATE,Name,...
|
||||
i = 0
|
||||
while i + 2 < len(parts):
|
||||
state_code = parts[i].split(";")[-1] if ";" in parts[i] else parts[i]
|
||||
state_code = state_code.strip()
|
||||
price_str = parts[i + 2].strip().lstrip("$")
|
||||
try:
|
||||
price = float(price_str)
|
||||
result["state_prices"].append({"state": state_code, "price": price})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
i += 4 # skip link and color-tagged next state
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_aaa_html() -> str | None:
|
||||
"""Fetch AAA gas prices page using urllib (bypasses Cloudflare)."""
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
_AAA_URL,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=20)
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_gas_prices(
|
||||
fetcher: Fetcher,
|
||||
region: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> dict:
|
||||
"""Fetch US retail gasoline & diesel prices from AAA (daily).
|
||||
|
||||
Scrapes gasprices.aaa.com for today's national averages across
|
||||
regular, mid-grade, premium, diesel, and E85. Includes day-over-day,
|
||||
week, month, and year-over-year deltas. Also includes per-state
|
||||
regular prices.
|
||||
|
||||
No API key required — public page. The *region* and *api_key*
|
||||
parameters are accepted for interface compatibility but ignored.
|
||||
"""
|
||||
# AAA is behind Cloudflare — httpx gets 403. Use urllib which
|
||||
# has a simpler TLS fingerprint and passes through. Cache via
|
||||
# the fetcher's cache to avoid redundant requests.
|
||||
cached = fetcher.cache.get("economic:gas_prices:aaa")
|
||||
if cached is not None:
|
||||
html = cached
|
||||
else:
|
||||
try:
|
||||
html = await asyncio.to_thread(_fetch_aaa_html)
|
||||
if html:
|
||||
fetcher.cache.set("economic:gas_prices:aaa", html, ttl_seconds=1800)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch AAA gas prices: %s", exc)
|
||||
html = fetcher.cache.get_stale("economic:gas_prices:aaa")
|
||||
|
||||
result: dict = {
|
||||
"region": "US",
|
||||
"prices": {},
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "aaa",
|
||||
"update_frequency": "daily",
|
||||
}
|
||||
|
||||
if html is None:
|
||||
return result
|
||||
|
||||
try:
|
||||
parsed = _parse_aaa_html(html)
|
||||
result["prices"] = parsed["prices"]
|
||||
result["state_prices"] = parsed.get("state_prices", [])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse AAA gas prices: %s", exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EIA: US residential natural gas prices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EIA_NATGAS_RESIDENTIAL_URL = "https://api.eia.gov/v2/natural-gas/pri/sum/data/"
|
||||
|
||||
# State codes (subset) — EIA uses 2-letter postal codes
|
||||
_US_STATES = {
|
||||
"US": "NUS", # nationwide
|
||||
}
|
||||
|
||||
|
||||
async def fetch_residential_natgas_prices(
|
||||
fetcher: Fetcher,
|
||||
api_key: str | None = None,
|
||||
) -> dict:
|
||||
"""Fetch US residential natural gas prices from EIA.
|
||||
|
||||
Returns the most recent monthly residential natural gas price
|
||||
($/thousand cubic feet) nationwide.
|
||||
|
||||
Requires ``EIA_API_KEY``.
|
||||
"""
|
||||
key = api_key or os.environ.get("EIA_API_KEY")
|
||||
if not key:
|
||||
return {"error": "EIA_API_KEY not configured"}
|
||||
|
||||
params = {
|
||||
"api_key": key,
|
||||
"frequency": "monthly",
|
||||
"data[0]": "value",
|
||||
"sort[0][column]": "period",
|
||||
"sort[0][direction]": "desc",
|
||||
"length": 6,
|
||||
"facets[duoarea][]": "NUS",
|
||||
"facets[process][]": "PRS", # residential
|
||||
}
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_EIA_NATGAS_RESIDENTIAL_URL,
|
||||
source="eia",
|
||||
cache_key="economic:natgas_residential",
|
||||
cache_ttl=3600,
|
||||
params=params,
|
||||
)
|
||||
|
||||
result: dict = {
|
||||
"prices": [],
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "eia",
|
||||
"unit": "$/thousand cubic feet",
|
||||
}
|
||||
|
||||
if data is None:
|
||||
return result
|
||||
|
||||
try:
|
||||
records = data.get("response", {}).get("data", [])
|
||||
for rec in records:
|
||||
value = rec.get("value")
|
||||
period = rec.get("period")
|
||||
if value is not None and period is not None:
|
||||
result["prices"].append(
|
||||
{
|
||||
"price": float(value),
|
||||
"period": period,
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.warning("Failed to parse EIA residential natgas data: %s", exc)
|
||||
|
||||
# Add change from previous period on the most recent entry
|
||||
if len(result["prices"]) >= 2:
|
||||
cur = result["prices"][0]["price"]
|
||||
prev = result["prices"][1]["price"]
|
||||
delta = cur - prev
|
||||
result["prices"][0]["change"] = round(delta, 2)
|
||||
result["prices"][0]["change_pct"] = (
|
||||
round(delta / prev * 100, 2) if prev else 0.0
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EIA: US electricity retail rates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EIA_ELECTRICITY_URL = "https://api.eia.gov/v2/electricity/retail-sales/data/"
|
||||
|
||||
|
||||
async def fetch_electricity_rates(
|
||||
fetcher: Fetcher,
|
||||
state: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> dict:
|
||||
"""Fetch US electricity retail rates from EIA.
|
||||
|
||||
Returns average retail electricity price (cents/kWh) by sector
|
||||
(residential, commercial, industrial). Optionally filter by
|
||||
2-letter *state* code (e.g., 'CA', 'TX'). Defaults to nationwide.
|
||||
|
||||
Requires ``EIA_API_KEY``.
|
||||
"""
|
||||
key = api_key or os.environ.get("EIA_API_KEY")
|
||||
if not key:
|
||||
return {"error": "EIA_API_KEY not configured"}
|
||||
|
||||
area = state.upper() if state else "US"
|
||||
|
||||
params = {
|
||||
"api_key": key,
|
||||
"frequency": "monthly",
|
||||
"data[0]": "price",
|
||||
"sort[0][column]": "period",
|
||||
"sort[0][direction]": "desc",
|
||||
"length": 12,
|
||||
"facets[stateid][]": area,
|
||||
"facets[sectorid][]": ["RES", "COM", "IND", "ALL"],
|
||||
}
|
||||
|
||||
data = await fetcher.get_json(
|
||||
_EIA_ELECTRICITY_URL,
|
||||
source="eia",
|
||||
cache_key=f"economic:electricity:{area}",
|
||||
cache_ttl=3600,
|
||||
params=params,
|
||||
)
|
||||
|
||||
sector_names = {
|
||||
"RES": "residential",
|
||||
"COM": "commercial",
|
||||
"IND": "industrial",
|
||||
"ALL": "all_sectors",
|
||||
}
|
||||
|
||||
result: dict = {
|
||||
"state": area,
|
||||
"rates": {},
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "eia",
|
||||
"unit": "cents/kWh",
|
||||
}
|
||||
|
||||
if data is None:
|
||||
return result
|
||||
|
||||
# Collect up to 2 most recent values per sector (for delta)
|
||||
by_sector: dict[str, list[dict]] = {}
|
||||
try:
|
||||
records = data.get("response", {}).get("data", [])
|
||||
for rec in records:
|
||||
sector_code = rec.get("sectorid")
|
||||
price = rec.get("price")
|
||||
period = rec.get("period")
|
||||
if sector_code is None or price is None or period is None:
|
||||
continue
|
||||
sector = sector_names.get(sector_code, sector_code)
|
||||
lst = by_sector.setdefault(sector, [])
|
||||
if len(lst) < 2:
|
||||
lst.append({"price": float(price), "period": period})
|
||||
|
||||
for sector, entries in by_sector.items():
|
||||
current = entries[0]
|
||||
entry: dict = {
|
||||
"price_cents_kwh": current["price"],
|
||||
"period": current["period"],
|
||||
}
|
||||
if len(entries) >= 2:
|
||||
prev = entries[1]["price"]
|
||||
delta = current["price"] - prev
|
||||
entry["change"] = round(delta, 2)
|
||||
entry["change_pct"] = round(delta / prev * 100, 2) if prev else 0.0
|
||||
entry["prev_period"] = entries[1]["period"]
|
||||
result["rates"][sector] = entry
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.warning("Failed to parse EIA electricity data: %s", exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FRED: Federal Reserve Economic Data
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -213,9 +569,9 @@ async def fetch_fred_series(
|
||||
_WB_BASE = "https://api.worldbank.org/v2/country"
|
||||
|
||||
_DEFAULT_INDICATORS = [
|
||||
"NY.GDP.MKTP.CD", # GDP (current US$)
|
||||
"FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %)
|
||||
"SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force)
|
||||
"NY.GDP.MKTP.CD", # GDP (current US$)
|
||||
"FP.CPI.TOTL.ZG", # Inflation, consumer prices (annual %)
|
||||
"SL.UEM.TOTL.ZS", # Unemployment, total (% of labor force)
|
||||
]
|
||||
|
||||
|
||||
@@ -249,9 +605,7 @@ async def fetch_world_bank_indicators(
|
||||
params=params,
|
||||
)
|
||||
|
||||
responses = await asyncio.gather(
|
||||
*[_fetch_one(ind) for ind in indicator_ids]
|
||||
)
|
||||
responses = await asyncio.gather(*[_fetch_one(ind) for ind in indicator_ids])
|
||||
|
||||
parsed_indicators: list[dict] = []
|
||||
|
||||
@@ -288,14 +642,18 @@ async def fetch_world_bank_indicators(
|
||||
parsed_value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
entry["values"].append({
|
||||
"year": year,
|
||||
"value": parsed_value,
|
||||
})
|
||||
entry["values"].append(
|
||||
{
|
||||
"year": year,
|
||||
"value": parsed_value,
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, IndexError) as exc:
|
||||
logger.warning(
|
||||
"Failed to parse World Bank indicator %s for %s: %s",
|
||||
indicator_id, country, exc,
|
||||
indicator_id,
|
||||
country,
|
||||
exc,
|
||||
)
|
||||
|
||||
parsed_indicators.append(entry)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Tests for source modules — uses respx to mock HTTP calls."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -37,14 +36,16 @@ async def test_fetch_market_quotes(fetcher: Fetcher) -> None:
|
||||
# Mock Yahoo Finance v8 chart response for ^GSPC
|
||||
chart_response = {
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "^GSPC",
|
||||
"regularMarketPrice": 5123.45,
|
||||
"regularMarketChangePercent": 0.42,
|
||||
"currency": "USD",
|
||||
"result": [
|
||||
{
|
||||
"meta": {
|
||||
"symbol": "^GSPC",
|
||||
"regularMarketPrice": 5123.45,
|
||||
"regularMarketChangePercent": 0.42,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +150,7 @@ async def test_fetch_wildfires_no_api_key(fetcher: Fetcher) -> None:
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
# Remove the key if present
|
||||
import os
|
||||
|
||||
os.environ.pop("NASA_FIRMS_API_KEY", None)
|
||||
result = await fetch_wildfires(fetcher, api_key=None)
|
||||
|
||||
@@ -189,6 +191,7 @@ async def test_fetch_fred_series_no_key(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.economic import fetch_fred_series
|
||||
|
||||
import os
|
||||
|
||||
os.environ.pop("FRED_API_KEY", None)
|
||||
result = await fetch_fred_series(fetcher, series_id="UNRATE", api_key=None)
|
||||
assert "error" in result
|
||||
@@ -202,8 +205,16 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
|
||||
wb_response = [
|
||||
{"page": 1, "pages": 1, "per_page": 5, "total": 2},
|
||||
[
|
||||
{"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"}, "date": "2023", "value": 25000000000000},
|
||||
{"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"}, "date": "2022", "value": 24000000000000},
|
||||
{
|
||||
"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"},
|
||||
"date": "2023",
|
||||
"value": 25000000000000,
|
||||
},
|
||||
{
|
||||
"indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP"},
|
||||
"date": "2022",
|
||||
"value": 24000000000000,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
@@ -211,13 +222,119 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
|
||||
return_value=httpx.Response(200, json=wb_response)
|
||||
)
|
||||
|
||||
result = await fetch_world_bank_indicators(fetcher, country="USA", indicators=["NY.GDP.MKTP.CD"])
|
||||
result = await fetch_world_bank_indicators(
|
||||
fetcher, country="USA", indicators=["NY.GDP.MKTP.CD"]
|
||||
)
|
||||
assert "indicators" in result
|
||||
assert len(result["indicators"]) == 1
|
||||
assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD"
|
||||
assert result["source"] == "world-bank"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gas prices, natural gas, electricity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_AAA_HTML = """<html><body>
|
||||
<table><thead><tr><th></th><th>Regular</th><th>Mid-Grade</th><th>Premium</th>
|
||||
<th>Diesel</th><th>E85</th></tr></thead><tbody>
|
||||
<tr><td>Current Avg.</td><td>$3.450</td><td>$3.942</td><td>$4.306</td><td>$4.595</td><td>$2.762</td></tr>
|
||||
<tr><td>Yesterday Avg.</td><td>$3.413</td><td>$3.897</td><td>$4.260</td><td>$4.510</td><td>$2.717</td></tr>
|
||||
<tr><td>Week Ago Avg.</td><td>$2.984</td><td>$3.482</td><td>$3.851</td><td>$3.761</td><td>$2.319</td></tr>
|
||||
<tr><td>Month Ago Avg.</td><td>$2.897</td><td>$3.402</td><td>$3.765</td><td>$3.644</td><td>$2.306</td></tr>
|
||||
<tr><td>Year Ago Avg.</td><td>$3.095</td><td>$3.572</td><td>$3.925</td><td>$3.640</td><td>$2.523</td></tr>
|
||||
</tbody></table></body></html>"""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_gas_prices(
|
||||
fetcher: Fetcher, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from world_intel_mcp.sources import economic
|
||||
|
||||
monkeypatch.setattr(economic, "_fetch_aaa_html", lambda: _AAA_HTML)
|
||||
|
||||
result = await economic.fetch_gas_prices(fetcher)
|
||||
assert result["source"] == "aaa"
|
||||
assert result["region"] == "US"
|
||||
assert "regular" in result["prices"]
|
||||
assert result["prices"]["regular"]["price_per_gallon"] == 3.450
|
||||
assert "diesel" in result["prices"]
|
||||
assert result["prices"]["diesel"]["price_per_gallon"] == 4.595
|
||||
# Day-over-day delta: 3.450 - 3.413 = 0.037
|
||||
assert result["prices"]["regular"]["change"] == 0.037
|
||||
assert result["prices"]["regular"]["change_pct"] is not None
|
||||
# Week-over-week comparison
|
||||
assert result["prices"]["regular"]["week_ago_pct"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_gas_prices_no_data(
|
||||
fetcher: Fetcher, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from world_intel_mcp.sources import economic
|
||||
|
||||
monkeypatch.setattr(economic, "_fetch_aaa_html", lambda: "<html>maintenance</html>")
|
||||
|
||||
result = await economic.fetch_gas_prices(fetcher)
|
||||
assert result["source"] == "aaa"
|
||||
assert result["prices"] == {}
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_residential_natgas(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.economic import fetch_residential_natgas_prices
|
||||
|
||||
eia_response = {
|
||||
"response": {
|
||||
"data": [
|
||||
{"value": 15.42, "period": "2026-01"},
|
||||
{"value": 14.87, "period": "2025-12"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
respx.get(url__regex=r".*api\.eia\.gov.*natural-gas/pri/sum.*").mock(
|
||||
return_value=httpx.Response(200, json=eia_response)
|
||||
)
|
||||
|
||||
result = await fetch_residential_natgas_prices(fetcher, api_key="test-key")
|
||||
assert result["source"] == "eia"
|
||||
assert len(result["prices"]) == 2
|
||||
assert result["prices"][0]["price"] == 15.42
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_electricity_rates(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.economic import fetch_electricity_rates
|
||||
|
||||
eia_response = {
|
||||
"response": {
|
||||
"data": [
|
||||
{"sectorid": "RES", "price": 16.21, "period": "2026-01"},
|
||||
{"sectorid": "COM", "price": 13.45, "period": "2026-01"},
|
||||
{"sectorid": "IND", "price": 8.76, "period": "2026-01"},
|
||||
{"sectorid": "ALL", "price": 12.89, "period": "2026-01"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
respx.get(url__regex=r".*api\.eia\.gov.*electricity/retail-sales.*").mock(
|
||||
return_value=httpx.Response(200, json=eia_response)
|
||||
)
|
||||
|
||||
result = await fetch_electricity_rates(fetcher, api_key="test-key")
|
||||
assert result["source"] == "eia"
|
||||
assert result["state"] == "US"
|
||||
assert "residential" in result["rates"]
|
||||
assert result["rates"]["residential"]["price_cents_kwh"] == 16.21
|
||||
assert "commercial" in result["rates"]
|
||||
assert "industrial" in result["rates"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health (disease outbreaks)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -351,14 +468,16 @@ async def test_fetch_shipping_index(fetcher: Fetcher) -> None:
|
||||
|
||||
chart_response = {
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "BDRY",
|
||||
"regularMarketPrice": 15.50,
|
||||
"regularMarketChangePercent": 4.2,
|
||||
"currency": "USD",
|
||||
"result": [
|
||||
{
|
||||
"meta": {
|
||||
"symbol": "BDRY",
|
||||
"regularMarketPrice": 15.50,
|
||||
"regularMarketChangePercent": 4.2,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +623,7 @@ async def test_fetch_internet_outages_ioda_fallback(fetcher: Fetcher) -> None:
|
||||
)
|
||||
|
||||
import os
|
||||
|
||||
os.environ.pop("CLOUDFLARE_API_TOKEN", None)
|
||||
|
||||
result = await fetch_internet_outages(fetcher)
|
||||
@@ -603,16 +723,32 @@ async def test_fetch_hacker_news(fetcher: Fetcher) -> None:
|
||||
return_value=httpx.Response(200, json=[101, 102])
|
||||
)
|
||||
respx.get("https://hacker-news.firebaseio.com/v0/item/101.json").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"id": 101, "title": "Show HN: AI Tool", "url": "https://example.com",
|
||||
"score": 200, "by": "user1", "time": 1700000000, "descendants": 50,
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 101,
|
||||
"title": "Show HN: AI Tool",
|
||||
"url": "https://example.com",
|
||||
"score": 200,
|
||||
"by": "user1",
|
||||
"time": 1700000000,
|
||||
"descendants": 50,
|
||||
},
|
||||
)
|
||||
)
|
||||
respx.get("https://hacker-news.firebaseio.com/v0/item/102.json").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"id": 102, "title": "Rust 2.0", "url": "https://example.com/rust",
|
||||
"score": 150, "by": "user2", "time": 1700001000, "descendants": 30,
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 102,
|
||||
"title": "Rust 2.0",
|
||||
"url": "https://example.com/rust",
|
||||
"score": 150,
|
||||
"by": "user2",
|
||||
"time": 1700001000,
|
||||
"descendants": 30,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_hacker_news(fetcher, limit=2)
|
||||
@@ -632,19 +768,24 @@ async def test_fetch_trending_repos(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.github_trending import fetch_trending_repos
|
||||
|
||||
respx.get(url__regex=r".*api\.github\.com/search/repositories.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"total_count": 1,
|
||||
"items": [{
|
||||
"full_name": "user/cool-repo",
|
||||
"description": "A cool tool",
|
||||
"html_url": "https://github.com/user/cool-repo",
|
||||
"stargazers_count": 500,
|
||||
"forks_count": 20,
|
||||
"language": "Python",
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"topics": ["ai", "ml"],
|
||||
}],
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"total_count": 1,
|
||||
"items": [
|
||||
{
|
||||
"full_name": "user/cool-repo",
|
||||
"description": "A cool tool",
|
||||
"html_url": "https://github.com/user/cool-repo",
|
||||
"stargazers_count": 500,
|
||||
"forks_count": 20,
|
||||
"language": "Python",
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"topics": ["ai", "ml"],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_trending_repos(fetcher, limit=5)
|
||||
@@ -702,17 +843,22 @@ async def test_fetch_usa_spending(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.usa_spending import fetch_usa_spending
|
||||
|
||||
respx.get(url__regex=r".*api\.usaspending\.gov.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"results": [{
|
||||
"agency_name": "Department of Defense",
|
||||
"abbreviation": "DOD",
|
||||
"current_total_budget_authority_amount": 850000000000,
|
||||
"obligated_amount": 700000000000,
|
||||
"outlay_amount": 650000000000,
|
||||
"agency_id": 97,
|
||||
}],
|
||||
"page_metadata": {"total": 1},
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"results": [
|
||||
{
|
||||
"agency_name": "Department of Defense",
|
||||
"abbreviation": "DOD",
|
||||
"current_total_budget_authority_amount": 850000000000,
|
||||
"obligated_amount": 700000000000,
|
||||
"outlay_amount": 650000000000,
|
||||
"agency_id": 97,
|
||||
}
|
||||
],
|
||||
"page_metadata": {"total": 1},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_usa_spending(fetcher, limit=5)
|
||||
@@ -732,15 +878,27 @@ async def test_fetch_environmental_events(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.environmental import fetch_environmental_events
|
||||
|
||||
respx.get(url__regex=r".*eonet\.gsfc\.nasa\.gov.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"events": [{
|
||||
"id": "EONET_1234",
|
||||
"title": "Wildfire in California",
|
||||
"categories": [{"id": "wildfires", "title": "Wildfires"}],
|
||||
"sources": [{"id": "InciWeb", "url": "https://inciweb.example.com"}],
|
||||
"geometry": [{"date": "2026-02-20T00:00:00Z", "coordinates": [-119.5, 34.5]}],
|
||||
}],
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"events": [
|
||||
{
|
||||
"id": "EONET_1234",
|
||||
"title": "Wildfire in California",
|
||||
"categories": [{"id": "wildfires", "title": "Wildfires"}],
|
||||
"sources": [
|
||||
{"id": "InciWeb", "url": "https://inciweb.example.com"}
|
||||
],
|
||||
"geometry": [
|
||||
{
|
||||
"date": "2026-02-20T00:00:00Z",
|
||||
"coordinates": [-119.5, 34.5],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_environmental_events(fetcher, days=7)
|
||||
@@ -761,22 +919,24 @@ async def test_fetch_disaster_alerts(fetcher: Fetcher) -> None:
|
||||
|
||||
gdacs_geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"eventtype": "EQ",
|
||||
"eventname": "M6.5 Earthquake",
|
||||
"alertlevel": "orange",
|
||||
"alertscore": 2.5,
|
||||
"severity": {"value": 6.5, "unit": "M"},
|
||||
"country": "Turkey",
|
||||
"fromdate": "2026-02-20T12:00:00Z",
|
||||
"todate": "2026-02-20T12:05:00Z",
|
||||
"url": {"report": "https://gdacs.example.com/report"},
|
||||
"population": {"value": 500000},
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
|
||||
}],
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"eventtype": "EQ",
|
||||
"eventname": "M6.5 Earthquake",
|
||||
"alertlevel": "orange",
|
||||
"alertscore": 2.5,
|
||||
"severity": {"value": 6.5, "unit": "M"},
|
||||
"country": "Turkey",
|
||||
"fromdate": "2026-02-20T12:00:00Z",
|
||||
"todate": "2026-02-20T12:05:00Z",
|
||||
"url": {"report": "https://gdacs.example.com/report"},
|
||||
"population": {"value": 500000},
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [29.0, 38.5]},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
respx.get(url__regex=r".*gdacs\.org.*").mock(
|
||||
@@ -856,14 +1016,16 @@ async def test_fetch_country_stocks(fetcher: Fetcher) -> None:
|
||||
|
||||
chart_response = {
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "^GSPC",
|
||||
"regularMarketPrice": 5200.0,
|
||||
"regularMarketChangePercent": 0.75,
|
||||
"currency": "USD",
|
||||
"result": [
|
||||
{
|
||||
"meta": {
|
||||
"symbol": "^GSPC",
|
||||
"regularMarketPrice": 5200.0,
|
||||
"regularMarketChangePercent": 0.75,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,21 +1050,29 @@ async def test_fetch_aircraft_details_batch(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.military import fetch_aircraft_details_batch
|
||||
|
||||
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae1234.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"Registration": "12-3456",
|
||||
"Type": "C-17A",
|
||||
"Operator": "USAF",
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Registration": "12-3456",
|
||||
"Type": "C-17A",
|
||||
"Operator": "USAF",
|
||||
},
|
||||
)
|
||||
)
|
||||
respx.get(url__regex=r".*hexdb\.io/api/v1/aircraft/ae5678.*").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"Registration": "78-9012",
|
||||
"Type": "KC-135R",
|
||||
"Operator": "USAF",
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Registration": "78-9012",
|
||||
"Type": "KC-135R",
|
||||
"Operator": "USAF",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_aircraft_details_batch(fetcher, icao24_list=["ae1234", "ae5678"])
|
||||
result = await fetch_aircraft_details_batch(
|
||||
fetcher, icao24_list=["ae1234", "ae5678"]
|
||||
)
|
||||
assert result["source"] == "hexdb"
|
||||
assert result["count"] == 2
|
||||
assert result["requested"] == 2
|
||||
@@ -964,6 +1134,7 @@ async def test_fetch_central_bank_rates_no_fred(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
|
||||
|
||||
import os
|
||||
|
||||
os.environ.pop("FRED_API_KEY", None)
|
||||
|
||||
result = await fetch_central_bank_rates(fetcher)
|
||||
@@ -984,13 +1155,10 @@ async def test_fetch_central_bank_rates_with_fred(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.central_banks import fetch_central_bank_rates
|
||||
|
||||
import os
|
||||
|
||||
os.environ["FRED_API_KEY"] = "test_key_123"
|
||||
|
||||
fred_response = {
|
||||
"observations": [
|
||||
{"date": "2026-02-25", "value": "4.33"}
|
||||
]
|
||||
}
|
||||
fred_response = {"observations": [{"date": "2026-02-25", "value": "4.33"}]}
|
||||
|
||||
respx.get("https://api.stlouisfed.org/fred/series/observations").mock(
|
||||
return_value=httpx.Response(200, json=fred_response)
|
||||
@@ -1189,6 +1357,7 @@ async def test_fetch_financial_centers_filter_country() -> None:
|
||||
# Country Dossier (Phase 16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
|
||||
@@ -1197,40 +1366,60 @@ async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
|
||||
|
||||
# Mock World Bank GDP
|
||||
respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"page": 1},
|
||||
[{"date": "2024", "value": 28000000000000}],
|
||||
])
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"page": 1},
|
||||
[{"date": "2024", "value": 28000000000000}],
|
||||
],
|
||||
)
|
||||
)
|
||||
# Mock World Bank inflation
|
||||
respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"page": 1},
|
||||
[{"date": "2024", "value": 3.2}],
|
||||
])
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"page": 1},
|
||||
[{"date": "2024", "value": 3.2}],
|
||||
],
|
||||
)
|
||||
)
|
||||
# Mock ACLED (no key = skip)
|
||||
# Mock Yahoo Finance for country stocks
|
||||
respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"chart": {"result": [{"meta": {
|
||||
"regularMarketPrice": 5800,
|
||||
"chartPreviousClose": 5750,
|
||||
"currency": "USD",
|
||||
"exchangeName": "SNP",
|
||||
}}]},
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"chart": {
|
||||
"result": [
|
||||
{
|
||||
"meta": {
|
||||
"regularMarketPrice": 5800,
|
||||
"chartPreviousClose": 5750,
|
||||
"currency": "USD",
|
||||
"exchangeName": "SNP",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Mock OFAC sanctions
|
||||
respx.get("https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML").mock(
|
||||
return_value=httpx.Response(200, text="<sdnList></sdnList>")
|
||||
)
|
||||
respx.get(
|
||||
"https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML"
|
||||
).mock(return_value=httpx.Response(200, text="<sdnList></sdnList>"))
|
||||
# Mock news feeds — just one category needed
|
||||
respx.route().mock(return_value=httpx.Response(200, text="""<?xml version="1.0"?>
|
||||
respx.route().mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
text="""<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Test</title>
|
||||
<item><title>US Economy Grows</title><link>https://example.com/1</link></item>
|
||||
<item><title>China Trade</title><link>https://example.com/2</link></item>
|
||||
</channel></rss>"""))
|
||||
</channel></rss>""",
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_country_dossier(fetcher, country="US")
|
||||
|
||||
@@ -1260,6 +1449,7 @@ async def test_fetch_country_dossier_invalid_code(fetcher: Fetcher) -> None:
|
||||
# Traffic (Phase 16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_traffic_flow_no_key(fetcher: Fetcher) -> None:
|
||||
@@ -1279,12 +1469,17 @@ async def test_fetch_traffic_flow_with_key(fetcher: Fetcher) -> None:
|
||||
"""Traffic flow fetches congestion data from TomTom."""
|
||||
from world_intel_mcp.sources.traffic import fetch_traffic_flow
|
||||
|
||||
respx.route().mock(return_value=httpx.Response(200, json={
|
||||
"flowSegmentData": {
|
||||
"currentSpeed": 30.0,
|
||||
"freeFlowSpeed": 60.0,
|
||||
},
|
||||
}))
|
||||
respx.route().mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"flowSegmentData": {
|
||||
"currentSpeed": 30.0,
|
||||
"freeFlowSpeed": 60.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}):
|
||||
result = await fetch_traffic_flow(fetcher)
|
||||
@@ -1311,6 +1506,7 @@ async def test_fetch_traffic_incidents_no_key(fetcher: Fetcher) -> None:
|
||||
# Aviation Domestic (Phase 16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
|
||||
@@ -1318,14 +1514,70 @@ async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
|
||||
from world_intel_mcp.sources.aviation import fetch_domestic_flights
|
||||
|
||||
# Simulate 3 airborne aircraft at known positions
|
||||
respx.route().mock(return_value=httpx.Response(200, json={
|
||||
"states": [
|
||||
# [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...]
|
||||
["abc123", "UAL123 ", "United States", None, None, -73.9, 40.7, 10000, False, None, None, None, None, None, None, None],
|
||||
["def456", "BAW789 ", "United Kingdom", None, None, -0.1, 51.5, 11000, False, None, None, None, None, None, None, None],
|
||||
["ghi789", "CCA100 ", "China", None, None, 116.4, 39.9, 12000, False, None, None, None, None, None, None, None],
|
||||
],
|
||||
}))
|
||||
respx.route().mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"states": [
|
||||
# [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...]
|
||||
[
|
||||
"abc123",
|
||||
"UAL123 ",
|
||||
"United States",
|
||||
None,
|
||||
None,
|
||||
-73.9,
|
||||
40.7,
|
||||
10000,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
],
|
||||
[
|
||||
"def456",
|
||||
"BAW789 ",
|
||||
"United Kingdom",
|
||||
None,
|
||||
None,
|
||||
-0.1,
|
||||
51.5,
|
||||
11000,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
],
|
||||
[
|
||||
"ghi789",
|
||||
"CCA100 ",
|
||||
"China",
|
||||
None,
|
||||
None,
|
||||
116.4,
|
||||
39.9,
|
||||
12000,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
],
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await fetch_domestic_flights(fetcher)
|
||||
|
||||
@@ -1339,6 +1591,7 @@ async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
|
||||
# Webcams (Phase 16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None:
|
||||
"""Webcams returns error when WINDY_API_KEY is not set."""
|
||||
@@ -1357,18 +1610,33 @@ async def test_fetch_webcams_with_key(fetcher: Fetcher) -> None:
|
||||
"""Webcams fetches camera data from Windy API."""
|
||||
from world_intel_mcp.sources.webcams import fetch_webcams
|
||||
|
||||
respx.route().mock(return_value=httpx.Response(200, json={
|
||||
"webcams": [
|
||||
{
|
||||
"webcamId": "cam-1",
|
||||
"title": "Times Square",
|
||||
"location": {"latitude": 40.758, "longitude": -73.985, "city": "New York", "country": "US"},
|
||||
"images": {"current": {"preview": "https://example.com/prev.jpg", "thumbnail": "https://example.com/thumb.jpg"}},
|
||||
"player": {"day": {"embed": "https://example.com/player"}},
|
||||
"status": "active",
|
||||
respx.route().mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"webcams": [
|
||||
{
|
||||
"webcamId": "cam-1",
|
||||
"title": "Times Square",
|
||||
"location": {
|
||||
"latitude": 40.758,
|
||||
"longitude": -73.985,
|
||||
"city": "New York",
|
||||
"country": "US",
|
||||
},
|
||||
"images": {
|
||||
"current": {
|
||||
"preview": "https://example.com/prev.jpg",
|
||||
"thumbnail": "https://example.com/thumb.jpg",
|
||||
}
|
||||
},
|
||||
"player": {"day": {"embed": "https://example.com/player"}},
|
||||
"status": "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {"WINDY_API_KEY": "test-key"}):
|
||||
result = await fetch_webcams(fetcher, category="traffic", limit=10)
|
||||
|
||||
Reference in New Issue
Block a user