Claude commited on
Commit
3f2df04
·
1 Parent(s): d75e874

Fix FPPDAILY fetcher: add User-Agent, monthly archive support, and logging

Browse files

- Add browser User-Agent header to avoid NEMWEB blocking Python requests
- Handle monthly archive ZIPs (PUBLIC_FPPDAILY_YYYYMM_xxx.zip) in addition
to daily ZIPs — when archive match is monthly, find daily CSV inside the ZIP
- Add INFO logging throughout fetch pipeline to diagnose future failures

https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx

Files changed (2) hide show
  1. app/main.py +3 -0
  2. app/services/aemo_fetcher.py +74 -26
app/main.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from contextlib import asynccontextmanager
2
  from pathlib import Path
3
 
@@ -9,6 +10,8 @@ from fastapi.staticfiles import StaticFiles
9
  from app.routers.api import router as api_router
10
  from app.services.analytics import init_db
11
 
 
 
12
 
13
  @asynccontextmanager
14
  async def lifespan(app: FastAPI):
 
1
+ import logging
2
  from contextlib import asynccontextmanager
3
  from pathlib import Path
4
 
 
10
  from app.routers.api import router as api_router
11
  from app.services.analytics import init_db
12
 
13
+ logging.basicConfig(level=logging.INFO)
14
+
15
 
16
  @asynccontextmanager
17
  async def lifespan(app: FastAPI):
app/services/aemo_fetcher.py CHANGED
@@ -2,6 +2,7 @@
2
  Downloads and extracts FPPDAILY ZIP files from AEMO NEMWEB.
3
  """
4
  import io
 
5
  import re
6
  import zipfile
7
  from datetime import date
@@ -16,6 +17,18 @@ from app.config import (
16
  DATA_START_DATE,
17
  )
18
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class AEMOFetchError(Exception):
21
  """Raised when data cannot be retrieved from AEMO."""
@@ -28,12 +41,14 @@ def _is_current(target_date: date) -> bool:
28
 
29
 
30
  async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
31
- """Fetch the HTML directory listing and extract filenames."""
 
32
  try:
33
  resp = await client.get(
34
  base_url,
35
  timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
36
  follow_redirects=True,
 
37
  )
38
  resp.raise_for_status()
39
  except httpx.TimeoutException:
@@ -41,54 +56,81 @@ async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]
41
  except httpx.HTTPStatusError as e:
42
  raise AEMOFetchError(f"AEMO server returned error {e.response.status_code}.")
43
 
44
- # Extract href links — handle single/double quotes and full URLs
45
  filenames = []
46
  for href in re.findall(r'href=["\']([^"\']+)["\']', resp.text, re.IGNORECASE):
47
  name = href.rstrip("/").split("/")[-1]
48
  if name.lower().endswith(".zip"):
49
  filenames.append(name)
 
 
 
 
50
  return filenames
51
 
52
 
 
 
 
 
 
53
  async def _find_file(
54
  target_date: date, date_str: str, base_url: str, client: httpx.AsyncClient
55
  ) -> tuple[list[str], str]:
56
  """
57
- Try multiple locations to find a ZIP file for date_str.
 
 
 
 
 
 
58
  Returns (matching_filenames, base_url_where_found).
59
  """
60
- urls_to_try: list[str] = []
61
 
 
62
  if base_url == AEMO_CURRENT_URL:
63
- urls_to_try.append(AEMO_CURRENT_URL)
64
- # Also try archive flat and archive monthly subdirectory as fallbacks
65
- urls_to_try.append(AEMO_ARCHIVE_URL)
66
- urls_to_try.append(AEMO_ARCHIVE_URL + target_date.strftime("%Y%m") + "/")
 
67
  else:
68
- # Archive: try flat listing first, then monthly subdirectory
69
- urls_to_try.append(AEMO_ARCHIVE_URL)
70
- urls_to_try.append(AEMO_ARCHIVE_URL + target_date.strftime("%Y%m") + "/")
 
71
 
72
  for url in urls_to_try:
73
  try:
74
  filenames = await _list_directory(url, client)
75
- except AEMOFetchError:
 
76
  continue
 
 
77
  matching = [f for f in filenames if date_str in f]
78
  if matching:
 
79
  return matching, url
80
 
81
- return [], base_url
82
-
 
 
 
 
 
83
 
84
- def _date_str(target_date: date) -> str:
85
- """Return date as YYYYMMDD string."""
86
- return target_date.strftime("%Y%m%d")
 
87
 
88
 
89
  async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
90
  """
