88import logging
99import os
1010import re
11+ from collections .abc import Sequence
1112from typing import Iterator
1213
1314import requests
15+ from requests .adapters import HTTPAdapter
16+ from urllib3 .util .retry import Retry
1417
1518from abstractions import AdoptablePet , PetSource
16- from config import CITY_NAME , CITY_STATE , POSTAL_CODE
19+ from config import CITY_NAME , CITY_STATE , PET_SPECIES , POSTAL_CODE , RESCUEGROUPS_LIMIT
1720
1821logger = logging .getLogger (__name__ )
1922
2326
2427SPECIES_SINGULAR = {"dogs" : "dog" , "cats" : "cat" }
2528
29+ # The RescueGroups API occasionally times out or returns a transient 5xx. A
30+ # single hiccup shouldn't fail the whole run, so retry a few times with
31+ # exponential backoff (0s, 2s, 4s, 8s between attempts).
32+ RETRY_TOTAL = 4
33+ RETRY_BACKOFF_FACTOR = 1
34+
35+
36+ def _session_with_retries () -> requests .Session :
37+ """Build a requests Session that retries transient errors with backoff."""
38+ retry = Retry (
39+ total = RETRY_TOTAL ,
40+ backoff_factor = RETRY_BACKOFF_FACTOR ,
41+ status_forcelist = (429 , 500 , 502 , 503 , 504 ),
42+ # We only POST, so POST must be opted in (it isn't retried by default).
43+ allowed_methods = frozenset ({"POST" }),
44+ raise_on_status = False ,
45+ )
46+ session = requests .Session ()
47+ session .mount ("https://" , HTTPAdapter (max_retries = retry ))
48+ return session
49+
50+
51+ def _build_species_filters (species : Sequence [str ]) -> tuple [list [dict ], str ]:
52+ """Build RescueGroups filters and filterProcessing for an OR species search."""
53+ filters = [
54+ {"fieldName" : "species.plural" , "operation" : "equal" , "criteria" : plural }
55+ for plural in species
56+ ]
57+ if not filters :
58+ raise ValueError ("At least one species is required" )
59+ filter_processing = " OR " .join (str (index ) for index in range (1 , len (filters ) + 1 ))
60+ return filters , filter_processing
61+
2662
2763class SourceRescueGroups (PetSource ):
2864 """
@@ -38,20 +74,20 @@ def __init__(
3874 api_key : str | None = None ,
3975 postal_code : str = POSTAL_CODE ,
4076 radius_miles : int = 50 ,
41- species : str = "dogs" , # "dogs" or "cats"
42- limit : int = 25 ,
77+ species : Sequence [ str ] | None = None ,
78+ limit : int = RESCUEGROUPS_LIMIT ,
4379 location_label : str = f"{ CITY_NAME } , { CITY_STATE } " ,
4480 ):
4581 self ._api_key = api_key or os .environ .get ("CUTEPETSBOSTON_RESCUEGROUPS_API_KEY" )
4682 self .postal_code = postal_code
4783 self .radius_miles = radius_miles
48- self .species = species
84+ self .species = tuple ( species if species is not None else PET_SPECIES )
4985 self .limit = limit
5086 self .location_label = location_label
5187
5288 @property
5389 def source_name (self ) -> str :
54- return f"RescueGroups ({ self .species } )"
90+ return f"RescueGroups ({ ', ' . join ( self .species ) } )"
5591
5692 def fetch_pets (self ) -> Iterator [AdoptablePet ]:
5793 """
@@ -69,71 +105,97 @@ def fetch_pets(self) -> Iterator[AdoptablePet]:
69105 "RescueGroups API key not configured. "
70106 "Set CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable."
71107 )
72-
108+
73109 url = (
74- f"{ self .BASE_URL } /available/{ self . species } / haspic"
75- f"?include=orgs,breeds,locations"
110+ f"{ self .BASE_URL } /available/haspic"
111+ f"?include=orgs,breeds,locations,species "
76112 f"&sort=random"
77113 f"&limit={ self .limit } "
78114 )
79115 headers = {
80116 "Content-Type" : "application/vnd.api+json" ,
81117 "Authorization" : self ._api_key ,
82118 }
119+ species_filters , filter_processing = _build_species_filters (self .species )
83120 payload = {
84121 "data" : {
85122 "filterRadius" : {
86123 "miles" : self .radius_miles ,
87124 "postalcode" : self .postal_code ,
88- }
125+ },
126+ "filters" : species_filters ,
127+ "filterProcessing" : filter_processing ,
89128 }
90129 }
91130
92-
93131 logger .info (
94- f"Fetching { self .species } from RescueGroups within { self .radius_miles } miles of { self .postal_code } "
132+ "Fetching %s from RescueGroups within %s miles of %s" ,
133+ ", " .join (self .species ),
134+ self .radius_miles ,
135+ self .postal_code ,
95136 )
96137
97- response = requests .post (url , json = payload , headers = headers , timeout = 30 )
138+ session = _session_with_retries ()
139+ response = session .post (url , json = payload , headers = headers , timeout = 30 )
98140 response .raise_for_status ()
99141
100142 body = response .json ()
101143 data = body .get ("data" , [])
102- logger .info (f "Received { len ( data ) } pets from RescueGroups" )
144+ logger .info ("Received %s pets from RescueGroups" , len ( data ) )
103145
104146 orgs_by_id = {
105147 item ["id" ]: item .get ("attributes" , {})
106148 for item in body .get ("included" , [])
107149 if item .get ("type" ) == "orgs"
108150 }
151+ species_by_id = {
152+ item ["id" ]: item .get ("attributes" , {})
153+ for item in body .get ("included" , [])
154+ if item .get ("type" ) == "species"
155+ }
109156
110157 for animal in data :
111- pet = self ._parse_animal (animal , orgs_by_id )
158+ pet = self ._parse_animal (animal , orgs_by_id , species_by_id )
112159 if not pet :
113160 continue
114161 if self ._is_placeholder_name (pet .name ):
115- logger .info (f "Skipping placeholder record: { pet .name !r } " )
162+ logger .info ("Skipping placeholder record: %r" , pet .name )
116163 continue
117164 yield pet
118165
119- def _parse_animal (self , animal : dict , orgs_by_id : dict ) -> AdoptablePet | None :
166+ def _parse_animal (
167+ self ,
168+ animal : dict ,
169+ orgs_by_id : dict ,
170+ species_by_id : dict ,
171+ ) -> AdoptablePet | None :
120172 """Parse a single animal record from the API response."""
121173 try :
122174 attrs = animal .get ("attributes" , {})
123175 animal_id = animal .get ("id" , "" )
124176
125- # Extract and clean the name
126177 name = self ._clean_name (attrs .get ("name" , "Unknown" ))
127178
128- species = SPECIES_SINGULAR [self .species ]
179+ species_id = (
180+ animal .get ("relationships" , {})
181+ .get ("species" , {})
182+ .get ("data" , [{}])[0 ]
183+ .get ("id" )
184+ )
185+ if not species_id :
186+ logger .warning ("Skipping animal %s with no species relationship" , animal_id )
187+ return None
188+
189+ plural = species_by_id .get (species_id , {}).get ("plural" )
190+ if plural not in self .species :
191+ logger .info ("Skipping animal %s with unconfigured species: %r" , animal_id , plural )
192+ return None
129193
130- # Get breed info
131- breed = attrs .get ("breedString" , attrs .get ("breedPrimary" , "Mixed" ))
194+ species = SPECIES_SINGULAR [plural ]
132195
133- # Clean up description (use text version, not HTML )
196+ breed = attrs . get ( "breedString" , attrs . get ( "breedPrimary" , "Mixed" ) )
134197 description = self ._clean_description (attrs .get ("descriptionText" , "" ))
135198
136- # Get adoption_url
137199 org_id = (
138200 animal .get ("relationships" , {})
139201 .get ("orgs" , {})
@@ -147,13 +209,9 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
147209 None
148210 )
149211
150- # Get best available image
151212 image_url = self ._get_image_url (attrs )
152-
153- # Location of the adoption org
154213 location = f"{ org_attrs .get ('city' )} , { org_attrs .get ('state' )} "
155214
156-
157215 return AdoptablePet (
158216 name = name ,
159217 species = species ,
@@ -168,7 +226,7 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
168226 pet_id = animal_id ,
169227 )
170228 except Exception as e :
171- logger .warning (f "Failed to parse animal { animal .get ('id' , ' unknown' ) } : { e } " )
229+ logger .warning ("Failed to parse animal %s: %s" , animal .get ("id" , " unknown" ), e )
172230 return None
173231
174232 def _is_placeholder_name (self , name : str ) -> bool :
@@ -182,8 +240,6 @@ def _clean_name(self, name: str) -> str:
182240 "Doli ***Home for the Holidays 1/2 price!" -> "Doli"
183241 "Kathy" -> "Kathy"
184242 """
185- # Remove common promotional suffixes
186- # Split on common delimiters and take the first part
187243 cleaned = re .split (r"\s*[\*\-\|]+\s*" , name )[0 ]
188244 return cleaned .strip ()
189245
@@ -192,19 +248,13 @@ def _clean_description(self, description: str) -> str:
192248 if not description :
193249 return ""
194250
195- # Decode HTML entities
196251 text = html .unescape (description )
197-
198- # Remove and normalize whitespace
199252 text = text .replace (" " , " " )
200253 text = re .sub (r"\s+" , " " , text )
201-
202- # Remove promotional headers
203254 text = re .sub (
204255 r"\*\*Home for the Holidays.*?\*\*" , "" , text , flags = re .IGNORECASE
205256 )
206257
207- # Trim to reasonable length for social posts
208258 text = text .strip ()
209259 if len (text ) > 500 :
210260 text = text [:497 ] + "..."
@@ -215,6 +265,5 @@ def _get_image_url(self, attrs: dict) -> str | None:
215265 """Get the best available image URL."""
216266 thumbnail = attrs .get ("pictureThumbnailUrl" )
217267 if thumbnail :
218- # Request a larger image instead of the 100px thumbnail
219268 return re .sub (r"\?width=\d+" , "?width=800" , thumbnail )
220269 return None
0 commit comments