Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 0 additions & 77 deletions .github/workflows/multi-species-vendored-client.yml

This file was deleted.

92 changes: 76 additions & 16 deletions adoption_sources/rescue_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pprint
import os
import re
from collections.abc import Sequence
from typing import Iterator

import requests
Expand All @@ -17,13 +18,20 @@

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!")

# 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
Expand All @@ -47,6 +55,28 @@ 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 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": "equals",
"criteria": FILTER_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.
Expand All @@ -61,20 +91,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]:
"""
Expand All @@ -92,29 +122,35 @@ 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}"
)
headers = {
"Content-Type": "application/vnd.api+json",
"Authorization": self._api_key,
}
species_filters, filter_processing = _build_species_filters(self.species)
# 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,
"filterRadius": {
"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()
Expand All @@ -134,17 +170,27 @@ 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):
logger.info(f"Skipping placeholder record: {pet.name!r}")
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", {})
Expand All @@ -153,8 +199,22 @@ 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")
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[normalized_plural]

# Get breed info
breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed"))
Expand Down
6 changes: 6 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 16 additions & 14 deletions main.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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


Expand Down
32 changes: 32 additions & 0 deletions tests/fixtures/sample_cats.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
]
Loading
Loading