91
- Download FPPDAILY ZIP for target_date from AEMO and return raw CSV bytes.
92
  Raises AEMOFetchError if unavailable.
93
  """
94
  if target_date < DATA_START_DATE:
@@ -103,22 +145,23 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
103
  date_str = _date_str(target_date)
104
 
105
  async with httpx.AsyncClient() as client:
106
- matching, base_url = await _find_file(target_date, date_str, base_url, client)
107
  if not matching:
108
  raise AEMOFetchError(
109
  f"No FPPDAILY data found for {target_date.strftime('%d %B %Y')}. "
110
  f"The file may not yet be published or the date may not have data."
111
  )
112
 
113
- # Use the first matching file
114
  filename = matching[0]
115
- zip_url = base_url + filename
116
 
 
117
  try:
118
  resp = await client.get(
119
  zip_url,
120
  timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
121
  follow_redirects=True,
 
122
  )
123
  resp.raise_for_status()
124
  except httpx.TimeoutException:
@@ -131,15 +174,20 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
131
  f"Could not download data file (HTTP {e.response.status_code})."
132
  )
133
 
134
- # Extract CSV from ZIP (may contain multiple files; pick the relevant one)
 
135
  try:
136
  zip_bytes = io.BytesIO(resp.content)
137
  with zipfile.ZipFile(zip_bytes) as zf:
138
- csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
139
- if not csv_names:
140
  raise AEMOFetchError("ZIP file contained no CSV files.")
141
- # Pick the first (usually only one) CSV
142
- csv_bytes = zf.read(csv_names[0])
 
 
 
 
143
  except zipfile.BadZipFile:
144
  raise AEMOFetchError("Downloaded file appears to be corrupt. Please try again.")
145
 
 
2
  Downloads and extracts FPPDAILY ZIP files from AEMO NEMWEB.
3
  """
4
  import io
5
+ import logging
6
  import re
7
  import zipfile
8
  from datetime import date
 
17
  DATA_START_DATE,
18
  )
19
 
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Mimic a browser to avoid being blocked by NEMWEB
23
+ _HEADERS = {
24
+ "User-Agent": (
25
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
26
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
27
+ "Chrome/120.0.0.0 Safari/537.36"
28
+ ),
29
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
30
+ }
31
+
32
 
33
  class AEMOFetchError(Exception):
34
  """Raised when data cannot be retrieved from AEMO."""
 
41
 
42
 
43
  async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
44
+ """Fetch the HTML directory listing and extract all ZIP filenames."""
45
+ logger.info("Listing directory: %s", base_url)
46
  try:
47
  resp = await client.get(
48
  base_url,
49
  timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
50
  follow_redirects=True,
51
+ headers=_HEADERS,
52
  )
53
  resp.raise_for_status()
54
  except httpx.TimeoutException:
 
56
  except httpx.HTTPStatusError as e:
57
  raise AEMOFetchError(f"AEMO server returned error {e.response.status_code}.")
58
 
 
59
  filenames = []
60
  for href in re.findall(r'href=["\']([^"\']+)["\']', resp.text, re.IGNORECASE):
61
  name = href.rstrip("/").split("/")[-1]
62
  if name.lower().endswith(".zip"):
63
  filenames.append(name)
64
+
65
+ logger.info("Found %d ZIP files at %s", len(filenames), base_url)
66
+ if filenames:
67
+ logger.info("Sample filenames: %s", filenames[:5])
68
  return filenames
69
 
70
 
71
+ def _date_str(target_date: date) -> str:
72
+ """Return date as YYYYMMDD string."""
73
+ return target_date.strftime("%Y%m%d")
74
+
75
+
76
  async def _find_file(
77
  target_date: date, date_str: str, base_url: str, client: httpx.AsyncClient
78
  ) -> tuple[list[str], str]:
