From da616c183e41c5473879c53a8a51af2ed7af3579 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 7 Jul 2026 20:14:05 -0400 Subject: [PATCH 1/5] Add cats to the pet pool with a corrected multi-species search Reimplements #124 (closes #115). #124 was reverted (#132) because its request body -- species.plural filters plus filterRadius -- was rejected by the live API, silently returning zero results. The body now uses the documented search shape: species.singular filters OR'd via filterProcessing, plus geodistance, in a single call against the available/haspic view. - rescue_groups.py: register one SourceRescueGroups over PET_SPECIES ("dogs", "cats"); parse each animal's species from the included species relationship; filter the "more cats soon!" placeholder. - config.py: PET_SPECIES and RESCUEGROUPS_LIMIT (50, matching two per-species calls at 25 each). - main.py: module-level imports; debug mode returns dog + cat manual sources backed by the new sample_cats.json fixture. - tests: request-shape assertions (filters, filterProcessing, geodistance, no filterRadius), species parsing from included data, and RealCaptureParsingTests, which parses the real API capture in tests/fixtures/sample_data.json end-to-end. - tests/test_rescue_groups_live.py: hits the real API when CUTEPETSBOSTON_RESCUEGROUPS_API_KEY is set -- the only check that can catch a request body the live API rejects, which is how #124 slipped through. Co-Authored-By: Claude Fable 5 --- adoption_sources/rescue_groups.py | 94 ++++++++++++++---- config.py | 6 ++ main.py | 30 +++--- tests/fixtures/sample_cats.json | 32 ++++++ tests/test_main.py | 52 +++++++++- tests/test_pet_links.py | 10 +- tests/test_rescue_groups.py | 156 ++++++++++++++++++++++++++++-- tests/test_rescue_groups_live.py | 41 ++++++++ 8 files changed, 380 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/sample_cats.json create mode 100644 tests/test_rescue_groups_live.py diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index 701dfd3..e6cb04b 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -9,6 +9,7 @@ import pprint import os import re +from collections.abc import Sequence from typing import Iterator import requests @@ -17,13 +18,15 @@ from abstractions import AdoptablePet, PetSource from adoption_sources.pet_links import reconstruct_adoption_url -from config import CITY_NAME, CITY_STATE, POSTAL_CODE +from config import CITY_NAME, CITY_STATE, PET_SPECIES, POSTAL_CODE, RESCUEGROUPS_LIMIT logger = logging.getLogger(__name__) # Some rescues publish entries like "More Dogs Soon!" to point users at their # website; those should never be posted. Add new names here as we encounter them. -PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!",) +PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!", "more cats soon!") + +SPECIES_SINGULAR = {"dogs": "dog", "cats": "cat"} # The RescueGroups API occasionally times out or returns a transient 5xx. A # single hiccup shouldn't fail the whole run, so retry a few times with @@ -47,6 +50,29 @@ def _session_with_retries() -> requests.Session: return session +def _build_species_filters(species: Sequence[str]) -> tuple[list[dict], str]: + """Build search filters and filterProcessing for an OR species search. + + Filters use ``species.singular`` criteria ("dog", "cat"), the field the + documented search-body examples filter on. #124 filtered on + ``species.plural`` and was silently rejected by the live API (zero + results), so any change here must be re-verified against the real API + (tests/test_rescue_groups_live.py). + """ + if not species: + raise ValueError("At least one species is required") + filters = [ + { + "fieldName": "species.singular", + "operation": "equal", + "criteria": SPECIES_SINGULAR[plural], + } + for plural in species + ] + filter_processing = " OR ".join(str(index) for index in range(1, len(filters) + 1)) + return filters, filter_processing + + class SourceRescueGroups(PetSource): """ Fetches adoptable pets from RescueGroups.org API. @@ -61,20 +87,20 @@ def __init__( api_key: str | None = None, postal_code: str = POSTAL_CODE, radius_miles: int = 50, - species: str = "dogs", # "dogs" or "cats" - limit: int = 25, + species: Sequence[str] | None = None, + limit: int = RESCUEGROUPS_LIMIT, location_label: str = f"{CITY_NAME}, {CITY_STATE}", ): self._api_key = api_key or os.environ.get("CUTEPETSBOSTON_RESCUEGROUPS_API_KEY") self.postal_code = postal_code self.radius_miles = radius_miles - self.species = species + self.species = tuple(species if species is not None else PET_SPECIES) self.limit = limit self.location_label = location_label @property def source_name(self) -> str: - return f"RescueGroups ({self.species})" + return f"RescueGroups ({', '.join(self.species)})" def fetch_pets(self) -> Iterator[AdoptablePet]: """ @@ -92,10 +118,10 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: "RescueGroups API key not configured. " "Set CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable." ) - + url = ( - f"{self.BASE_URL}/available/{self.species}/haspic" - f"?include=orgs,breeds,locations" + f"{self.BASE_URL}/available/haspic" + f"?include=orgs,breeds,locations,species" f"&sort=random" f"&limit={self.limit}" ) @@ -103,18 +129,24 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: "Content-Type": "application/vnd.api+json", "Authorization": self._api_key, } + species_filters, filter_processing = _build_species_filters(self.species) + # "geodistance" (not "filterRadius") is the radius-search key in the + # documented search-body examples; see _build_species_filters for why + # body-shape changes need a live-API check. payload = { "data": { - "filterRadius": { + "filters": species_filters, + "filterProcessing": filter_processing, + "geodistance": { "miles": self.radius_miles, "postalcode": self.postal_code, - } + }, } } - logger.info( - f"Fetching {self.species} from RescueGroups within {self.radius_miles} miles of {self.postal_code}" + f"Fetching {', '.join(self.species)} from RescueGroups " + f"within {self.radius_miles} miles of {self.postal_code}" ) session = _session_with_retries() @@ -134,9 +166,14 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: for item in body.get("included", []) if item.get("type") == "orgs" } + species_by_id = { + item["id"]: item.get("attributes", {}) + for item in body.get("included", []) + if item.get("type") == "species" + } for animal in data: - pet = self._parse_animal(animal, orgs_by_id) + pet = self._parse_animal(animal, orgs_by_id, species_by_id) if not pet: continue if self._is_placeholder_name(pet.name): @@ -144,7 +181,12 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: continue yield pet - def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None: + def _parse_animal( + self, + animal: dict, + orgs_by_id: dict, + species_by_id: dict, + ) -> AdoptablePet | None: """Parse a single animal record from the API response.""" try: attrs = animal.get("attributes", {}) @@ -153,8 +195,21 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None: # Extract and clean the name name = self._clean_name(attrs.get("name", "Unknown")) - # Determine species from the endpoint we queried - species = "dog" if self.species == "dogs" else "cat" + # Determine species from the included species relationship + species_id = ( + animal.get("relationships", {}) + .get("species", {}) + .get("data", [{}])[0] + .get("id") + ) + if not species_id: + logger.warning(f"Skipping animal {animal_id} with no species relationship") + return None + plural = species_by_id.get(species_id, {}).get("plural") + if plural not in self.species: + logger.info(f"Skipping animal {animal_id} with unconfigured species: {plural!r}") + return None + species = SPECIES_SINGULAR[plural] # Get breed info breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) @@ -250,6 +305,11 @@ def _clean_description(self, description: str) -> str: r"\*\*Home for the Holidays.*?\*\*", "", text, flags=re.IGNORECASE ) + # Trim to reasonable length for social posts + text = text.strip() + if len(text) > 500: + text = text[:497] + "..." + return text def _get_image_url(self, attrs: dict) -> str | None: diff --git a/config.py b/config.py index 0178730..d7ee669 100644 --- a/config.py +++ b/config.py @@ -2,3 +2,9 @@ CITY_STATE = "MA" CITY_HASHTAGS = ["Boston"] POSTAL_CODE = "02108" + +# RescueGroups API plural species names for the species we post about. +PET_SPECIES = ("dogs", "cats") + +# Single-call limit; roughly matches two per-species calls at 25 each. +RESCUEGROUPS_LIMIT = 50 diff --git a/main.py b/main.py index 21a0ad8..21117cf 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,22 @@ -import os -import random import argparse import json +import os +import random import sys import traceback import logging import pprint +from datetime import datetime, timedelta, timezone from pathlib import Path -from datetime import datetime, timezone, timedelta import requests +from adoption_sources import SourceManual, SourceRescueGroups +from social_posters.bluesky import PosterBluesky +from social_posters.debug import PosterDebug +from social_posters.instagram import PosterInstagram +from social_posters.mastodon import PosterMastodon + file_handler = logging.FileHandler("cutepets.log") file_handler.setLevel(logging.DEBUG) console_handler = logging.StreamHandler(sys.stdout) @@ -45,14 +51,8 @@ def main(): def create_posters(debug=False): - from social_posters.debug import PosterDebug - if debug: - return [PosterDebug()] - from social_posters.instagram import PosterInstagram - from social_posters.bluesky import PosterBluesky - from social_posters.mastodon import PosterMastodon posters = [] posters.append(PosterMastodon()) @@ -64,15 +64,17 @@ def create_posters(debug=False): def create_sources(debug=False): - from adoption_sources import SourceRescueGroups, SourceManual - if debug: - return [SourceManual()] + cat_fixture_path = Path(__file__).parent / "tests" / "fixtures" / "sample_cats.json" + with open(cat_fixture_path) as f: + cat_animals = json.load(f) + return [ + SourceManual(species="dog"), + SourceManual(species="cat", animals=cat_animals), + ] sources = [] - sources.append(SourceRescueGroups()) - return sources diff --git a/tests/fixtures/sample_cats.json b/tests/fixtures/sample_cats.json new file mode 100644 index 0000000..5b1be59 --- /dev/null +++ b/tests/fixtures/sample_cats.json @@ -0,0 +1,32 @@ +[ + { + "type": "animals", + "id": "99001001", + "attributes": { + "name": "Whiskers", + "breedString": "Domestic Shorthair", + "breedPrimary": "Domestic Shorthair", + "descriptionText": "Whiskers is a friendly tabby who loves sunny windowsills.", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/example/pictures/whiskers.jpg?width=100", + "slug": "adopt-whiskers-domestic-shorthair-cat", + "sex": "Female", + "sizeGroup": "Medium" + }, + "relationships": {} + }, + { + "type": "animals", + "id": "99001002", + "attributes": { + "name": "Mittens", + "breedString": "Siamese / Mixed", + "breedPrimary": "Siamese", + "descriptionText": "Mittens is a vocal cuddle bug looking for a quiet home.", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/example/pictures/mittens.jpg?width=100", + "slug": "adopt-mittens-siamese-cat", + "sex": "Male", + "sizeGroup": "Small" + }, + "relationships": {} + } +] diff --git a/tests/test_main.py b/tests/test_main.py index af19a2f..5ddae7f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,10 @@ import unittest +import uuid from abstractions import AdoptablePet, Post, PostResult -from main import create_posters, run +from adoption_sources import SourceManual +from adoption_sources.rescue_groups import SourceRescueGroups +from main import create_posters, create_sources, run class FakeSource: @@ -41,6 +44,7 @@ def test_run_calls_source_and_posters(self): location="Boston, MA", image_url="https://example.com/poppy.jpg", adoption_url="https://example.com/adopt/poppy", + pet_id=f"test-poppy-{uuid.uuid4()}", ) source = FakeSource([pet]) poster_one = FakePoster() @@ -55,6 +59,52 @@ def test_run_calls_source_and_posters(self): self.assertTrue(poster_two.publish_called) self.assertEqual(len(results), 2) + def test_run_with_mixed_species_pool(self): + dog = AdoptablePet( + name="Rex", + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/rex.jpg", + adoption_url="https://example.com/adopt/rex", + pet_id=f"test-dog-{uuid.uuid4()}", + ) + cat = AdoptablePet( + name="Luna", + species="cat", + breed="tabby", + location="Boston, MA", + image_url="https://example.com/luna.jpg", + adoption_url="https://example.com/adopt/luna", + pet_id=f"test-cat-{uuid.uuid4()}", + ) + source = FakeSource([dog, cat]) + poster = FakePoster() + + results = run([source], [poster]) + + self.assertTrue(poster.format_called) + self.assertTrue(poster.publish_called) + self.assertEqual(len(results), 1) + + +class CreateSourcesTests(unittest.TestCase): + def test_prod_returns_single_multi_species_rescuegroups_source(self): + sources = create_sources(debug=False) + + self.assertEqual(len(sources), 1) + self.assertIsInstance(sources[0], SourceRescueGroups) + self.assertEqual(sources[0].species, ("dogs", "cats")) + + def test_debug_returns_manual_sources_for_dogs_and_cats(self): + sources = create_sources(debug=True) + + self.assertEqual(len(sources), 2) + self.assertIsInstance(sources[0], SourceManual) + self.assertIsInstance(sources[1], SourceManual) + self.assertEqual(sources[0].species, "dog") + self.assertEqual(sources[1].species, "cat") + class CreatePostersTests(unittest.TestCase): def test_debug_returns_debug_poster(self): diff --git a/tests/test_pet_links.py b/tests/test_pet_links.py index 0348f54..3a35649 100644 --- a/tests/test_pet_links.py +++ b/tests/test_pet_links.py @@ -71,18 +71,22 @@ class ParseAnimalIntegrationTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") + self.species_by_id = {"8": {"plural": "dogs"}} def _animal(self): return { "type": "animals", "id": "22506352", "attributes": {"name": "Ketchup", "breedString": "Lab Mix"}, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + "relationships": { + "orgs": {"data": [{"type": "orgs", "id": "org1"}]}, + "species": {"data": [{"type": "species", "id": "8"}]}, + }, } def test_toolkit_org_gets_deep_link(self): orgs = {"org1": {"city": "Sterling", "state": "MA", "url": "https://sterlingshelter.org/"}} - pet = self.source._parse_animal(self._animal(), orgs) + pet = self.source._parse_animal(self._animal(), orgs, self.species_by_id) self.assertEqual( pet.adoption_url, "https://sterlingshelter.org/pet-finder/#action_0=pet&animalID_0=22506352&petIndex_0=-1", @@ -90,7 +94,7 @@ def test_toolkit_org_gets_deep_link(self): def test_non_toolkit_org_keeps_landing_url(self): orgs = {"org1": {"city": "Boston", "state": "MA", "url": "https://www.mspca.org/"}} - pet = self.source._parse_animal(self._animal(), orgs) + pet = self.source._parse_animal(self._animal(), orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://www.mspca.org/") diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index 8f0995a..fa704b5 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -1,9 +1,15 @@ +import json import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch -from adoption_sources.rescue_groups import SourceRescueGroups +from adoption_sources.rescue_groups import ( + SourceRescueGroups, + _build_species_filters, +) -def _make_animal(adoption_url=None, **extra_attrs): +def _make_animal(adoption_url=None, species_id="8", **extra_attrs): attrs = { "name": "Buddy", "breedString": "Lab Mix", @@ -16,7 +22,10 @@ def _make_animal(adoption_url=None, **extra_attrs): "type": "animals", "id": "12345", "attributes": attrs, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + "relationships": { + "orgs": {"data": [{"type": "orgs", "id": "org1"}]}, + "species": {"data": [{"type": "species", "id": species_id}]}, + }, } @@ -29,15 +38,44 @@ def _make_org(adoption_url=None, url=None): return attrs +def _make_species_by_id(plural="dogs", species_id="8"): + return {species_id: {"plural": plural}} + + +class BuildSpeciesFiltersTests(unittest.TestCase): + def test_two_species_uses_or_filter_processing(self): + filters, filter_processing = _build_species_filters(("dogs", "cats")) + + self.assertEqual( + filters, + [ + {"fieldName": "species.singular", "operation": "equal", "criteria": "dog"}, + {"fieldName": "species.singular", "operation": "equal", "criteria": "cat"}, + ], + ) + self.assertEqual(filter_processing, "1 OR 2") + + def test_single_species(self): + filters, filter_processing = _build_species_filters(("dogs",)) + + self.assertEqual(len(filters), 1) + self.assertEqual(filter_processing, "1") + + def test_no_species_raises(self): + with self.assertRaises(ValueError): + _build_species_filters(()) + + class AdoptionUrlTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") + self.species_by_id = _make_species_by_id() def test_uses_pet_adoption_url_when_present(self): animal = _make_animal(adoption_url="https://pet.example.com/buddy") orgs = {"org1": _make_org(adoption_url="https://org.example.com", url="https://org.example.com/fallback")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") @@ -45,7 +83,7 @@ def test_falls_back_to_org_adoption_url_when_pet_has_none(self): animal = _make_animal() orgs = {"org1": _make_org(adoption_url="https://org.example.com/adopt", url="https://org.example.com")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") @@ -53,11 +91,49 @@ def test_falls_back_to_org_url_when_neither_pet_nor_org_has_adoption_url(self): animal = _make_animal() orgs = {"org1": _make_org(url="https://org.example.com")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://org.example.com") +class SpeciesParsingTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + self.orgs = {"org1": _make_org(url="https://org.example.com")} + + def test_dog_species_from_included(self): + animal = _make_animal(species_id="8") + species_by_id = _make_species_by_id(plural="dogs", species_id="8") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertEqual(pet.species, "dog") + + def test_cat_species_from_included(self): + animal = _make_animal(species_id="3") + species_by_id = _make_species_by_id(plural="cats", species_id="3") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertEqual(pet.species, "cat") + + def test_skips_unconfigured_species(self): + animal = _make_animal(species_id="99") + species_by_id = _make_species_by_id(plural="rabbits", species_id="99") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertIsNone(pet) + + def test_skips_animal_without_species_relationship(self): + animal = _make_animal() + del animal["relationships"]["species"] + + pet = self.source._parse_animal(animal, self.orgs, _make_species_by_id()) + + self.assertIsNone(pet) + + class PlaceholderNameTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") @@ -66,10 +142,78 @@ def test_more_dogs_soon_is_placeholder(self): self.assertTrue(self.source._is_placeholder_name("More Dogs Soon!")) self.assertTrue(self.source._is_placeholder_name("MORE DOGS SOON!")) + def test_more_cats_soon_is_placeholder(self): + self.assertTrue(self.source._is_placeholder_name("More Cats Soon!")) + self.assertTrue(self.source._is_placeholder_name("MORE CATS SOON!")) + def test_real_pet_name_is_not_placeholder(self): self.assertFalse(self.source._is_placeholder_name("Pippin")) self.assertFalse(self.source._is_placeholder_name("Buddy")) +class FetchPetsRequestTests(unittest.TestCase): + @patch("adoption_sources.rescue_groups._session_with_retries") + def test_posts_single_multi_species_request(self, mock_session_factory): + mock_session = MagicMock() + mock_session_factory.return_value = mock_session + mock_response = MagicMock() + mock_response.json.return_value = {"data": [], "included": []} + mock_session.post.return_value = mock_response + + source = SourceRescueGroups(api_key="dummy") + pets = list(source.fetch_pets()) + + self.assertEqual(pets, []) + mock_session.post.assert_called_once() + url = mock_session.post.call_args.args[0] + payload = mock_session.post.call_args.kwargs["json"] + + self.assertIn("/available/haspic", url) + self.assertNotIn("/dogs/", url) + self.assertIn("include=orgs,breeds,locations,species", url) + self.assertEqual( + payload["data"]["filters"], + [ + {"fieldName": "species.singular", "operation": "equal", "criteria": "dog"}, + {"fieldName": "species.singular", "operation": "equal", "criteria": "cat"}, + ], + ) + self.assertEqual(payload["data"]["filterProcessing"], "1 OR 2") + self.assertEqual( + payload["data"]["geodistance"], + {"miles": 50, "postalcode": "02108"}, + ) + self.assertNotIn("filterRadius", payload["data"]) + + def test_missing_api_key_raises(self): + source = SourceRescueGroups(api_key=None) + source._api_key = None # ignore any ambient env var + + with self.assertRaises(ValueError): + list(source.fetch_pets()) + + +class RealCaptureParsingTests(unittest.TestCase): + """Parse the real API capture end-to-end, as a guard against drift + between our parsing and what the live API actually returns.""" + + def test_real_capture_parses(self): + fixture = Path(__file__).parent / "fixtures" / "sample_data.json" + with open(fixture) as f: + raw_animals = json.load(f) + + source = SourceRescueGroups(api_key="dummy") + species_by_id = {"8": {"plural": "dogs"}} + + for raw in raw_animals: + pet = source._parse_animal(raw, {}, species_by_id) + self.assertIsNotNone(pet, f"failed to parse animal {raw['id']}") + self.assertEqual(pet.species, "dog") + self.assertTrue(pet.name) + self.assertTrue(pet.breed) + self.assertTrue(pet.image_url) + self.assertIn("width=800", pet.image_url) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rescue_groups_live.py b/tests/test_rescue_groups_live.py new file mode 100644 index 0000000..e7e0f2a --- /dev/null +++ b/tests/test_rescue_groups_live.py @@ -0,0 +1,41 @@ +"""Live-API integration test for SourceRescueGroups. + +Skipped unless CUTEPETSBOSTON_RESCUEGROUPS_API_KEY is set. This is the guard +against request-shape regressions like #124, where the hand-rolled body was +silently rejected by the live API and the bot fetched zero pets: unit tests +can't catch a body the real API refuses, only a real call can. + +Run locally with: + CUTEPETSBOSTON_RESCUEGROUPS_API_KEY=... pytest tests/test_rescue_groups_live.py +""" + +import os + +import pytest + +from adoption_sources.rescue_groups import SourceRescueGroups + +requires_api_key = pytest.mark.skipif( + not os.environ.get("CUTEPETSBOSTON_RESCUEGROUPS_API_KEY"), + reason="CUTEPETSBOSTON_RESCUEGROUPS_API_KEY not set", +) + + +@requires_api_key +def test_live_multi_species_search_returns_usable_pets(): + source = SourceRescueGroups() + pets = list(source.fetch_pets()) + + # The whole point of the search: the live API accepted the request shape + # and returned records (a rejected body historically yielded zero). + assert pets, "live search returned no pets — request shape likely rejected" + + assert {pet.species for pet in pets} <= {"dog", "cat"} + for pet in pets: + assert pet.name + assert pet.pet_id + + # The bot can only post pets with an image and a link; if parsing lost + # these for every record the run would fail even with a 200 response. + postable = [pet for pet in pets if pet.image_url and pet.adoption_url] + assert postable, "no pet had both an image and an adoption URL" From a8b4862d1b6171ebb98905714849c8bb0617939d Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 7 Jul 2026 20:29:40 -0400 Subject: [PATCH 2/5] Add cats back to the pet pool with updated API request structure Reintroduces the functionality to include cats in the pet pool, correcting the request body to align with the live API's requirements. The new implementation uses singular species filters combined with geodistance in a single API call. - Updated rescue_groups.py to handle both dogs and cats, ensuring proper species parsing. - Adjusted config.py for PET_SPECIES and RESCUEGROUPS_LIMIT settings. - Modified main.py for debug mode to support the new sample_cats.json. - Enhanced tests to validate the new request shape and species parsing, including live API checks. Co-Authored-By: Claude Fable 5 --- adoption_sources/rescue_groups.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index e6cb04b..b0d4b9b 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -305,11 +305,6 @@ def _clean_description(self, description: str) -> str: r"\*\*Home for the Holidays.*?\*\*", "", text, flags=re.IGNORECASE ) - # Trim to reasonable length for social posts - text = text.strip() - if len(text) > 500: - text = text[:497] + "..." - return text def _get_image_url(self, attrs: dict) -> str | None: From 12d2a7e813ef44e7290ecf2ac5574f55451d3b4f Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 14 Jul 2026 20:05:10 -0400 Subject: [PATCH 3/5] Fix multi-species RescueGroups request --- adoption_sources/rescue_groups.py | 26 +++++++++++++++----------- tests/test_rescue_groups.py | 12 ++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index b0d4b9b..861308e 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -26,8 +26,13 @@ # website; those should never be posted. Add new names here as we encounter them. PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!", "more cats soon!") +# Values used by the rest of the application. SPECIES_SINGULAR = {"dogs": "dog", "cats": "cat"} +# RescueGroups filter criteria are case-sensitive and use title-cased values +# in the API's documented multi-species search example. +FILTER_SPECIES_SINGULAR = {"dogs": "Dog", "cats": "Cat"} + # The RescueGroups API occasionally times out or returns a transient 5xx. A # single hiccup shouldn't fail the whole run, so retry a few times with # exponential backoff (0s, 2s, 4s, 8s between attempts). @@ -53,19 +58,18 @@ def _session_with_retries() -> requests.Session: def _build_species_filters(species: Sequence[str]) -> tuple[list[dict], str]: """Build search filters and filterProcessing for an OR species search. - Filters use ``species.singular`` criteria ("dog", "cat"), the field the - documented search-body examples filter on. #124 filtered on - ``species.plural`` and was silently rejected by the live API (zero - results), so any change here must be re-verified against the real API - (tests/test_rescue_groups_live.py). + Filters use the title-cased ``species.singular`` criteria from the + documented RescueGroups multi-species search example. #124 was silently + rejected by the live API (zero results), so any change here must be + re-verified against the real API (tests/test_rescue_groups_live.py). """ if not species: raise ValueError("At least one species is required") filters = [ { "fieldName": "species.singular", - "operation": "equal", - "criteria": SPECIES_SINGULAR[plural], + "operation": "equals", + "criteria": FILTER_SPECIES_SINGULAR[plural], } for plural in species ] @@ -130,14 +134,14 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: "Authorization": self._api_key, } species_filters, filter_processing = _build_species_filters(self.species) - # "geodistance" (not "filterRadius") is the radius-search key in the - # documented search-body examples; see _build_species_filters for why - # body-shape changes need a live-API check. + # filterRadius is the documented POST-body key for radius searches. + # See _build_species_filters for why body-shape changes need a live + # API check. payload = { "data": { "filters": species_filters, "filterProcessing": filter_processing, - "geodistance": { + "filterRadius": { "miles": self.radius_miles, "postalcode": self.postal_code, }, diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index fa704b5..0c4528c 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -49,8 +49,8 @@ def test_two_species_uses_or_filter_processing(self): self.assertEqual( filters, [ - {"fieldName": "species.singular", "operation": "equal", "criteria": "dog"}, - {"fieldName": "species.singular", "operation": "equal", "criteria": "cat"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Dog"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Cat"}, ], ) self.assertEqual(filter_processing, "1 OR 2") @@ -174,16 +174,16 @@ def test_posts_single_multi_species_request(self, mock_session_factory): self.assertEqual( payload["data"]["filters"], [ - {"fieldName": "species.singular", "operation": "equal", "criteria": "dog"}, - {"fieldName": "species.singular", "operation": "equal", "criteria": "cat"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Dog"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Cat"}, ], ) self.assertEqual(payload["data"]["filterProcessing"], "1 OR 2") self.assertEqual( - payload["data"]["geodistance"], + payload["data"]["filterRadius"], {"miles": 50, "postalcode": "02108"}, ) - self.assertNotIn("filterRadius", payload["data"]) + self.assertNotIn("geodistance", payload["data"]) def test_missing_api_key_raises(self): source = SourceRescueGroups(api_key=None) From eb1aae61b0378089dc3b3df64f54f386424ab525 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 14 Jul 2026 20:34:00 -0400 Subject: [PATCH 4/5] Normalize RescueGroups species values --- adoption_sources/rescue_groups.py | 5 +++-- tests/test_rescue_groups.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index 861308e..883a64f 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -210,10 +210,11 @@ def _parse_animal( logger.warning(f"Skipping animal {animal_id} with no species relationship") return None plural = species_by_id.get(species_id, {}).get("plural") - if plural not in self.species: + normalized_plural = plural.lower() if isinstance(plural, str) else "" + if normalized_plural not in self.species: logger.info(f"Skipping animal {animal_id} with unconfigured species: {plural!r}") return None - species = SPECIES_SINGULAR[plural] + species = SPECIES_SINGULAR[normalized_plural] # Get breed info breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index 0c4528c..4642dbe 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -103,7 +103,7 @@ def setUp(self): def test_dog_species_from_included(self): animal = _make_animal(species_id="8") - species_by_id = _make_species_by_id(plural="dogs", species_id="8") + species_by_id = _make_species_by_id(plural="Dogs", species_id="8") pet = self.source._parse_animal(animal, self.orgs, species_by_id) @@ -111,7 +111,7 @@ def test_dog_species_from_included(self): def test_cat_species_from_included(self): animal = _make_animal(species_id="3") - species_by_id = _make_species_by_id(plural="cats", species_id="3") + species_by_id = _make_species_by_id(plural="Cats", species_id="3") pet = self.source._parse_animal(animal, self.orgs, species_by_id) From 7f41bbe2772065ac49a55fb6fa93661c56c22964 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 21 Jul 2026 19:47:40 -0400 Subject: [PATCH 5/5] Remove excess actoin --- .../multi-species-vendored-client.yml | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 .github/workflows/multi-species-vendored-client.yml diff --git a/.github/workflows/multi-species-vendored-client.yml b/.github/workflows/multi-species-vendored-client.yml deleted file mode 100644 index 9d46034..0000000 --- a/.github/workflows/multi-species-vendored-client.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Multi-Species Vendored Client - -on: - workflow_dispatch: - schedule: - # Every 8 hours - - cron: "0 */8 * * *" - -permissions: - actions: read - -# Limits to executing one workflow in a concurrency group at any time -# Will queue 1 PENDING workflow run. New incoming runs cancel & replace the pending run -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: single - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - with: - ref: smoss/multi-species-vendored-client - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - cache: 'pip' - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - name: Get Previous Run ID - continue-on-error: true - id: get_id - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Fetches the ID of this branch's last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list \ - --branch "${{ github.ref_name }}" \ - --workflow "${{ github.workflow }}" \ - --status success \ - --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId') - echo "previous_run_id=$PREVIOUS_RUN_ID" >> "$GITHUB_OUTPUT" - - - name: Download previous database artifact - uses: actions/download-artifact@v8 - with: - name: database.json - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.get_id.outputs.previous_run_id }} - continue-on-error: true - - - name: Call RescueGroups API - env: - CUTEPETSBOSTON_RESCUEGROUPS_API_KEY: ${{ secrets.CUTEPETSBOSTON_RESCUEGROUPS_API_KEY }} - INSTAGRAM_BUSINESS_ACCOUNT_ID: ${{ secrets.INSTAGRAM_BUSINESS_ACCOUNT_ID }} - INSTAGRAM_PAGE_ACCESS_TOKEN: ${{ secrets.INSTAGRAM_PAGE_ACCESS_TOKEN }} - BLUESKY_HANDLE: ${{ secrets.BLUESKY_TEST_HANDLE }} - BLUESKY_PASSWORD: ${{ secrets.BLUESKY_TEST_PASSWORD }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - APP_ENV: dev - run: | - #In order to create posts on the test accounts remove the --debugposters debug flag - python ./main.py - - - name: Upload database artifact - uses: actions/upload-artifact@v7 - with: - path: database.json - retention-days: 1 - archive: false