11import logging
2- import re
32from datetime import datetime , timedelta , timezone
43
54import httpx
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
0 commit comments