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
105 changes: 63 additions & 42 deletions adoption_sources/rescue_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
import os
import re
from collections.abc import Sequence
from typing import Iterator

import requests
Expand All @@ -16,13 +17,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
Expand All @@ -46,6 +49,18 @@ def _session_with_retries() -> requests.Session:
return session


def _build_species_filters(species: Sequence[str]) -> tuple[list[dict], str]:
"""Build RescueGroups filters and filterProcessing for an OR species search."""
filters = [
{"fieldName": "species.plural", "operation": "equal", "criteria": plural}
for plural in species
]
if not filters:
raise ValueError("At least one species is required")
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 @@ -60,20 +75,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 @@ -91,29 +106,34 @@ 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)
payload = {
"data": {
"filterRadius": {
"miles": self.radius_miles,
"postalcode": self.postal_code,
}
},
"filters": species_filters,
"filterProcessing": filter_processing,
}
}


logger.info(
f"Fetching {self.species} from RescueGroups within {self.radius_miles} miles of {self.postal_code}"
"Fetching %s from RescueGroups within %s miles of %s",
", ".join(self.species),
self.radius_miles,
self.postal_code,
)

session = _session_with_retries()
Expand All @@ -122,42 +142,61 @@ def fetch_pets(self) -> Iterator[AdoptablePet]:

body = response.json()
data = body.get("data", [])
logger.info(f"Received {len(data)} pets from RescueGroups")
logger.info("Received %s pets from RescueGroups", len(data))

orgs_by_id = {
item["id"]: item.get("attributes", {})
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}")
logger.info("Skipping placeholder record: %r", pet.name)
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", {})
animal_id = animal.get("id", "")

# 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"
species_id = (
animal.get("relationships", {})
.get("species", {})
.get("data", [{}])[0]
.get("id")
)
if not species_id:
logger.warning("Skipping animal %s with no species relationship", animal_id)
return None

# Get breed info
breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed"))
plural = species_by_id.get(species_id, {}).get("plural")
if plural not in self.species:
logger.info("Skipping animal %s with unconfigured species: %r", animal_id, plural)
return None

# Clean up description (use text version, not HTML)
species = SPECIES_SINGULAR[plural]

breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed"))
description = self._clean_description(attrs.get("descriptionText", ""))

# Get adoption_url
org_id = (
animal.get("relationships", {})
.get("orgs", {})
Expand All @@ -176,24 +215,15 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
None
)

# Shelter's own animal id (e.g. MSPCA's "A468573"); some orgs' deep
# links are keyed on this rather than the RescueGroups id.
rescue_id = attrs.get("rescueId")

# For shelters we have a template for, rebuild a deep link to this
# specific pet; otherwise keep the org landing page from above.
adoption_url = (
reconstruct_adoption_url(url_candidates, animal_id, rescue_id)
or adoption_url
)

# Get best available image
image_url = self._get_image_url(attrs)

# Location of the adoption org
location = f"{org_attrs.get('city')}, {org_attrs.get('state')}"


return AdoptablePet(
name=name,
species=species,
Expand All @@ -209,7 +239,7 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None:
rescue_id=rescue_id,
)
except Exception as e:
logger.warning(f"Failed to parse animal {animal.get('id', 'unknown')}: {e}")
logger.warning("Failed to parse animal %s: %s", animal.get("id", "unknown"), e)
return None

def _is_placeholder_name(self, name: str) -> bool:
Expand All @@ -223,8 +253,6 @@ def _clean_name(self, name: str) -> str:
"Doli ***Home for the Holidays 1/2 price!" -> "Doli"
"Kathy" -> "Kathy"
"""
# Remove common promotional suffixes
# Split on common delimiters and take the first part
cleaned = re.split(r"\s*[\*\-\|]+\s*", name)[0]
return cleaned.strip()

Expand All @@ -233,19 +261,13 @@ def _clean_description(self, description: str) -> str:
if not description:
return ""

# Decode HTML entities
text = html.unescape(description)

# Remove   and normalize whitespace
text = text.replace(" ", " ")
text = re.sub(r"\s+", " ", text)

# Remove promotional headers
text = re.sub(
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] + "..."
Expand All @@ -256,6 +278,5 @@ def _get_image_url(self, attrs: dict) -> str | None:
"""Get the best available image URL."""
thumbnail = attrs.get("pictureThumbnailUrl")
if thumbnail:
# Request a larger image instead of the 100px thumbnail
return re.sub(r"\?width=\d+", "?width=800", thumbnail)
return None
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 endpoint names for species to fetch.
PET_SPECIES = ("dogs", "cats")

# Single-call limit; roughly matches two per-species calls at 25 each.
RESCUEGROUPS_LIMIT = 50
29 changes: 16 additions & 13 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import os
import random
import argparse
import json
import os
import random
import sys
import traceback
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


def main():
parser = argparse.ArgumentParser()
Expand All @@ -28,14 +34,9 @@ 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 @@ -47,15 +48,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