Files
planet/backend/app/services/collectors/spacetrack.py
linkong 19d5ac0fee
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.72.0
2026-06-29 14:05:06 +08:00

242 lines
9.8 KiB
Python

"""Space-Track TLE Collector.
Collects satellite TLE (Two-Line Element) data from Space-Track.org.
API documentation: https://www.space-track.org/documentation
"""
from typing import Any, Dict, List
from urllib.parse import urlparse
import httpx
from app.core.data_sources import get_data_sources_config
from app.core.logging import get_logger
from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.collectors.base import BaseCollector
logger = get_logger(__name__, service="collector")
class SpaceTrackTLECollector(BaseCollector):
name = "spacetrack_tle"
priority = "P2"
module = "L3"
frequency_hours = 24
data_type = "satellite_tle"
@property
def query_url(self) -> str:
config = get_data_sources_config()
if self._resolved_url:
return self._resolved_url
return config.get_yaml_url("spacetrack_tle")
@property
def site_root(self) -> str:
config = get_data_sources_config()
configured_root = config.get_yaml_value("spacetrack.base_url")
if isinstance(configured_root, str) and configured_root:
return configured_root.rstrip("/")
parsed = urlparse(self.query_url)
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
@property
def login_url(self) -> str:
return f"{self.site_root}/ajaxauth/login"
@property
def probe_url(self) -> str:
return f"{self.site_root}/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
async def fetch(self) -> List[Dict[str, Any]]:
from app.core.config import settings
username = settings.SPACETRACK_USERNAME
password = settings.SPACETRACK_PASSWORD
if not username or not password:
logger.warning_event(
"Space-Track credentials are not configured; using sample data",
event="collector.spacetrack.credentials_missing",
)
return self._get_sample_data()
logger.info_event(
"Space-Track TLE fetch started",
event="collector.spacetrack.fetch.start",
)
try:
async with httpx.AsyncClient(
timeout=120.0,
follow_redirects=True,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/html, */*",
"Accept-Language": "en-US,en;q=0.9",
"Referer": f"{self.site_root}/",
},
) as client:
await client.get(f"{self.site_root}/")
login_response = await client.post(
self.login_url,
data={
"identity": username,
"password": password,
},
)
logger.info_event(
"Space-Track login response received",
event="collector.spacetrack.login.response",
context={"status_code": login_response.status_code},
)
if login_response.status_code == 403:
logger.warning_event(
"Space-Track login returned forbidden; trying alternate method",
event="collector.spacetrack.login.forbidden",
)
async with httpx.AsyncClient(
timeout=120.0,
follow_redirects=True,
) as alt_client:
await alt_client.get(f"{self.site_root}/")
alt_login = await alt_client.post(
self.login_url,
data={
"identity": username,
"password": password,
},
)
logger.info_event(
"Space-Track alternate login response received",
event="collector.spacetrack.alt_login.response",
context={"status_code": alt_login.status_code},
)
if alt_login.status_code == 200:
tle_response = await alt_client.get(self.probe_url)
if tle_response.status_code == 200:
data = tle_response.json()
logger.info_event(
"Space-Track alternate query completed",
event="collector.spacetrack.alt_query.completed",
context={"record_count": len(data)},
)
return data
if login_response.status_code != 200:
logger.warning_event(
"Space-Track login failed; using sample data",
event="collector.spacetrack.login.failed",
context={"status_code": login_response.status_code},
)
return self._get_sample_data()
tle_response = await client.get(self.probe_url)
logger.info_event(
"Space-Track TLE query response received",
event="collector.spacetrack.query.response",
context={"status_code": tle_response.status_code},
)
if tle_response.status_code != 200:
logger.warning_event(
"Space-Track TLE query failed; using sample data",
event="collector.spacetrack.query.failed",
context={"status_code": tle_response.status_code},
)
return self._get_sample_data()
data = tle_response.json()
logger.info_event(
"Space-Track TLE fetch completed",
event="collector.spacetrack.fetch.completed",
context={"record_count": len(data)},
)
return data
except Exception as e:
logger.warning_event(
"Space-Track TLE fetch failed; using sample data",
event="collector.spacetrack.fetch.failed",
context={"error": str(e)},
)
return self._get_sample_data()
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Transform TLE data to internal format"""
transformed = []
for item in raw_data:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=item.get("NORAD_CAT_ID"),
epoch=item.get("EPOCH"),
inclination=item.get("INCLINATION"),
raan=item.get("RAAN"),
eccentricity=item.get("ECCENTRICITY"),
arg_of_perigee=item.get("ARG_OF_PERIGEE"),
mean_anomaly=item.get("MEAN_ANOMALY"),
mean_motion=item.get("MEAN_MOTION"),
)
transformed.append(
{
"name": item.get("OBJECT_NAME", "Unknown"),
"reference_date": item.get("EPOCH", ""),
"metadata": {
"norad_cat_id": item.get("NORAD_CAT_ID"),
"international_designator": item.get("INTL_DESIGNATOR"),
"epoch": item.get("EPOCH"),
"mean_motion": item.get("MEAN_MOTION"),
"eccentricity": item.get("ECCENTRICITY"),
"inclination": item.get("INCLINATION"),
"raan": item.get("RAAN"),
"arg_of_perigee": item.get("ARG_OF_PERIGEE"),
"mean_anomaly": item.get("MEAN_ANOMALY"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
"classification_type": item.get("CLASSIFICATION_TYPE"),
"element_set_no": item.get("ELEMENT_SET_NO"),
"rev_at_epoch": item.get("REV_AT_EPOCH"),
"bstar": item.get("BSTAR"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
# Prefer original lines from the source, but keep a backend-built pair as a stable fallback.
"tle_line1": item.get("TLE_LINE1") or item.get("TLE1") or tle_line1,
"tle_line2": item.get("TLE_LINE2") or item.get("TLE2") or tle_line2,
},
}
)
return transformed
def _get_sample_data(self) -> List[Dict[str, Any]]:
"""Return sample TLE data for testing"""
return [
{
"name": "ISS (ZARYA)",
"norad_cat_id": 25544,
"international_designator": "1998-067A",
"epoch": "2026-03-13T00:00:00Z",
"mean_motion": 15.49872723,
"eccentricity": 0.0006292,
"inclination": 51.6400,
"raan": 315.0000,
"arg_of_perigee": 100.0000,
"mean_anomaly": 260.0000,
},
{
"name": "STARLINK-1000",
"norad_cat_id": 44720,
"international_designator": "2019-029AZ",
"epoch": "2026-03-13T00:00:00Z",
"mean_motion": 15.79234567,
"eccentricity": 0.0001234,
"inclination": 53.0000,
"raan": 120.0000,
"arg_of_perigee": 90.0000,
"mean_anomaly": 270.0000,
},
]