Skip to content

Commit cec8c88

Browse files
committed
fix: switch AVL data source to portal proxy (no token needed)
1 parent 15971d5 commit cec8c88

4 files changed

Lines changed: 21 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
<!-- changelog-id: 10 -->
1+
<!-- changelog-id: 11 -->
22
# Changelog
33

4-
## 2026-02-25 - St. John's Data Restored
4+
## 2026-02-25 — St. John's Data Restored (Again)
5+
The city rebuilt their plow tracking page, which broke our data feed for the
6+
second time today. We lost about two hours of data, but we're back again. The
7+
new page no longer exposes an API token at all, so we've switched to using the
8+
city's portal proxy instead.
9+
10+
[View changes](https://github.com/jackharrhy/where-the-plow/compare/5dc3f4b...HEAD)
11+
12+
## 2026-02-25 — St. John's Data Restored
513
The city added authentication to their plow tracking service, which broke our
614
data feed. We've worked around it by scraping the token from the city's own
715
public-facing map page and using that to authenticate. A great use of tax

src/where_the_plow/client.py

Lines changed: 3 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import re
32
from datetime import datetime, timedelta, timezone
43

54
import httpx
@@ -12,58 +11,6 @@
1211
# To get the real UTC time we must add the 3:30 offset back.
1312
_NST_CORRECTION = timedelta(hours=3, minutes=30)
1413

15-
# The AVL page embeds an ArcGIS API key via esriId.registerToken().
16-
# We scrape it and cache it; the page is public so this is stable.
17-
_AVL_TOKEN_PAGE = "https://map.stjohns.ca/avl/"
18-
_AVL_TOKEN_RE = re.compile(r'token:\s*"(AAPT[^"]+)"')
19-
_AVL_TOKEN_TTL = timedelta(hours=1)
20-
21-
22-
class AvlTokenManager:
23-
"""Scrapes and caches the ArcGIS API token from the public AVL page."""
24-
25-
def __init__(self):
26-
self._token: str | None = None
27-
self._fetched_at: datetime | None = None
28-
29-
@property
30-
def _expired(self) -> bool:
31-
if self._token is None or self._fetched_at is None:
32-
return True
33-
return datetime.now(timezone.utc) - self._fetched_at > _AVL_TOKEN_TTL
34-
35-
async def get_token(self, client: httpx.AsyncClient) -> str:
36-
"""Return a cached token, refreshing from the AVL page if stale."""
37-
if not self._expired:
38-
return self._token # type: ignore[return-value]
39-
return await self._refresh(client)
40-
41-
async def invalidate_and_refresh(self, client: httpx.AsyncClient) -> str:
42-
"""Force a fresh token fetch (e.g. after a 499 Token Required)."""
43-
self._token = None
44-
self._fetched_at = None
45-
return await self._refresh(client)
46-
47-
async def _refresh(self, client: httpx.AsyncClient) -> str:
48-
logger.info("Fetching AVL token from %s", _AVL_TOKEN_PAGE)
49-
resp = await client.get(_AVL_TOKEN_PAGE, follow_redirects=True, timeout=15)
50-
resp.raise_for_status()
51-
match = _AVL_TOKEN_RE.search(resp.text)
52-
if not match:
53-
raise RuntimeError(
54-
"Could not extract ArcGIS token from AVL page — "
55-
"the page format may have changed"
56-
)
57-
token = match.group(1)
58-
self._token = token
59-
self._fetched_at = datetime.now(timezone.utc)
60-
logger.info("AVL token refreshed (%d chars)", len(token))
61-
return token
62-
63-
64-
# Module-level singleton so all AVL poll loops share one cached token.
65-
avl_token_manager = AvlTokenManager()
66-
6714

6815
# ── AVL (St. John's) response models ────────────────────────────────
6916

@@ -232,14 +179,15 @@ async def fetch_source(client: httpx.AsyncClient, source) -> dict | list:
232179
params = {}
233180

234181
if source.parser == "avl":
235-
token = await avl_token_manager.get_token(client)
182+
# The AVL data is served through an ArcGIS Enterprise portal proxy
183+
# that handles authentication server-side. No API token is needed;
184+
# only the Referer header must match the public AVL page.
236185
params = {
237186
"f": "json",
238187
"outFields": "*",
239188
"outSR": "4326",
240189
"returnGeometry": "true",
241190
"where": "1=1",
242-
"token": token,
243191
}
244192
if source.referer:
245193
headers["Referer"] = source.referer
@@ -248,17 +196,4 @@ async def fetch_source(client: httpx.AsyncClient, source) -> dict | list:
248196
resp.raise_for_status()
249197
data = resp.json()
250198

251-
# ArcGIS returns 200 with an error body when the token is bad/expired.
252-
if isinstance(data, dict) and "error" in data:
253-
code = data["error"].get("code")
254-
if code in (498, 499):
255-
logger.warning("AVL token rejected (code %s), refreshing", code)
256-
token = await avl_token_manager.invalidate_and_refresh(client)
257-
params["token"] = token
258-
resp = await client.get(
259-
source.api_url, params=params, headers=headers, timeout=10
260-
)
261-
resp.raise_for_status()
262-
data = resp.json()
263-
264199
return data

src/where_the_plow/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class Settings(BaseSettings):
1515

1616
# Source API URLs
1717
avl_api_url: str = (
18-
"https://map.stjohns.ca/mapsrv/rest/services/AVL/MapServer/0/query"
18+
"https://map.stjohns.ca/portal/sharing/servers/"
19+
"e99efa79b60948dda2939a7d08204a61/rest/services/AVL/MapServer/0/query"
1920
)
2021
mt_pearl_api_url: str = "https://gps5.aatracking.com/api/MtPearlPortal/GetPlows"
2122
provincial_api_url: str = (

tests/test_collector.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -337,11 +337,11 @@ async def test_fetch_source_raises_on_http_error():
337337
await fetch_source(client, config)
338338

339339

340-
async def test_fetch_source_avl_sends_referer_and_token():
341-
"""AVL sources should send the Referer header and token param."""
340+
async def test_fetch_source_avl_sends_referer():
341+
"""AVL sources should send the Referer header (no token needed — portal proxy)."""
342342
config = _test_source_config(
343343
parser="avl",
344-
api_url="https://map.stjohns.ca/arcgis/rest/services/test",
344+
api_url="https://map.stjohns.ca/portal/sharing/servers/abc/rest/services/AVL/MapServer/0/query",
345345
referer="https://map.stjohns.ca/avl/",
346346
)
347347

@@ -353,16 +353,11 @@ async def test_fetch_source_avl_sends_referer_and_token():
353353
client = AsyncMock(spec=httpx.AsyncClient)
354354
client.get = AsyncMock(return_value=mock_response)
355355

356-
with patch(
357-
"where_the_plow.client.avl_token_manager.get_token",
358-
new_callable=AsyncMock,
359-
return_value="fake-token-123",
360-
):
361-
result = await fetch_source(client, config)
356+
result = await fetch_source(client, config)
362357

363358
# Verify Referer was sent
364359
call_kwargs = client.get.call_args
365360
assert call_kwargs.kwargs["headers"]["Referer"] == "https://map.stjohns.ca/avl/"
366-
# Verify token was included in params
367-
assert call_kwargs.kwargs["params"]["token"] == "fake-token-123"
361+
# No token should be present in params
362+
assert "token" not in call_kwargs.kwargs["params"]
368363
assert result == {"features": []}

0 commit comments

Comments
 (0)