feat: calendar eco

This commit is contained in:
OpenSquared
2026-07-21 14:49:59 +02:00
parent 59fbb9b023
commit 5f23141992
5 changed files with 67 additions and 13 deletions

View File

@@ -3,10 +3,12 @@ Unified calendar sync.
1. FF live JSON (nfs.faireconomy.media) → this week + next week (actual values, precise)
2. FF HTML scraper → weeks 3..N ahead (upcoming forecasts)
3. Fallback for weeks 3..N, only if step 2 got blocked (ForexFactory now 403s the
scraper — likely Cloudflare): Trading Economics first (services.te_calendar, free
tier, no card required), then FMP (services.fmp_calendar, free tier doesn't include
the economic_calendar endpoint — needs their paid Starter plan) — whichever has a
configured key and actually returns events.
scraper — likely Cloudflare): Trading Economics first (services.te_calendar), then
FMP (services.fmp_calendar) — whichever has a configured key and actually returns
events. NOTE: neither is actually free for this endpoint — TE's calendar needs
their $149/mo base plan, FMP's free tier excludes economic_calendar (needs their
$14.99/mo Starter). Kept wired for whichever the user ends up paying for; look for
a cheaper source before recommending either.
Called by the background loop in main.py and manually via POST /api/eco/calendar-sync.
"""
import logging

View File

@@ -435,18 +435,52 @@ def _parse_impact(impact_td) -> str:
return "low"
def _fetch_via_flaresolverr(url: str) -> Optional[str]:
"""
Fetch a page through FlareSolverr (a headless-browser proxy that solves Cloudflare's
JS challenge) instead of a plain HTTP client. FF started 403'ing our direct requests
— the response is Cloudflare's "Just a moment..." interstitial, which no amount of
header/cookie spoofing can pass (confirmed: replaying a real logged-in session's
cf_clearance cookie from a different IP still 403s — it's IP-bound). FlareSolverr runs
as a sibling container (see deploy/docker-compose.yml) and is reached over the internal
docker network; not configured (e.g. local dev without that container) → returns None
so the caller falls back to the direct httpx path unchanged.
"""
import os
base = os.environ.get("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
try:
resp = httpx.post(
base,
json={"cmd": "request.get", "url": url, "maxTimeout": 60000},
timeout=70,
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "ok":
print(f"[FF scrape] FlareSolverr error for {url}: {data.get('message')}", flush=True)
return None
return data.get("solution", {}).get("response")
except Exception as e:
print(f"[FF scrape] FlareSolverr unavailable ({e}) — falling back to direct fetch", flush=True)
return None
def _scrape_week(client: httpx.Client, monday: date) -> list[tuple]:
"""Fetch and parse one week of FF calendar. Returns list of row tuples."""
url = _week_url(monday)
print(f"[FF scrape] GET {url}", flush=True)
try:
resp = client.get(url, timeout=20)
resp.raise_for_status()
except Exception as e:
print(f"[FF scrape] fetch error {url}: {e}", flush=True)
return []
soup = BeautifulSoup(resp.text, "lxml")
html = _fetch_via_flaresolverr(url)
if html is None:
try:
resp = client.get(url, timeout=20)
resp.raise_for_status()
html = resp.text
except Exception as e:
print(f"[FF scrape] fetch error {url}: {e}", flush=True)
return []
soup = BeautifulSoup(html, "lxml")
table = soup.find("table", class_="calendar__table")
if not table:
# Try alternate selector (FF occasionally restructures)

View File

@@ -1,6 +1,10 @@
"""
Trading Economics calendar service — upcoming events with consensus forecasts.
Free API: https://tradingeconomics.com/api/login (500 calls/month)
NOTE: confirmed (2026-07) that the calendar endpoint is NOT actually free despite the
signup page — their base paid plan is $149/month. Kept wired in case a key is ever
available, but don't recommend this as a free option (same dead end as FMP, whose
free tier also excludes the economic_calendar endpoint — needs their $14.99/mo Starter).
Signup: https://tradingeconomics.com/api/login
Endpoint: GET https://api.tradingeconomics.com/calendar/country/united%20states?c=KEY
"""
import logging