79
  """
80
+ Try multiple locations to find a ZIP for the target date.
81
+
82
+ Handles two archive structures:
83
+ 1. Daily ZIPs: PUBLIC_FPPDAILY_D_YYYYMMDD_xxx.zip → match by YYYYMMDD
84
+ 2. Monthly ZIPs: PUBLIC_FPPDAILY_YYYYMM_xxx.zip → match by YYYYMM,
85
+ then search inside the ZIP for the correct daily CSV
86
+
87
  Returns (matching_filenames, base_url_where_found).
88
  """
89
+ month_str = target_date.strftime("%Y%m")
90
 
91
+ urls_to_try: list[str] = []
92
  if base_url == AEMO_CURRENT_URL:
93
+ urls_to_try = [
94
+ AEMO_CURRENT_URL,
95
+ AEMO_ARCHIVE_URL,
96
+ AEMO_ARCHIVE_URL + month_str + "/",
97
+ ]
98
  else:
99
+ urls_to_try = [
100
+ AEMO_ARCHIVE_URL,
101
+ AEMO_ARCHIVE_URL + month_str + "/",
102
+ ]
103
 
104
  for url in urls_to_try:
105
  try:
106
  filenames = await _list_directory(url, client)
107
+ except AEMOFetchError as e:
108
+ logger.warning("Could not list %s: %s", url, e)
109
  continue
110
+
111
+ # Try exact daily match first
112
  matching = [f for f in filenames if date_str in f]
113
  if matching:
114
+ logger.info("Found daily match at %s: %s", url, matching[0])
115
  return matching, url
116
 
117
+ # Try monthly match (archive stores one ZIP per month containing all daily CSVs)
118
+ monthly = [f for f in filenames if month_str in f and date_str not in f]
119
+ if monthly:
120
+ logger.info(
121
+ "No daily match; found monthly archive at %s: %s", url, monthly[0]
122
+ )
123
+ return monthly, url
124
 
125
+ logger.error(
126
+ "No file found for date %s in any of: %s", date_str, urls_to_try
127
+ )
128
+ return [], base_url
129
 
130
 
131
  async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
132
  """
133
+ Download FPPDAILY data for target_date and return raw CSV bytes.
134
  Raises AEMOFetchError if unavailable.
135
  """
136
  if target_date < DATA_START_DATE:
 
145
  date_str = _date_str(target_date)
146
 
147
  async with httpx.AsyncClient() as client:
148
+ matching, found_url = await _find_file(target_date, date_str, base_url, client)
149
  if not matching:
150
  raise AEMOFetchError(
151
  f"No FPPDAILY data found for {target_date.strftime('%d %B %Y')}. "
152
  f"The file may not yet be published or the date may not have data."
153
  )
154
 
 
155
  filename = matching[0]
156
+ zip_url = found_url + filename
157
 
158
+ logger.info("Downloading: %s", zip_url)
159
  try:
160
  resp = await client.get(
161
  zip_url,
162
  timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
163
  follow_redirects=True,
164
+ headers=_HEADERS,
165
  )
166
  resp.raise_for_status()
167
  except httpx.TimeoutException:
 
174
  f"Could not download data file (HTTP {e.response.status_code})."
175
  )
176
 
177
+ # Extract CSV from ZIP.
178
+ # If this is a monthly archive ZIP, find the CSV matching the specific date.
179
  try:
180
  zip_bytes = io.BytesIO(resp.content)
181
  with zipfile.ZipFile(zip_bytes) as zf:
182
+ all_csvs = [n for n in zf.namelist() if n.lower().endswith(".csv")]
183
+ if not all_csvs:
184
  raise AEMOFetchError("ZIP file contained no CSV files.")
185
+
186
+ # Prefer the CSV that matches the target date; fall back to first
187
+ daily_csvs = [n for n in all_csvs if date_str in n]
188
+ csv_name = daily_csvs[0] if daily_csvs else all_csvs[0]
189
+ logger.info("Extracting CSV: %s", csv_name)
190
+ csv_bytes = zf.read(csv_name)
191
  except zipfile.BadZipFile:
192
  raise AEMOFetchError("Downloaded file appears to be corrupt. Please try again.")
193