Skip to content

Commit 8ad7ba6

Browse files
Smosscursoragent
andcommitted
Merge single RescueGroups API call into add-cats-to-display.
Use one filterProcessing request for dogs and cats while keeping top-level imports and debug fixtures. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents b921a94 + 62a29c1 commit 8ad7ba6

5 files changed

Lines changed: 181 additions & 70 deletions

File tree

adoption_sources/rescue_groups.py

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import logging
99
import os
1010
import re
11+
from collections.abc import Sequence
1112
from typing import Iterator
1213

1314
import requests
15+
from requests.adapters import HTTPAdapter
16+
from urllib3.util.retry import Retry
1417

1518
from 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

1821
logger = logging.getLogger(__name__)
1922

@@ -23,6 +26,39 @@
2326

2427
SPECIES_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

2763
class 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 &nbsp; and normalize whitespace
199252
text = text.replace("&nbsp;", " ")
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

config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55

66
# RescueGroups API plural endpoint names for species to fetch.
77
PET_SPECIES = ("dogs", "cats")
8+
9+
# Single-call limit; roughly matches two per-species calls at 25 each.
10+
RESCUEGROUPS_LIMIT = 50

main.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import requests
1111

1212
from adoption_sources import SourceManual, SourceRescueGroups
13-
from config import PET_SPECIES
1413
from social_posters.bluesky import PosterBluesky
1514
from social_posters.debug import PosterDebug
1615
from social_posters.instagram import PosterInstagram
@@ -59,9 +58,7 @@ def create_sources(debug=False):
5958
]
6059

6160
sources = []
62-
for species in PET_SPECIES:
63-
sources.append(SourceRescueGroups(species=species))
64-
61+
sources.append(SourceRescueGroups())
6562
return sources
6663

6764

tests/test_main.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,12 @@ def test_run_with_mixed_species_pool(self):
9090

9191

9292
class CreateSourcesTests(unittest.TestCase):
93-
def test_prod_returns_rescuegroups_for_each_species(self):
93+
def test_prod_returns_single_rescuegroups_source(self):
9494
sources = create_sources(debug=False)
9595

96-
self.assertEqual(len(sources), 2)
96+
self.assertEqual(len(sources), 1)
9797
self.assertIsInstance(sources[0], SourceRescueGroups)
98-
self.assertIsInstance(sources[1], SourceRescueGroups)
99-
self.assertEqual(sources[0].species, "dogs")
100-
self.assertEqual(sources[1].species, "cats")
98+
self.assertEqual(sources[0].species, ("dogs", "cats"))
10199

102100
def test_debug_returns_manual_sources_for_dogs_and_cats(self):
103101
sources = create_sources(debug=True)
@@ -117,6 +115,5 @@ def test_debug_returns_debug_poster(self):
117115
self.assertEqual(posters[0].platform_name, "Debug")
118116

119117

120-
121118
if __name__ == "__main__":
122119
unittest.main()

0 commit comments

Comments
 (0)