From 5ac1c9dda5c4e9f2cebe4ab718a98f246b0cfef9 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 7 Apr 2026 20:29:12 -0400 Subject: [PATCH 01/25] test setting a variable from action --- .github/workflows/vartest.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/vartest.yml diff --git a/.github/workflows/vartest.yml b/.github/workflows/vartest.yml new file mode 100644 index 0000000..5c0253f --- /dev/null +++ b/.github/workflows/vartest.yml @@ -0,0 +1,20 @@ +name: VARTEST + +on: + push: + branches: + - '**' + - '!master' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: | + gh variable list + gh variable set TEST_VAR --body "Set from workflow" + gh variable list + env: + GITHUB_TOKEN: ${{ secrets.FULL_PAT }} \ No newline at end of file From e90427f18c2559fa296009e4c7f400390b1fbe69 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 7 Apr 2026 20:33:41 -0400 Subject: [PATCH 02/25] Experiment with database options --- .github/workflows/vartest.yml | 48 ++++++++++++++++++++++++++++++----- vartest.py | 26 +++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 vartest.py diff --git a/.github/workflows/vartest.yml b/.github/workflows/vartest.yml index 5c0253f..8077a91 100644 --- a/.github/workflows/vartest.yml +++ b/.github/workflows/vartest.yml @@ -7,14 +7,50 @@ on: - '!master' workflow_dispatch: +permissions: + actions: read + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - run: | - gh variable list - gh variable set TEST_VAR --body "Set from workflow" - gh variable list + - name: Checkout repo + uses: actions/checkout@v4 + - name: Get Previous Run ID + id: get_id env: - GITHUB_TOKEN: ${{ secrets.FULL_PAT }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Fetches the ID of the last completed run for the current workflow + PREVIOUS_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId') + echo "previous_run_id=$PREVIOUS_RUN_ID" >> "$GITHUB_OUTPUT" + - name: Test previous run id + run: echo "${{ steps.get_id.outputs.previous_run_id }}" + - name: Download test artifact + uses: actions/download-artifact@v8 + with: + skip-decompress: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.get_id.outputs.previous_run_id }} + continue-on-error: true + - name: Display structure of downloaded files + run: ls -R + - name: Display contents of downloaded artifact + run: cat my_file.txt + continue-on-error: true + - name: Test write variable + run: python ./vartest.py + - name: Upload test artifact + uses: actions/upload-artifact@v7 + with: + path: my_file.txt + retention-days: 10 + overwrite: true + archive: false + - name: Print current run id + run: "echo ${{ github.run_id }}" + diff --git a/vartest.py b/vartest.py new file mode 100644 index 0000000..2eba5e8 --- /dev/null +++ b/vartest.py @@ -0,0 +1,26 @@ +import json +import sys +import traceback +from pathlib import Path +from datetime import datetime, timezone + +def main(): + Path("my_file.txt").touch(exist_ok=True) + with open("my_file.txt", "r+") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + f.seek(0) + if "pet_list" not in data: + data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] + else: + data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) + json.dump(data, f) + f.truncate() + + +if __name__ == "__main__": + main() From 21c879b9dec4c6440927414e9ff8e90d60236c6d Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 12 May 2026 22:36:36 -0400 Subject: [PATCH 03/25] Add database artifact logic to dev action and main workflow --- .github/workflows/dev.yml | 31 +++++++++++++++++++++++++++++ main.py | 41 +++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 8dda238..7b81877 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -7,6 +7,9 @@ on: - '!master' workflow_dispatch: +permissions: + actions: read + jobs: run-cute-pets: runs-on: ubuntu-latest @@ -22,6 +25,27 @@ jobs: - name: Install dependencies run: pip install --break-system-packages -r requirements.txt + - name: Get Previous Run ID + id: get_id + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Fetches the ID of the last completed run for the current workflow + PREVIOUS_RUN_ID=$(gh run list --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 }} @@ -34,3 +58,10 @@ jobs: run: | #In order to create posts on the test accounts remove the --debugposters debug flag python ./main.py --debugsources --debugposters + + - name: Upload database artifact + uses: actions/upload-artifact@v7 + with: + path: database.json + retention-days: 14 + archive: false diff --git a/main.py b/main.py index a0f0701..c110a39 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,11 @@ import os import random import argparse +import json +import sys import traceback +from pathlib import Path +from datetime import datetime, timezone, timedelta import requests @@ -87,10 +91,39 @@ def run(sources, posters): def pick_pet(pets): - eligible = [pet for pet in pets if pet.image_url and pet.adoption_url] - if not eligible: - return None - return random.choice(eligible) + Path("database.json").touch(exist_ok=True) + # Open file + with open("database.json", "r+") as f: + # Load json + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"{type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + + if "posted_pets" in data: + posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} + else: + pet_ids = {} + data["posted_pets"] = [] + # Check pet has an image, adoption url, and has not been posted + eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] + if not eligible: + return None + + selected_pet = random.choice(eligible) + # Add pet ID to list of posted pets + data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) + # Remove old pets + cutoff = datetime.now() - timedelta(weeks=12) + new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] + data["posted_pets"] = new_pets + # Export json + f.seek(0) + json.dump(data, f) + f.truncate() + return selected_pet # Slack incoming-webhook messages have a ~40k-char limit; cap the traceback From c4c081840cd94eb54ec30c97cce5a49883064f46 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:08:59 -0400 Subject: [PATCH 04/25] Continue action if previous run can't be identified --- .github/workflows/dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 7b81877..df90b75 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -26,6 +26,7 @@ jobs: 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 }} From cb7b239c3cc0ae4b567356c2a77f61b07e43bad9 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:13:11 -0400 Subject: [PATCH 05/25] Initialize correct variable --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index c110a39..fa60dc5 100644 --- a/main.py +++ b/main.py @@ -105,7 +105,7 @@ def pick_pet(pets): if "posted_pets" in data: posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} else: - pet_ids = {} + posted_pet_ids = {} data["posted_pets"] = [] # Check pet has an image, adoption url, and has not been posted eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] From e32941fb8c83fe3aff0dd5065613754401fd3849 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:15:43 -0400 Subject: [PATCH 06/25] Add timezone arg to cutoff date --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index fa60dc5..b60acce 100644 --- a/main.py +++ b/main.py @@ -116,7 +116,7 @@ def pick_pet(pets): # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets - cutoff = datetime.now() - timedelta(weeks=12) + cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] data["posted_pets"] = new_pets # Export json From 839b93c4838fd3bba2742f35edb6857e2cd69440 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:35:42 -0400 Subject: [PATCH 07/25] Try displaying the selected pet --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index b60acce..bfe7bfc 100644 --- a/main.py +++ b/main.py @@ -113,6 +113,7 @@ def pick_pet(pets): return None selected_pet = random.choice(eligible) + print(selected_pet) # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets From ae900b469dbbfd1d6c1b175ec558e7a7b2e306e9 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:40:45 -0400 Subject: [PATCH 08/25] Add pet_id field to debug source --- adoption_sources/manual.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adoption_sources/manual.py b/adoption_sources/manual.py index ef958a4..ea9cb36 100644 --- a/adoption_sources/manual.py +++ b/adoption_sources/manual.py @@ -36,6 +36,7 @@ def fetch_pets(self) -> Iterable[AdoptablePet]: def _build_pet(self, animal: dict) -> AdoptablePet: attrs = animal.get("attributes", {}) + animal_id = animal.get("id", "") return AdoptablePet( name=attrs.get("name", "Unknown"), species=self.species, @@ -44,6 +45,7 @@ def _build_pet(self, animal: dict) -> AdoptablePet: description=(attrs.get("descriptionText") or "").strip(), adoption_url=self._adoption_url(attrs.get("slug")), image_url=attrs.get("pictureThumbnailUrl"), + pet_id=animal_id, ) @staticmethod From 237c98280b63fe1058108bf5166a0d0a9310d905 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Tue, 19 May 2026 20:12:35 -0400 Subject: [PATCH 09/25] Remove experimental action and script --- .github/workflows/vartest.yml | 56 ----------------------------------- vartest.py | 26 ---------------- 2 files changed, 82 deletions(-) delete mode 100644 .github/workflows/vartest.yml delete mode 100644 vartest.py diff --git a/.github/workflows/vartest.yml b/.github/workflows/vartest.yml deleted file mode 100644 index 8077a91..0000000 --- a/.github/workflows/vartest.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: VARTEST - -on: - push: - branches: - - '**' - - '!master' - workflow_dispatch: - -permissions: - actions: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Get Previous Run ID - id: get_id - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Fetches the ID of the last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" \ - --status success \ - --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId') - echo "previous_run_id=$PREVIOUS_RUN_ID" >> "$GITHUB_OUTPUT" - - name: Test previous run id - run: echo "${{ steps.get_id.outputs.previous_run_id }}" - - name: Download test artifact - uses: actions/download-artifact@v8 - with: - skip-decompress: true - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.get_id.outputs.previous_run_id }} - continue-on-error: true - - name: Display structure of downloaded files - run: ls -R - - name: Display contents of downloaded artifact - run: cat my_file.txt - continue-on-error: true - - name: Test write variable - run: python ./vartest.py - - name: Upload test artifact - uses: actions/upload-artifact@v7 - with: - path: my_file.txt - retention-days: 10 - overwrite: true - archive: false - - name: Print current run id - run: "echo ${{ github.run_id }}" - diff --git a/vartest.py b/vartest.py deleted file mode 100644 index 2eba5e8..0000000 --- a/vartest.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import sys -import traceback -from pathlib import Path -from datetime import datetime, timezone - -def main(): - Path("my_file.txt").touch(exist_ok=True) - with open("my_file.txt", "r+") as f: - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) - traceback.print_exc() - data = {} - f.seek(0) - if "pet_list" not in data: - data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] - else: - data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) - json.dump(data, f) - f.truncate() - - -if __name__ == "__main__": - main() From 05aae2ede1f7acc6ba54b94fe6584abf3e05af19 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 7 Apr 2026 20:33:41 -0400 Subject: [PATCH 10/25] Experiment with database options --- vartest.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 vartest.py diff --git a/vartest.py b/vartest.py new file mode 100644 index 0000000..2eba5e8 --- /dev/null +++ b/vartest.py @@ -0,0 +1,26 @@ +import json +import sys +import traceback +from pathlib import Path +from datetime import datetime, timezone + +def main(): + Path("my_file.txt").touch(exist_ok=True) + with open("my_file.txt", "r+") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + f.seek(0) + if "pet_list" not in data: + data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] + else: + data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) + json.dump(data, f) + f.truncate() + + +if __name__ == "__main__": + main() From 12949508c5f3d51856295d0de6b93fe4ed2e629e Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 12 May 2026 22:36:36 -0400 Subject: [PATCH 11/25] Add database artifact logic to dev action and main workflow --- .github/workflows/dev.yml | 1 - main.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index df90b75..7b81877 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -26,7 +26,6 @@ jobs: 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 }} diff --git a/main.py b/main.py index bfe7bfc..c110a39 100644 --- a/main.py +++ b/main.py @@ -105,7 +105,7 @@ def pick_pet(pets): if "posted_pets" in data: posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} else: - posted_pet_ids = {} + pet_ids = {} data["posted_pets"] = [] # Check pet has an image, adoption url, and has not been posted eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] @@ -113,11 +113,10 @@ def pick_pet(pets): return None selected_pet = random.choice(eligible) - print(selected_pet) # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets - cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) + cutoff = datetime.now() - timedelta(weeks=12) new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] data["posted_pets"] = new_pets # Export json From 5a500a13135c373d077f2372af1121b15594f0b5 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 12 May 2026 22:39:48 -0400 Subject: [PATCH 12/25] Revert fix to move to new branch --- .env.example | 14 +- .github/workflows/dev.yml | 134 +++---- .github/workflows/prod.yml | 68 ++-- .github/workflows/tests.yml | 52 +-- CNAME | 2 +- README.md | 110 +++--- abstractions.py | 280 +++++++------- adoption_sources/manual.py | 124 +++---- adoption_sources/rescue_groups.py | 438 +++++++++++----------- config.py | 8 +- docs/index.html | 370 +++++++++--------- docs/shelters.json | 38 +- docs/styles.css | 496 ++++++++++++------------- main.py | 336 ++++++++--------- manual_testing/mastodon_manual_test.py | 64 ++-- manual_testing/mastodon_simple_test.py | 24 +- pytest.ini | 4 +- requirements.txt | 104 +++--- social_posters/__init__.py | 14 +- social_posters/bluesky.py | 416 ++++++++++----------- social_posters/debug.py | 62 ++-- social_posters/instagram.py | 230 ++++++------ social_posters/mastodon.py | 222 +++++------ tests/test_main.py | 138 +++---- tests/test_mastodon.py | 52 +-- tests/test_rescue_groups.py | 150 ++++---- vartest.py | 52 +-- 27 files changed, 2001 insertions(+), 2001 deletions(-) diff --git a/.env.example b/.env.example index f696ab0..dbc61b4 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -CUTEPETSBOSTON_RESCUEGROUPS_API_KEY= -INSTAGRAM_USERNAME= -INSTAGRAM_PASSWORD= -BLUESKY_HANDLE= -BLUESKY_PASSWORD= -APP_ENV= - +CUTEPETSBOSTON_RESCUEGROUPS_API_KEY= +INSTAGRAM_USERNAME= +INSTAGRAM_PASSWORD= +BLUESKY_HANDLE= +BLUESKY_PASSWORD= +APP_ENV= + diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 7b81877..bf67489 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -1,67 +1,67 @@ -name: Debug Post - -on: - push: - branches: - - '**' - - '!master' - workflow_dispatch: - -permissions: - actions: read - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: 'pip' - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - name: Get Previous Run ID - id: get_id - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Fetches the ID of the last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list --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 --debugsources --debugposters - - - name: Upload database artifact - uses: actions/upload-artifact@v7 - with: - path: database.json - retention-days: 14 - archive: false +name: Debug Post + +on: + push: + branches: + - '**' + - '!master' + workflow_dispatch: + +permissions: + actions: read + +jobs: + run-cute-pets: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies + run: pip install --break-system-packages -r requirements.txt + + - name: Get Previous Run ID + id: get_id + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Fetches the ID of the last completed run for the current workflow + PREVIOUS_RUN_ID=$(gh run list --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 --debugsources --debugposters + + - name: Upload database artifact + uses: actions/upload-artifact@v7 + with: + path: database.json + retention-days: 14 + archive: false diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 299987f..44860e0 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -1,34 +1,34 @@ -name: Prod Account Post - -on: - workflow_dispatch: - schedule: - # Every 4 hours - - cron: "0 */4 * * *" - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: 'pip' - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - 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_HANDLE }} - BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} - MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - APP_ENV: prod - run: python ./main.py +name: Prod Account Post + +on: + workflow_dispatch: + schedule: + # Every 4 hours + - cron: "0 */4 * * *" + +jobs: + run-cute-pets: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies + run: pip install --break-system-packages -r requirements.txt + + - 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_HANDLE }} + BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} + MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + APP_ENV: prod + run: python ./main.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee7d44e..09fd2be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,26 +1,26 @@ -name: Tests - -on: - pull_request: - push: - branches: [master] - workflow_dispatch: - -jobs: - pytest: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - name: Run pytest - run: pytest +name: Tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install --break-system-packages -r requirements.txt + + - name: Run pytest + run: pytest diff --git a/CNAME b/CNAME index c2566ef..15c7e83 100644 --- a/CNAME +++ b/CNAME @@ -1 +1 @@ -cutepetsboston.com +cutepetsboston.com diff --git a/README.md b/README.md index 56f617b..57b325f 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,55 @@ -# CutePetsBoston - -# About - -Posts a random adoptable pet from the Boston MSPCA to different social media feeds. - -It should be easily extendable to other shelters and social media feeds for various locations. - -## Github Actions - -This Project runs on github actions and runs periodically. - -## Set up your environment variables - -Required: -- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY` - -Optional for Instagram posting: -- `INSTAGRAM_HANDLE` -- `INSTAGRAM_PASSWORD` - -Optional for Bluesky posting: -- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`) -- `BLUESKY_PASSWORD` (or `BLUESKY_TEST_PASSWORD`) - -Optional for Mastodon posting: -- `MASTODON_TOKEN` or `MASTODON_TEST_TOKEN` -- `MASTODON_API_BASE_URL` (defaults to `https://mastodon.social`) - -Optional platform selection: -- `POSTER_PLATFORMS` to limit posting to specific platforms, for example `mastodon` or `bluesky,mastodon` - -## File organization - -- `main.py`: orchestrates fetching pets and publishing posts. -- `abstractions.py`: shared interfaces and data models. -- `source_*.py`: pet source implementations (ingest from APIs). -- `poster_*.py`: social media poster implementations. -- `manually_test_post.py`: CLI for manual posting with sample data. - -# How to run the script - - python main.py - -To run only the Mastodon poster locally or in GitHub Actions: - - POSTER_PLATFORMS=mastodon python main.py - -# History - -This project was originally started by [Becky Boone](https://github.com/boonrs) and [Drew](https://github.com/drewrwilson) during their fellowship at Code for America in 2014. - -## Sister Projects - -- CutePetsDenver +# CutePetsBoston + +# About + +Posts a random adoptable pet from the Boston MSPCA to different social media feeds. + +It should be easily extendable to other shelters and social media feeds for various locations. + +## Github Actions + +This Project runs on github actions and runs periodically. + +## Set up your environment variables + +Required: +- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY` + +Optional for Instagram posting: +- `INSTAGRAM_HANDLE` +- `INSTAGRAM_PASSWORD` + +Optional for Bluesky posting: +- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`) +- `BLUESKY_PASSWORD` (or `BLUESKY_TEST_PASSWORD`) + +Optional for Mastodon posting: +- `MASTODON_TOKEN` or `MASTODON_TEST_TOKEN` +- `MASTODON_API_BASE_URL` (defaults to `https://mastodon.social`) + +Optional platform selection: +- `POSTER_PLATFORMS` to limit posting to specific platforms, for example `mastodon` or `bluesky,mastodon` + +## File organization + +- `main.py`: orchestrates fetching pets and publishing posts. +- `abstractions.py`: shared interfaces and data models. +- `source_*.py`: pet source implementations (ingest from APIs). +- `poster_*.py`: social media poster implementations. +- `manually_test_post.py`: CLI for manual posting with sample data. + +# How to run the script + + python main.py + +To run only the Mastodon poster locally or in GitHub Actions: + + POSTER_PLATFORMS=mastodon python main.py + +# History + +This project was originally started by [Becky Boone](https://github.com/boonrs) and [Drew](https://github.com/drewrwilson) during their fellowship at Code for America in 2014. + +## Sister Projects + +- CutePetsDenver diff --git a/abstractions.py b/abstractions.py index 870b428..ddcc0c4 100644 --- a/abstractions.py +++ b/abstractions.py @@ -1,140 +1,140 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Iterable - -from config import CITY_NAME, CITY_STATE - - -# ============================================================================= -# Pet Ingestor Interface -# ============================================================================= - - -@dataclass -class AdoptablePet: - """Represents a pet available for adoption.""" - - name: str - species: str # "dog" or "cat" - breed: str - location: str - description: str = "" - adoption_url: str | None = None - image_url: str | None = None - age_string: str | None = None - sex: str | None = None - size_group: str | None = None - pet_id: str | None = None - - -class PetSource(ABC): - """Interface for fetching pets from various adoption APIs.""" - - @property - @abstractmethod - def source_name(self) -> str: - """Return the name of the pet source.""" - ... - - @abstractmethod - def fetch_pets(self) -> Iterable[AdoptablePet]: - """Fetch available pets from the source.""" - ... - - -# ============================================================================= -# Social Media Poster Interface -# ============================================================================= - - -@dataclass -class Post: - """Represents a social media post about an adoptable pet.""" - - text: str - image_url: str | None = None - link: str | None = None - alt_text: str | None = None # For image accessibility - tags: list[str] = field(default_factory=list) - - -@dataclass -class PostResult: - """Result of attempting to publish a post.""" - - success: bool - post_id: str | None = None - post_url: str | None = None - error_message: str | None = None - - -class SocialPoster(ABC): - """ - Abstract base class for social media platform implementations. - - Concrete implementations should inherit from this class and implement - the abstract methods for their specific platform (e.g., Bluesky, Instagram). - """ - - @property - @abstractmethod - def platform_name(self) -> str: - """Return the name of the social media platform.""" - ... - - @abstractmethod - def authenticate(self) -> bool: - """ - Authenticate with the platform. - - Returns: - True if authentication was successful, False otherwise. - """ - ... - - @abstractmethod - def publish(self, post: Post) -> PostResult: - """ - Publish a post to the platform. - - Args: - post: The post to publish. - - Returns: - PostResult indicating success/failure and relevant details. - """ - ... - - def is_authenticated(self) -> bool: - """Check if currently authenticated. Override if platform supports this.""" - return False - - def format_post(self, pet: AdoptablePet) -> Post: - """ - Create a Post from an AdoptablePet. - - Override this method to customize post formatting for specific platforms. - """ - text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}." - if pet.description: - text += f"\n\n{pet.description}" - if pet.adoption_url: - text += f"\n\nAdopt {pet.name}: {pet.adoption_url}" - - city = "" - if pet.location != f"{CITY_NAME}, {CITY_STATE}": - city = pet.location.split(",")[0].capitalize() - - return Post( - text=text, - image_url=pet.image_url, - link=pet.adoption_url, - alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption", - tags=[ - "adoptdontshop", - "rescue", - city, - pet.species, - pet.breed.lower().replace(" ", ""), - ], - ) +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Iterable + +from config import CITY_NAME, CITY_STATE + + +# ============================================================================= +# Pet Ingestor Interface +# ============================================================================= + + +@dataclass +class AdoptablePet: + """Represents a pet available for adoption.""" + + name: str + species: str # "dog" or "cat" + breed: str + location: str + description: str = "" + adoption_url: str | None = None + image_url: str | None = None + age_string: str | None = None + sex: str | None = None + size_group: str | None = None + pet_id: str | None = None + + +class PetSource(ABC): + """Interface for fetching pets from various adoption APIs.""" + + @property + @abstractmethod + def source_name(self) -> str: + """Return the name of the pet source.""" + ... + + @abstractmethod + def fetch_pets(self) -> Iterable[AdoptablePet]: + """Fetch available pets from the source.""" + ... + + +# ============================================================================= +# Social Media Poster Interface +# ============================================================================= + + +@dataclass +class Post: + """Represents a social media post about an adoptable pet.""" + + text: str + image_url: str | None = None + link: str | None = None + alt_text: str | None = None # For image accessibility + tags: list[str] = field(default_factory=list) + + +@dataclass +class PostResult: + """Result of attempting to publish a post.""" + + success: bool + post_id: str | None = None + post_url: str | None = None + error_message: str | None = None + + +class SocialPoster(ABC): + """ + Abstract base class for social media platform implementations. + + Concrete implementations should inherit from this class and implement + the abstract methods for their specific platform (e.g., Bluesky, Instagram). + """ + + @property + @abstractmethod + def platform_name(self) -> str: + """Return the name of the social media platform.""" + ... + + @abstractmethod + def authenticate(self) -> bool: + """ + Authenticate with the platform. + + Returns: + True if authentication was successful, False otherwise. + """ + ... + + @abstractmethod + def publish(self, post: Post) -> PostResult: + """ + Publish a post to the platform. + + Args: + post: The post to publish. + + Returns: + PostResult indicating success/failure and relevant details. + """ + ... + + def is_authenticated(self) -> bool: + """Check if currently authenticated. Override if platform supports this.""" + return False + + def format_post(self, pet: AdoptablePet) -> Post: + """ + Create a Post from an AdoptablePet. + + Override this method to customize post formatting for specific platforms. + """ + text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}." + if pet.description: + text += f"\n\n{pet.description}" + if pet.adoption_url: + text += f"\n\nAdopt {pet.name}: {pet.adoption_url}" + + city = "" + if pet.location != f"{CITY_NAME}, {CITY_STATE}": + city = pet.location.split(",")[0].capitalize() + + return Post( + text=text, + image_url=pet.image_url, + link=pet.adoption_url, + alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption", + tags=[ + "adoptdontshop", + "rescue", + city, + pet.species, + pet.breed.lower().replace(" ", ""), + ], + ) diff --git a/adoption_sources/manual.py b/adoption_sources/manual.py index ea9cb36..92c0851 100644 --- a/adoption_sources/manual.py +++ b/adoption_sources/manual.py @@ -1,62 +1,62 @@ -"""Manual PetSource returning a fixed set of adoptable pets.""" - -from __future__ import annotations - -import json -from typing import Iterable, Sequence - -from abstractions import AdoptablePet, PetSource -from config import CITY_NAME, CITY_STATE - -_data_path = __file__.replace(".py", ".json") -with open(_data_path) as _f: - MANUAL_SOURCE_DATA: tuple[dict, ...] = tuple(json.loads(_f.read())) - - -class SourceManual(PetSource): - """Static PetSource useful for offline testing and demos.""" - - def __init__( - self, - animals: Sequence[dict] | None = None, - location_label: str = f"{CITY_NAME}, {CITY_STATE}", - species: str = "dog", - ) -> None: - self._animals: Sequence[dict] = animals if animals is not None else MANUAL_SOURCE_DATA - self.location_label = location_label - self.species = species - - @property - def source_name(self) -> str: - return "Manual" - - def fetch_pets(self) -> Iterable[AdoptablePet]: - for animal in self._animals: - yield self._build_pet(animal) - - def _build_pet(self, animal: dict) -> AdoptablePet: - attrs = animal.get("attributes", {}) - animal_id = animal.get("id", "") - return AdoptablePet( - name=attrs.get("name", "Unknown"), - species=self.species, - breed=self._determine_breed(attrs), - location=self.location_label, - description=(attrs.get("descriptionText") or "").strip(), - adoption_url=self._adoption_url(attrs.get("slug")), - image_url=attrs.get("pictureThumbnailUrl"), - pet_id=animal_id, - ) - - @staticmethod - def _determine_breed(attrs: dict) -> str: - return attrs.get("breedString") or attrs.get("breedPrimary") or "Mixed" - - @staticmethod - def _adoption_url(slug: str | None) -> str | None: - if slug: - return f"https://www.rescuegroups.org/pet/{slug}" - return None - - -__all__ = ["SourceManual", "MANUAL_SOURCE_DATA"] +"""Manual PetSource returning a fixed set of adoptable pets.""" + +from __future__ import annotations + +import json +from typing import Iterable, Sequence + +from abstractions import AdoptablePet, PetSource +from config import CITY_NAME, CITY_STATE + +_data_path = __file__.replace(".py", ".json") +with open(_data_path) as _f: + MANUAL_SOURCE_DATA: tuple[dict, ...] = tuple(json.loads(_f.read())) + + +class SourceManual(PetSource): + """Static PetSource useful for offline testing and demos.""" + + def __init__( + self, + animals: Sequence[dict] | None = None, + location_label: str = f"{CITY_NAME}, {CITY_STATE}", + species: str = "dog", + ) -> None: + self._animals: Sequence[dict] = animals if animals is not None else MANUAL_SOURCE_DATA + self.location_label = location_label + self.species = species + + @property + def source_name(self) -> str: + return "Manual" + + def fetch_pets(self) -> Iterable[AdoptablePet]: + for animal in self._animals: + yield self._build_pet(animal) + + def _build_pet(self, animal: dict) -> AdoptablePet: + attrs = animal.get("attributes", {}) + animal_id = animal.get("id", "") + return AdoptablePet( + name=attrs.get("name", "Unknown"), + species=self.species, + breed=self._determine_breed(attrs), + location=self.location_label, + description=(attrs.get("descriptionText") or "").strip(), + adoption_url=self._adoption_url(attrs.get("slug")), + image_url=attrs.get("pictureThumbnailUrl"), + pet_id=animal_id, + ) + + @staticmethod + def _determine_breed(attrs: dict) -> str: + return attrs.get("breedString") or attrs.get("breedPrimary") or "Mixed" + + @staticmethod + def _adoption_url(slug: str | None) -> str | None: + if slug: + return f"https://www.rescuegroups.org/pet/{slug}" + return None + + +__all__ = ["SourceManual", "MANUAL_SOURCE_DATA"] diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index 5fffcae..dbb4910 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -1,219 +1,219 @@ -""" -RescueGroups.org API implementation of the PetSource interface. - -API Documentation: https://api.rescuegroups.org/v5/public/docs -""" - -import html -import logging -import os -import re -from typing import Iterator - -import requests - -from abstractions import AdoptablePet, PetSource -from config import CITY_NAME, CITY_STATE, POSTAL_CODE - -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!",) - - -class SourceRescueGroups(PetSource): - """ - Fetches adoptable pets from RescueGroups.org API. - - Requires CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable or api_key constructor arg. - """ - - BASE_URL = "https://api.rescuegroups.org/v5/public/animals/search" - - def __init__( - self, - api_key: str | None = None, - postal_code: str = POSTAL_CODE, - radius_miles: int = 50, - species: str = "dogs", # "dogs" or "cats" - limit: int = 25, - 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.limit = limit - self.location_label = location_label - - @property - def source_name(self) -> str: - return f"RescueGroups ({self.species})" - - def fetch_pets(self) -> Iterator[AdoptablePet]: - """ - Fetch available pets from RescueGroups.org. - - Yields: - AdoptablePet objects for each available pet. - - Raises: - ValueError: If API key is not configured. - requests.HTTPError: If the API request fails. - """ - if not self._api_key: - raise ValueError( - "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"&sort=random" - f"&limit={self.limit}" - ) - headers = { - "Content-Type": "application/vnd.api+json", - "Authorization": self._api_key, - } - payload = { - "data": { - "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}" - ) - - response = requests.post(url, json=payload, headers=headers, timeout=30) - response.raise_for_status() - - body = response.json() - data = body.get("data", []) - logger.info(f"Received {len(data)} pets from RescueGroups") - - orgs_by_id = { - item["id"]: item.get("attributes", {}) - for item in body.get("included", []) - if item.get("type") == "orgs" - } - - for animal in data: - pet = self._parse_animal(animal, orgs_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: - """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" - - # Get breed info - breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) - - # Clean up description (use text version, not HTML) - description = self._clean_description(attrs.get("descriptionText", "")) - - # Get adoption_url - org_id = ( - animal.get("relationships", {}) - .get("orgs", {}) - .get("data", [{}])[0] - .get("id") - ) - org_attrs = orgs_by_id.get(org_id, {}) if org_id else {} - adoption_url = next( - (u for u in (attrs.get("adoptionUrl"), org_attrs.get("adoptionUrl"), org_attrs.get("url")) - if u and u.strip().rstrip("/") not in ("http:", "https:", "http://", "https://")), - None - ) - - # 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, - breed=breed, - location=location, - description=description, - adoption_url=adoption_url, - image_url=image_url, - age_string=attrs.get("ageString"), - sex=attrs.get("sex"), - size_group=attrs.get("sizeGroup"), - pet_id=animal_id, - ) - except Exception as e: - logger.warning(f"Failed to parse animal {animal.get('id', 'unknown')}: {e}") - return None - - def _is_placeholder_name(self, name: str) -> bool: - return name.lower() in PLACEHOLDER_NAMES - - def _clean_name(self, name: str) -> str: - """ - Clean up pet name by removing promotional text. - - Examples: - "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() - - def _clean_description(self, description: str) -> str: - """Clean up description text.""" - 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] + "..." - - return text - - 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 +""" +RescueGroups.org API implementation of the PetSource interface. + +API Documentation: https://api.rescuegroups.org/v5/public/docs +""" + +import html +import logging +import os +import re +from typing import Iterator + +import requests + +from abstractions import AdoptablePet, PetSource +from config import CITY_NAME, CITY_STATE, POSTAL_CODE + +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!",) + + +class SourceRescueGroups(PetSource): + """ + Fetches adoptable pets from RescueGroups.org API. + + Requires CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable or api_key constructor arg. + """ + + BASE_URL = "https://api.rescuegroups.org/v5/public/animals/search" + + def __init__( + self, + api_key: str | None = None, + postal_code: str = POSTAL_CODE, + radius_miles: int = 50, + species: str = "dogs", # "dogs" or "cats" + limit: int = 25, + 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.limit = limit + self.location_label = location_label + + @property + def source_name(self) -> str: + return f"RescueGroups ({self.species})" + + def fetch_pets(self) -> Iterator[AdoptablePet]: + """ + Fetch available pets from RescueGroups.org. + + Yields: + AdoptablePet objects for each available pet. + + Raises: + ValueError: If API key is not configured. + requests.HTTPError: If the API request fails. + """ + if not self._api_key: + raise ValueError( + "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"&sort=random" + f"&limit={self.limit}" + ) + headers = { + "Content-Type": "application/vnd.api+json", + "Authorization": self._api_key, + } + payload = { + "data": { + "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}" + ) + + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + + body = response.json() + data = body.get("data", []) + logger.info(f"Received {len(data)} pets from RescueGroups") + + orgs_by_id = { + item["id"]: item.get("attributes", {}) + for item in body.get("included", []) + if item.get("type") == "orgs" + } + + for animal in data: + pet = self._parse_animal(animal, orgs_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: + """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" + + # Get breed info + breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) + + # Clean up description (use text version, not HTML) + description = self._clean_description(attrs.get("descriptionText", "")) + + # Get adoption_url + org_id = ( + animal.get("relationships", {}) + .get("orgs", {}) + .get("data", [{}])[0] + .get("id") + ) + org_attrs = orgs_by_id.get(org_id, {}) if org_id else {} + adoption_url = next( + (u for u in (attrs.get("adoptionUrl"), org_attrs.get("adoptionUrl"), org_attrs.get("url")) + if u and u.strip().rstrip("/") not in ("http:", "https:", "http://", "https://")), + None + ) + + # 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, + breed=breed, + location=location, + description=description, + adoption_url=adoption_url, + image_url=image_url, + age_string=attrs.get("ageString"), + sex=attrs.get("sex"), + size_group=attrs.get("sizeGroup"), + pet_id=animal_id, + ) + except Exception as e: + logger.warning(f"Failed to parse animal {animal.get('id', 'unknown')}: {e}") + return None + + def _is_placeholder_name(self, name: str) -> bool: + return name.lower() in PLACEHOLDER_NAMES + + def _clean_name(self, name: str) -> str: + """ + Clean up pet name by removing promotional text. + + Examples: + "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() + + def _clean_description(self, description: str) -> str: + """Clean up description text.""" + 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] + "..." + + return text + + 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 diff --git a/config.py b/config.py index 0178730..b164161 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,4 @@ -CITY_NAME = "Boston" -CITY_STATE = "MA" -CITY_HASHTAGS = ["Boston"] -POSTAL_CODE = "02108" +CITY_NAME = "Boston" +CITY_STATE = "MA" +CITY_HASHTAGS = ["Boston"] +POSTAL_CODE = "02108" diff --git a/docs/index.html b/docs/index.html index 7a77853..56325ad 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,185 +1,185 @@ - - - - - - CutePetsBoston - - - - - - -
-
-

CutePetsBoston

-

Finding forever homes, one cute pet at a time.

-

CutePetsBoston is a volunteer-run social media bot that shares
adoptable pets daily from Boston-area shelters.

- -
-
- -
-
-

Featured shelters

-

- Shelters we've confirmed featuring on the feed. Our broader pet pool comes - from RescueGroups.org - within 50 miles of Boston, so plenty of other rescues appear too. -

-
-

Loading shelters…

-
-
- -
-

Recent posts

-

Latest pets from our Mastodon feed:

-
-

Loading recent posts…

-
-
- -
-

How to contribute

-

- CutePetsBoston is a Code for Boston - project. Come hack on it with us. -

- -
-
- - - - - - + + + + + + CutePetsBoston + + + + + + +
+
+

CutePetsBoston

+

Finding forever homes, one cute pet at a time.

+

CutePetsBoston is a volunteer-run social media bot that shares
adoptable pets daily from Boston-area shelters.

+ +
+
+ +
+
+

Featured shelters

+

+ Shelters we've confirmed featuring on the feed. Our broader pet pool comes + from RescueGroups.org + within 50 miles of Boston, so plenty of other rescues appear too. +

+
+

Loading shelters…

+
+
+ +
+

Recent posts

+

Latest pets from our Mastodon feed:

+
+

Loading recent posts…

+
+
+ +
+

How to contribute

+

+ CutePetsBoston is a Code for Boston + project. Come hack on it with us. +

+ +
+
+ + + + + + diff --git a/docs/shelters.json b/docs/shelters.json index fad075c..a5d9fec 100644 --- a/docs/shelters.json +++ b/docs/shelters.json @@ -1,19 +1,19 @@ -{ - "shelters": [ - { - "name": "MSPCA-Angell", - "location": "Boston, MA", - "url": "https://www.mspca.org/adoption/" - }, - { - "name": "Sterling Animal Shelter", - "location": "Sterling, MA", - "url": "https://www.sterlingshelter.org/" - }, - { - "name": "Small Dog Rescue of New England", - "location": "Rhode Island", - "url": "https://www.smalldogrescuene.org/" - } - ] -} +{ + "shelters": [ + { + "name": "MSPCA-Angell", + "location": "Boston, MA", + "url": "https://www.mspca.org/adoption/" + }, + { + "name": "Sterling Animal Shelter", + "location": "Sterling, MA", + "url": "https://www.sterlingshelter.org/" + }, + { + "name": "Small Dog Rescue of New England", + "location": "Rhode Island", + "url": "https://www.smalldogrescuene.org/" + } + ] +} diff --git a/docs/styles.css b/docs/styles.css index e710ca3..e076b54 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -1,248 +1,248 @@ -:root { - --bg: #fef9f4; - --surface: #ffffff; - --ink: #1f2937; - --muted: #6b7280; - --accent: #d6336c; - --accent-soft: #fde2ec; - --border: #e5e7eb; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.04); -} - -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, - Ubuntu, sans-serif; - background: var(--bg); - color: var(--ink); - line-height: 1.5; -} - -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -.container { - max-width: 960px; - margin: 0 auto; - padding: 0 1.25rem; -} - -.hero { - background: linear-gradient(135deg, var(--accent-soft), #fff); - border-bottom: 1px solid var(--border); - padding: 3.5rem 0 2.5rem; - text-align: center; -} - -.hero h1 { - margin: 0 0 0.5rem; - font-size: 2.5rem; - letter-spacing: -0.02em; -} - -.hero .headline { - margin: 0 auto 0.6rem; - max-width: 640px; - color: var(--ink); - font-size: 1.35rem; - font-weight: 500; -} - -.hero .subheader { - margin: 0 auto; - max-width: 560px; - color: var(--muted); - font-size: 1.05rem; -} - -section { - padding: 2.5rem 0; - border-bottom: 1px solid var(--border); -} - -section:last-of-type { - border-bottom: none; -} - -h2 { - margin: 0 0 1rem; - font-size: 1.5rem; -} - -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; -} - -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1.1rem 1.2rem; - box-shadow: var(--shadow); -} - -.card h3 { - margin: 0 0 0.25rem; - font-size: 1.05rem; -} - -.card .meta { - color: var(--muted); - font-size: 0.9rem; - margin-bottom: 0.5rem; -} - -.dashboard-note { - margin-top: 1rem; - color: var(--muted); - font-size: 0.9rem; -} - -.action-links { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; -} - -.follow { - margin-top: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - flex-wrap: wrap; -} - -.follow-label { - color: var(--muted); - font-size: 0.95rem; - font-weight: 500; -} - -.social-icons { - display: flex; - justify-content: center; - gap: 0.75rem; -} - -.social-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: 999px; - color: var(--muted); - background: var(--surface); - border: 1px solid var(--border); - transition: color 120ms ease, border-color 120ms ease, transform 120ms ease; -} - -.social-icon svg { - width: 1.25rem; - height: 1.25rem; - fill: currentColor; -} - -.social-icon:hover { - color: var(--accent); - border-color: var(--accent); - text-decoration: none; - transform: translateY(-1px); -} - -.btn { - display: inline-block; - padding: 0.55rem 1rem; - border-radius: 999px; - background: var(--surface); - border: 1px solid var(--border); - color: var(--ink); - font-weight: 500; -} - -.btn:hover { - border-color: var(--accent); - color: var(--accent); - text-decoration: none; -} - -.btn-primary { - background: var(--accent); - color: #fff; - border-color: var(--accent); -} - -.btn-primary:hover { - background: #b8265a; - border-color: #b8265a; - color: #fff; -} - -.post-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; -} - -.post-card { - display: flex; - flex-direction: column; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - color: var(--ink); - box-shadow: var(--shadow); - transition: transform 120ms ease, border-color 120ms ease; -} - -.post-card:hover { - text-decoration: none; - transform: translateY(-2px); - border-color: var(--accent); -} - -.post-image { - width: 100%; - aspect-ratio: 1 / 1; - object-fit: cover; - display: block; - background: var(--accent-soft); -} - -.post-image-placeholder { - background: var(--accent-soft); -} - -.post-body { - padding: 0.75rem 0.9rem 0.9rem; -} - -.post-caption { - font-size: 0.95rem; - margin-bottom: 0.3rem; -} - -footer { - text-align: center; - padding: 2rem 1rem 3rem; - color: var(--muted); - font-size: 0.9rem; -} +:root { + --bg: #fef9f4; + --surface: #ffffff; + --ink: #1f2937; + --muted: #6b7280; + --accent: #d6336c; + --accent-soft: #fde2ec; + --border: #e5e7eb; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.04); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, sans-serif; + background: var(--bg); + color: var(--ink); + line-height: 1.5; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 0 1.25rem; +} + +.hero { + background: linear-gradient(135deg, var(--accent-soft), #fff); + border-bottom: 1px solid var(--border); + padding: 3.5rem 0 2.5rem; + text-align: center; +} + +.hero h1 { + margin: 0 0 0.5rem; + font-size: 2.5rem; + letter-spacing: -0.02em; +} + +.hero .headline { + margin: 0 auto 0.6rem; + max-width: 640px; + color: var(--ink); + font-size: 1.35rem; + font-weight: 500; +} + +.hero .subheader { + margin: 0 auto; + max-width: 560px; + color: var(--muted); + font-size: 1.05rem; +} + +section { + padding: 2.5rem 0; + border-bottom: 1px solid var(--border); +} + +section:last-of-type { + border-bottom: none; +} + +h2 { + margin: 0 0 1rem; + font-size: 1.5rem; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.1rem 1.2rem; + box-shadow: var(--shadow); +} + +.card h3 { + margin: 0 0 0.25rem; + font-size: 1.05rem; +} + +.card .meta { + color: var(--muted); + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.dashboard-note { + margin-top: 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.action-links { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.follow { + margin-top: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.follow-label { + color: var(--muted); + font-size: 0.95rem; + font-weight: 500; +} + +.social-icons { + display: flex; + justify-content: center; + gap: 0.75rem; +} + +.social-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + transition: color 120ms ease, border-color 120ms ease, transform 120ms ease; +} + +.social-icon svg { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; +} + +.social-icon:hover { + color: var(--accent); + border-color: var(--accent); + text-decoration: none; + transform: translateY(-1px); +} + +.btn { + display: inline-block; + padding: 0.55rem 1rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--ink); + font-weight: 500; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.btn-primary:hover { + background: #b8265a; + border-color: #b8265a; + color: #fff; +} + +.post-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.post-card { + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + color: var(--ink); + box-shadow: var(--shadow); + transition: transform 120ms ease, border-color 120ms ease; +} + +.post-card:hover { + text-decoration: none; + transform: translateY(-2px); + border-color: var(--accent); +} + +.post-image { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--accent-soft); +} + +.post-image-placeholder { + background: var(--accent-soft); +} + +.post-body { + padding: 0.75rem 0.9rem 0.9rem; +} + +.post-caption { + font-size: 0.95rem; + margin-bottom: 0.3rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + color: var(--muted); + font-size: 0.9rem; +} diff --git a/main.py b/main.py index c110a39..6daf6b4 100644 --- a/main.py +++ b/main.py @@ -1,168 +1,168 @@ -import os -import random -import argparse -import json -import sys -import traceback -from pathlib import Path -from datetime import datetime, timezone, timedelta - -import requests - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--debugsources", action="store_true") # this defaults to False - parser.add_argument("--debugposters", action="store_true") # this defaults to False - - args = parser.parse_args() - - try: - sources = create_sources(debug=args.debugsources) - posters = create_posters(debug=args.debugposters) - - run(sources, posters) - except Exception: - notify_slack_of_exception(traceback.format_exc()) - raise - - -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()) - posters.append(PosterBluesky()) - posters.append(PosterInstagram()) - return posters - - - - -def create_sources(debug=False): - from adoption_sources import SourceRescueGroups, SourceManual - - if debug: - return [SourceManual()] - - sources = [] - - sources.append(SourceRescueGroups()) - - return sources - - -def run(sources, posters): - pets = [] - for source in sources: - try: - pets.extend(list(source.fetch_pets())) - except ValueError as exc: - raise SystemExit(str(exc)) from exc - - print("Fetched", len(pets), "records") - pet = pick_pet(pets) - if not pet: - print("No pets available to post.") - return [] - - if not posters: - print("No social media credentials set; skipping post.") - return [] - - results = [] - for poster in posters: - post = poster.format_post(pet) - result = poster.publish(post) - results.append(result) - if not result.success: - print(f"{poster.platform_name} post failed: {result.error_message}") - else: - print(f"{poster.platform_name} post published.") - - return results - - -def pick_pet(pets): - Path("database.json").touch(exist_ok=True) - # Open file - with open("database.json", "r+") as f: - # Load json - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - print(f"{type(e).__name__}:{e}", file=sys.stderr) - traceback.print_exc() - data = {} - - if "posted_pets" in data: - posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} - else: - pet_ids = {} - data["posted_pets"] = [] - # Check pet has an image, adoption url, and has not been posted - eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] - if not eligible: - return None - - selected_pet = random.choice(eligible) - # Add pet ID to list of posted pets - data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) - # Remove old pets - cutoff = datetime.now() - timedelta(weeks=12) - new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] - data["posted_pets"] = new_pets - # Export json - f.seek(0) - json.dump(data, f) - f.truncate() - return selected_pet - - -# Slack incoming-webhook messages have a ~40k-char limit; cap the traceback -# well below that so the post stays readable and is never rejected. -MAX_TRACEBACK_CHARS = 2500 - - -def notify_slack_of_exception(traceback_text): - print(traceback_text) - - webhook_url = os.environ.get("SLACK_WEBHOOK_URL") - if not webhook_url: - print("SLACK_WEBHOOK_URL not set; skipping Slack alert.") - return - - app_env = os.environ.get("APP_ENV", "local") - workflow = os.environ.get("GITHUB_WORKFLOW", "local run") - event = os.environ.get("GITHUB_EVENT_NAME") - repo = os.environ.get("GITHUB_REPOSITORY") - run_id = os.environ.get("GITHUB_RUN_ID") - run_link = ( - f"https://github.com/{repo}/actions/runs/{run_id}" - if repo and run_id - else None - ) - - header = f"CutePetsBoston [{app_env}] run failed in *{workflow}*" - if event: - header += f" (trigger: {event})" - if run_link: - header += f" (<{run_link}|view run>)" - text = f"{header}\n```{traceback_text.strip()[-MAX_TRACEBACK_CHARS:]}```" - - try: - response = requests.post(webhook_url, json={"text": text}, timeout=10) - response.raise_for_status() - except Exception as slack_exc: - print(f"Failed to post Slack alert: {slack_exc}") - - -if __name__ == "__main__": - main() +import os +import random +import argparse +import json +import sys +import traceback +from pathlib import Path +from datetime import datetime, timezone, timedelta + +import requests + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--debugsources", action="store_true") # this defaults to False + parser.add_argument("--debugposters", action="store_true") # this defaults to False + + args = parser.parse_args() + + try: + sources = create_sources(debug=args.debugsources) + posters = create_posters(debug=args.debugposters) + + run(sources, posters) + except Exception: + notify_slack_of_exception(traceback.format_exc()) + raise + + +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()) + posters.append(PosterBluesky()) + posters.append(PosterInstagram()) + return posters + + + + +def create_sources(debug=False): + from adoption_sources import SourceRescueGroups, SourceManual + + if debug: + return [SourceManual()] + + sources = [] + + sources.append(SourceRescueGroups()) + + return sources + + +def run(sources, posters): + pets = [] + for source in sources: + try: + pets.extend(list(source.fetch_pets())) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + + print("Fetched", len(pets), "records") + pet = pick_pet(pets) + if not pet: + print("No pets available to post.") + return [] + + if not posters: + print("No social media credentials set; skipping post.") + return [] + + results = [] + for poster in posters: + post = poster.format_post(pet) + result = poster.publish(post) + results.append(result) + if not result.success: + print(f"{poster.platform_name} post failed: {result.error_message}") + else: + print(f"{poster.platform_name} post published.") + + return results + + +def pick_pet(pets): + Path("database.json").touch(exist_ok=True) + # Open file + with open("database.json", "r+") as f: + # Load json + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"{type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + + if "posted_pets" in data: + posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} + else: + pet_ids = {} + data["posted_pets"] = [] + # Check pet has an image, adoption url, and has not been posted + eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] + if not eligible: + return None + + selected_pet = random.choice(eligible) + # Add pet ID to list of posted pets + data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) + # Remove old pets + cutoff = datetime.now() - timedelta(weeks=12) + new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] + data["posted_pets"] = new_pets + # Export json + f.seek(0) + json.dump(data, f) + f.truncate() + return selected_pet + + +# Slack incoming-webhook messages have a ~40k-char limit; cap the traceback +# well below that so the post stays readable and is never rejected. +MAX_TRACEBACK_CHARS = 2500 + + +def notify_slack_of_exception(traceback_text): + print(traceback_text) + + webhook_url = os.environ.get("SLACK_WEBHOOK_URL") + if not webhook_url: + print("SLACK_WEBHOOK_URL not set; skipping Slack alert.") + return + + app_env = os.environ.get("APP_ENV", "local") + workflow = os.environ.get("GITHUB_WORKFLOW", "local run") + event = os.environ.get("GITHUB_EVENT_NAME") + repo = os.environ.get("GITHUB_REPOSITORY") + run_id = os.environ.get("GITHUB_RUN_ID") + run_link = ( + f"https://github.com/{repo}/actions/runs/{run_id}" + if repo and run_id + else None + ) + + header = f"CutePetsBoston [{app_env}] run failed in *{workflow}*" + if event: + header += f" (trigger: {event})" + if run_link: + header += f" (<{run_link}|view run>)" + text = f"{header}\n```{traceback_text.strip()[-MAX_TRACEBACK_CHARS:]}```" + + try: + response = requests.post(webhook_url, json={"text": text}, timeout=10) + response.raise_for_status() + except Exception as slack_exc: + print(f"Failed to post Slack alert: {slack_exc}") + + +if __name__ == "__main__": + main() diff --git a/manual_testing/mastodon_manual_test.py b/manual_testing/mastodon_manual_test.py index e4b1c01..4851ace 100644 --- a/manual_testing/mastodon_manual_test.py +++ b/manual_testing/mastodon_manual_test.py @@ -1,33 +1,33 @@ -import sys, os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from abstractions import Post -from social_posters.mastodon import PosterMastodon - - - -def main(): - poster = PosterMastodon() - - if not poster.authenticate(): - print("Authentication failed!") - exit(1) - - print("Authenticated to Mastodon!") - - post = Post( - text="Test post", - image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447", - alt_text="Cute animal", - tags=["Test", "Mastodon"], - ) - - result = poster.publish(post) - - if result.success: - print(f"Posted successfully! URL: {result.post_url}") - else: - print(f"Post failed: {result.error_message}") - -if __name__ == "__main__": +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from abstractions import Post +from social_posters.mastodon import PosterMastodon + + + +def main(): + poster = PosterMastodon() + + if not poster.authenticate(): + print("Authentication failed!") + exit(1) + + print("Authenticated to Mastodon!") + + post = Post( + text="Test post", + image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447", + alt_text="Cute animal", + tags=["Test", "Mastodon"], + ) + + result = poster.publish(post) + + if result.success: + print(f"Posted successfully! URL: {result.post_url}") + else: + print(f"Post failed: {result.error_message}") + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/manual_testing/mastodon_simple_test.py b/manual_testing/mastodon_simple_test.py index b4a5024..8218c7d 100644 --- a/manual_testing/mastodon_simple_test.py +++ b/manual_testing/mastodon_simple_test.py @@ -1,12 +1,12 @@ -from mastodon import Mastodon -import os -from datetime import datetime - -client = Mastodon( - access_token=os.environ.get("MASTODON_TEST_TOKEN"), - api_base_url=os.environ.get("MASTODON_API_BASE_URL", "https://mastodon.social"), -) - -client.account_verify_credentials() -client.status_post(f"Simple Test at {datetime.now()}") -print("Success") +from mastodon import Mastodon +import os +from datetime import datetime + +client = Mastodon( + access_token=os.environ.get("MASTODON_TEST_TOKEN"), + api_base_url=os.environ.get("MASTODON_API_BASE_URL", "https://mastodon.social"), +) + +client.account_verify_credentials() +client.status_post(f"Simple Test at {datetime.now()}") +print("Success") diff --git a/pytest.ini b/pytest.ini index 5ee6477..53bfcf9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ -[pytest] -testpaths = tests +[pytest] +testpaths = tests diff --git a/requirements.txt b/requirements.txt index 1915a71..0bb98a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,52 +1,52 @@ -anyio==4.12.1 -api-display-purposes==0.0.3 -attrs==25.4.0 -beautifulsoup4==4.14.3 -blurhash==1.1.5 -certifi==2026.2.25 -chardet==3.0.4 -charset-normalizer==3.4.4 -clarifai==2.6.2 -configparser==3.8.1 -decorator==4.0.2 -EasyProcess==1.1 -emoji==1.7.0 -future==1.0.0 -googleapis-common-protos==1.72.0 -grpcio==1.78.0 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==2.10 -instapy==0.6.16 -jsonschema==2.6.0 -Mastodon.py==2.1.4 -MeaningCloud-python==2.0.0 -outcome==1.3.0.post0 -plyer==2.1.0 -protobuf==3.20.3 -PySocks==1.7.1 -pytest==9.0.3 -python-dateutil==2.9.0.post0 -python-magic==0.4.27 -python-telegram-bot==22.6 -PyVirtualDisplay==3.0 -PyYAML==6.0.3 -regex==2026.2.28 -requests==2.32.5 -selenium==4.41.0 -semantic-version==2.10.0 -setuptools==82.0.0 -setuptools-rust==1.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -soupsieve==2.8.3 -tqdm==4.67.3 -trio==0.33.0 -trio-websocket==0.12.2 -typing_extensions==4.15.0 -urllib3==2.6.3 -webdriverdownloader==1.1.0.4 -websocket-client==1.9.0 -wsproto==1.3.2 +anyio==4.12.1 +api-display-purposes==0.0.3 +attrs==25.4.0 +beautifulsoup4==4.14.3 +blurhash==1.1.5 +certifi==2026.2.25 +chardet==3.0.4 +charset-normalizer==3.4.4 +clarifai==2.6.2 +configparser==3.8.1 +decorator==4.0.2 +EasyProcess==1.1 +emoji==1.7.0 +future==1.0.0 +googleapis-common-protos==1.72.0 +grpcio==1.78.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==2.10 +instapy==0.6.16 +jsonschema==2.6.0 +Mastodon.py==2.1.4 +MeaningCloud-python==2.0.0 +outcome==1.3.0.post0 +plyer==2.1.0 +protobuf==3.20.3 +PySocks==1.7.1 +pytest==9.0.3 +python-dateutil==2.9.0.post0 +python-magic==0.4.27 +python-telegram-bot==22.6 +PyVirtualDisplay==3.0 +PyYAML==6.0.3 +regex==2026.2.28 +requests==2.32.5 +selenium==4.41.0 +semantic-version==2.10.0 +setuptools==82.0.0 +setuptools-rust==1.12.0 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.8.3 +tqdm==4.67.3 +trio==0.33.0 +trio-websocket==0.12.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +webdriverdownloader==1.1.0.4 +websocket-client==1.9.0 +wsproto==1.3.2 diff --git a/social_posters/__init__.py b/social_posters/__init__.py index d160de4..32387d8 100644 --- a/social_posters/__init__.py +++ b/social_posters/__init__.py @@ -1,7 +1,7 @@ -"""Social media poster implementations implementing the SocialPoster interface.""" -from social_posters.debug import PosterDebug - - -__all__ = ["PosterBluesky", "PosterDebug", "PosterMastodon", "PosterInstagram"] - - +"""Social media poster implementations implementing the SocialPoster interface.""" +from social_posters.debug import PosterDebug + + +__all__ = ["PosterBluesky", "PosterDebug", "PosterMastodon", "PosterInstagram"] + + diff --git a/social_posters/bluesky.py b/social_posters/bluesky.py index 10d32d4..337ea12 100644 --- a/social_posters/bluesky.py +++ b/social_posters/bluesky.py @@ -1,208 +1,208 @@ -from datetime import datetime -import os - -import requests - -from abstractions import Post, PostResult, SocialPoster -from config import CITY_HASHTAGS, CITY_NAME, CITY_STATE - - -class PosterBluesky(SocialPoster): - def __init__(self): - # Handle environment variable validation internally - self.username = os.environ.get("BLUESKY_HANDLE") - self.password = os.environ.get("BLUESKY_PASSWORD") - self._access_token = None - self._did = None # Decentralized identifier from the Bluesky session. - self._is_available = bool(self.username and self.password) - - @property - def platform_name(self) -> str: - return "Bluesky" - - def authenticate(self) -> bool: - try: - response = requests.post( - "https://bsky.social/xrpc/com.atproto.server.createSession", - json={"identifier": self.username, "password": self.password}, - timeout=20, - ) - response.raise_for_status() - session = response.json() - self._access_token = session.get("accessJwt") - self._did = session.get("did") - return bool(self._access_token and self._did) - except Exception: - self._access_token = None - self._did = None - return False - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult( - success=False, - error_message="Bluesky credentials not available." - ) - - if not self._access_token or not self._did: - if not self.authenticate(): - return PostResult( - success=False, error_message="Bluesky authentication failed." - ) - - headers = {"Authorization": f"Bearer {self._access_token}"} - image_blob = None - - if post.image_url: - try: - img_response = requests.get(post.image_url, timeout=20) - img_response.raise_for_status() - upload = requests.post( - "https://bsky.social/xrpc/com.atproto.repo.uploadBlob", - headers={**headers, "Content-Type": "image/jpeg"}, - data=img_response.content, - timeout=30, - ) - upload.raise_for_status() - image_blob = upload.json().get("blob") - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - - text, facets = self._build_text_and_facets(post) - record = { - "$type": "app.bsky.feed.post", - "text": text, - "createdAt": datetime.utcnow().isoformat() + "Z", - } - - if facets: - record["facets"] = facets - - if image_blob: - record["embed"] = { - "$type": "app.bsky.embed.images", - "images": [ - { - "alt": post.alt_text or "Adoptable pet", - "image": image_blob, - } - ], - } - - try: - response = requests.post( - "https://bsky.social/xrpc/com.atproto.repo.createRecord", - headers=headers, - json={ - "repo": self._did, - "collection": "app.bsky.feed.post", - "record": record, - }, - timeout=30, - ) - response.raise_for_status() - data = response.json() - return PostResult( - success=True, - post_id=data.get("cid"), - post_url=data.get("uri"), - ) - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - - def format_post(self, pet): - from abstractions import Post - - name = pet.name.split("*")[0].strip() - - text = f"Hi, I'm {name}! I'm a {pet.breed} looking for a forever home" - if pet.location: - text += f" in {pet.location}" - text += "." - - city = "" - if pet.location != f"{CITY_NAME}, {CITY_STATE}": - city = pet.location.split(",")[0].capitalize() - - detail_parts = [] - if pet.age_string: - detail_parts.append(pet.age_string) - if pet.sex: - detail_parts.append(pet.sex) - if pet.size_group: - detail_parts.append(f"{pet.size_group} size") - details = " · ".join(detail_parts) - - if details: - text += f"\n\n{details}" - elif pet.description: - text += f"\n\n{pet.description[:120]}" - - if pet.adoption_url: - text += f"\n\nLearn more and adopt me: {pet.adoption_url}" - - species_tag = "DogsOfBluesky" if pet.species == "dog" else "CatsOfBluesky" - tags = ["AdoptDontShop", *CITY_HASHTAGS, city, species_tag] - - return Post( - text=text, - image_url=pet.image_url, - link=pet.adoption_url, - alt_text=f"Photo of {name}, a {pet.breed} available for adoption", - tags=tags, - ) - - def _build_text_and_facets(self, post: Post) -> tuple[str, list]: - body = post.text - facets: list = [] - separator = "\n\n" - limit = 300 - - tag_strings = [f"#{tag}" for tag in (post.tags) if tag] - tags_section = " ".join(tag_strings) - # Truncate body so the full text (body + separators + tags) fits in limit chars. - max_body = limit - (len(separator) + len(tags_section) if tags_section else 0) - # When the link URL is embedded in the body and would be truncated, preserve it - # by truncating only the prefix before it. - if post.link and post.link in body: - link_pos = body.find(post.link) - if link_pos >= max_body: - suffix = body[link_pos:] - available = max_body - len(suffix) - truncated_body = (body[:available] + suffix) if available >= 0 else body[:max_body] - else: - truncated_body = body[:max_body] - else: - truncated_body = body[:max_body] - full_text = f"{truncated_body}{separator}{tags_section}" if tags_section else truncated_body - - encoded = full_text.encode("utf-8") - - if post.link: - link_bytes = post.link.encode("utf-8") - link_idx = encoded.find(link_bytes) - if link_idx != -1: - facets.append({ - "index": { - "byteStart": link_idx, - "byteEnd": link_idx + len(link_bytes), - }, - "features": [ - {"$type": "app.bsky.richtext.facet#link", "uri": post.link} - ], - }) - - search_from = 0 - for tag_str in tag_strings: - tag_bytes = tag_str.encode("utf-8") - idx = encoded.find(tag_bytes, search_from) - if idx != -1: - facets.append({ - "index": {"byteStart": idx, "byteEnd": idx + len(tag_bytes)}, - "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag_str[1:]}], - }) - search_from = idx + len(tag_bytes) - - facets.sort(key=lambda f: f["index"]["byteStart"]) - return full_text, facets - +from datetime import datetime +import os + +import requests + +from abstractions import Post, PostResult, SocialPoster +from config import CITY_HASHTAGS, CITY_NAME, CITY_STATE + + +class PosterBluesky(SocialPoster): + def __init__(self): + # Handle environment variable validation internally + self.username = os.environ.get("BLUESKY_HANDLE") + self.password = os.environ.get("BLUESKY_PASSWORD") + self._access_token = None + self._did = None # Decentralized identifier from the Bluesky session. + self._is_available = bool(self.username and self.password) + + @property + def platform_name(self) -> str: + return "Bluesky" + + def authenticate(self) -> bool: + try: + response = requests.post( + "https://bsky.social/xrpc/com.atproto.server.createSession", + json={"identifier": self.username, "password": self.password}, + timeout=20, + ) + response.raise_for_status() + session = response.json() + self._access_token = session.get("accessJwt") + self._did = session.get("did") + return bool(self._access_token and self._did) + except Exception: + self._access_token = None + self._did = None + return False + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult( + success=False, + error_message="Bluesky credentials not available." + ) + + if not self._access_token or not self._did: + if not self.authenticate(): + return PostResult( + success=False, error_message="Bluesky authentication failed." + ) + + headers = {"Authorization": f"Bearer {self._access_token}"} + image_blob = None + + if post.image_url: + try: + img_response = requests.get(post.image_url, timeout=20) + img_response.raise_for_status() + upload = requests.post( + "https://bsky.social/xrpc/com.atproto.repo.uploadBlob", + headers={**headers, "Content-Type": "image/jpeg"}, + data=img_response.content, + timeout=30, + ) + upload.raise_for_status() + image_blob = upload.json().get("blob") + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + + text, facets = self._build_text_and_facets(post) + record = { + "$type": "app.bsky.feed.post", + "text": text, + "createdAt": datetime.utcnow().isoformat() + "Z", + } + + if facets: + record["facets"] = facets + + if image_blob: + record["embed"] = { + "$type": "app.bsky.embed.images", + "images": [ + { + "alt": post.alt_text or "Adoptable pet", + "image": image_blob, + } + ], + } + + try: + response = requests.post( + "https://bsky.social/xrpc/com.atproto.repo.createRecord", + headers=headers, + json={ + "repo": self._did, + "collection": "app.bsky.feed.post", + "record": record, + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return PostResult( + success=True, + post_id=data.get("cid"), + post_url=data.get("uri"), + ) + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + + def format_post(self, pet): + from abstractions import Post + + name = pet.name.split("*")[0].strip() + + text = f"Hi, I'm {name}! I'm a {pet.breed} looking for a forever home" + if pet.location: + text += f" in {pet.location}" + text += "." + + city = "" + if pet.location != f"{CITY_NAME}, {CITY_STATE}": + city = pet.location.split(",")[0].capitalize() + + detail_parts = [] + if pet.age_string: + detail_parts.append(pet.age_string) + if pet.sex: + detail_parts.append(pet.sex) + if pet.size_group: + detail_parts.append(f"{pet.size_group} size") + details = " · ".join(detail_parts) + + if details: + text += f"\n\n{details}" + elif pet.description: + text += f"\n\n{pet.description[:120]}" + + if pet.adoption_url: + text += f"\n\nLearn more and adopt me: {pet.adoption_url}" + + species_tag = "DogsOfBluesky" if pet.species == "dog" else "CatsOfBluesky" + tags = ["AdoptDontShop", *CITY_HASHTAGS, city, species_tag] + + return Post( + text=text, + image_url=pet.image_url, + link=pet.adoption_url, + alt_text=f"Photo of {name}, a {pet.breed} available for adoption", + tags=tags, + ) + + def _build_text_and_facets(self, post: Post) -> tuple[str, list]: + body = post.text + facets: list = [] + separator = "\n\n" + limit = 300 + + tag_strings = [f"#{tag}" for tag in (post.tags) if tag] + tags_section = " ".join(tag_strings) + # Truncate body so the full text (body + separators + tags) fits in limit chars. + max_body = limit - (len(separator) + len(tags_section) if tags_section else 0) + # When the link URL is embedded in the body and would be truncated, preserve it + # by truncating only the prefix before it. + if post.link and post.link in body: + link_pos = body.find(post.link) + if link_pos >= max_body: + suffix = body[link_pos:] + available = max_body - len(suffix) + truncated_body = (body[:available] + suffix) if available >= 0 else body[:max_body] + else: + truncated_body = body[:max_body] + else: + truncated_body = body[:max_body] + full_text = f"{truncated_body}{separator}{tags_section}" if tags_section else truncated_body + + encoded = full_text.encode("utf-8") + + if post.link: + link_bytes = post.link.encode("utf-8") + link_idx = encoded.find(link_bytes) + if link_idx != -1: + facets.append({ + "index": { + "byteStart": link_idx, + "byteEnd": link_idx + len(link_bytes), + }, + "features": [ + {"$type": "app.bsky.richtext.facet#link", "uri": post.link} + ], + }) + + search_from = 0 + for tag_str in tag_strings: + tag_bytes = tag_str.encode("utf-8") + idx = encoded.find(tag_bytes, search_from) + if idx != -1: + facets.append({ + "index": {"byteStart": idx, "byteEnd": idx + len(tag_bytes)}, + "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag_str[1:]}], + }) + search_from = idx + len(tag_bytes) + + facets.sort(key=lambda f: f["index"]["byteStart"]) + return full_text, facets + diff --git a/social_posters/debug.py b/social_posters/debug.py index 9330efe..45b235c 100644 --- a/social_posters/debug.py +++ b/social_posters/debug.py @@ -1,31 +1,31 @@ -"""Debug poster that prints post content instead of publishing.""" - -from abstractions import AdoptablePet, Post, PostResult, SocialPoster - - -class PosterDebug(SocialPoster): - def __init__(self, stream=None): - self.stream = stream - - @property - def platform_name(self) -> str: - return "Debug" - - def authenticate(self) -> bool: - return True - - def publish(self, post: Post) -> PostResult: - output = ( - f"Debug post\n" - f"Text:\n{post.text}\n" - f"Image: {post.image_url}\n" - f"Link: {post.link}\n" - f"Alt: {post.alt_text}\n" - f"Tags: {post.tags}\n" - f"Url: {post.link}\n" - ) - if self.stream: - self.stream.write(output) - else: - print(output) - return PostResult(success=True, post_id="debug") +"""Debug poster that prints post content instead of publishing.""" + +from abstractions import AdoptablePet, Post, PostResult, SocialPoster + + +class PosterDebug(SocialPoster): + def __init__(self, stream=None): + self.stream = stream + + @property + def platform_name(self) -> str: + return "Debug" + + def authenticate(self) -> bool: + return True + + def publish(self, post: Post) -> PostResult: + output = ( + f"Debug post\n" + f"Text:\n{post.text}\n" + f"Image: {post.image_url}\n" + f"Link: {post.link}\n" + f"Alt: {post.alt_text}\n" + f"Tags: {post.tags}\n" + f"Url: {post.link}\n" + ) + if self.stream: + self.stream.write(output) + else: + print(output) + return PostResult(success=True, post_id="debug") diff --git a/social_posters/instagram.py b/social_posters/instagram.py index 03d7545..37efe2f 100644 --- a/social_posters/instagram.py +++ b/social_posters/instagram.py @@ -1,115 +1,115 @@ -import os -import time - -import requests - -from abstractions import Post, PostResult, SocialPoster - - -GRAPH_API_VERSION = "v21.0" -GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}" - - -class PosterInstagram(SocialPoster): - def __init__(self): - self.account_id = os.environ.get("INSTAGRAM_BUSINESS_ACCOUNT_ID") - self.access_token = os.environ.get("INSTAGRAM_PAGE_ACCESS_TOKEN") - self._is_available = bool(self.account_id and self.access_token) - self._authenticated = False - - @property - def platform_name(self) -> str: - return "Instagram" - - def authenticate(self) -> bool: - if not self._is_available: - print("Instagram: credentials not set (INSTAGRAM_BUSINESS_ACCOUNT_ID or INSTAGRAM_PAGE_ACCESS_TOKEN missing)") - return False - try: - response = requests.get( - f"{GRAPH_API_BASE}/{self.account_id}", - params={"fields": "id,username", "access_token": self.access_token}, - timeout=10, - ) - response.raise_for_status() - self._authenticated = True - return True - except requests.exceptions.HTTPError as exc: - body = exc.response.text if exc.response is not None else "no response body" - print(f"Instagram auth failed (HTTP {exc.response.status_code}): {body}") - self._authenticated = False - return False - except Exception as exc: - print(f"Instagram auth failed: {exc}") - self._authenticated = False - return False - - def is_authenticated(self) -> bool: - return self._authenticated - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult(success=False, error_message="Instagram credentials not available.") - - if not post.image_url: - return PostResult(success=False, error_message="Instagram posts require an image URL.") - - if not self._authenticated and not self.authenticate(): - return PostResult(success=False, error_message="Instagram authentication failed.") - - try: - container_id = self._create_media_container(post) - # Instagram needs time to process the uploaded image before publishing. - # Publishing immediately returns "Media ID is not available" (error 9007). - time.sleep(10) - - media_id = self._publish_media(container_id) - return PostResult( - success=True, - post_id=media_id, - post_url="https://www.instagram.com/cute.pets.boston/", - ) - except requests.exceptions.HTTPError as exc: - body = exc.response.text if exc.response is not None else "no response body" - error = f"Instagram publish failed (HTTP {exc.response.status_code}): {body}" - print(error) - return PostResult(success=False, error_message=error) - except Exception as exc: - error = f"Instagram publish failed: {exc}" - print(error) - return PostResult(success=False, error_message=error) - - def _create_media_container(self, post: Post) -> str: - """Create a media container and return its ID.""" - caption = self._format_caption(post) - response = requests.post( - f"{GRAPH_API_BASE}/{self.account_id}/media", - data={ - "image_url": post.image_url, - "caption": caption, - "access_token": self.access_token, - }, - timeout=30, - ) - response.raise_for_status() - return response.json()["id"] - - - def _publish_media(self, container_id: str) -> str: - response = requests.post( - f"{GRAPH_API_BASE}/{self.account_id}/media_publish", - data={ - "creation_id": container_id, - "access_token": self.access_token, - }, - timeout=30, - ) - response.raise_for_status() - return response.json()["id"] - - def _format_caption(self, post: Post) -> str: - caption = post.text - if post.tags: - tags = " ".join(f"#{tag}" for tag in post.tags if tag) - caption = f"{caption}\n\n{tags}" - return caption[:2200] +import os +import time + +import requests + +from abstractions import Post, PostResult, SocialPoster + + +GRAPH_API_VERSION = "v21.0" +GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}" + + +class PosterInstagram(SocialPoster): + def __init__(self): + self.account_id = os.environ.get("INSTAGRAM_BUSINESS_ACCOUNT_ID") + self.access_token = os.environ.get("INSTAGRAM_PAGE_ACCESS_TOKEN") + self._is_available = bool(self.account_id and self.access_token) + self._authenticated = False + + @property + def platform_name(self) -> str: + return "Instagram" + + def authenticate(self) -> bool: + if not self._is_available: + print("Instagram: credentials not set (INSTAGRAM_BUSINESS_ACCOUNT_ID or INSTAGRAM_PAGE_ACCESS_TOKEN missing)") + return False + try: + response = requests.get( + f"{GRAPH_API_BASE}/{self.account_id}", + params={"fields": "id,username", "access_token": self.access_token}, + timeout=10, + ) + response.raise_for_status() + self._authenticated = True + return True + except requests.exceptions.HTTPError as exc: + body = exc.response.text if exc.response is not None else "no response body" + print(f"Instagram auth failed (HTTP {exc.response.status_code}): {body}") + self._authenticated = False + return False + except Exception as exc: + print(f"Instagram auth failed: {exc}") + self._authenticated = False + return False + + def is_authenticated(self) -> bool: + return self._authenticated + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult(success=False, error_message="Instagram credentials not available.") + + if not post.image_url: + return PostResult(success=False, error_message="Instagram posts require an image URL.") + + if not self._authenticated and not self.authenticate(): + return PostResult(success=False, error_message="Instagram authentication failed.") + + try: + container_id = self._create_media_container(post) + # Instagram needs time to process the uploaded image before publishing. + # Publishing immediately returns "Media ID is not available" (error 9007). + time.sleep(10) + + media_id = self._publish_media(container_id) + return PostResult( + success=True, + post_id=media_id, + post_url="https://www.instagram.com/cute.pets.boston/", + ) + except requests.exceptions.HTTPError as exc: + body = exc.response.text if exc.response is not None else "no response body" + error = f"Instagram publish failed (HTTP {exc.response.status_code}): {body}" + print(error) + return PostResult(success=False, error_message=error) + except Exception as exc: + error = f"Instagram publish failed: {exc}" + print(error) + return PostResult(success=False, error_message=error) + + def _create_media_container(self, post: Post) -> str: + """Create a media container and return its ID.""" + caption = self._format_caption(post) + response = requests.post( + f"{GRAPH_API_BASE}/{self.account_id}/media", + data={ + "image_url": post.image_url, + "caption": caption, + "access_token": self.access_token, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["id"] + + + def _publish_media(self, container_id: str) -> str: + response = requests.post( + f"{GRAPH_API_BASE}/{self.account_id}/media_publish", + data={ + "creation_id": container_id, + "access_token": self.access_token, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["id"] + + def _format_caption(self, post: Post) -> str: + caption = post.text + if post.tags: + tags = " ".join(f"#{tag}" for tag in post.tags if tag) + caption = f"{caption}\n\n{tags}" + return caption[:2200] diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index b50001b..8bbf25d 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -1,111 +1,111 @@ -import os -from urllib.parse import urlparse -import tempfile - -import requests -from mastodon import Mastodon - -from abstractions import Post, PostResult, SocialPoster - -MASTODON_CHARACTER_LIMIT = 500 -TRUNCATION_SUFFIX = "..." - - -class PosterMastodon(SocialPoster): - def __init__(self): - raw_token = os.environ.get("MASTODON_TOKEN") - self.token = raw_token.strip() if raw_token else None - self.api_base_url = "https://mastodon.social" - self._session = None - self._is_available = bool(self.token) - self._auth_error = None - - @property - def platform_name(self) -> str: - return "Mastodon" - - def authenticate(self) -> bool: - try: - self._session = Mastodon( - access_token=self.token, - api_base_url=self.api_base_url, - ) - self._session.account_verify_credentials() - self._auth_error = None - return True - except Exception as exc: - self._session = None - self._auth_error = f"{type(exc).__name__}: {exc}" - return False - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult( - success=False, - error_message="Mastodon credentials not available.", - ) - - if not post.image_url: - return PostResult( - success=False, - error_message="Mastodon posts require an image URL.", - ) - - if not self._session and not self.authenticate(): - return PostResult( - success=False, - error_message=( - "Mastodon authentication failed." - if not self._auth_error - else f"Mastodon authentication failed: {self._auth_error}" - ), - ) - - image_path = None - try: - image_path = self._download_image(post.image_url) - media = self._session.media_post( - image_path, - description=post.alt_text or "Photo of an adoptable pet", - ) - status = self._session.status_post( - self._format_caption(post), - media_ids=[media["id"]], - ) - return PostResult( - success=True, - post_id=str(status["id"]), - post_url=status.get("url"), - ) - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - finally: - self._session = None - if image_path and os.path.exists(image_path): - os.unlink(image_path) - - def _format_caption(self, post: Post) -> str: - tags = " ".join(f"#{tag}" for tag in post.tags if tag) - tag_suffix = f"\n\n{tags}" if tags else "" - available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix) - - if available_text_length <= len(TRUNCATION_SUFFIX): - return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip() - - caption_text = post.text.strip() - if len(caption_text) > available_text_length: - caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip() - caption_text = f"{caption_text}{TRUNCATION_SUFFIX}" - - return f"{caption_text}{tag_suffix}" - - def _download_image(self, image_url: str) -> str: - parsed_url = urlparse(image_url) - ext = os.path.splitext(parsed_url.path)[1] or ".jpg" - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: - response = requests.get(image_url, stream=True, timeout=20) - response.raise_for_status() - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - tmp.write(chunk) - return tmp.name +import os +from urllib.parse import urlparse +import tempfile + +import requests +from mastodon import Mastodon + +from abstractions import Post, PostResult, SocialPoster + +MASTODON_CHARACTER_LIMIT = 500 +TRUNCATION_SUFFIX = "..." + + +class PosterMastodon(SocialPoster): + def __init__(self): + raw_token = os.environ.get("MASTODON_TOKEN") + self.token = raw_token.strip() if raw_token else None + self.api_base_url = "https://mastodon.social" + self._session = None + self._is_available = bool(self.token) + self._auth_error = None + + @property + def platform_name(self) -> str: + return "Mastodon" + + def authenticate(self) -> bool: + try: + self._session = Mastodon( + access_token=self.token, + api_base_url=self.api_base_url, + ) + self._session.account_verify_credentials() + self._auth_error = None + return True + except Exception as exc: + self._session = None + self._auth_error = f"{type(exc).__name__}: {exc}" + return False + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult( + success=False, + error_message="Mastodon credentials not available.", + ) + + if not post.image_url: + return PostResult( + success=False, + error_message="Mastodon posts require an image URL.", + ) + + if not self._session and not self.authenticate(): + return PostResult( + success=False, + error_message=( + "Mastodon authentication failed." + if not self._auth_error + else f"Mastodon authentication failed: {self._auth_error}" + ), + ) + + image_path = None + try: + image_path = self._download_image(post.image_url) + media = self._session.media_post( + image_path, + description=post.alt_text or "Photo of an adoptable pet", + ) + status = self._session.status_post( + self._format_caption(post), + media_ids=[media["id"]], + ) + return PostResult( + success=True, + post_id=str(status["id"]), + post_url=status.get("url"), + ) + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + finally: + self._session = None + if image_path and os.path.exists(image_path): + os.unlink(image_path) + + def _format_caption(self, post: Post) -> str: + tags = " ".join(f"#{tag}" for tag in post.tags if tag) + tag_suffix = f"\n\n{tags}" if tags else "" + available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix) + + if available_text_length <= len(TRUNCATION_SUFFIX): + return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip() + + caption_text = post.text.strip() + if len(caption_text) > available_text_length: + caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip() + caption_text = f"{caption_text}{TRUNCATION_SUFFIX}" + + return f"{caption_text}{tag_suffix}" + + def _download_image(self, image_url: str) -> str: + parsed_url = urlparse(image_url) + ext = os.path.splitext(parsed_url.path)[1] or ".jpg" + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: + response = requests.get(image_url, stream=True, timeout=20) + response.raise_for_status() + for chunk in response.iter_content(chunk_size=1024 * 128): + if chunk: + tmp.write(chunk) + return tmp.name diff --git a/tests/test_main.py b/tests/test_main.py index af19a2f..375aa4c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,69 +1,69 @@ -import unittest - -from abstractions import AdoptablePet, Post, PostResult -from main import create_posters, run - - -class FakeSource: - def __init__(self, pets): - self.pets = pets - self.fetch_called = False - - def fetch_pets(self): - self.fetch_called = True - return self.pets - - -class FakePoster: - platform_name = "FakePoster" - - def __init__(self): - self.format_called = False - self.publish_called = False - self.posts = [] - - def format_post(self, pet): - self.format_called = True - return Post(text=f"Meet {pet.name}", image_url=pet.image_url) - - def publish(self, post): - self.publish_called = True - self.posts.append(post) - return PostResult(success=True) - - -class RunFlowTests(unittest.TestCase): - def test_run_calls_source_and_posters(self): - pet = AdoptablePet( - name="Poppy", - species="dog", - breed="mutt", - location="Boston, MA", - image_url="https://example.com/poppy.jpg", - adoption_url="https://example.com/adopt/poppy", - ) - source = FakeSource([pet]) - poster_one = FakePoster() - poster_two = FakePoster() - - results = run([source], [poster_one, poster_two]) - - self.assertTrue(source.fetch_called) - self.assertTrue(poster_one.format_called) - self.assertTrue(poster_one.publish_called) - self.assertTrue(poster_two.format_called) - self.assertTrue(poster_two.publish_called) - self.assertEqual(len(results), 2) - - -class CreatePostersTests(unittest.TestCase): - def test_debug_returns_debug_poster(self): - posters = create_posters(debug=True) - - self.assertEqual(len(posters), 1) - self.assertEqual(posters[0].platform_name, "Debug") - - - -if __name__ == "__main__": - unittest.main() +import unittest + +from abstractions import AdoptablePet, Post, PostResult +from main import create_posters, run + + +class FakeSource: + def __init__(self, pets): + self.pets = pets + self.fetch_called = False + + def fetch_pets(self): + self.fetch_called = True + return self.pets + + +class FakePoster: + platform_name = "FakePoster" + + def __init__(self): + self.format_called = False + self.publish_called = False + self.posts = [] + + def format_post(self, pet): + self.format_called = True + return Post(text=f"Meet {pet.name}", image_url=pet.image_url) + + def publish(self, post): + self.publish_called = True + self.posts.append(post) + return PostResult(success=True) + + +class RunFlowTests(unittest.TestCase): + def test_run_calls_source_and_posters(self): + pet = AdoptablePet( + name="Poppy", + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/poppy.jpg", + adoption_url="https://example.com/adopt/poppy", + ) + source = FakeSource([pet]) + poster_one = FakePoster() + poster_two = FakePoster() + + results = run([source], [poster_one, poster_two]) + + self.assertTrue(source.fetch_called) + self.assertTrue(poster_one.format_called) + self.assertTrue(poster_one.publish_called) + self.assertTrue(poster_two.format_called) + self.assertTrue(poster_two.publish_called) + self.assertEqual(len(results), 2) + + +class CreatePostersTests(unittest.TestCase): + def test_debug_returns_debug_poster(self): + posters = create_posters(debug=True) + + self.assertEqual(len(posters), 1) + self.assertEqual(posters[0].platform_name, "Debug") + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index aadc9c8..9fbd70f 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -1,27 +1,27 @@ -from abstractions import Post -from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT - - -class TestMastodonCaption: - def setup_method(self): - self.poster = PosterMastodon.__new__(PosterMastodon) - - def test_no_tags(self): - post = Post(text="Hello, world!") - assert self.poster._format_caption(post) == "Hello, world!" - - def test_with_tags(self): - post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) - assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" - - def test_caption_stays_under_limit(self): - post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) - caption = self.poster._format_caption(post) - - assert len(caption) <= MASTODON_CHARACTER_LIMIT - assert caption.endswith("\n\n#AdoptDontShop #Boston") - assert "..." in caption - - def test_empty_tags_are_ignored(self): - post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) +from abstractions import Post +from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT + + +class TestMastodonCaption: + def setup_method(self): + self.poster = PosterMastodon.__new__(PosterMastodon) + + def test_no_tags(self): + post = Post(text="Hello, world!") + assert self.poster._format_caption(post) == "Hello, world!" + + def test_with_tags(self): + post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) + assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" + + def test_caption_stays_under_limit(self): + post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) + caption = self.poster._format_caption(post) + + assert len(caption) <= MASTODON_CHARACTER_LIMIT + assert caption.endswith("\n\n#AdoptDontShop #Boston") + assert "..." in caption + + def test_empty_tags_are_ignored(self): + post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" \ No newline at end of file diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index 8f0995a..5c140e6 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -1,75 +1,75 @@ -import unittest - -from adoption_sources.rescue_groups import SourceRescueGroups - - -def _make_animal(adoption_url=None, **extra_attrs): - attrs = { - "name": "Buddy", - "breedString": "Lab Mix", - "pictureThumbnailUrl": "https://example.com/buddy.jpg", - **extra_attrs, - } - if adoption_url is not None: - attrs["adoptionUrl"] = adoption_url - return { - "type": "animals", - "id": "12345", - "attributes": attrs, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, - } - - -def _make_org(adoption_url=None, url=None): - attrs = {"city": "Boston", "state": "MA"} - if adoption_url is not None: - attrs["adoptionUrl"] = adoption_url - if url is not None: - attrs["url"] = url - return attrs - - -class AdoptionUrlTests(unittest.TestCase): - def setUp(self): - self.source = SourceRescueGroups(api_key="dummy") - - 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) - - self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") - - 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) - - self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") - - 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) - - self.assertEqual(pet.adoption_url, "https://org.example.com") - - -class PlaceholderNameTests(unittest.TestCase): - def setUp(self): - self.source = SourceRescueGroups(api_key="dummy") - - 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_real_pet_name_is_not_placeholder(self): - self.assertFalse(self.source._is_placeholder_name("Pippin")) - self.assertFalse(self.source._is_placeholder_name("Buddy")) - - -if __name__ == "__main__": - unittest.main() +import unittest + +from adoption_sources.rescue_groups import SourceRescueGroups + + +def _make_animal(adoption_url=None, **extra_attrs): + attrs = { + "name": "Buddy", + "breedString": "Lab Mix", + "pictureThumbnailUrl": "https://example.com/buddy.jpg", + **extra_attrs, + } + if adoption_url is not None: + attrs["adoptionUrl"] = adoption_url + return { + "type": "animals", + "id": "12345", + "attributes": attrs, + "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + } + + +def _make_org(adoption_url=None, url=None): + attrs = {"city": "Boston", "state": "MA"} + if adoption_url is not None: + attrs["adoptionUrl"] = adoption_url + if url is not None: + attrs["url"] = url + return attrs + + +class AdoptionUrlTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + + 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) + + self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") + + 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) + + self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") + + 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) + + self.assertEqual(pet.adoption_url, "https://org.example.com") + + +class PlaceholderNameTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + + 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_real_pet_name_is_not_placeholder(self): + self.assertFalse(self.source._is_placeholder_name("Pippin")) + self.assertFalse(self.source._is_placeholder_name("Buddy")) + + +if __name__ == "__main__": + unittest.main() diff --git a/vartest.py b/vartest.py index 2eba5e8..f5aa9ea 100644 --- a/vartest.py +++ b/vartest.py @@ -1,26 +1,26 @@ -import json -import sys -import traceback -from pathlib import Path -from datetime import datetime, timezone - -def main(): - Path("my_file.txt").touch(exist_ok=True) - with open("my_file.txt", "r+") as f: - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) - traceback.print_exc() - data = {} - f.seek(0) - if "pet_list" not in data: - data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] - else: - data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) - json.dump(data, f) - f.truncate() - - -if __name__ == "__main__": - main() +import json +import sys +import traceback +from pathlib import Path +from datetime import datetime, timezone + +def main(): + Path("my_file.txt").touch(exist_ok=True) + with open("my_file.txt", "r+") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + f.seek(0) + if "pet_list" not in data: + data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] + else: + data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) + json.dump(data, f) + f.truncate() + + +if __name__ == "__main__": + main() From e55031d89f0d90a6b7f8faebc90adee9d87c60dd Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:08:59 -0400 Subject: [PATCH 13/25] Continue action if previous run can't be identified --- .github/workflows/dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index bf67489..ce82280 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -26,6 +26,7 @@ jobs: 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 }} From 1f368c143abcad2f5d6cb80b611670787aec1b41 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:13:11 -0400 Subject: [PATCH 14/25] Initialize correct variable --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 6daf6b4..7cdcc1d 100644 --- a/main.py +++ b/main.py @@ -105,7 +105,7 @@ def pick_pet(pets): if "posted_pets" in data: posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} else: - pet_ids = {} + posted_pet_ids = {} data["posted_pets"] = [] # Check pet has an image, adoption url, and has not been posted eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] From 724d5e02fe7cd66a7ab5ee8e2869ca64cd0bdd00 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:15:43 -0400 Subject: [PATCH 15/25] Add timezone arg to cutoff date --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 7cdcc1d..ec56817 100644 --- a/main.py +++ b/main.py @@ -116,7 +116,7 @@ def pick_pet(pets): # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets - cutoff = datetime.now() - timedelta(weeks=12) + cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] data["posted_pets"] = new_pets # Export json From df12fa26c2b6ed1550597db75c1f6649e5b43cf5 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:25:42 -0400 Subject: [PATCH 16/25] test pet id From ae653caaa9625600017c329f275e0cf7bf97d1de Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 13 May 2026 16:35:42 -0400 Subject: [PATCH 17/25] Try displaying the selected pet --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index ec56817..915fe9a 100644 --- a/main.py +++ b/main.py @@ -113,6 +113,7 @@ def pick_pet(pets): return None selected_pet = random.choice(eligible) + print(selected_pet) # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets From 1120d9cf353ff2000fb06384ac775b50421075d0 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Tue, 19 May 2026 20:12:35 -0400 Subject: [PATCH 18/25] Remove experimental action and script --- vartest.py | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 vartest.py diff --git a/vartest.py b/vartest.py deleted file mode 100644 index f5aa9ea..0000000 --- a/vartest.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import sys -import traceback -from pathlib import Path -from datetime import datetime, timezone - -def main(): - Path("my_file.txt").touch(exist_ok=True) - with open("my_file.txt", "r+") as f: - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - print(f"Failed to load json file - {type(e).__name__}:{e}", file=sys.stderr) - traceback.print_exc() - data = {} - f.seek(0) - if "pet_list" not in data: - data["pet_list"] = [{"name": "Spike", "id": 124, "timestamp": datetime.now(timezone.utc).isoformat()}] - else: - data["pet_list"].append({"name": "Spot", "id": 123, "timestamp": datetime.now(timezone.utc).isoformat()}) - json.dump(data, f) - f.truncate() - - -if __name__ == "__main__": - main() From 4f7d3a97ae087aed56473b7df8229e4dd25372fd Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Tue, 19 May 2026 23:29:18 -0400 Subject: [PATCH 19/25] Fix line endings --- .env.example | 14 +- .github/workflows/dev.yml | 136 +++---- .github/workflows/prod.yml | 68 ++-- .github/workflows/tests.yml | 52 +-- CNAME | 2 +- README.md | 110 +++--- abstractions.py | 280 +++++++------- adoption_sources/manual.py | 124 +++---- adoption_sources/rescue_groups.py | 438 +++++++++++----------- config.py | 8 +- docs/index.html | 370 +++++++++--------- docs/shelters.json | 38 +- docs/styles.css | 496 ++++++++++++------------- main.py | 338 ++++++++--------- manual_testing/mastodon_manual_test.py | 64 ++-- manual_testing/mastodon_simple_test.py | 24 +- pytest.ini | 4 +- requirements.txt | 104 +++--- social_posters/__init__.py | 14 +- social_posters/bluesky.py | 416 ++++++++++----------- social_posters/debug.py | 62 ++-- social_posters/instagram.py | 230 ++++++------ social_posters/mastodon.py | 222 +++++------ tests/test_main.py | 138 +++---- tests/test_mastodon.py | 52 +-- tests/test_rescue_groups.py | 150 ++++---- 26 files changed, 1977 insertions(+), 1977 deletions(-) diff --git a/.env.example b/.env.example index dbc61b4..f696ab0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -CUTEPETSBOSTON_RESCUEGROUPS_API_KEY= -INSTAGRAM_USERNAME= -INSTAGRAM_PASSWORD= -BLUESKY_HANDLE= -BLUESKY_PASSWORD= -APP_ENV= - +CUTEPETSBOSTON_RESCUEGROUPS_API_KEY= +INSTAGRAM_USERNAME= +INSTAGRAM_PASSWORD= +BLUESKY_HANDLE= +BLUESKY_PASSWORD= +APP_ENV= + diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ce82280..df90b75 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -1,68 +1,68 @@ -name: Debug Post - -on: - push: - branches: - - '**' - - '!master' - workflow_dispatch: - -permissions: - actions: read - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - 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 the last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list --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 --debugsources --debugposters - - - name: Upload database artifact - uses: actions/upload-artifact@v7 - with: - path: database.json - retention-days: 14 - archive: false +name: Debug Post + +on: + push: + branches: + - '**' + - '!master' + workflow_dispatch: + +permissions: + actions: read + +jobs: + run-cute-pets: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + 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 the last completed run for the current workflow + PREVIOUS_RUN_ID=$(gh run list --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 --debugsources --debugposters + + - name: Upload database artifact + uses: actions/upload-artifact@v7 + with: + path: database.json + retention-days: 14 + archive: false diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 44860e0..299987f 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -1,34 +1,34 @@ -name: Prod Account Post - -on: - workflow_dispatch: - schedule: - # Every 4 hours - - cron: "0 */4 * * *" - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: 'pip' - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - 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_HANDLE }} - BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} - MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - APP_ENV: prod - run: python ./main.py +name: Prod Account Post + +on: + workflow_dispatch: + schedule: + # Every 4 hours + - cron: "0 */4 * * *" + +jobs: + run-cute-pets: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies + run: pip install --break-system-packages -r requirements.txt + + - 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_HANDLE }} + BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} + MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + APP_ENV: prod + run: python ./main.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09fd2be..ee7d44e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,26 +1,26 @@ -name: Tests - -on: - pull_request: - push: - branches: [master] - workflow_dispatch: - -jobs: - pytest: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - name: Run pytest - run: pytest +name: Tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install --break-system-packages -r requirements.txt + + - name: Run pytest + run: pytest diff --git a/CNAME b/CNAME index 15c7e83..c2566ef 100644 --- a/CNAME +++ b/CNAME @@ -1 +1 @@ -cutepetsboston.com +cutepetsboston.com diff --git a/README.md b/README.md index 57b325f..56f617b 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,55 @@ -# CutePetsBoston - -# About - -Posts a random adoptable pet from the Boston MSPCA to different social media feeds. - -It should be easily extendable to other shelters and social media feeds for various locations. - -## Github Actions - -This Project runs on github actions and runs periodically. - -## Set up your environment variables - -Required: -- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY` - -Optional for Instagram posting: -- `INSTAGRAM_HANDLE` -- `INSTAGRAM_PASSWORD` - -Optional for Bluesky posting: -- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`) -- `BLUESKY_PASSWORD` (or `BLUESKY_TEST_PASSWORD`) - -Optional for Mastodon posting: -- `MASTODON_TOKEN` or `MASTODON_TEST_TOKEN` -- `MASTODON_API_BASE_URL` (defaults to `https://mastodon.social`) - -Optional platform selection: -- `POSTER_PLATFORMS` to limit posting to specific platforms, for example `mastodon` or `bluesky,mastodon` - -## File organization - -- `main.py`: orchestrates fetching pets and publishing posts. -- `abstractions.py`: shared interfaces and data models. -- `source_*.py`: pet source implementations (ingest from APIs). -- `poster_*.py`: social media poster implementations. -- `manually_test_post.py`: CLI for manual posting with sample data. - -# How to run the script - - python main.py - -To run only the Mastodon poster locally or in GitHub Actions: - - POSTER_PLATFORMS=mastodon python main.py - -# History - -This project was originally started by [Becky Boone](https://github.com/boonrs) and [Drew](https://github.com/drewrwilson) during their fellowship at Code for America in 2014. - -## Sister Projects - -- CutePetsDenver +# CutePetsBoston + +# About + +Posts a random adoptable pet from the Boston MSPCA to different social media feeds. + +It should be easily extendable to other shelters and social media feeds for various locations. + +## Github Actions + +This Project runs on github actions and runs periodically. + +## Set up your environment variables + +Required: +- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY` + +Optional for Instagram posting: +- `INSTAGRAM_HANDLE` +- `INSTAGRAM_PASSWORD` + +Optional for Bluesky posting: +- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`) +- `BLUESKY_PASSWORD` (or `BLUESKY_TEST_PASSWORD`) + +Optional for Mastodon posting: +- `MASTODON_TOKEN` or `MASTODON_TEST_TOKEN` +- `MASTODON_API_BASE_URL` (defaults to `https://mastodon.social`) + +Optional platform selection: +- `POSTER_PLATFORMS` to limit posting to specific platforms, for example `mastodon` or `bluesky,mastodon` + +## File organization + +- `main.py`: orchestrates fetching pets and publishing posts. +- `abstractions.py`: shared interfaces and data models. +- `source_*.py`: pet source implementations (ingest from APIs). +- `poster_*.py`: social media poster implementations. +- `manually_test_post.py`: CLI for manual posting with sample data. + +# How to run the script + + python main.py + +To run only the Mastodon poster locally or in GitHub Actions: + + POSTER_PLATFORMS=mastodon python main.py + +# History + +This project was originally started by [Becky Boone](https://github.com/boonrs) and [Drew](https://github.com/drewrwilson) during their fellowship at Code for America in 2014. + +## Sister Projects + +- CutePetsDenver diff --git a/abstractions.py b/abstractions.py index ddcc0c4..870b428 100644 --- a/abstractions.py +++ b/abstractions.py @@ -1,140 +1,140 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Iterable - -from config import CITY_NAME, CITY_STATE - - -# ============================================================================= -# Pet Ingestor Interface -# ============================================================================= - - -@dataclass -class AdoptablePet: - """Represents a pet available for adoption.""" - - name: str - species: str # "dog" or "cat" - breed: str - location: str - description: str = "" - adoption_url: str | None = None - image_url: str | None = None - age_string: str | None = None - sex: str | None = None - size_group: str | None = None - pet_id: str | None = None - - -class PetSource(ABC): - """Interface for fetching pets from various adoption APIs.""" - - @property - @abstractmethod - def source_name(self) -> str: - """Return the name of the pet source.""" - ... - - @abstractmethod - def fetch_pets(self) -> Iterable[AdoptablePet]: - """Fetch available pets from the source.""" - ... - - -# ============================================================================= -# Social Media Poster Interface -# ============================================================================= - - -@dataclass -class Post: - """Represents a social media post about an adoptable pet.""" - - text: str - image_url: str | None = None - link: str | None = None - alt_text: str | None = None # For image accessibility - tags: list[str] = field(default_factory=list) - - -@dataclass -class PostResult: - """Result of attempting to publish a post.""" - - success: bool - post_id: str | None = None - post_url: str | None = None - error_message: str | None = None - - -class SocialPoster(ABC): - """ - Abstract base class for social media platform implementations. - - Concrete implementations should inherit from this class and implement - the abstract methods for their specific platform (e.g., Bluesky, Instagram). - """ - - @property - @abstractmethod - def platform_name(self) -> str: - """Return the name of the social media platform.""" - ... - - @abstractmethod - def authenticate(self) -> bool: - """ - Authenticate with the platform. - - Returns: - True if authentication was successful, False otherwise. - """ - ... - - @abstractmethod - def publish(self, post: Post) -> PostResult: - """ - Publish a post to the platform. - - Args: - post: The post to publish. - - Returns: - PostResult indicating success/failure and relevant details. - """ - ... - - def is_authenticated(self) -> bool: - """Check if currently authenticated. Override if platform supports this.""" - return False - - def format_post(self, pet: AdoptablePet) -> Post: - """ - Create a Post from an AdoptablePet. - - Override this method to customize post formatting for specific platforms. - """ - text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}." - if pet.description: - text += f"\n\n{pet.description}" - if pet.adoption_url: - text += f"\n\nAdopt {pet.name}: {pet.adoption_url}" - - city = "" - if pet.location != f"{CITY_NAME}, {CITY_STATE}": - city = pet.location.split(",")[0].capitalize() - - return Post( - text=text, - image_url=pet.image_url, - link=pet.adoption_url, - alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption", - tags=[ - "adoptdontshop", - "rescue", - city, - pet.species, - pet.breed.lower().replace(" ", ""), - ], - ) +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Iterable + +from config import CITY_NAME, CITY_STATE + + +# ============================================================================= +# Pet Ingestor Interface +# ============================================================================= + + +@dataclass +class AdoptablePet: + """Represents a pet available for adoption.""" + + name: str + species: str # "dog" or "cat" + breed: str + location: str + description: str = "" + adoption_url: str | None = None + image_url: str | None = None + age_string: str | None = None + sex: str | None = None + size_group: str | None = None + pet_id: str | None = None + + +class PetSource(ABC): + """Interface for fetching pets from various adoption APIs.""" + + @property + @abstractmethod + def source_name(self) -> str: + """Return the name of the pet source.""" + ... + + @abstractmethod + def fetch_pets(self) -> Iterable[AdoptablePet]: + """Fetch available pets from the source.""" + ... + + +# ============================================================================= +# Social Media Poster Interface +# ============================================================================= + + +@dataclass +class Post: + """Represents a social media post about an adoptable pet.""" + + text: str + image_url: str | None = None + link: str | None = None + alt_text: str | None = None # For image accessibility + tags: list[str] = field(default_factory=list) + + +@dataclass +class PostResult: + """Result of attempting to publish a post.""" + + success: bool + post_id: str | None = None + post_url: str | None = None + error_message: str | None = None + + +class SocialPoster(ABC): + """ + Abstract base class for social media platform implementations. + + Concrete implementations should inherit from this class and implement + the abstract methods for their specific platform (e.g., Bluesky, Instagram). + """ + + @property + @abstractmethod + def platform_name(self) -> str: + """Return the name of the social media platform.""" + ... + + @abstractmethod + def authenticate(self) -> bool: + """ + Authenticate with the platform. + + Returns: + True if authentication was successful, False otherwise. + """ + ... + + @abstractmethod + def publish(self, post: Post) -> PostResult: + """ + Publish a post to the platform. + + Args: + post: The post to publish. + + Returns: + PostResult indicating success/failure and relevant details. + """ + ... + + def is_authenticated(self) -> bool: + """Check if currently authenticated. Override if platform supports this.""" + return False + + def format_post(self, pet: AdoptablePet) -> Post: + """ + Create a Post from an AdoptablePet. + + Override this method to customize post formatting for specific platforms. + """ + text = f"Meet {pet.name}! This adorable {pet.breed} {pet.species} is looking for a forever home in {pet.location}." + if pet.description: + text += f"\n\n{pet.description}" + if pet.adoption_url: + text += f"\n\nAdopt {pet.name}: {pet.adoption_url}" + + city = "" + if pet.location != f"{CITY_NAME}, {CITY_STATE}": + city = pet.location.split(",")[0].capitalize() + + return Post( + text=text, + image_url=pet.image_url, + link=pet.adoption_url, + alt_text=f"Photo of {pet.name}, a {pet.breed} {pet.species} available for adoption", + tags=[ + "adoptdontshop", + "rescue", + city, + pet.species, + pet.breed.lower().replace(" ", ""), + ], + ) diff --git a/adoption_sources/manual.py b/adoption_sources/manual.py index 92c0851..ea9cb36 100644 --- a/adoption_sources/manual.py +++ b/adoption_sources/manual.py @@ -1,62 +1,62 @@ -"""Manual PetSource returning a fixed set of adoptable pets.""" - -from __future__ import annotations - -import json -from typing import Iterable, Sequence - -from abstractions import AdoptablePet, PetSource -from config import CITY_NAME, CITY_STATE - -_data_path = __file__.replace(".py", ".json") -with open(_data_path) as _f: - MANUAL_SOURCE_DATA: tuple[dict, ...] = tuple(json.loads(_f.read())) - - -class SourceManual(PetSource): - """Static PetSource useful for offline testing and demos.""" - - def __init__( - self, - animals: Sequence[dict] | None = None, - location_label: str = f"{CITY_NAME}, {CITY_STATE}", - species: str = "dog", - ) -> None: - self._animals: Sequence[dict] = animals if animals is not None else MANUAL_SOURCE_DATA - self.location_label = location_label - self.species = species - - @property - def source_name(self) -> str: - return "Manual" - - def fetch_pets(self) -> Iterable[AdoptablePet]: - for animal in self._animals: - yield self._build_pet(animal) - - def _build_pet(self, animal: dict) -> AdoptablePet: - attrs = animal.get("attributes", {}) - animal_id = animal.get("id", "") - return AdoptablePet( - name=attrs.get("name", "Unknown"), - species=self.species, - breed=self._determine_breed(attrs), - location=self.location_label, - description=(attrs.get("descriptionText") or "").strip(), - adoption_url=self._adoption_url(attrs.get("slug")), - image_url=attrs.get("pictureThumbnailUrl"), - pet_id=animal_id, - ) - - @staticmethod - def _determine_breed(attrs: dict) -> str: - return attrs.get("breedString") or attrs.get("breedPrimary") or "Mixed" - - @staticmethod - def _adoption_url(slug: str | None) -> str | None: - if slug: - return f"https://www.rescuegroups.org/pet/{slug}" - return None - - -__all__ = ["SourceManual", "MANUAL_SOURCE_DATA"] +"""Manual PetSource returning a fixed set of adoptable pets.""" + +from __future__ import annotations + +import json +from typing import Iterable, Sequence + +from abstractions import AdoptablePet, PetSource +from config import CITY_NAME, CITY_STATE + +_data_path = __file__.replace(".py", ".json") +with open(_data_path) as _f: + MANUAL_SOURCE_DATA: tuple[dict, ...] = tuple(json.loads(_f.read())) + + +class SourceManual(PetSource): + """Static PetSource useful for offline testing and demos.""" + + def __init__( + self, + animals: Sequence[dict] | None = None, + location_label: str = f"{CITY_NAME}, {CITY_STATE}", + species: str = "dog", + ) -> None: + self._animals: Sequence[dict] = animals if animals is not None else MANUAL_SOURCE_DATA + self.location_label = location_label + self.species = species + + @property + def source_name(self) -> str: + return "Manual" + + def fetch_pets(self) -> Iterable[AdoptablePet]: + for animal in self._animals: + yield self._build_pet(animal) + + def _build_pet(self, animal: dict) -> AdoptablePet: + attrs = animal.get("attributes", {}) + animal_id = animal.get("id", "") + return AdoptablePet( + name=attrs.get("name", "Unknown"), + species=self.species, + breed=self._determine_breed(attrs), + location=self.location_label, + description=(attrs.get("descriptionText") or "").strip(), + adoption_url=self._adoption_url(attrs.get("slug")), + image_url=attrs.get("pictureThumbnailUrl"), + pet_id=animal_id, + ) + + @staticmethod + def _determine_breed(attrs: dict) -> str: + return attrs.get("breedString") or attrs.get("breedPrimary") or "Mixed" + + @staticmethod + def _adoption_url(slug: str | None) -> str | None: + if slug: + return f"https://www.rescuegroups.org/pet/{slug}" + return None + + +__all__ = ["SourceManual", "MANUAL_SOURCE_DATA"] diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index dbb4910..5fffcae 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -1,219 +1,219 @@ -""" -RescueGroups.org API implementation of the PetSource interface. - -API Documentation: https://api.rescuegroups.org/v5/public/docs -""" - -import html -import logging -import os -import re -from typing import Iterator - -import requests - -from abstractions import AdoptablePet, PetSource -from config import CITY_NAME, CITY_STATE, POSTAL_CODE - -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!",) - - -class SourceRescueGroups(PetSource): - """ - Fetches adoptable pets from RescueGroups.org API. - - Requires CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable or api_key constructor arg. - """ - - BASE_URL = "https://api.rescuegroups.org/v5/public/animals/search" - - def __init__( - self, - api_key: str | None = None, - postal_code: str = POSTAL_CODE, - radius_miles: int = 50, - species: str = "dogs", # "dogs" or "cats" - limit: int = 25, - 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.limit = limit - self.location_label = location_label - - @property - def source_name(self) -> str: - return f"RescueGroups ({self.species})" - - def fetch_pets(self) -> Iterator[AdoptablePet]: - """ - Fetch available pets from RescueGroups.org. - - Yields: - AdoptablePet objects for each available pet. - - Raises: - ValueError: If API key is not configured. - requests.HTTPError: If the API request fails. - """ - if not self._api_key: - raise ValueError( - "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"&sort=random" - f"&limit={self.limit}" - ) - headers = { - "Content-Type": "application/vnd.api+json", - "Authorization": self._api_key, - } - payload = { - "data": { - "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}" - ) - - response = requests.post(url, json=payload, headers=headers, timeout=30) - response.raise_for_status() - - body = response.json() - data = body.get("data", []) - logger.info(f"Received {len(data)} pets from RescueGroups") - - orgs_by_id = { - item["id"]: item.get("attributes", {}) - for item in body.get("included", []) - if item.get("type") == "orgs" - } - - for animal in data: - pet = self._parse_animal(animal, orgs_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: - """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" - - # Get breed info - breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) - - # Clean up description (use text version, not HTML) - description = self._clean_description(attrs.get("descriptionText", "")) - - # Get adoption_url - org_id = ( - animal.get("relationships", {}) - .get("orgs", {}) - .get("data", [{}])[0] - .get("id") - ) - org_attrs = orgs_by_id.get(org_id, {}) if org_id else {} - adoption_url = next( - (u for u in (attrs.get("adoptionUrl"), org_attrs.get("adoptionUrl"), org_attrs.get("url")) - if u and u.strip().rstrip("/") not in ("http:", "https:", "http://", "https://")), - None - ) - - # 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, - breed=breed, - location=location, - description=description, - adoption_url=adoption_url, - image_url=image_url, - age_string=attrs.get("ageString"), - sex=attrs.get("sex"), - size_group=attrs.get("sizeGroup"), - pet_id=animal_id, - ) - except Exception as e: - logger.warning(f"Failed to parse animal {animal.get('id', 'unknown')}: {e}") - return None - - def _is_placeholder_name(self, name: str) -> bool: - return name.lower() in PLACEHOLDER_NAMES - - def _clean_name(self, name: str) -> str: - """ - Clean up pet name by removing promotional text. - - Examples: - "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() - - def _clean_description(self, description: str) -> str: - """Clean up description text.""" - 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] + "..." - - return text - - 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 +""" +RescueGroups.org API implementation of the PetSource interface. + +API Documentation: https://api.rescuegroups.org/v5/public/docs +""" + +import html +import logging +import os +import re +from typing import Iterator + +import requests + +from abstractions import AdoptablePet, PetSource +from config import CITY_NAME, CITY_STATE, POSTAL_CODE + +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!",) + + +class SourceRescueGroups(PetSource): + """ + Fetches adoptable pets from RescueGroups.org API. + + Requires CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable or api_key constructor arg. + """ + + BASE_URL = "https://api.rescuegroups.org/v5/public/animals/search" + + def __init__( + self, + api_key: str | None = None, + postal_code: str = POSTAL_CODE, + radius_miles: int = 50, + species: str = "dogs", # "dogs" or "cats" + limit: int = 25, + 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.limit = limit + self.location_label = location_label + + @property + def source_name(self) -> str: + return f"RescueGroups ({self.species})" + + def fetch_pets(self) -> Iterator[AdoptablePet]: + """ + Fetch available pets from RescueGroups.org. + + Yields: + AdoptablePet objects for each available pet. + + Raises: + ValueError: If API key is not configured. + requests.HTTPError: If the API request fails. + """ + if not self._api_key: + raise ValueError( + "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"&sort=random" + f"&limit={self.limit}" + ) + headers = { + "Content-Type": "application/vnd.api+json", + "Authorization": self._api_key, + } + payload = { + "data": { + "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}" + ) + + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + + body = response.json() + data = body.get("data", []) + logger.info(f"Received {len(data)} pets from RescueGroups") + + orgs_by_id = { + item["id"]: item.get("attributes", {}) + for item in body.get("included", []) + if item.get("type") == "orgs" + } + + for animal in data: + pet = self._parse_animal(animal, orgs_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: + """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" + + # Get breed info + breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) + + # Clean up description (use text version, not HTML) + description = self._clean_description(attrs.get("descriptionText", "")) + + # Get adoption_url + org_id = ( + animal.get("relationships", {}) + .get("orgs", {}) + .get("data", [{}])[0] + .get("id") + ) + org_attrs = orgs_by_id.get(org_id, {}) if org_id else {} + adoption_url = next( + (u for u in (attrs.get("adoptionUrl"), org_attrs.get("adoptionUrl"), org_attrs.get("url")) + if u and u.strip().rstrip("/") not in ("http:", "https:", "http://", "https://")), + None + ) + + # 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, + breed=breed, + location=location, + description=description, + adoption_url=adoption_url, + image_url=image_url, + age_string=attrs.get("ageString"), + sex=attrs.get("sex"), + size_group=attrs.get("sizeGroup"), + pet_id=animal_id, + ) + except Exception as e: + logger.warning(f"Failed to parse animal {animal.get('id', 'unknown')}: {e}") + return None + + def _is_placeholder_name(self, name: str) -> bool: + return name.lower() in PLACEHOLDER_NAMES + + def _clean_name(self, name: str) -> str: + """ + Clean up pet name by removing promotional text. + + Examples: + "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() + + def _clean_description(self, description: str) -> str: + """Clean up description text.""" + 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] + "..." + + return text + + 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 diff --git a/config.py b/config.py index b164161..0178730 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,4 @@ -CITY_NAME = "Boston" -CITY_STATE = "MA" -CITY_HASHTAGS = ["Boston"] -POSTAL_CODE = "02108" +CITY_NAME = "Boston" +CITY_STATE = "MA" +CITY_HASHTAGS = ["Boston"] +POSTAL_CODE = "02108" diff --git a/docs/index.html b/docs/index.html index 56325ad..7a77853 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,185 +1,185 @@ - - - - - - CutePetsBoston - - - - - - -
-
-

CutePetsBoston

-

Finding forever homes, one cute pet at a time.

-

CutePetsBoston is a volunteer-run social media bot that shares
adoptable pets daily from Boston-area shelters.

- -
-
- -
-
-

Featured shelters

-

- Shelters we've confirmed featuring on the feed. Our broader pet pool comes - from RescueGroups.org - within 50 miles of Boston, so plenty of other rescues appear too. -

-
-

Loading shelters…

-
-
- -
-

Recent posts

-

Latest pets from our Mastodon feed:

-
-

Loading recent posts…

-
-
- -
-

How to contribute

-

- CutePetsBoston is a Code for Boston - project. Come hack on it with us. -

- -
-
- - - - - - + + + + + + CutePetsBoston + + + + + + +
+
+

CutePetsBoston

+

Finding forever homes, one cute pet at a time.

+

CutePetsBoston is a volunteer-run social media bot that shares
adoptable pets daily from Boston-area shelters.

+ +
+
+ +
+
+

Featured shelters

+

+ Shelters we've confirmed featuring on the feed. Our broader pet pool comes + from RescueGroups.org + within 50 miles of Boston, so plenty of other rescues appear too. +

+
+

Loading shelters…

+
+
+ +
+

Recent posts

+

Latest pets from our Mastodon feed:

+
+

Loading recent posts…

+
+
+ +
+

How to contribute

+

+ CutePetsBoston is a Code for Boston + project. Come hack on it with us. +

+ +
+
+ + + + + + diff --git a/docs/shelters.json b/docs/shelters.json index a5d9fec..fad075c 100644 --- a/docs/shelters.json +++ b/docs/shelters.json @@ -1,19 +1,19 @@ -{ - "shelters": [ - { - "name": "MSPCA-Angell", - "location": "Boston, MA", - "url": "https://www.mspca.org/adoption/" - }, - { - "name": "Sterling Animal Shelter", - "location": "Sterling, MA", - "url": "https://www.sterlingshelter.org/" - }, - { - "name": "Small Dog Rescue of New England", - "location": "Rhode Island", - "url": "https://www.smalldogrescuene.org/" - } - ] -} +{ + "shelters": [ + { + "name": "MSPCA-Angell", + "location": "Boston, MA", + "url": "https://www.mspca.org/adoption/" + }, + { + "name": "Sterling Animal Shelter", + "location": "Sterling, MA", + "url": "https://www.sterlingshelter.org/" + }, + { + "name": "Small Dog Rescue of New England", + "location": "Rhode Island", + "url": "https://www.smalldogrescuene.org/" + } + ] +} diff --git a/docs/styles.css b/docs/styles.css index e076b54..e710ca3 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -1,248 +1,248 @@ -:root { - --bg: #fef9f4; - --surface: #ffffff; - --ink: #1f2937; - --muted: #6b7280; - --accent: #d6336c; - --accent-soft: #fde2ec; - --border: #e5e7eb; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.04); -} - -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, - Ubuntu, sans-serif; - background: var(--bg); - color: var(--ink); - line-height: 1.5; -} - -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -.container { - max-width: 960px; - margin: 0 auto; - padding: 0 1.25rem; -} - -.hero { - background: linear-gradient(135deg, var(--accent-soft), #fff); - border-bottom: 1px solid var(--border); - padding: 3.5rem 0 2.5rem; - text-align: center; -} - -.hero h1 { - margin: 0 0 0.5rem; - font-size: 2.5rem; - letter-spacing: -0.02em; -} - -.hero .headline { - margin: 0 auto 0.6rem; - max-width: 640px; - color: var(--ink); - font-size: 1.35rem; - font-weight: 500; -} - -.hero .subheader { - margin: 0 auto; - max-width: 560px; - color: var(--muted); - font-size: 1.05rem; -} - -section { - padding: 2.5rem 0; - border-bottom: 1px solid var(--border); -} - -section:last-of-type { - border-bottom: none; -} - -h2 { - margin: 0 0 1rem; - font-size: 1.5rem; -} - -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; -} - -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1.1rem 1.2rem; - box-shadow: var(--shadow); -} - -.card h3 { - margin: 0 0 0.25rem; - font-size: 1.05rem; -} - -.card .meta { - color: var(--muted); - font-size: 0.9rem; - margin-bottom: 0.5rem; -} - -.dashboard-note { - margin-top: 1rem; - color: var(--muted); - font-size: 0.9rem; -} - -.action-links { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; -} - -.follow { - margin-top: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - flex-wrap: wrap; -} - -.follow-label { - color: var(--muted); - font-size: 0.95rem; - font-weight: 500; -} - -.social-icons { - display: flex; - justify-content: center; - gap: 0.75rem; -} - -.social-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: 999px; - color: var(--muted); - background: var(--surface); - border: 1px solid var(--border); - transition: color 120ms ease, border-color 120ms ease, transform 120ms ease; -} - -.social-icon svg { - width: 1.25rem; - height: 1.25rem; - fill: currentColor; -} - -.social-icon:hover { - color: var(--accent); - border-color: var(--accent); - text-decoration: none; - transform: translateY(-1px); -} - -.btn { - display: inline-block; - padding: 0.55rem 1rem; - border-radius: 999px; - background: var(--surface); - border: 1px solid var(--border); - color: var(--ink); - font-weight: 500; -} - -.btn:hover { - border-color: var(--accent); - color: var(--accent); - text-decoration: none; -} - -.btn-primary { - background: var(--accent); - color: #fff; - border-color: var(--accent); -} - -.btn-primary:hover { - background: #b8265a; - border-color: #b8265a; - color: #fff; -} - -.post-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; -} - -.post-card { - display: flex; - flex-direction: column; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - color: var(--ink); - box-shadow: var(--shadow); - transition: transform 120ms ease, border-color 120ms ease; -} - -.post-card:hover { - text-decoration: none; - transform: translateY(-2px); - border-color: var(--accent); -} - -.post-image { - width: 100%; - aspect-ratio: 1 / 1; - object-fit: cover; - display: block; - background: var(--accent-soft); -} - -.post-image-placeholder { - background: var(--accent-soft); -} - -.post-body { - padding: 0.75rem 0.9rem 0.9rem; -} - -.post-caption { - font-size: 0.95rem; - margin-bottom: 0.3rem; -} - -footer { - text-align: center; - padding: 2rem 1rem 3rem; - color: var(--muted); - font-size: 0.9rem; -} +:root { + --bg: #fef9f4; + --surface: #ffffff; + --ink: #1f2937; + --muted: #6b7280; + --accent: #d6336c; + --accent-soft: #fde2ec; + --border: #e5e7eb; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.04); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, sans-serif; + background: var(--bg); + color: var(--ink); + line-height: 1.5; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 0 1.25rem; +} + +.hero { + background: linear-gradient(135deg, var(--accent-soft), #fff); + border-bottom: 1px solid var(--border); + padding: 3.5rem 0 2.5rem; + text-align: center; +} + +.hero h1 { + margin: 0 0 0.5rem; + font-size: 2.5rem; + letter-spacing: -0.02em; +} + +.hero .headline { + margin: 0 auto 0.6rem; + max-width: 640px; + color: var(--ink); + font-size: 1.35rem; + font-weight: 500; +} + +.hero .subheader { + margin: 0 auto; + max-width: 560px; + color: var(--muted); + font-size: 1.05rem; +} + +section { + padding: 2.5rem 0; + border-bottom: 1px solid var(--border); +} + +section:last-of-type { + border-bottom: none; +} + +h2 { + margin: 0 0 1rem; + font-size: 1.5rem; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.1rem 1.2rem; + box-shadow: var(--shadow); +} + +.card h3 { + margin: 0 0 0.25rem; + font-size: 1.05rem; +} + +.card .meta { + color: var(--muted); + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.dashboard-note { + margin-top: 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.action-links { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.follow { + margin-top: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.follow-label { + color: var(--muted); + font-size: 0.95rem; + font-weight: 500; +} + +.social-icons { + display: flex; + justify-content: center; + gap: 0.75rem; +} + +.social-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + transition: color 120ms ease, border-color 120ms ease, transform 120ms ease; +} + +.social-icon svg { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; +} + +.social-icon:hover { + color: var(--accent); + border-color: var(--accent); + text-decoration: none; + transform: translateY(-1px); +} + +.btn { + display: inline-block; + padding: 0.55rem 1rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--ink); + font-weight: 500; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.btn-primary:hover { + background: #b8265a; + border-color: #b8265a; + color: #fff; +} + +.post-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.post-card { + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + color: var(--ink); + box-shadow: var(--shadow); + transition: transform 120ms ease, border-color 120ms ease; +} + +.post-card:hover { + text-decoration: none; + transform: translateY(-2px); + border-color: var(--accent); +} + +.post-image { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--accent-soft); +} + +.post-image-placeholder { + background: var(--accent-soft); +} + +.post-body { + padding: 0.75rem 0.9rem 0.9rem; +} + +.post-caption { + font-size: 0.95rem; + margin-bottom: 0.3rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + color: var(--muted); + font-size: 0.9rem; +} diff --git a/main.py b/main.py index 915fe9a..bfe7bfc 100644 --- a/main.py +++ b/main.py @@ -1,169 +1,169 @@ -import os -import random -import argparse -import json -import sys -import traceback -from pathlib import Path -from datetime import datetime, timezone, timedelta - -import requests - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--debugsources", action="store_true") # this defaults to False - parser.add_argument("--debugposters", action="store_true") # this defaults to False - - args = parser.parse_args() - - try: - sources = create_sources(debug=args.debugsources) - posters = create_posters(debug=args.debugposters) - - run(sources, posters) - except Exception: - notify_slack_of_exception(traceback.format_exc()) - raise - - -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()) - posters.append(PosterBluesky()) - posters.append(PosterInstagram()) - return posters - - - - -def create_sources(debug=False): - from adoption_sources import SourceRescueGroups, SourceManual - - if debug: - return [SourceManual()] - - sources = [] - - sources.append(SourceRescueGroups()) - - return sources - - -def run(sources, posters): - pets = [] - for source in sources: - try: - pets.extend(list(source.fetch_pets())) - except ValueError as exc: - raise SystemExit(str(exc)) from exc - - print("Fetched", len(pets), "records") - pet = pick_pet(pets) - if not pet: - print("No pets available to post.") - return [] - - if not posters: - print("No social media credentials set; skipping post.") - return [] - - results = [] - for poster in posters: - post = poster.format_post(pet) - result = poster.publish(post) - results.append(result) - if not result.success: - print(f"{poster.platform_name} post failed: {result.error_message}") - else: - print(f"{poster.platform_name} post published.") - - return results - - -def pick_pet(pets): - Path("database.json").touch(exist_ok=True) - # Open file - with open("database.json", "r+") as f: - # Load json - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - print(f"{type(e).__name__}:{e}", file=sys.stderr) - traceback.print_exc() - data = {} - - if "posted_pets" in data: - posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} - else: - posted_pet_ids = {} - data["posted_pets"] = [] - # Check pet has an image, adoption url, and has not been posted - eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] - if not eligible: - return None - - selected_pet = random.choice(eligible) - print(selected_pet) - # Add pet ID to list of posted pets - data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) - # Remove old pets - cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) - new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] - data["posted_pets"] = new_pets - # Export json - f.seek(0) - json.dump(data, f) - f.truncate() - return selected_pet - - -# Slack incoming-webhook messages have a ~40k-char limit; cap the traceback -# well below that so the post stays readable and is never rejected. -MAX_TRACEBACK_CHARS = 2500 - - -def notify_slack_of_exception(traceback_text): - print(traceback_text) - - webhook_url = os.environ.get("SLACK_WEBHOOK_URL") - if not webhook_url: - print("SLACK_WEBHOOK_URL not set; skipping Slack alert.") - return - - app_env = os.environ.get("APP_ENV", "local") - workflow = os.environ.get("GITHUB_WORKFLOW", "local run") - event = os.environ.get("GITHUB_EVENT_NAME") - repo = os.environ.get("GITHUB_REPOSITORY") - run_id = os.environ.get("GITHUB_RUN_ID") - run_link = ( - f"https://github.com/{repo}/actions/runs/{run_id}" - if repo and run_id - else None - ) - - header = f"CutePetsBoston [{app_env}] run failed in *{workflow}*" - if event: - header += f" (trigger: {event})" - if run_link: - header += f" (<{run_link}|view run>)" - text = f"{header}\n```{traceback_text.strip()[-MAX_TRACEBACK_CHARS:]}```" - - try: - response = requests.post(webhook_url, json={"text": text}, timeout=10) - response.raise_for_status() - except Exception as slack_exc: - print(f"Failed to post Slack alert: {slack_exc}") - - -if __name__ == "__main__": - main() +import os +import random +import argparse +import json +import sys +import traceback +from pathlib import Path +from datetime import datetime, timezone, timedelta + +import requests + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--debugsources", action="store_true") # this defaults to False + parser.add_argument("--debugposters", action="store_true") # this defaults to False + + args = parser.parse_args() + + try: + sources = create_sources(debug=args.debugsources) + posters = create_posters(debug=args.debugposters) + + run(sources, posters) + except Exception: + notify_slack_of_exception(traceback.format_exc()) + raise + + +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()) + posters.append(PosterBluesky()) + posters.append(PosterInstagram()) + return posters + + + + +def create_sources(debug=False): + from adoption_sources import SourceRescueGroups, SourceManual + + if debug: + return [SourceManual()] + + sources = [] + + sources.append(SourceRescueGroups()) + + return sources + + +def run(sources, posters): + pets = [] + for source in sources: + try: + pets.extend(list(source.fetch_pets())) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + + print("Fetched", len(pets), "records") + pet = pick_pet(pets) + if not pet: + print("No pets available to post.") + return [] + + if not posters: + print("No social media credentials set; skipping post.") + return [] + + results = [] + for poster in posters: + post = poster.format_post(pet) + result = poster.publish(post) + results.append(result) + if not result.success: + print(f"{poster.platform_name} post failed: {result.error_message}") + else: + print(f"{poster.platform_name} post published.") + + return results + + +def pick_pet(pets): + Path("database.json").touch(exist_ok=True) + # Open file + with open("database.json", "r+") as f: + # Load json + try: + data = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"{type(e).__name__}:{e}", file=sys.stderr) + traceback.print_exc() + data = {} + + if "posted_pets" in data: + posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} + else: + posted_pet_ids = {} + data["posted_pets"] = [] + # Check pet has an image, adoption url, and has not been posted + eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] + if not eligible: + return None + + selected_pet = random.choice(eligible) + print(selected_pet) + # Add pet ID to list of posted pets + data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) + # Remove old pets + cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) + new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] + data["posted_pets"] = new_pets + # Export json + f.seek(0) + json.dump(data, f) + f.truncate() + return selected_pet + + +# Slack incoming-webhook messages have a ~40k-char limit; cap the traceback +# well below that so the post stays readable and is never rejected. +MAX_TRACEBACK_CHARS = 2500 + + +def notify_slack_of_exception(traceback_text): + print(traceback_text) + + webhook_url = os.environ.get("SLACK_WEBHOOK_URL") + if not webhook_url: + print("SLACK_WEBHOOK_URL not set; skipping Slack alert.") + return + + app_env = os.environ.get("APP_ENV", "local") + workflow = os.environ.get("GITHUB_WORKFLOW", "local run") + event = os.environ.get("GITHUB_EVENT_NAME") + repo = os.environ.get("GITHUB_REPOSITORY") + run_id = os.environ.get("GITHUB_RUN_ID") + run_link = ( + f"https://github.com/{repo}/actions/runs/{run_id}" + if repo and run_id + else None + ) + + header = f"CutePetsBoston [{app_env}] run failed in *{workflow}*" + if event: + header += f" (trigger: {event})" + if run_link: + header += f" (<{run_link}|view run>)" + text = f"{header}\n```{traceback_text.strip()[-MAX_TRACEBACK_CHARS:]}```" + + try: + response = requests.post(webhook_url, json={"text": text}, timeout=10) + response.raise_for_status() + except Exception as slack_exc: + print(f"Failed to post Slack alert: {slack_exc}") + + +if __name__ == "__main__": + main() diff --git a/manual_testing/mastodon_manual_test.py b/manual_testing/mastodon_manual_test.py index 4851ace..e4b1c01 100644 --- a/manual_testing/mastodon_manual_test.py +++ b/manual_testing/mastodon_manual_test.py @@ -1,33 +1,33 @@ -import sys, os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from abstractions import Post -from social_posters.mastodon import PosterMastodon - - - -def main(): - poster = PosterMastodon() - - if not poster.authenticate(): - print("Authentication failed!") - exit(1) - - print("Authenticated to Mastodon!") - - post = Post( - text="Test post", - image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447", - alt_text="Cute animal", - tags=["Test", "Mastodon"], - ) - - result = poster.publish(post) - - if result.success: - print(f"Posted successfully! URL: {result.post_url}") - else: - print(f"Post failed: {result.error_message}") - -if __name__ == "__main__": +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from abstractions import Post +from social_posters.mastodon import PosterMastodon + + + +def main(): + poster = PosterMastodon() + + if not poster.authenticate(): + print("Authentication failed!") + exit(1) + + print("Authenticated to Mastodon!") + + post = Post( + text="Test post", + image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447", + alt_text="Cute animal", + tags=["Test", "Mastodon"], + ) + + result = poster.publish(post) + + if result.success: + print(f"Posted successfully! URL: {result.post_url}") + else: + print(f"Post failed: {result.error_message}") + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/manual_testing/mastodon_simple_test.py b/manual_testing/mastodon_simple_test.py index 8218c7d..b4a5024 100644 --- a/manual_testing/mastodon_simple_test.py +++ b/manual_testing/mastodon_simple_test.py @@ -1,12 +1,12 @@ -from mastodon import Mastodon -import os -from datetime import datetime - -client = Mastodon( - access_token=os.environ.get("MASTODON_TEST_TOKEN"), - api_base_url=os.environ.get("MASTODON_API_BASE_URL", "https://mastodon.social"), -) - -client.account_verify_credentials() -client.status_post(f"Simple Test at {datetime.now()}") -print("Success") +from mastodon import Mastodon +import os +from datetime import datetime + +client = Mastodon( + access_token=os.environ.get("MASTODON_TEST_TOKEN"), + api_base_url=os.environ.get("MASTODON_API_BASE_URL", "https://mastodon.social"), +) + +client.account_verify_credentials() +client.status_post(f"Simple Test at {datetime.now()}") +print("Success") diff --git a/pytest.ini b/pytest.ini index 53bfcf9..5ee6477 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ -[pytest] -testpaths = tests +[pytest] +testpaths = tests diff --git a/requirements.txt b/requirements.txt index 0bb98a2..1915a71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,52 +1,52 @@ -anyio==4.12.1 -api-display-purposes==0.0.3 -attrs==25.4.0 -beautifulsoup4==4.14.3 -blurhash==1.1.5 -certifi==2026.2.25 -chardet==3.0.4 -charset-normalizer==3.4.4 -clarifai==2.6.2 -configparser==3.8.1 -decorator==4.0.2 -EasyProcess==1.1 -emoji==1.7.0 -future==1.0.0 -googleapis-common-protos==1.72.0 -grpcio==1.78.0 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==2.10 -instapy==0.6.16 -jsonschema==2.6.0 -Mastodon.py==2.1.4 -MeaningCloud-python==2.0.0 -outcome==1.3.0.post0 -plyer==2.1.0 -protobuf==3.20.3 -PySocks==1.7.1 -pytest==9.0.3 -python-dateutil==2.9.0.post0 -python-magic==0.4.27 -python-telegram-bot==22.6 -PyVirtualDisplay==3.0 -PyYAML==6.0.3 -regex==2026.2.28 -requests==2.32.5 -selenium==4.41.0 -semantic-version==2.10.0 -setuptools==82.0.0 -setuptools-rust==1.12.0 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -soupsieve==2.8.3 -tqdm==4.67.3 -trio==0.33.0 -trio-websocket==0.12.2 -typing_extensions==4.15.0 -urllib3==2.6.3 -webdriverdownloader==1.1.0.4 -websocket-client==1.9.0 -wsproto==1.3.2 +anyio==4.12.1 +api-display-purposes==0.0.3 +attrs==25.4.0 +beautifulsoup4==4.14.3 +blurhash==1.1.5 +certifi==2026.2.25 +chardet==3.0.4 +charset-normalizer==3.4.4 +clarifai==2.6.2 +configparser==3.8.1 +decorator==4.0.2 +EasyProcess==1.1 +emoji==1.7.0 +future==1.0.0 +googleapis-common-protos==1.72.0 +grpcio==1.78.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==2.10 +instapy==0.6.16 +jsonschema==2.6.0 +Mastodon.py==2.1.4 +MeaningCloud-python==2.0.0 +outcome==1.3.0.post0 +plyer==2.1.0 +protobuf==3.20.3 +PySocks==1.7.1 +pytest==9.0.3 +python-dateutil==2.9.0.post0 +python-magic==0.4.27 +python-telegram-bot==22.6 +PyVirtualDisplay==3.0 +PyYAML==6.0.3 +regex==2026.2.28 +requests==2.32.5 +selenium==4.41.0 +semantic-version==2.10.0 +setuptools==82.0.0 +setuptools-rust==1.12.0 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.8.3 +tqdm==4.67.3 +trio==0.33.0 +trio-websocket==0.12.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +webdriverdownloader==1.1.0.4 +websocket-client==1.9.0 +wsproto==1.3.2 diff --git a/social_posters/__init__.py b/social_posters/__init__.py index 32387d8..d160de4 100644 --- a/social_posters/__init__.py +++ b/social_posters/__init__.py @@ -1,7 +1,7 @@ -"""Social media poster implementations implementing the SocialPoster interface.""" -from social_posters.debug import PosterDebug - - -__all__ = ["PosterBluesky", "PosterDebug", "PosterMastodon", "PosterInstagram"] - - +"""Social media poster implementations implementing the SocialPoster interface.""" +from social_posters.debug import PosterDebug + + +__all__ = ["PosterBluesky", "PosterDebug", "PosterMastodon", "PosterInstagram"] + + diff --git a/social_posters/bluesky.py b/social_posters/bluesky.py index 337ea12..10d32d4 100644 --- a/social_posters/bluesky.py +++ b/social_posters/bluesky.py @@ -1,208 +1,208 @@ -from datetime import datetime -import os - -import requests - -from abstractions import Post, PostResult, SocialPoster -from config import CITY_HASHTAGS, CITY_NAME, CITY_STATE - - -class PosterBluesky(SocialPoster): - def __init__(self): - # Handle environment variable validation internally - self.username = os.environ.get("BLUESKY_HANDLE") - self.password = os.environ.get("BLUESKY_PASSWORD") - self._access_token = None - self._did = None # Decentralized identifier from the Bluesky session. - self._is_available = bool(self.username and self.password) - - @property - def platform_name(self) -> str: - return "Bluesky" - - def authenticate(self) -> bool: - try: - response = requests.post( - "https://bsky.social/xrpc/com.atproto.server.createSession", - json={"identifier": self.username, "password": self.password}, - timeout=20, - ) - response.raise_for_status() - session = response.json() - self._access_token = session.get("accessJwt") - self._did = session.get("did") - return bool(self._access_token and self._did) - except Exception: - self._access_token = None - self._did = None - return False - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult( - success=False, - error_message="Bluesky credentials not available." - ) - - if not self._access_token or not self._did: - if not self.authenticate(): - return PostResult( - success=False, error_message="Bluesky authentication failed." - ) - - headers = {"Authorization": f"Bearer {self._access_token}"} - image_blob = None - - if post.image_url: - try: - img_response = requests.get(post.image_url, timeout=20) - img_response.raise_for_status() - upload = requests.post( - "https://bsky.social/xrpc/com.atproto.repo.uploadBlob", - headers={**headers, "Content-Type": "image/jpeg"}, - data=img_response.content, - timeout=30, - ) - upload.raise_for_status() - image_blob = upload.json().get("blob") - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - - text, facets = self._build_text_and_facets(post) - record = { - "$type": "app.bsky.feed.post", - "text": text, - "createdAt": datetime.utcnow().isoformat() + "Z", - } - - if facets: - record["facets"] = facets - - if image_blob: - record["embed"] = { - "$type": "app.bsky.embed.images", - "images": [ - { - "alt": post.alt_text or "Adoptable pet", - "image": image_blob, - } - ], - } - - try: - response = requests.post( - "https://bsky.social/xrpc/com.atproto.repo.createRecord", - headers=headers, - json={ - "repo": self._did, - "collection": "app.bsky.feed.post", - "record": record, - }, - timeout=30, - ) - response.raise_for_status() - data = response.json() - return PostResult( - success=True, - post_id=data.get("cid"), - post_url=data.get("uri"), - ) - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - - def format_post(self, pet): - from abstractions import Post - - name = pet.name.split("*")[0].strip() - - text = f"Hi, I'm {name}! I'm a {pet.breed} looking for a forever home" - if pet.location: - text += f" in {pet.location}" - text += "." - - city = "" - if pet.location != f"{CITY_NAME}, {CITY_STATE}": - city = pet.location.split(",")[0].capitalize() - - detail_parts = [] - if pet.age_string: - detail_parts.append(pet.age_string) - if pet.sex: - detail_parts.append(pet.sex) - if pet.size_group: - detail_parts.append(f"{pet.size_group} size") - details = " · ".join(detail_parts) - - if details: - text += f"\n\n{details}" - elif pet.description: - text += f"\n\n{pet.description[:120]}" - - if pet.adoption_url: - text += f"\n\nLearn more and adopt me: {pet.adoption_url}" - - species_tag = "DogsOfBluesky" if pet.species == "dog" else "CatsOfBluesky" - tags = ["AdoptDontShop", *CITY_HASHTAGS, city, species_tag] - - return Post( - text=text, - image_url=pet.image_url, - link=pet.adoption_url, - alt_text=f"Photo of {name}, a {pet.breed} available for adoption", - tags=tags, - ) - - def _build_text_and_facets(self, post: Post) -> tuple[str, list]: - body = post.text - facets: list = [] - separator = "\n\n" - limit = 300 - - tag_strings = [f"#{tag}" for tag in (post.tags) if tag] - tags_section = " ".join(tag_strings) - # Truncate body so the full text (body + separators + tags) fits in limit chars. - max_body = limit - (len(separator) + len(tags_section) if tags_section else 0) - # When the link URL is embedded in the body and would be truncated, preserve it - # by truncating only the prefix before it. - if post.link and post.link in body: - link_pos = body.find(post.link) - if link_pos >= max_body: - suffix = body[link_pos:] - available = max_body - len(suffix) - truncated_body = (body[:available] + suffix) if available >= 0 else body[:max_body] - else: - truncated_body = body[:max_body] - else: - truncated_body = body[:max_body] - full_text = f"{truncated_body}{separator}{tags_section}" if tags_section else truncated_body - - encoded = full_text.encode("utf-8") - - if post.link: - link_bytes = post.link.encode("utf-8") - link_idx = encoded.find(link_bytes) - if link_idx != -1: - facets.append({ - "index": { - "byteStart": link_idx, - "byteEnd": link_idx + len(link_bytes), - }, - "features": [ - {"$type": "app.bsky.richtext.facet#link", "uri": post.link} - ], - }) - - search_from = 0 - for tag_str in tag_strings: - tag_bytes = tag_str.encode("utf-8") - idx = encoded.find(tag_bytes, search_from) - if idx != -1: - facets.append({ - "index": {"byteStart": idx, "byteEnd": idx + len(tag_bytes)}, - "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag_str[1:]}], - }) - search_from = idx + len(tag_bytes) - - facets.sort(key=lambda f: f["index"]["byteStart"]) - return full_text, facets - +from datetime import datetime +import os + +import requests + +from abstractions import Post, PostResult, SocialPoster +from config import CITY_HASHTAGS, CITY_NAME, CITY_STATE + + +class PosterBluesky(SocialPoster): + def __init__(self): + # Handle environment variable validation internally + self.username = os.environ.get("BLUESKY_HANDLE") + self.password = os.environ.get("BLUESKY_PASSWORD") + self._access_token = None + self._did = None # Decentralized identifier from the Bluesky session. + self._is_available = bool(self.username and self.password) + + @property + def platform_name(self) -> str: + return "Bluesky" + + def authenticate(self) -> bool: + try: + response = requests.post( + "https://bsky.social/xrpc/com.atproto.server.createSession", + json={"identifier": self.username, "password": self.password}, + timeout=20, + ) + response.raise_for_status() + session = response.json() + self._access_token = session.get("accessJwt") + self._did = session.get("did") + return bool(self._access_token and self._did) + except Exception: + self._access_token = None + self._did = None + return False + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult( + success=False, + error_message="Bluesky credentials not available." + ) + + if not self._access_token or not self._did: + if not self.authenticate(): + return PostResult( + success=False, error_message="Bluesky authentication failed." + ) + + headers = {"Authorization": f"Bearer {self._access_token}"} + image_blob = None + + if post.image_url: + try: + img_response = requests.get(post.image_url, timeout=20) + img_response.raise_for_status() + upload = requests.post( + "https://bsky.social/xrpc/com.atproto.repo.uploadBlob", + headers={**headers, "Content-Type": "image/jpeg"}, + data=img_response.content, + timeout=30, + ) + upload.raise_for_status() + image_blob = upload.json().get("blob") + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + + text, facets = self._build_text_and_facets(post) + record = { + "$type": "app.bsky.feed.post", + "text": text, + "createdAt": datetime.utcnow().isoformat() + "Z", + } + + if facets: + record["facets"] = facets + + if image_blob: + record["embed"] = { + "$type": "app.bsky.embed.images", + "images": [ + { + "alt": post.alt_text or "Adoptable pet", + "image": image_blob, + } + ], + } + + try: + response = requests.post( + "https://bsky.social/xrpc/com.atproto.repo.createRecord", + headers=headers, + json={ + "repo": self._did, + "collection": "app.bsky.feed.post", + "record": record, + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return PostResult( + success=True, + post_id=data.get("cid"), + post_url=data.get("uri"), + ) + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + + def format_post(self, pet): + from abstractions import Post + + name = pet.name.split("*")[0].strip() + + text = f"Hi, I'm {name}! I'm a {pet.breed} looking for a forever home" + if pet.location: + text += f" in {pet.location}" + text += "." + + city = "" + if pet.location != f"{CITY_NAME}, {CITY_STATE}": + city = pet.location.split(",")[0].capitalize() + + detail_parts = [] + if pet.age_string: + detail_parts.append(pet.age_string) + if pet.sex: + detail_parts.append(pet.sex) + if pet.size_group: + detail_parts.append(f"{pet.size_group} size") + details = " · ".join(detail_parts) + + if details: + text += f"\n\n{details}" + elif pet.description: + text += f"\n\n{pet.description[:120]}" + + if pet.adoption_url: + text += f"\n\nLearn more and adopt me: {pet.adoption_url}" + + species_tag = "DogsOfBluesky" if pet.species == "dog" else "CatsOfBluesky" + tags = ["AdoptDontShop", *CITY_HASHTAGS, city, species_tag] + + return Post( + text=text, + image_url=pet.image_url, + link=pet.adoption_url, + alt_text=f"Photo of {name}, a {pet.breed} available for adoption", + tags=tags, + ) + + def _build_text_and_facets(self, post: Post) -> tuple[str, list]: + body = post.text + facets: list = [] + separator = "\n\n" + limit = 300 + + tag_strings = [f"#{tag}" for tag in (post.tags) if tag] + tags_section = " ".join(tag_strings) + # Truncate body so the full text (body + separators + tags) fits in limit chars. + max_body = limit - (len(separator) + len(tags_section) if tags_section else 0) + # When the link URL is embedded in the body and would be truncated, preserve it + # by truncating only the prefix before it. + if post.link and post.link in body: + link_pos = body.find(post.link) + if link_pos >= max_body: + suffix = body[link_pos:] + available = max_body - len(suffix) + truncated_body = (body[:available] + suffix) if available >= 0 else body[:max_body] + else: + truncated_body = body[:max_body] + else: + truncated_body = body[:max_body] + full_text = f"{truncated_body}{separator}{tags_section}" if tags_section else truncated_body + + encoded = full_text.encode("utf-8") + + if post.link: + link_bytes = post.link.encode("utf-8") + link_idx = encoded.find(link_bytes) + if link_idx != -1: + facets.append({ + "index": { + "byteStart": link_idx, + "byteEnd": link_idx + len(link_bytes), + }, + "features": [ + {"$type": "app.bsky.richtext.facet#link", "uri": post.link} + ], + }) + + search_from = 0 + for tag_str in tag_strings: + tag_bytes = tag_str.encode("utf-8") + idx = encoded.find(tag_bytes, search_from) + if idx != -1: + facets.append({ + "index": {"byteStart": idx, "byteEnd": idx + len(tag_bytes)}, + "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag_str[1:]}], + }) + search_from = idx + len(tag_bytes) + + facets.sort(key=lambda f: f["index"]["byteStart"]) + return full_text, facets + diff --git a/social_posters/debug.py b/social_posters/debug.py index 45b235c..9330efe 100644 --- a/social_posters/debug.py +++ b/social_posters/debug.py @@ -1,31 +1,31 @@ -"""Debug poster that prints post content instead of publishing.""" - -from abstractions import AdoptablePet, Post, PostResult, SocialPoster - - -class PosterDebug(SocialPoster): - def __init__(self, stream=None): - self.stream = stream - - @property - def platform_name(self) -> str: - return "Debug" - - def authenticate(self) -> bool: - return True - - def publish(self, post: Post) -> PostResult: - output = ( - f"Debug post\n" - f"Text:\n{post.text}\n" - f"Image: {post.image_url}\n" - f"Link: {post.link}\n" - f"Alt: {post.alt_text}\n" - f"Tags: {post.tags}\n" - f"Url: {post.link}\n" - ) - if self.stream: - self.stream.write(output) - else: - print(output) - return PostResult(success=True, post_id="debug") +"""Debug poster that prints post content instead of publishing.""" + +from abstractions import AdoptablePet, Post, PostResult, SocialPoster + + +class PosterDebug(SocialPoster): + def __init__(self, stream=None): + self.stream = stream + + @property + def platform_name(self) -> str: + return "Debug" + + def authenticate(self) -> bool: + return True + + def publish(self, post: Post) -> PostResult: + output = ( + f"Debug post\n" + f"Text:\n{post.text}\n" + f"Image: {post.image_url}\n" + f"Link: {post.link}\n" + f"Alt: {post.alt_text}\n" + f"Tags: {post.tags}\n" + f"Url: {post.link}\n" + ) + if self.stream: + self.stream.write(output) + else: + print(output) + return PostResult(success=True, post_id="debug") diff --git a/social_posters/instagram.py b/social_posters/instagram.py index 37efe2f..03d7545 100644 --- a/social_posters/instagram.py +++ b/social_posters/instagram.py @@ -1,115 +1,115 @@ -import os -import time - -import requests - -from abstractions import Post, PostResult, SocialPoster - - -GRAPH_API_VERSION = "v21.0" -GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}" - - -class PosterInstagram(SocialPoster): - def __init__(self): - self.account_id = os.environ.get("INSTAGRAM_BUSINESS_ACCOUNT_ID") - self.access_token = os.environ.get("INSTAGRAM_PAGE_ACCESS_TOKEN") - self._is_available = bool(self.account_id and self.access_token) - self._authenticated = False - - @property - def platform_name(self) -> str: - return "Instagram" - - def authenticate(self) -> bool: - if not self._is_available: - print("Instagram: credentials not set (INSTAGRAM_BUSINESS_ACCOUNT_ID or INSTAGRAM_PAGE_ACCESS_TOKEN missing)") - return False - try: - response = requests.get( - f"{GRAPH_API_BASE}/{self.account_id}", - params={"fields": "id,username", "access_token": self.access_token}, - timeout=10, - ) - response.raise_for_status() - self._authenticated = True - return True - except requests.exceptions.HTTPError as exc: - body = exc.response.text if exc.response is not None else "no response body" - print(f"Instagram auth failed (HTTP {exc.response.status_code}): {body}") - self._authenticated = False - return False - except Exception as exc: - print(f"Instagram auth failed: {exc}") - self._authenticated = False - return False - - def is_authenticated(self) -> bool: - return self._authenticated - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult(success=False, error_message="Instagram credentials not available.") - - if not post.image_url: - return PostResult(success=False, error_message="Instagram posts require an image URL.") - - if not self._authenticated and not self.authenticate(): - return PostResult(success=False, error_message="Instagram authentication failed.") - - try: - container_id = self._create_media_container(post) - # Instagram needs time to process the uploaded image before publishing. - # Publishing immediately returns "Media ID is not available" (error 9007). - time.sleep(10) - - media_id = self._publish_media(container_id) - return PostResult( - success=True, - post_id=media_id, - post_url="https://www.instagram.com/cute.pets.boston/", - ) - except requests.exceptions.HTTPError as exc: - body = exc.response.text if exc.response is not None else "no response body" - error = f"Instagram publish failed (HTTP {exc.response.status_code}): {body}" - print(error) - return PostResult(success=False, error_message=error) - except Exception as exc: - error = f"Instagram publish failed: {exc}" - print(error) - return PostResult(success=False, error_message=error) - - def _create_media_container(self, post: Post) -> str: - """Create a media container and return its ID.""" - caption = self._format_caption(post) - response = requests.post( - f"{GRAPH_API_BASE}/{self.account_id}/media", - data={ - "image_url": post.image_url, - "caption": caption, - "access_token": self.access_token, - }, - timeout=30, - ) - response.raise_for_status() - return response.json()["id"] - - - def _publish_media(self, container_id: str) -> str: - response = requests.post( - f"{GRAPH_API_BASE}/{self.account_id}/media_publish", - data={ - "creation_id": container_id, - "access_token": self.access_token, - }, - timeout=30, - ) - response.raise_for_status() - return response.json()["id"] - - def _format_caption(self, post: Post) -> str: - caption = post.text - if post.tags: - tags = " ".join(f"#{tag}" for tag in post.tags if tag) - caption = f"{caption}\n\n{tags}" - return caption[:2200] +import os +import time + +import requests + +from abstractions import Post, PostResult, SocialPoster + + +GRAPH_API_VERSION = "v21.0" +GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}" + + +class PosterInstagram(SocialPoster): + def __init__(self): + self.account_id = os.environ.get("INSTAGRAM_BUSINESS_ACCOUNT_ID") + self.access_token = os.environ.get("INSTAGRAM_PAGE_ACCESS_TOKEN") + self._is_available = bool(self.account_id and self.access_token) + self._authenticated = False + + @property + def platform_name(self) -> str: + return "Instagram" + + def authenticate(self) -> bool: + if not self._is_available: + print("Instagram: credentials not set (INSTAGRAM_BUSINESS_ACCOUNT_ID or INSTAGRAM_PAGE_ACCESS_TOKEN missing)") + return False + try: + response = requests.get( + f"{GRAPH_API_BASE}/{self.account_id}", + params={"fields": "id,username", "access_token": self.access_token}, + timeout=10, + ) + response.raise_for_status() + self._authenticated = True + return True + except requests.exceptions.HTTPError as exc: + body = exc.response.text if exc.response is not None else "no response body" + print(f"Instagram auth failed (HTTP {exc.response.status_code}): {body}") + self._authenticated = False + return False + except Exception as exc: + print(f"Instagram auth failed: {exc}") + self._authenticated = False + return False + + def is_authenticated(self) -> bool: + return self._authenticated + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult(success=False, error_message="Instagram credentials not available.") + + if not post.image_url: + return PostResult(success=False, error_message="Instagram posts require an image URL.") + + if not self._authenticated and not self.authenticate(): + return PostResult(success=False, error_message="Instagram authentication failed.") + + try: + container_id = self._create_media_container(post) + # Instagram needs time to process the uploaded image before publishing. + # Publishing immediately returns "Media ID is not available" (error 9007). + time.sleep(10) + + media_id = self._publish_media(container_id) + return PostResult( + success=True, + post_id=media_id, + post_url="https://www.instagram.com/cute.pets.boston/", + ) + except requests.exceptions.HTTPError as exc: + body = exc.response.text if exc.response is not None else "no response body" + error = f"Instagram publish failed (HTTP {exc.response.status_code}): {body}" + print(error) + return PostResult(success=False, error_message=error) + except Exception as exc: + error = f"Instagram publish failed: {exc}" + print(error) + return PostResult(success=False, error_message=error) + + def _create_media_container(self, post: Post) -> str: + """Create a media container and return its ID.""" + caption = self._format_caption(post) + response = requests.post( + f"{GRAPH_API_BASE}/{self.account_id}/media", + data={ + "image_url": post.image_url, + "caption": caption, + "access_token": self.access_token, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["id"] + + + def _publish_media(self, container_id: str) -> str: + response = requests.post( + f"{GRAPH_API_BASE}/{self.account_id}/media_publish", + data={ + "creation_id": container_id, + "access_token": self.access_token, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["id"] + + def _format_caption(self, post: Post) -> str: + caption = post.text + if post.tags: + tags = " ".join(f"#{tag}" for tag in post.tags if tag) + caption = f"{caption}\n\n{tags}" + return caption[:2200] diff --git a/social_posters/mastodon.py b/social_posters/mastodon.py index 8bbf25d..b50001b 100644 --- a/social_posters/mastodon.py +++ b/social_posters/mastodon.py @@ -1,111 +1,111 @@ -import os -from urllib.parse import urlparse -import tempfile - -import requests -from mastodon import Mastodon - -from abstractions import Post, PostResult, SocialPoster - -MASTODON_CHARACTER_LIMIT = 500 -TRUNCATION_SUFFIX = "..." - - -class PosterMastodon(SocialPoster): - def __init__(self): - raw_token = os.environ.get("MASTODON_TOKEN") - self.token = raw_token.strip() if raw_token else None - self.api_base_url = "https://mastodon.social" - self._session = None - self._is_available = bool(self.token) - self._auth_error = None - - @property - def platform_name(self) -> str: - return "Mastodon" - - def authenticate(self) -> bool: - try: - self._session = Mastodon( - access_token=self.token, - api_base_url=self.api_base_url, - ) - self._session.account_verify_credentials() - self._auth_error = None - return True - except Exception as exc: - self._session = None - self._auth_error = f"{type(exc).__name__}: {exc}" - return False - - def publish(self, post: Post) -> PostResult: - if not self._is_available: - return PostResult( - success=False, - error_message="Mastodon credentials not available.", - ) - - if not post.image_url: - return PostResult( - success=False, - error_message="Mastodon posts require an image URL.", - ) - - if not self._session and not self.authenticate(): - return PostResult( - success=False, - error_message=( - "Mastodon authentication failed." - if not self._auth_error - else f"Mastodon authentication failed: {self._auth_error}" - ), - ) - - image_path = None - try: - image_path = self._download_image(post.image_url) - media = self._session.media_post( - image_path, - description=post.alt_text or "Photo of an adoptable pet", - ) - status = self._session.status_post( - self._format_caption(post), - media_ids=[media["id"]], - ) - return PostResult( - success=True, - post_id=str(status["id"]), - post_url=status.get("url"), - ) - except Exception as exc: - return PostResult(success=False, error_message=str(exc)) - finally: - self._session = None - if image_path and os.path.exists(image_path): - os.unlink(image_path) - - def _format_caption(self, post: Post) -> str: - tags = " ".join(f"#{tag}" for tag in post.tags if tag) - tag_suffix = f"\n\n{tags}" if tags else "" - available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix) - - if available_text_length <= len(TRUNCATION_SUFFIX): - return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip() - - caption_text = post.text.strip() - if len(caption_text) > available_text_length: - caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip() - caption_text = f"{caption_text}{TRUNCATION_SUFFIX}" - - return f"{caption_text}{tag_suffix}" - - def _download_image(self, image_url: str) -> str: - parsed_url = urlparse(image_url) - ext = os.path.splitext(parsed_url.path)[1] or ".jpg" - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: - response = requests.get(image_url, stream=True, timeout=20) - response.raise_for_status() - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - tmp.write(chunk) - return tmp.name +import os +from urllib.parse import urlparse +import tempfile + +import requests +from mastodon import Mastodon + +from abstractions import Post, PostResult, SocialPoster + +MASTODON_CHARACTER_LIMIT = 500 +TRUNCATION_SUFFIX = "..." + + +class PosterMastodon(SocialPoster): + def __init__(self): + raw_token = os.environ.get("MASTODON_TOKEN") + self.token = raw_token.strip() if raw_token else None + self.api_base_url = "https://mastodon.social" + self._session = None + self._is_available = bool(self.token) + self._auth_error = None + + @property + def platform_name(self) -> str: + return "Mastodon" + + def authenticate(self) -> bool: + try: + self._session = Mastodon( + access_token=self.token, + api_base_url=self.api_base_url, + ) + self._session.account_verify_credentials() + self._auth_error = None + return True + except Exception as exc: + self._session = None + self._auth_error = f"{type(exc).__name__}: {exc}" + return False + + def publish(self, post: Post) -> PostResult: + if not self._is_available: + return PostResult( + success=False, + error_message="Mastodon credentials not available.", + ) + + if not post.image_url: + return PostResult( + success=False, + error_message="Mastodon posts require an image URL.", + ) + + if not self._session and not self.authenticate(): + return PostResult( + success=False, + error_message=( + "Mastodon authentication failed." + if not self._auth_error + else f"Mastodon authentication failed: {self._auth_error}" + ), + ) + + image_path = None + try: + image_path = self._download_image(post.image_url) + media = self._session.media_post( + image_path, + description=post.alt_text or "Photo of an adoptable pet", + ) + status = self._session.status_post( + self._format_caption(post), + media_ids=[media["id"]], + ) + return PostResult( + success=True, + post_id=str(status["id"]), + post_url=status.get("url"), + ) + except Exception as exc: + return PostResult(success=False, error_message=str(exc)) + finally: + self._session = None + if image_path and os.path.exists(image_path): + os.unlink(image_path) + + def _format_caption(self, post: Post) -> str: + tags = " ".join(f"#{tag}" for tag in post.tags if tag) + tag_suffix = f"\n\n{tags}" if tags else "" + available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix) + + if available_text_length <= len(TRUNCATION_SUFFIX): + return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip() + + caption_text = post.text.strip() + if len(caption_text) > available_text_length: + caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip() + caption_text = f"{caption_text}{TRUNCATION_SUFFIX}" + + return f"{caption_text}{tag_suffix}" + + def _download_image(self, image_url: str) -> str: + parsed_url = urlparse(image_url) + ext = os.path.splitext(parsed_url.path)[1] or ".jpg" + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: + response = requests.get(image_url, stream=True, timeout=20) + response.raise_for_status() + for chunk in response.iter_content(chunk_size=1024 * 128): + if chunk: + tmp.write(chunk) + return tmp.name diff --git a/tests/test_main.py b/tests/test_main.py index 375aa4c..af19a2f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,69 +1,69 @@ -import unittest - -from abstractions import AdoptablePet, Post, PostResult -from main import create_posters, run - - -class FakeSource: - def __init__(self, pets): - self.pets = pets - self.fetch_called = False - - def fetch_pets(self): - self.fetch_called = True - return self.pets - - -class FakePoster: - platform_name = "FakePoster" - - def __init__(self): - self.format_called = False - self.publish_called = False - self.posts = [] - - def format_post(self, pet): - self.format_called = True - return Post(text=f"Meet {pet.name}", image_url=pet.image_url) - - def publish(self, post): - self.publish_called = True - self.posts.append(post) - return PostResult(success=True) - - -class RunFlowTests(unittest.TestCase): - def test_run_calls_source_and_posters(self): - pet = AdoptablePet( - name="Poppy", - species="dog", - breed="mutt", - location="Boston, MA", - image_url="https://example.com/poppy.jpg", - adoption_url="https://example.com/adopt/poppy", - ) - source = FakeSource([pet]) - poster_one = FakePoster() - poster_two = FakePoster() - - results = run([source], [poster_one, poster_two]) - - self.assertTrue(source.fetch_called) - self.assertTrue(poster_one.format_called) - self.assertTrue(poster_one.publish_called) - self.assertTrue(poster_two.format_called) - self.assertTrue(poster_two.publish_called) - self.assertEqual(len(results), 2) - - -class CreatePostersTests(unittest.TestCase): - def test_debug_returns_debug_poster(self): - posters = create_posters(debug=True) - - self.assertEqual(len(posters), 1) - self.assertEqual(posters[0].platform_name, "Debug") - - - -if __name__ == "__main__": - unittest.main() +import unittest + +from abstractions import AdoptablePet, Post, PostResult +from main import create_posters, run + + +class FakeSource: + def __init__(self, pets): + self.pets = pets + self.fetch_called = False + + def fetch_pets(self): + self.fetch_called = True + return self.pets + + +class FakePoster: + platform_name = "FakePoster" + + def __init__(self): + self.format_called = False + self.publish_called = False + self.posts = [] + + def format_post(self, pet): + self.format_called = True + return Post(text=f"Meet {pet.name}", image_url=pet.image_url) + + def publish(self, post): + self.publish_called = True + self.posts.append(post) + return PostResult(success=True) + + +class RunFlowTests(unittest.TestCase): + def test_run_calls_source_and_posters(self): + pet = AdoptablePet( + name="Poppy", + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/poppy.jpg", + adoption_url="https://example.com/adopt/poppy", + ) + source = FakeSource([pet]) + poster_one = FakePoster() + poster_two = FakePoster() + + results = run([source], [poster_one, poster_two]) + + self.assertTrue(source.fetch_called) + self.assertTrue(poster_one.format_called) + self.assertTrue(poster_one.publish_called) + self.assertTrue(poster_two.format_called) + self.assertTrue(poster_two.publish_called) + self.assertEqual(len(results), 2) + + +class CreatePostersTests(unittest.TestCase): + def test_debug_returns_debug_poster(self): + posters = create_posters(debug=True) + + self.assertEqual(len(posters), 1) + self.assertEqual(posters[0].platform_name, "Debug") + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mastodon.py b/tests/test_mastodon.py index 9fbd70f..aadc9c8 100644 --- a/tests/test_mastodon.py +++ b/tests/test_mastodon.py @@ -1,27 +1,27 @@ -from abstractions import Post -from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT - - -class TestMastodonCaption: - def setup_method(self): - self.poster = PosterMastodon.__new__(PosterMastodon) - - def test_no_tags(self): - post = Post(text="Hello, world!") - assert self.poster._format_caption(post) == "Hello, world!" - - def test_with_tags(self): - post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) - assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" - - def test_caption_stays_under_limit(self): - post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) - caption = self.poster._format_caption(post) - - assert len(caption) <= MASTODON_CHARACTER_LIMIT - assert caption.endswith("\n\n#AdoptDontShop #Boston") - assert "..." in caption - - def test_empty_tags_are_ignored(self): - post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) +from abstractions import Post +from social_posters.mastodon import PosterMastodon, MASTODON_CHARACTER_LIMIT + + +class TestMastodonCaption: + def setup_method(self): + self.poster = PosterMastodon.__new__(PosterMastodon) + + def test_no_tags(self): + post = Post(text="Hello, world!") + assert self.poster._format_caption(post) == "Hello, world!" + + def test_with_tags(self): + post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "Boston"]) + assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" + + def test_caption_stays_under_limit(self): + post = Post(text="x" * 1000, tags=["AdoptDontShop", "Boston"]) + caption = self.poster._format_caption(post) + + assert len(caption) <= MASTODON_CHARACTER_LIMIT + assert caption.endswith("\n\n#AdoptDontShop #Boston") + assert "..." in caption + + def test_empty_tags_are_ignored(self): + post = Post(text="Meet Poppy!", tags=["AdoptDontShop", "", None, "Boston"]) assert self.poster._format_caption(post) == "Meet Poppy!\n\n#AdoptDontShop #Boston" \ No newline at end of file diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index 5c140e6..8f0995a 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -1,75 +1,75 @@ -import unittest - -from adoption_sources.rescue_groups import SourceRescueGroups - - -def _make_animal(adoption_url=None, **extra_attrs): - attrs = { - "name": "Buddy", - "breedString": "Lab Mix", - "pictureThumbnailUrl": "https://example.com/buddy.jpg", - **extra_attrs, - } - if adoption_url is not None: - attrs["adoptionUrl"] = adoption_url - return { - "type": "animals", - "id": "12345", - "attributes": attrs, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, - } - - -def _make_org(adoption_url=None, url=None): - attrs = {"city": "Boston", "state": "MA"} - if adoption_url is not None: - attrs["adoptionUrl"] = adoption_url - if url is not None: - attrs["url"] = url - return attrs - - -class AdoptionUrlTests(unittest.TestCase): - def setUp(self): - self.source = SourceRescueGroups(api_key="dummy") - - 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) - - self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") - - 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) - - self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") - - 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) - - self.assertEqual(pet.adoption_url, "https://org.example.com") - - -class PlaceholderNameTests(unittest.TestCase): - def setUp(self): - self.source = SourceRescueGroups(api_key="dummy") - - 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_real_pet_name_is_not_placeholder(self): - self.assertFalse(self.source._is_placeholder_name("Pippin")) - self.assertFalse(self.source._is_placeholder_name("Buddy")) - - -if __name__ == "__main__": - unittest.main() +import unittest + +from adoption_sources.rescue_groups import SourceRescueGroups + + +def _make_animal(adoption_url=None, **extra_attrs): + attrs = { + "name": "Buddy", + "breedString": "Lab Mix", + "pictureThumbnailUrl": "https://example.com/buddy.jpg", + **extra_attrs, + } + if adoption_url is not None: + attrs["adoptionUrl"] = adoption_url + return { + "type": "animals", + "id": "12345", + "attributes": attrs, + "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + } + + +def _make_org(adoption_url=None, url=None): + attrs = {"city": "Boston", "state": "MA"} + if adoption_url is not None: + attrs["adoptionUrl"] = adoption_url + if url is not None: + attrs["url"] = url + return attrs + + +class AdoptionUrlTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + + 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) + + self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") + + 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) + + self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") + + 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) + + self.assertEqual(pet.adoption_url, "https://org.example.com") + + +class PlaceholderNameTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + + 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_real_pet_name_is_not_placeholder(self): + self.assertFalse(self.source._is_placeholder_name("Pippin")) + self.assertFalse(self.source._is_placeholder_name("Buddy")) + + +if __name__ == "__main__": + unittest.main() From ba31cd326c4e1cf914a7753cb2520261639aa2e8 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Tue, 19 May 2026 23:44:34 -0400 Subject: [PATCH 20/25] Fix json reading and only use current branch db --- .github/workflows/dev.yml | 4 +++- main.py | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index df90b75..998dbbb 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -32,7 +32,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Fetches the ID of the last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" \ + PREVIOUS_RUN_ID=$(gh run list \ + --branch "${{ github.ref_name }}" \ + --workflow "${{ github.workflow }}" \ --status success \ --limit 1 \ --json databaseId \ diff --git a/main.py b/main.py index bfe7bfc..1461451 100644 --- a/main.py +++ b/main.py @@ -103,7 +103,7 @@ def pick_pet(pets): data = {} if "posted_pets" in data: - posted_pet_ids = {pet.pet_id for pet in data["posted_pets"]} + posted_pet_ids = {posted_pet["pet_id"] for posted_pet in data["posted_pets"]} else: posted_pet_ids = {} data["posted_pets"] = [] @@ -113,16 +113,15 @@ def pick_pet(pets): return None selected_pet = random.choice(eligible) - print(selected_pet) # Add pet ID to list of posted pets data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "time": datetime.now(timezone.utc).isoformat()}) # Remove old pets cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) - new_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] - data["posted_pets"] = new_pets + recent_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['time']) > cutoff] + data["posted_pets"] = recent_pets # Export json f.seek(0) - json.dump(data, f) + json.dump(data, f, indent=4) f.truncate() return selected_pet From b3ea665f1e302f4d8ff0b3b4206eee4c44420ba5 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Tue, 19 May 2026 23:52:02 -0400 Subject: [PATCH 21/25] Add database logic to prod action --- .github/workflows/prod.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 299987f..91cb8d4 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -21,6 +21,30 @@ jobs: - 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 the 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 }} @@ -32,3 +56,10 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} APP_ENV: prod run: python ./main.py + + - name: Upload database artifact + uses: actions/upload-artifact@v7 + with: + path: database.json + retention-days: 14 + archive: false From bbc5f9f71ca9a8f8f1a99ef593415b88ce0e5f55 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Wed, 20 May 2026 00:05:44 -0400 Subject: [PATCH 22/25] Delete dev artifacts faster to reduce chance of running out of pets --- .github/workflows/dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 998dbbb..7f6b196 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -66,5 +66,5 @@ jobs: uses: actions/upload-artifact@v7 with: path: database.json - retention-days: 14 + retention-days: 1 archive: false From 3f7ae988239d5420bea755a09b7de2ee72a51a13 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Wed, 20 May 2026 00:20:36 -0400 Subject: [PATCH 23/25] Fix action permissions --- .github/workflows/prod.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 91cb8d4..5d58fb6 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -6,6 +6,9 @@ on: # Every 4 hours - cron: "0 */4 * * *" +permissions: + actions: read + jobs: run-cute-pets: runs-on: ubuntu-latest From aa21e2a69318e418f25fdeca35d5897b8e032dc4 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Wed, 20 May 2026 20:53:52 -0400 Subject: [PATCH 24/25] Exit with error when no elligible pets are found --- adoption_sources/manual.json | 1461 +--------------------------------- main.py | 1 + 2 files changed, 16 insertions(+), 1446 deletions(-) diff --git a/adoption_sources/manual.json b/adoption_sources/manual.json index 01325b1..a69575f 100644 --- a/adoption_sources/manual.json +++ b/adoption_sources/manual.json @@ -1,1472 +1,41 @@ [ { "type": "animals", - "id": "19427790", - "attributes": { - "isAdoptionPending": false, - "ageString": "7 Years 8 Months", - "birthDate": "2018-05-12T00:00:00Z", - "isBirthDateExact": false, - "breedString": "American Staffordshire Terrier / Mixed", - "breedPrimary": "American Staffordshire Terrier", - "breedPrimaryId": 82, - "isBreedMixed": true, - "isCourtesyListing": false, - "descriptionHtml": "

Found with a chain around his neck.  The chain was dropped into the sewer and he could not move.

\"\"", - "descriptionText": "Found with a chain around his neck.  The chain was dropped into the sewer and he could not move.", - "isFound": false, - "priority": 10, - "name": "Flynn", - "pictureCount": 2, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8552/pictures/animals/19427/19427790/100783814.jpg?width=100", - "searchString": "Flynn Blue/Silver/Salt & Pepper Male X-Large Dogs American Staffordshire Terrier / Mixeds", - "sex": "Male", - "sizeCurrent": 109, - "sizeGroup": "X-Large", - "sizeUOM": "Pounds", - "slug": "adopt-flynn-american-staffordshire-terrier-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?19427790", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2023-06-24T17:12:25Z", - "updatedDate": "2025-11-03T02:20:53Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "82" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000008552" - } - ] - } - } - }, - { - "type": "animals", - "id": "22339942", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Baby", - "isBirthDateExact": false, - "breedString": "Hound", - "breedPrimary": "Hound", - "breedPrimaryId": 151, - "isBreedMixed": false, - "isCourtesyListing": false, - "isCurrentVaccinations": false, - "isDeclawed": false, - "descriptionText": "Meet Merry and Pippin!\nThese sweet 8 week old pups are looking for their forever homes. They love to play with their foster brother, chase down balls, and then follow it all up with a nap, in a lap preferably.\nCome meet Merry and Pippin this Saturday at the Woodbury Fire Department hoagie sale (9-12) or Sunday at Magnify Brewing.\nEmail ammrsabrina@gmail.com with questions or fill out an application at:\nTinyurl.com/AMMRadoptapp\n\nCheck us out on FB https://m.facebook.com/groups/927573409001459/?ref=share\nOr our Instagram https://www.instagram.com/all_mutts_matter_rescue?igsh=MWVycnBzMjFkOG5lNQ==\nAdoption donation of $425 includes age-appropriate vaccinations and spay/neuter when pup is approximately 5-7 months of age. Copays vary by location.\n$110 reimbursement if adopters' own vet/clinic is used within same timeframe for alter and microchip.\nAll Mutts Matter Rescue is a registered 501c3 non-profit organization and donations are tax deductible.\n\n*** Breed determination in most cases is often based on the limited history typically available for most rescued animals. We try to be as accurate as possible with the information we have and the rescue assessment of the pup.\n\nCurrent Adoptions are within 3 hours of our foster homes, most within the SJ area - within NJ/PA/DE", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": false, - "name": "Pippin", - "pictureCount": 2, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/11627/pictures/animals/22339/22339942/102851090.jpg?width=100", - "rescueId": "Sabrina", - "searchString": "Pippin Male Medium Sabrina Dogs Hounds", - "sex": "Male", - "sizeGroup": "Medium", - "slug": "adopt-pippin-hound-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-19T21:57:50Z", - "updatedDate": "2026-03-19T22:58:41Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "151" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000011627" - } - ] - } - } - }, - { - "type": "animals", - "id": "21218464", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "isBirthDateExact": false, - "breedString": "German Shepherd Dog / Rottweiler / Mixed (short coat)", - "breedPrimary": "German Shepherd Dog", - "breedPrimaryId": 142, - "breedSecondary": "Rottweiler", - "breedSecondaryId": 189, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": true, - "isCurrentVaccinations": true, - "isDeclawed": false, - "descriptionText": "Check out my video!\nWell, hello! My name is Zelda and I'm a spayed, 2 years old, German Shepherd/Rottweiler mix who currently weighs around 62 lbs. I'm an energetic and effervescent girl that LOVES to play in the water! I would be the perfect companion to beach goers, river rafters or sprinkler hoppers! I'm a girl who feels the call of the wild! I'm a rambunctious girl that prefers to be outdoors exploring. I'm hoping to continue working on my social skills and impulse control with an active, loving family. I can't wait to meet everyone in the household including any resident dogs before I head home. Just ask Customer Service about Zelda ID# A955664", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "name": "Zelda", - "pictureCount": 4, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1776/pictures/animals/21218/21218464/100323336.jpg?width=100", - "rescueId": "A955664", - "searchString": "Zelda Black with Brown, Red, Golden, Orange or Chestnut Female Large A955664 Dogs German Shepherd Dog / Rottweiler / Mixed (short coat)s", - "sex": "Female", - "sizeGroup": "Large", - "slug": "adopt-zelda-german-shepherd-dog-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 1, - "createdDate": "2025-01-19T19:21:52Z", - "updatedDate": "2026-03-20T22:42:38Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "142" - }, - { - "type": "breeds", - "id": "189" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "13256" - } - ] - } - } - }, - { - "type": "animals", - "id": "21907959", - "attributes": { - "adoptionFeeString": "$225 includes spay and vaccines", - "isAdoptionPending": false, - "ageGroup": "Young", - "isBirthDateExact": false, - "breedString": "Greyhound / Mixed", - "breedPrimary": "Greyhound", - "breedPrimaryId": 150, - "isBreedMixed": true, - "isCourtesyListing": false, - "descriptionHtml": "Cassie is a fun loving playful girl whose approx dob is  August 13 2025. We weren't given any info on her parents so our best guess is Greyhound mix due to her very slender body frame and unique markings. Cassie does great with other dogs LOVES to meet people and guve hugs and kisses! She has such a fun spunky personality. Www.barconline.com or call 951 845 1513 we are located in Cherry Valley Ca.\"\"", - "descriptionText": "Cassie is a fun loving playful girl whose approx dob is  August 13 2025. We weren't given any info on her parents so our best guess is Greyhound mix due to her very slender body frame and unique markings. Cassie does great with other dogs LOVES to meet people and guve hugs and kisses! She has such a fun spunky personality. Www.barconline.com or call 951 845 1513 we are located in Cherry Valley Ca.", - "isDogsOk": true, - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "name": "Cassie", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/105/pictures/animals/21907/21907959/101859184.jpg?width=100", - "rescueId": "5738", - "searchString": "Cassie Female 5738 Dogs Greyhound / Mixeds", - "sex": "Female", - "slug": "adopt-cassie-greyhound-dog", - "isSpecialNeeds": false, - "isSponsorable": true, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21907959", - "url": "https://www.barconline.com/animals/detail?AnimalID=21907959", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-09-24T21:43:38Z", - "updatedDate": "2026-01-03T01:03:49Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "150" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000000105" - } - ] - } - } - }, - { - "type": "animals", - "id": "22326727", - "attributes": { - "activityLevel": "Moderately Active", - "adoptionFeeString": "350", - "isAdoptionPending": false, - "adultSexesOk": "All", - "ageGroup": "Adult", - "ageString": "1 Year 5 Months", - "birthDate": "2024-09-14T00:00:00Z", - "isBirthDateExact": false, - "breedString": "American Staffordshire Terrier / Mixed (short coat)", - "breedPrimary": "American Staffordshire Terrier", - "breedPrimaryId": 82, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": true, - "isCurrentVaccinations": true, - "descriptionText": "Polly Pockets is 1.5 year sold, 50 lbs\n\n \n\nShe is a dollbaby Good with all people and dogs. Very sweet. Her owner had openheart surgery and can no longer care for her. He is disrtraught. In danger of going to the shelter and they will kill her.", - "isDogsOk": true, - "energyLevel": "Moderate", - "exerciseNeeds": "Moderate", - "fenceNeeds": "Any Type", - "isFound": false, - "groomingNeeds": "Not Required", - "priority": 10, - "isHousetrained": true, - "indoorOutdoor": "Indoor and Outdoor", - "name": "Polly Pockets", - "newPeopleReaction": "Friendly", - "obedienceTraining": "Well Trained", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6198/pictures/animals/22326/22326727/102819970.jpg?width=100", - "qualities": [ - "affectionate", - "cratetrained", - "eagerToPlease", - "doesWellInCar", - "lap", - "leashtrained", - "playful" - ], - "searchString": "Polly Pockets Tan Female Medium Dogs American Staffordshire Terrier / Mixed (short coat)s", - "sex": "Female", - "sheddingLevel": "Moderate", - "sizeCurrent": 50, - "sizeGroup": "Medium", - "sizePotential": 50, - "sizeUOM": "Pounds", - "slug": "adopt-polly-pockets-american-staffordshire-terrier-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "vocalLevel": "Quiet", - "isYardRequired": true, - "createdDate": "2026-03-13T16:20:17Z", - "updatedDate": "2026-03-13T16:20:18Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "82" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000006198" - } - ] - } - } - }, - { - "type": "animals", - "id": "21388410", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Young", - "isBirthDateExact": false, - "breedString": "Labrador Retriever / Akita / Mixed", - "breedPrimary": "Labrador Retriever", - "breedPrimaryId": 162, - "breedSecondary": "Akita", - "breedSecondaryId": 78, - "isBreedMixed": true, - "isCourtesyListing": false, - "descriptionText": "Meet Snow, a 3-4 year old friendly and well-mannered dog who loves people. He’s fully potty trained and crate trained, making him an easy addition to any home. Snow enjoys car rides and going for walks, so he’s always ready for an adventure or a relaxing drive. With his sweet nature and great temperament, Snow would make a wonderful companion for anyone looking for a loyal, loving friend. ", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "isKidsOk": true, - "name": "Snow", - "pictureCount": 3, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/637/pictures/animals/21388/21388410/100692761.jpg?width=100", - "searchString": "Snow White Male Large Dogs Labrador Retriever / Akita / Mixeds", - "sex": "Male", - "sizeGroup": "Large", - "slug": "adopt-snow-labrador-retriever-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-03-30T05:07:47Z", - "updatedDate": "2025-03-30T05:37:25Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "162" - }, - { - "type": "breeds", - "id": "78" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000000637" - } - ] - } - } - }, - { - "type": "animals", - "id": "22335553", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Senior", - "ageString": "8 Years", - "birthDate": "2018-03-09T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Terrier / Mixed (medium coat)", - "breedPrimary": "Terrier", - "breedPrimaryId": 208, - "isBreedMixed": true, - "coatLength": "Medium", - "isCourtesyListing": false, - "descriptionText": "Hi I'm Frito!I\u00e2\u0080\u0099m a sweet, senior guy who loves nothing more than curling up on a fluffy bed and soaking in the cozy vibes. I enjoy sniffing and exploring when we go on walks, and treats always make my day. Once I warm up to you, I become a little attention-seeker, happily nudging for pets and cuddles. If you\u00e2\u0080\u0099re looking for a calm, loving companion with a big heart, I\u00e2\u0080\u0099d love to meet you!", - "isFound": false, - "priority": 10, - "name": "FRITO", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8846/pictures/animals/22335/22335553/102866911.jpg?width=100", - "rescueId": "A270361", - "searchString": "FRITO White Male Medium A270361 Dogs Terrier / Mixed (medium coat)s", - "sex": "Male", - "sizeGroup": "Medium", - "slug": "adopt-frito-terrier-dog", - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-17T21:52:18Z", - "updatedDate": "2026-03-23T01:27:14Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "208" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000008846" - } - ] - } - } - }, - { - "type": "animals", - "id": "16027333", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "isBirthDateExact": false, - "breedString": "Husky / Pit Bull Terrier (medium coat)", - "breedPrimary": "Husky", - "breedPrimaryId": 152, - "breedSecondary": "Pit Bull Terrier", - "breedSecondaryId": 179, - "isCatsOk": true, - "coatLength": "Medium", - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionHtml": "

COCO came to us after she had a cluster of seizures in her newly adoptive home. She was adopted only a month before from a rescue in Tennessee which did not respond to our inquiries regarding her previous history. Coco is a SWEET girl! She is currently in a foster home with a Chihuahua and several cats and does well with all! She enjoys her walks as much as she enjoys her couch time! Coco is a beautiful red brindle! she is 110% housebroken. Great with meeting new people! Coco is in Keppra and \nphenobarbital \nto control her seizures. She has had a couple of clusters since arriving in her foster home and we are working In fine tuning her meds. Coco will need more frequent vet visits than the average dog her age. With excellent care, we feel she can live a relatively normal life and will make an excellent companion for some lucky family. Coco has been spayed, vaccinated, microchipped and heartworm tested. Adoption $200. Please fill out an application at www.companimals.org if you are interested in adding Coco to your family! 

\"\"", - "descriptionText": "COCO came to us after she had a cluster of seizures in her newly adoptive home. She was adopted only a month before from a rescue in Tennessee which did not respond to our inquiries regarding her previous history. Coco is a SWEET girl! She is currently in a foster home with a Chihuahua and several cats and does well with all! She enjoys her walks as much as she enjoys her couch time! Coco is a beautiful red brindle! she is 110% housebroken. Great with meeting new people! Coco is in Keppra and \nphenobarbital \nto control her seizures. She has had a couple of clusters since arriving in her foster home and we are working In fine tuning her meds. Coco will need more frequent vet visits than the average dog her age. With excellent care, we feel she can live a relatively normal life and will make an excellent companion for some lucky family. Coco has been spayed, vaccinated, microchipped and heartworm tested. Adoption $200. Please fill out an application at www.companimals.org if you are interested in adding Coco to your family! ", - "isDogsOk": true, - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "isKidsOk": true, - "name": "Coco", - "pictureCount": 12, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/4012/pictures/animals/16027/16027333/76723483.jpg?width=100", - "searchString": "Coco Red Female Large Dogs Husky / Pit Bull Terrier (medium coat)s", - "sex": "Female", - "sizeGroup": "Large", - "sizeUOM": "Pounds", - "slug": "adopt-coco-husky-dog", - "isSpecialNeeds": true, - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?16027333", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2020-09-07T09:47:40Z", - "updatedDate": "2023-06-05T01:03:41Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "152" - }, - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000004012" - } - ] - } - } - }, - { - "type": "animals", - "id": "22276752", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Baby", - "isBirthDateExact": false, - "breedString": "Staffordshire Bull Terrier / Mixed (short coat)", - "breedPrimary": "Staffordshire Bull Terrier", - "breedPrimaryId": 207, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": false, - "descriptionHtml": "\"\"", - "isFound": false, - "priority": 10, - "name": "Rei", - "pictureCount": 0, - "searchString": "Rei Luna Rose Black with White Female Medium Dogs Staffordshire Bull Terrier / Mixed (short coat)s", - "sex": "Female", - "sizeGroup": "Medium", - "sizeUOM": "Pounds", - "slug": "adopt-rei-staffordshire-bull-terrier-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22276752", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-02-18T20:24:01Z", - "updatedDate": "2026-02-26T13:31:04Z", - "pictureThumbnailUrl": "https://www.rescuegroups.org/images/photos/22276752/22276752-1-400x400.jpg" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "207" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000003398" - } - ] - } - } - }, - { - "type": "animals", - "id": "17439767", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "6 Years 4 Months", - "birthDate": "2019-11-09T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Chinese Shar-Pei / Mixed", - "breedPrimary": "Chinese Shar-Pei", - "breedPrimaryId": 120, - "isBreedMixed": true, - "isCourtesyListing": false, - "descriptionText": "Myrtle is a fun-loving, energetic girl with a sweet spirit and a love for the great outdoors. She enjoys hikes and leisurely walks where she can take in all the sights and smells, making her a wonderful companion for fresh-air adventures.After a day out, Myrtle is happy to relax with her favorite toys and enjoy some well-earned downtime. With her affectionate nature and adventurous heart, Myrtle is ready to share lifeâ\u20ac\u2122s simple joys with a loving home. Ask an Adoption Specialist to meet Myrtle today!", - "isDogsOk": true, - "isFound": false, - "priority": 10, - "name": "Myrtle", - "pictureCount": 9, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6685/pictures/animals/17439/17439767/97865248.jpg?width=100", - "qualities": [ - "protective" - ], - "searchString": "Myrtle Black Female Large Dogs Chinese Shar-Pei / Mixeds", - "sex": "Female", - "sizeCurrent": 54.674598693847656, - "sizeGroup": "Large", - "sizeUOM": "Pounds", - "slug": "adopt-myrtle-chinese-shar-pei-dog", - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2021-11-10T19:02:15Z", - "updatedDate": "2026-03-13T21:08:32Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "120" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000006685" - } - ] - } - } - }, - { - "type": "animals", - "id": "21639940", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Young", - "ageString": "2 Years 1 Month", - "birthDate": "2024-01-01T00:00:00Z", - "isBirthDateExact": false, - "breedString": "American Staffordshire Terrier / Mixed (short coat)", - "breedPrimary": "American Staffordshire Terrier", - "breedPrimaryId": 82, - "isBreedMixed": true, - "isCatsOk": false, - "coatLength": "Short", - "isCourtesyListing": true, - "isCurrentVaccinations": true, - "isDeclawed": false, - "descriptionHtml": "

Part Staffy? Part Adorable? Full on adorable is Thor! He is just not another black dog; if fact, he is so fun that if you meet him, you will see why we love this breed at the shelter (Thor is a courtesy post for the Brooke County Animal Shelter in BeechBottom, WV, about 45 minutes from Pittsburgh). 

\n\n

Thor is a young adult -- about a year or two old who sports a smooth, sleek and easy-to-care for coat and ears that stand up or down according to his mood!

\n\n

Thor likes to bounce and hop when he is happy and his joy is infectious. He is great with other dogs and always has his eyes on people looking for love and attention. Thor promises to bring the best of his breed traits including lots of contact and snuggles as well as being game for going wherever you go. He ADORES toys, especially if they squeak. This boy is medium weight, ready-to-travel size and is game for anything as long as he his with his family! He can be a bit excitable initially but quickly calms down to scoot close for cuddles. Thor is almost impossible to resist when he gazes up at you with eyes filled with love and kindness.

\n\n

Please contact the Brooke County Animal Shelter at 304-394-0800 for more information or to arrange a meet and greet with Thor.

\"\"", - "descriptionText": "Part Staffy? Part Adorable? Full on adorable is Thor! He is just not another black dog; if fact, he is so fun that if you meet him, you will see why we love this breed at the shelter (Thor is a courtesy post for the Brooke County Animal Shelter in BeechBottom, WV, about 45 minutes from Pittsburgh). \n\nThor is a young adult -- about a year or two old who sports a smooth, sleek and easy-to-care for coat and ears that stand up or down according to his mood!\n\nThor likes to bounce and hop when he is happy and his joy is infectious. He is great with other dogs and always has his eyes on people looking for love and attention. Thor promises to bring the best of his breed traits including lots of contact and snuggles as well as being game for going wherever you go. He ADORES toys, especially if they squeak. This boy is medium weight, ready-to-travel size and is game for anything as long as he his with his family! He can be a bit excitable initially but quickly calms down to scoot close for cuddles. Thor is almost impossible to resist when he gazes up at you with eyes filled with love and kindness.\n\nPlease contact the Brooke County Animal Shelter at 304-394-0800 for more information or to arrange a meet and greet with Thor.", - "isDogsOk": true, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "isKidsOk": true, - "killReason": "0", - "name": "Thor (courtesy post)", - "pictureCount": 3, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3365/pictures/animals/21639/21639940/101248442.jpg?width=100", - "rescueId": "25-0086", - "searchString": "Thor (courtesy post) Black Male Medium 25-0086 Dogs American Staffordshire Terrier / Mixed (short coat)s", - "sex": "Male", - "sizeCurrent": 60, - "sizeGroup": "Medium", - "sizeUOM": "Pounds", - "slug": "adopt-thor-courtesy-post-american-staffordshire-terrier-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21639940", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-06-30T14:04:15Z", - "updatedDate": "2025-06-30T14:09:12Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "82" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000003365" - } - ] - } - } - }, - { - "type": "animals", - "id": "22277252", - "attributes": { - "activityLevel": "Moderately Active", - "adoptionFeeString": "350", - "isAdoptionPending": false, - "adultSexesOk": "All", - "ageGroup": "Adult", - "ageString": "2 Years 6 Months", - "birthDate": "2023-08-23T00:00:00Z", - "isBirthDateExact": false, - "breedString": "American Pit Bull Terrier / Mixed (short coat)", - "breedPrimary": "American Pit Bull Terrier", - "breedPrimaryId": 729, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionText": "Meet Gumdrop!\n\nGumdrop is a 1.5–2 year old  45 lb female pittie mix who is as sweet as her name suggests. This adorable girl is the total package — smart, playful, affectionate, and ready to find a family to call her own.\n\nGumdrop is already housebroken and crate trained, which makes the transition into her new home that much easier. She’s also completed 8 weeks of puppy training, so she has a great foundation and loves to keep learning. She’s a bright girl who enjoys engaging with her people and showing off what she knows.\n\nWhen it’s time to play, Gumdrop is happy to have fun and burn off some energy, but when the day winds down her favorite place to be is curled up right next to you for cuddles. She truly loves being close to her people.\n\nGumdrop gets along well with other dogs and would enjoy having a canine companion to play with, though she’d also be perfectly happy soaking up all the attention as your one and only.\n\nThis sweet, affectionate, and well-mannered girl is more than ready to start the next chapter of her life with a loving family.\n\nIf you’re looking for a loyal companion who can play, learn, and snuggle, Gumdrop might just be your perfect match\n\n \n\nIf you are interested in adopting/meeting this pup , please complete our online adoption application that you can find here: https://www.starfishanimalrescue.com/adopt/dog-adoption-application  . Your application will be reviewed by our team and if it is a good fit, we will send to the foster family to set up a meet!\n\nThe adoption fee of $350 will include spay/neuter, microchip, age appropriate/required vaccinations, treatment for heartworm and any other necessary medical treatment to assure a healthy new pup for your family. IMPORTANT DISCLAIMER: Although we do our best to describe breed, since our pups all come from shelters we can not and will not make any guarantee on breed or size. We can tell you that they are 100% RESCUED! And that is the BEST breed!\n\n*please note that we are a foster home based rescue and we do home visits as part of the adoption process - we are unable to adopt to families who live outside of the Chicago and surrounding suburbs. Thank you for your understanding.", - "isDogsOk": true, - "energyLevel": "Moderate", - "exerciseNeeds": "Moderate", - "fenceNeeds": "6 foot", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "indoorOutdoor": "Indoor Only", - "isKidsOk": true, - "name": "Gumdrop", - "newPeopleReaction": "Friendly", - "obedienceTraining": "Needs Training", - "pictureCount": 6, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8605/pictures/animals/22277/22277252/102786566.jpg?width=100", - "qualities": [ - "affectionate", - "cratetrained", - "eagerToPlease", - "lap", - "olderKidsOnly", - "playful", - "playsToys" - ], - "rescueId": "D260086", - "searchString": "Gumdrop White with Black Female Medium D260086 Dogs American Pit Bull Terrier / Mixed (short coat)s", - "sex": "Female", - "sizeCurrent": 45, - "sizeGroup": "Medium", - "sizeUOM": "Pounds", - "slug": "adopt-gumdrop-american-pit-bull-terrier-dog", - "isSponsorable": true, - "url": "https://Crowe.rescuegroups.org/animals/detail?AnimalID=22277252", - "videoCount": 0, - "videoUrlCount": 0, - "isYardRequired": true, - "createdDate": "2026-02-18T23:08:59Z", - "updatedDate": "2026-03-19T17:25:39Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "729" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000008605" - } - ] - } - } - }, - { - "type": "animals", - "id": "22315844", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "5 Years 11 Months", - "birthDate": "2020-04-17T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Pit Bull Terrier (medium coat)", - "breedPrimary": "Pit Bull Terrier", - "breedPrimaryId": 179, - "isBreedMixed": false, - "coatLength": "Medium", - "isCourtesyListing": false, - "isFound": false, - "priority": 10, - "name": "HARLEY", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1991/pictures/animals/22315/22315844/102863279.jpg?width=100", - "rescueId": "A115110", - "searchString": "HARLEY Brown/Chocolate Female Large A115110 Dogs Pit Bull Terrier (medium coat)s", - "sex": "Female", - "sizeGroup": "Large", - "slug": "adopt-harley-pit-bull-terrier-dog", - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-08T16:39:01Z", - "updatedDate": "2026-03-22T02:18:24Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000001991" - } - ] - } - } - }, - { - "type": "animals", - "id": "22308825", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "2 Years", - "birthDate": "2024-02-17T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Pit Bull Terrier (medium coat)", - "breedPrimary": "Pit Bull Terrier", - "breedPrimaryId": 179, - "isBreedMixed": false, - "coatLength": "Medium", - "isCourtesyListing": false, - "descriptionHtml": "\"\"", - "isFound": false, - "priority": 10, - "name": "DOS", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/4156/pictures/animals/22308/22308825/102782448.jpg?width=100", - "rescueId": "A071698", - "searchString": "DOS Gray Male Medium A071698 Dogs Pit Bull Terrier (medium coat)s", - "sex": "Male", - "sizeGroup": "Medium", - "slug": "adopt-dos-pit-bull-terrier-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22308825", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-04T22:38:23Z", - "updatedDate": "2026-03-05T23:12:41Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000004156" - } - ] - } - } - }, - { - "type": "animals", - "id": "22323834", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Young", - "isBirthDateExact": false, - "breedString": "Pit Bull Terrier / Mixed (short coat)", - "breedPrimary": "Pit Bull Terrier", - "breedPrimaryId": 179, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": true, - "isCurrentVaccinations": true, - "descriptionText": "This animal is available at:\nSonoma County Animal Services (707) 565-7100\nPLEASE DO NOT CONTACT NORTH BAY\nDaisy A438421\n \nLocated At: Sonoma County Animal Services\n \nDescription: I am a spayed female, white Pit Bull Terrier mix.\n \nAge: I am estimated to be about 1 year and 4 months old.\n \nWeight: I weigh approximately 43 pounds.\n \nMore Info: I have been at the shelter since Nov 29, 2025. \nFor more information about this animal, call:Sonoma County Animal Services at (707) 565-71001247 Century Court, Santa Rosa\nTuesday - Saturday12:00 - 5:00 p.m.Adoptions are processed until 4:30 p.m.", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "name": "Daisy A438421", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3325/pictures/animals/22323/22323834/102812908.jpg?width=100", - "rescueId": "Sonoma County AS", - "searchString": "Daisy A438421 White Female Medium Sonoma County AS Dogs Pit Bull Terrier / Mixed (short coat)s", - "sex": "Female", - "sizeGroup": "Medium", - "slug": "adopt-daisy-a-pit-bull-terrier-dog", - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-12T02:03:23Z", - "updatedDate": "2026-03-12T02:03:23Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "6833" - } - ] - } - } - }, - { - "type": "animals", - "id": "20063917", - "attributes": { - "activityLevel": "Highly Active", - "isAdoptionPending": false, - "adultSexesOk": "All", - "ageGroup": "Adult", - "ageString": "3 Years 6 Months", - "birthDate": "2021-07-22T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Pit Bull Terrier / Mixed (short coat)", - "breedPrimary": "Pit Bull Terrier", - "breedPrimaryId": 179, - "isBreedMixed": true, - "coatLength": "Short", - "colorDetails": "White with Black", - "isCourtesyListing": false, - "isDeclawed": false, - "descriptionHtml": "

AFFECTIONATE AND PLAYFUL

\n\n

My name is Star. My estimated date of birth is 7/22/2021. I am very affectionate! I love to be the center of attention, and won't allow you much personal space. I enjoy lounging and will sit on your lap. I am energetic and enjoy playing. I am a black and white Pit Bull. I am 85 lbs. Due to my size, I would do best in a home without young children or frail adults. I am good with older, considerate children.

\n\n

*** A courtesy post means this cat is NOT a part of Forever Love Rescue. We are trying to help a fellow rescuer find a home for this cat. We accept the adoption application, assist in processing the application, and then all communication will be handled by the private rescuer onwards. We do not attest to the information they provide, or to the health or vet care of the cat. Specific questions about this cat should be emailed to us at foreverloverescue@gmail.com so we can forward them to the appropriate person! ***

\"\"", - "descriptionText": "AFFECTIONATE AND PLAYFUL\n\nMy name is Star. My estimated date of birth is 7/22/2021. I am very affectionate! I love to be the center of attention, and won't allow you much personal space. I enjoy lounging and will sit on your lap. I am energetic and enjoy playing. I am a black and white Pit Bull. I am 85 lbs. Due to my size, I would do best in a home without young children or frail adults. I am good with older, considerate children.\n\n*** A courtesy post means this cat is NOT a part of Forever Love Rescue. We are trying to help a fellow rescuer find a home for this cat. We accept the adoption application, assist in processing the application, and then all communication will be handled by the private rescuer onwards. We do not attest to the information they provide, or to the health or vet care of the cat. Specific questions about this cat should be emailed to us at foreverloverescue@gmail.com so we can forward them to the appropriate person! ***", - "energyLevel": "High", - "exerciseNeeds": "High", - "isFound": false, - "groomingNeeds": "Not Required", - "priority": 10, - "isHousetrained": true, - "indoorOutdoor": "Indoor and Outdoor", - "isKidsOk": true, - "name": "(KS Courtesy Post) Star", - "newPeopleReaction": "Friendly", - "ownerExperience": "Breed", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6552/pictures/animals/20063/20063917/97344032.jpg?width=100", - "qualities": [ - "affectionate", - "lap", - "olderKidsOnly", - "playful", - "playsToys" - ], - "searchString": "(KS Courtesy Post) Star White with Black White with Black Female Large White with Black Dogs Pit Bull Terrier / Mixed (short coat)s", - "sex": "Female", - "sheddingLevel": "Moderate", - "sizeCurrent": 85, - "sizeGroup": "Large", - "sizeUOM": "Pounds", - "slug": "adopt-ks-courtesy-post-star-pit-bull-terrier-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?20063917", - "videoCount": 0, - "videoUrlCount": 0, - "vocalLevel": "Lots", - "isYardRequired": true, - "createdDate": "2023-12-11T18:28:02Z", - "updatedDate": "2025-02-14T23:34:01Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000006552" - } - ] - } - } - }, - { - "type": "animals", - "id": "22087654", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Young", - "ageString": "1 Year 3 Months", - "birthDate": "2024-11-26T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Labrador Retriever / Pit Bull Terrier / Mixed (medium coat)", - "breedPrimary": "Labrador Retriever", - "breedPrimaryId": 162, - "breedSecondary": "Pit Bull Terrier", - "breedSecondaryId": 179, - "isBreedMixed": true, - "coatLength": "Medium", - "isCourtesyListing": false, - "descriptionHtml": "Kairo is a 53-pound boy who was surrendered to the shelter by his owner due to the owner's medical issues. He is known to be active, friendly, playful and loving. He can be an escape artist so he will need a home with a secure yard with room to run around. He's scared of vacuums and loud noises but loves to chase anything that moves. He's crate trained but this young man needs a little more help with house training. This playful pup is looking for his forever home! Could that be yours?\"\"", - "descriptionText": "Kairo is a 53-pound boy who was surrendered to the shelter by his owner due to the owner's medical issues. He is known to be active, friendly, playful and loving. He can be an escape artist so he will need a home with a secure yard with room to run around. He's scared of vacuums and loud noises but loves to chase anything that moves. He's crate trained but this young man needs a little more help with house training. This playful pup is looking for his forever home! Could that be yours?", - "isFound": false, - "priority": 10, - "name": "KAIRO", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1903/pictures/animals/22087/22087654/102787305.jpg?width=100", - "rescueId": "A216619", - "searchString": "KAIRO Black Male Large A216619 Dogs Labrador Retriever / Pit Bull Terrier / Mixed (medium coat)s", - "sex": "Male", - "sizeGroup": "Large", - "slug": "adopt-kairo-labrador-retriever-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22087654", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-11-29T16:27:27Z", - "updatedDate": "2026-03-06T22:16:06Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "162" - }, - { - "type": "breeds", - "id": "179" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000001903" - } - ] - } - } - }, - { - "type": "animals", - "id": "22294941", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Baby", - "ageString": "2 Months 8 Days", - "birthDate": "2026-01-14T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Boxer / Labrador Retriever / Mixed", - "breedPrimary": "Boxer", - "breedPrimaryId": 104, - "breedSecondary": "Labrador Retriever", - "breedSecondaryId": 162, - "isBreedMixed": true, - "isCourtesyListing": false, - "descriptionText": "Gilbert and his siblings were rescued with their mom from the streets of Arkansas. These adorable pups are now taking applications. Meet them and fall in love!\n\nApply here: https://projecthopearf.rescuegroups.org/forms/form?formid=6707", - "isFound": false, - "priority": 10, - "name": "Gilbert", - "pictureCount": 4, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10006/pictures/animals/22294/22294941/102813227.jpg?width=100", - "rescueId": "26-0224-1736", - "searchString": "Gilbert Tan Male 26-0224-1736 Dogs Boxer / Labrador Retriever / Mixeds", - "sex": "Male", - "sizeUOM": "Pounds", - "slug": "adopt-gilbert-boxer-dog", - "isSponsorable": false, - "url": "https://projecthopearf.rescuegroups.org/animals/detail?AnimalID=22294941", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-02-26T20:58:00Z", - "updatedDate": "2026-03-23T00:13:18Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "104" - }, - { - "type": "breeds", - "id": "162" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000010006" - } - ] - } - } - }, - { - "type": "animals", - "id": "22058131", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Senior", - "ageString": "10 Years 2 Months", - "birthDate": "2015-11-15T00:00:00Z", - "isBirthDateExact": false, - "breedString": "American Staffordshire Terrier / Mixed (medium coat)", - "breedPrimary": "American Staffordshire Terrier", - "breedPrimaryId": 82, - "isBreedMixed": true, - "isCatsOk": false, - "coatLength": "Medium", - "isCourtesyListing": true, - "isCurrentVaccinations": true, - "isDeclawed": false, - "descriptionHtml": "

Stark is a 10-year old "Pittie" mix neutered male with a personality that instantly wins people over. Don't let his age fool you -- this sweet guy is full of spunk and loves showing off his playful side. He's a big fan of toys and will happily bounce around with them like a much younger pup.

\n\n

When Stark isn't playing, he is the most incredibly affectionate boy and loves soaking up all the attention he can get. And his ears? Truly the cutest ever since one stands straight up while the other flops perfectly to the side giving him a look that melts the heart on sight.

\n\n

Stark may be a senior but he has tons of love, joy and a touch of goofiness left to share. He's just waiting for the right person or family to appreciate his charm and give him the cozy loving home he deserves.

\n\n

Stark is a courtesy post for the Brooke County Animal Shelter in Beechbottom, WV, about 45 minutes from downtown Pittsburgh. Please contact the shelter at 304-394-0800 for more information or to arrange a meet-and-greet with Stark. 

\"\"", - "descriptionText": "Stark is a 10-year old "Pittie" mix neutered male with a personality that instantly wins people over. Don't let his age fool you -- this sweet guy is full of spunk and loves showing off his playful side. He's a big fan of toys and will happily bounce around with them like a much younger pup.\n\nWhen Stark isn't playing, he is the most incredibly affectionate boy and loves soaking up all the attention he can get. And his ears? Truly the cutest ever since one stands straight up while the other flops perfectly to the side giving him a look that melts the heart on sight.\n\nStark may be a senior but he has tons of love, joy and a touch of goofiness left to share. He's just waiting for the right person or family to appreciate his charm and give him the cozy loving home he deserves.\n\nStark is a courtesy post for the Brooke County Animal Shelter in Beechbottom, WV, about 45 minutes from downtown Pittsburgh. Please contact the shelter at 304-394-0800 for more information or to arrange a meet-and-greet with Stark. ", - "isDogsOk": true, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "killReason": "0", - "name": "Stark (courtesy post)", - "pictureCount": 3, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3365/pictures/animals/22058/22058131/102195876.jpg?width=100", - "rescueId": "25-0126", - "searchString": "Stark (courtesy post) Brindle with White Male Medium 25-0126 Dogs American Staffordshire Terrier / Mixed (medium coat)s", - "sex": "Male", - "sizeCurrent": 50, - "sizeGroup": "Medium", - "sizeUOM": "Pounds", - "slug": "adopt-stark-courtesy-post-american-staffordshire-terrier-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22058131", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-11-16T18:51:44Z", - "updatedDate": "2025-11-16T18:54:20Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "82" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000003365" - } - ] - } - } - }, - { - "type": "animals", - "id": "21839884", + "id": "22315844", "attributes": { "isAdoptionPending": false, - "ageGroup": "Baby", + "ageGroup": "Adult", + "ageString": "5 Years 11 Months", + "birthDate": "2020-04-17T00:00:00Z", "isBirthDateExact": false, - "breedString": "Pit Bull Terrier / Plott Hound / Mixed (short coat)", + "breedString": "Pit Bull Terrier (medium coat)", "breedPrimary": "Pit Bull Terrier", "breedPrimaryId": 179, - "breedSecondary": "Plott Hound", - "breedSecondaryId": 400, - "isBreedMixed": true, - "coatLength": "Short", - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionHtml": "Meet Sasha \u2013 Our Sweet Survivor Ready to be Part of a Single-Dog Loving Home! Sasha is a one-year-old brindle American Terrier mix bursting with love and joyful energy! She adores belly rubs, long walks, splashing around in water, camping, car rides, and most of all, snuggling up with her people. Sasha is the ultimate companion; she\u2019ll keep you company while you cook, settle by your side as you work, and snuggle up close for movie nights.Her journey is a true \u201crags to riches\u201d story. At just six months old she was found starving in a California alley, abandoned in a kennel chained to a pole. With a body score of one, she was skin and bones barely hanging onto life. And to top it off, the initial shelter that picked her up deemed her unadoptable and gave her 72 hours until euthanization. Working with Pack Lyfe Rescue, we quickly stepped up to commit to giving her a better life and gave Sasha her first cross-country road trip to bring this sweet girl home to Minnesota. Today, Sasha\u2019s days are filled with the warmth of a loving home, cozy beds, endless pets, and delicious treats.For the past eight months, Sasha has been sharing her life with three cats, another dog, and all the humans she loves. Sasha is truly part of our family. We love Sasha and wish more than anything that she could stay with us forever. She\u2019s such an amazing girl, however, her wonderful puppy energy has been a bit too much for our resident dog, who\u2019s eight years old and set in her ways. While Sasha and her \u201csister\u201d have bonded beautifully and get along well most of the time, there have been a few incidents where our older pup\u2019s quick temper has triggered intense disagreements between the two. It breaks our hearts, but because of this we believe Sasha will thrive as the only dog in a home where she can soak up all the love and attention she deserves. And there's even more to adore about Sasha! She's fully potty trained and incredibly smart, knowing lots of commands like sit, stay, lay down, turn around, and heel. Sasha is so attentive and well-mannered that she\u2019ll even sit patiently, waiting for the \u201cokay\u201d before diving into her dinner. She\u2019s eager to learn, loves making her people proud, and is always ready to show off her good-girl skills! If you\u2019re looking for a loyal, affectionate, and adventurous friend, Sasha is ready to be the light of your life. Let\u2019s find this amazing girl the forever family she so deserves!\"\"", - "descriptionText": "Meet Sasha \u2013 Our Sweet Survivor Ready to be Part of a Single-Dog Loving Home! Sasha is a one-year-old brindle American Terrier mix bursting with love and joyful energy! She adores belly rubs, long walks, splashing around in water, camping, car rides, and most of all, snuggling up with her people. Sasha is the ultimate companion; she\u2019ll keep you company while you cook, settle by your side as you work, and snuggle up close for movie nights.Her journey is a true \u201crags to riches\u201d story. At just six months old she was found starving in a California alley, abandoned in a kennel chained to a pole. With a body score of one, she was skin and bones barely hanging onto life. And to top it off, the initial shelter that picked her up deemed her unadoptable and gave her 72 hours until euthanization. Working with Pack Lyfe Rescue, we quickly stepped up to commit to giving her a better life and gave Sasha her first cross-country road trip to bring this sweet girl home to Minnesota. Today, Sasha\u2019s days are filled with the warmth of a loving home, cozy beds, endless pets, and delicious treats.For the past eight months, Sasha has been sharing her life with three cats, another dog, and all the humans she loves. Sasha is truly part of our family. We love Sasha and wish more than anything that she could stay with us forever. She\u2019s such an amazing girl, however, her wonderful puppy energy has been a bit too much for our resident dog, who\u2019s eight years old and set in her ways. While Sasha and her \u201csister\u201d have bonded beautifully and get along well most of the time, there have been a few incidents where our older pup\u2019s quick temper has triggered intense disagreements between the two. It breaks our hearts, but because of this we believe Sasha will thrive as the only dog in a home where she can soak up all the love and attention she deserves. And there's even more to adore about Sasha! She's fully potty trained and incredibly smart, knowing lots of commands like sit, stay, lay down, turn around, and heel. Sasha is so attentive and well-mannered that she\u2019ll even sit patiently, waiting for the \u201cokay\u201d before diving into her dinner. She\u2019s eager to learn, loves making her people proud, and is always ready to show off her good-girl skills! If you\u2019re looking for a loyal, affectionate, and adventurous friend, Sasha is ready to be the light of your life. Let\u2019s find this amazing girl the forever family she so deserves!", - "isFound": false, - "priority": 10, - "isHousetrained": true, - "isKidsOk": true, - "killReason": "0", - "name": "Sasha", - "pictureCount": 6, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10308/pictures/animals/21839/21839884/101707821.jpg?width=100", - "searchString": "Sasha Brindle Female Medium Dogs Pit Bull Terrier / Plott Hound / Mixed (short coat)s", - "sex": "Female", - "sizeGroup": "Medium", - "slug": "adopt-sasha-pit-bull-terrier-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21839884", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-09-02T12:45:52Z", - "updatedDate": "2025-09-02T12:46:21Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "179" - }, - { - "type": "breeds", - "id": "400" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000010308" - } - ] - } - } - }, - { - "type": "animals", - "id": "22251807", - "attributes": { - "adoptionFeeString": "300.00", - "isAdoptionPending": false, - "ageGroup": "Young", - "ageString": "8 Months", - "birthDate": "2025-06-02T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Shepherd / Mixed", - "breedPrimary": "Shepherd", - "breedPrimaryId": 411, - "isBreedMixed": true, - "isCatsOk": true, - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionHtml": "

DOGS:   
CATS:    
KIDS:    
IDEAL FAMILY:  
\n

If you're looking for a gorgeous, young, trainable buddy
who will love you forever,
Gaby may be your ideal family member!

\nGABY'S ADOPTION FEE:   $300
This fee covers only part of what we spend to vet, board and rehab the dogs we save. On average we spend over $450 on each dog. We made a decision to keep our adoption fee at the 2005 level even though vet prices have doubled and tripled since then. We are constantly fundraising to cover the deficit. At minimum, your adoption fee includes the dog's spay/neuter, heartworm test, heartworm treatment if needed, rabies shot, distemper/parvo shot, bordatella shot, deworming, monthly heartworm and flea preventives, and microchip. In many cases it also includes surgery and various types of vet treatment for standard issues such as hot spots, ear infections and so on.

INTERESTED IN ADOPTING GABY?
Complete an Adoption Application Now!
\"\"", - "descriptionText": "DOGS:   CATS:    KIDS:    IDEAL FAMILY:  \nIf you're looking for a gorgeous, young, trainable buddy who will love you forever,Gaby may be your ideal family member!\nGABY'S ADOPTION FEE:   $300 This fee covers only part of what we spend to vet, board and rehab the dogs we save. On average we spend over $450 on each dog. We made a decision to keep our adoption fee at the 2005 level even though vet prices have doubled and tripled since then. We are constantly fundraising to cover the deficit. At minimum, your adoption fee includes the dog's spay/neuter, heartworm test, heartworm treatment if needed, rabies shot, distemper/parvo shot, bordatella shot, deworming, monthly heartworm and flea preventives, and microchip. In many cases it also includes surgery and various types of vet treatment for standard issues such as hot spots, ear infections and so on.INTERESTED IN ADOPTING GABY?Complete an Adoption Application Now!", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "name": "Gaby", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/2152/pictures/animals/22251/22251807/102643524.jpg?width=100", - "rescueId": "2026-2.1", - "searchString": "Gaby Gypsy (OS) Black with Brown, Red, Golden, Orange or Chestnut Female Medium 2026-2.1 Dogs Shepherd / Mixeds", - "sex": "Female", - "sizeGroup": "Medium", - "slug": "adopt-gaby-shepherd-dog", - "isSpecialNeeds": false, - "isSponsorable": true, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22251807", - "url": "https://mogsrescue.rescuegroups.org/animals/detail?AnimalID=22251807", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-02-07T01:43:03Z", - "updatedDate": "2026-02-07T01:43:04Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "411" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000002152" - } - ] - } - } - }, - { - "type": "animals", - "id": "22331986", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Baby", - "isBirthDateExact": false, - "breedString": "Terrier / Mixed", - "breedPrimary": "Terrier", - "breedPrimaryId": 208, - "isBreedMixed": true, - "isCatsOk": true, - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionText": "Meet Velma! Breed: Scruffy Terrier Mix Estimated DOB: 11/12/2025 Sex: Female (spayed) Weight: 10-15 lbs Currently up to date on all age appropriate vaccinationsThings To Know about Velma:\tRescued from Mississippi \tLoves people and takes to everyone she meets like they're her new best friend\tEasygoing, affectionate, and full of puppy charm — when she's not busy playing or exploring, she'll launch herself into your lap for a full-on kiss attack\tPlayful and curious with a love for squeaky toys, bones, and balls\tTreat motivated and eager to learn — future star student written all over her\tCrate training is progressing nicely\tCurrently working on housetraining and will require continued, consistent housetraining habits to establish a good routine\tDog friendly\tYoung enough to learn to love cats\tLooking for a loving and committed forever home to provide her with the care and love she deservesLearn more and apply to adopt at TLCrescuePA.org/adoptInterested in fostering? Join our 100% foster-based team at tlcrescuepa.org/foster. #AdoptVelma #ScruffyTerrier #TLCRescuePA", - "isDogsOk": true, - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isKidsOk": true, - "name": "Velma 031426", - "pictureCount": 3, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10212/pictures/animals/22331/22331986/102832437.png?width=100", - "searchString": "Velma 031426 Black with Tan, Yellow or Fawn Female Medium Dogs Terrier / Mixeds", - "sex": "Female", - "sizeGroup": "Medium", - "slug": "adopt-velma-terrier-dog", - "isSpecialNeeds": false, - "isSponsorable": false, - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2026-03-16T05:32:11Z", - "updatedDate": "2026-03-17T04:42:18Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "208" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000010212" - } - ] - } - } - }, - { - "type": "animals", - "id": "22024006", - "attributes": { - "activityLevel": "Moderately Active", - "adoptionFeeString": "295.00", - "isAdoptionPending": false, - "adultSexesOk": "All", - "ageGroup": "Baby", - "ageString": "10 Months", - "birthDate": "2025-04-17T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Labrador Retriever / Border Collie / Mixed (short coat)", - "breedPrimary": "Labrador Retriever", - "breedPrimaryId": 162, - "breedSecondary": "Border Collie", - "breedSecondaryId": 99, - "isBreedMixed": true, - "isCatsOk": false, - "coatLength": "Short", - "isCourtesyListing": false, - "isCurrentVaccinations": true, - "descriptionHtml": "

\nMeet Dante!\n

\n\n

\nDante is a lovable goofball with charm for days and energy to spare! This clumsy, silly pup is guaranteed to keep you smiling—from his wiggly zoomies (he especially loves being outside) to his playful personality.\n

\n\n

\nDante is crate trained and knows the command “Kennel” like a champ. He’s still working on sit, but proudly does it before meals. He’s also potty trained and confidently uses a doggy door. Dante is smart, eager to please, and making progress every day!\n

\n\n

\nDante gets along wonderfully with his three foster dog siblings and loves having canine friends to play with. He would do best in a home without cats, and because he’s still polishing his manners, he may be better suited for a family with older kids.\n

\n\n

\nIf interested, please  \n complete the  \n Adoption Application \n  on our website at  \n www.NeedyPaws.org \n  and we will contact you.  \n Adopters must live within an hour’s drive of St. Louis, MO. \n

\n\n

\nOur $295 adoption fee includes up-to-date vaccinations, heartworm testing and preventative, spay/neuter and microchip.\n

\n\n

\nWe are a 100% foster-based rescue and all of our dogs live in approved foster homes. We are dedicated to finding the best Forever Homes, and will perform home visits and conduct veterinarian and reference checks for each potential adopter.\n

\"\"", - "descriptionText": "\nMeet Dante!\n\n\n\nDante is a lovable goofball with charm for days and energy to spare! This clumsy, silly pup is guaranteed to keep you smiling—from his wiggly zoomies (he especially loves being outside) to his playful personality.\n\n\n\nDante is crate trained and knows the command “Kennel” like a champ. He’s still working on sit, but proudly does it before meals. He’s also potty trained and confidently uses a doggy door. Dante is smart, eager to please, and making progress every day!\n\n\n\nDante gets along wonderfully with his three foster dog siblings and loves having canine friends to play with. He would do best in a home without cats, and because he’s still polishing his manners, he may be better suited for a family with older kids.\n\n\n\nIf interested, please  \n complete the  \n Adoption Application \n  on our website at  \n www.NeedyPaws.org \n  and we will contact you.  \n Adopters must live within an hour’s drive of St. Louis, MO. \n\n\n\nOur $295 adoption fee includes up-to-date vaccinations, heartworm testing and preventative, spay/neuter and microchip.\n\n\n\nWe are a 100% foster-based rescue and all of our dogs live in approved foster homes. We are dedicated to finding the best Forever Homes, and will perform home visits and conduct veterinarian and reference checks for each potential adopter.\n", - "isDogsOk": true, - "energyLevel": "Moderate", - "exerciseNeeds": "Moderate", - "isNeedingFoster": false, - "isFound": false, - "priority": 10, - "isHousetrained": true, - "indoorOutdoor": "Indoor Only", - "name": "Dante", - "newPeopleReaction": "Friendly", - "obedienceTraining": "Needs Training", - "pictureCount": 10, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6733/pictures/animals/22024/22024006/102432921.jpg?width=100", - "qualities": [ - "cratetrained", - "doesWellInCar", - "goofy", - "olderKidsOnly", - "playful", - "playsToys" - ], - "rescueId": "251104-404", - "searchString": "Dante Black with White Male Large 251104-404 Dogs Labrador Retriever / Border Collie / Mixed (short coat)s", - "sex": "Male", - "sizeCurrent": 43, - "sizeGroup": "Large", - "sizeUOM": "Pounds", - "slug": "adopt-dante-labrador-retriever-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22024006", - "url": "https://needypaws.rescuegroups.org/animals/detail?AnimalID=22024006", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2025-11-05T02:04:57Z", - "updatedDate": "2026-03-01T22:54:36Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "162" - }, - { - "type": "breeds", - "id": "99" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000006733" - } - ] - } - } - }, - { - "type": "animals", - "id": "20391987", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "5 Years", - "birthDate": "2019-04-12T00:00:00Z", - "isBirthDateExact": false, - "breedString": "Labrador Retriever (medium coat)", - "breedPrimary": "Labrador Retriever", - "breedPrimaryId": 162, - "isBreedMixed": false, - "coatLength": "Medium", - "isCourtesyListing": false, - "descriptionHtml": "\"\"", - "isFound": false, - "priority": 10, - "name": "FREDERICK", - "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/7832/pictures/animals/20391/20391987/98176198.jpg?width=100", - "rescueId": "A111253", - "searchString": "FREDERICK Black Male Large A111253 Dogs Labrador Retriever (medium coat)s", - "sex": "Male", - "sizeGroup": "Large", - "slug": "adopt-frederick-labrador-retriever-dog", - "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?20391987", - "videoCount": 0, - "videoUrlCount": 0, - "createdDate": "2024-04-15T00:00:41Z", - "updatedDate": "2024-04-23T12:39:49Z" - }, - "relationships": { - "breeds": { - "data": [ - { - "type": "breeds", - "id": "162" - } - ] - }, - "locations": { - "data": [ - { - "type": "locations", - "id": "1000007832" - } - ] - } - } - }, - { - "type": "animals", - "id": "21737563", - "attributes": { - "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "1 Year 5 Months", - "birthDate": "2024-05-24T00:00:00Z", - "isBirthDateExact": false, - "breedString": "German Shepherd Dog (medium coat)", - "breedPrimary": "German Shepherd Dog", - "breedPrimaryId": 142, "isBreedMixed": false, "coatLength": "Medium", "isCourtesyListing": false, - "descriptionHtml": "\"\"", "isFound": false, "priority": 10, - "name": "LUNA", + "name": "HARLEY", "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/2013/pictures/animals/21737/21737563/101546619.jpg?width=100", - "rescueId": "A2199610", - "searchString": "LUNA Tan Female Medium A2199610 Dogs German Shepherd Dog (medium coat)s", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1991/pictures/animals/22315/22315844/102863279.jpg?width=100", + "rescueId": "A115110", + "searchString": "HARLEY Brown/Chocolate Female Large A115110 Dogs Pit Bull Terrier (medium coat)s", "sex": "Female", - "sizeGroup": "Medium", - "slug": "adopt-luna-german-shepherd-dog-dog", + "sizeGroup": "Large", + "slug": "adopt-harley-pit-bull-terrier-dog", "isSponsorable": false, - "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21737563", "videoCount": 0, "videoUrlCount": 0, - "createdDate": "2025-07-31T19:58:52Z", - "updatedDate": "2025-11-01T08:24:57Z" + "createdDate": "2026-03-08T16:39:01Z", + "updatedDate": "2026-03-22T02:18:24Z" }, "relationships": { "breeds": { "data": [ { "type": "breeds", - "id": "142" + "id": "179" } ] }, @@ -1474,7 +43,7 @@ "data": [ { "type": "locations", - "id": "1000002013" + "id": "1000001991" } ] } diff --git a/main.py b/main.py index 1461451..d281dee 100644 --- a/main.py +++ b/main.py @@ -110,6 +110,7 @@ def pick_pet(pets): # Check pet has an image, adoption url, and has not been posted eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] if not eligible: + sys.exit("Error: No elligible pets found.") return None selected_pet = random.choice(eligible) From b0ab31931d7d9e9c386ca09f71f081909ff647f1 Mon Sep 17 00:00:00 2001 From: Peter Garrity Date: Wed, 20 May 2026 20:55:59 -0400 Subject: [PATCH 25/25] Revert test change --- adoption_sources/manual.json | 1459 +++++++++++++++++++++++++++++++++- 1 file changed, 1445 insertions(+), 14 deletions(-) diff --git a/adoption_sources/manual.json b/adoption_sources/manual.json index a69575f..01325b1 100644 --- a/adoption_sources/manual.json +++ b/adoption_sources/manual.json @@ -1,41 +1,1420 @@ [ + { + "type": "animals", + "id": "19427790", + "attributes": { + "isAdoptionPending": false, + "ageString": "7 Years 8 Months", + "birthDate": "2018-05-12T00:00:00Z", + "isBirthDateExact": false, + "breedString": "American Staffordshire Terrier / Mixed", + "breedPrimary": "American Staffordshire Terrier", + "breedPrimaryId": 82, + "isBreedMixed": true, + "isCourtesyListing": false, + "descriptionHtml": "

Found with a chain around his neck.  The chain was dropped into the sewer and he could not move.

\"\"", + "descriptionText": "Found with a chain around his neck.  The chain was dropped into the sewer and he could not move.", + "isFound": false, + "priority": 10, + "name": "Flynn", + "pictureCount": 2, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8552/pictures/animals/19427/19427790/100783814.jpg?width=100", + "searchString": "Flynn Blue/Silver/Salt & Pepper Male X-Large Dogs American Staffordshire Terrier / Mixeds", + "sex": "Male", + "sizeCurrent": 109, + "sizeGroup": "X-Large", + "sizeUOM": "Pounds", + "slug": "adopt-flynn-american-staffordshire-terrier-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?19427790", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2023-06-24T17:12:25Z", + "updatedDate": "2025-11-03T02:20:53Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "82" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000008552" + } + ] + } + } + }, + { + "type": "animals", + "id": "22339942", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Baby", + "isBirthDateExact": false, + "breedString": "Hound", + "breedPrimary": "Hound", + "breedPrimaryId": 151, + "isBreedMixed": false, + "isCourtesyListing": false, + "isCurrentVaccinations": false, + "isDeclawed": false, + "descriptionText": "Meet Merry and Pippin!\nThese sweet 8 week old pups are looking for their forever homes. They love to play with their foster brother, chase down balls, and then follow it all up with a nap, in a lap preferably.\nCome meet Merry and Pippin this Saturday at the Woodbury Fire Department hoagie sale (9-12) or Sunday at Magnify Brewing.\nEmail ammrsabrina@gmail.com with questions or fill out an application at:\nTinyurl.com/AMMRadoptapp\n\nCheck us out on FB https://m.facebook.com/groups/927573409001459/?ref=share\nOr our Instagram https://www.instagram.com/all_mutts_matter_rescue?igsh=MWVycnBzMjFkOG5lNQ==\nAdoption donation of $425 includes age-appropriate vaccinations and spay/neuter when pup is approximately 5-7 months of age. Copays vary by location.\n$110 reimbursement if adopters' own vet/clinic is used within same timeframe for alter and microchip.\nAll Mutts Matter Rescue is a registered 501c3 non-profit organization and donations are tax deductible.\n\n*** Breed determination in most cases is often based on the limited history typically available for most rescued animals. We try to be as accurate as possible with the information we have and the rescue assessment of the pup.\n\nCurrent Adoptions are within 3 hours of our foster homes, most within the SJ area - within NJ/PA/DE", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": false, + "name": "Pippin", + "pictureCount": 2, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/11627/pictures/animals/22339/22339942/102851090.jpg?width=100", + "rescueId": "Sabrina", + "searchString": "Pippin Male Medium Sabrina Dogs Hounds", + "sex": "Male", + "sizeGroup": "Medium", + "slug": "adopt-pippin-hound-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-19T21:57:50Z", + "updatedDate": "2026-03-19T22:58:41Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "151" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000011627" + } + ] + } + } + }, + { + "type": "animals", + "id": "21218464", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "isBirthDateExact": false, + "breedString": "German Shepherd Dog / Rottweiler / Mixed (short coat)", + "breedPrimary": "German Shepherd Dog", + "breedPrimaryId": 142, + "breedSecondary": "Rottweiler", + "breedSecondaryId": 189, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": true, + "isCurrentVaccinations": true, + "isDeclawed": false, + "descriptionText": "Check out my video!\nWell, hello! My name is Zelda and I'm a spayed, 2 years old, German Shepherd/Rottweiler mix who currently weighs around 62 lbs. I'm an energetic and effervescent girl that LOVES to play in the water! I would be the perfect companion to beach goers, river rafters or sprinkler hoppers! I'm a girl who feels the call of the wild! I'm a rambunctious girl that prefers to be outdoors exploring. I'm hoping to continue working on my social skills and impulse control with an active, loving family. I can't wait to meet everyone in the household including any resident dogs before I head home. Just ask Customer Service about Zelda ID# A955664", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "name": "Zelda", + "pictureCount": 4, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1776/pictures/animals/21218/21218464/100323336.jpg?width=100", + "rescueId": "A955664", + "searchString": "Zelda Black with Brown, Red, Golden, Orange or Chestnut Female Large A955664 Dogs German Shepherd Dog / Rottweiler / Mixed (short coat)s", + "sex": "Female", + "sizeGroup": "Large", + "slug": "adopt-zelda-german-shepherd-dog-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 1, + "createdDate": "2025-01-19T19:21:52Z", + "updatedDate": "2026-03-20T22:42:38Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "142" + }, + { + "type": "breeds", + "id": "189" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "13256" + } + ] + } + } + }, + { + "type": "animals", + "id": "21907959", + "attributes": { + "adoptionFeeString": "$225 includes spay and vaccines", + "isAdoptionPending": false, + "ageGroup": "Young", + "isBirthDateExact": false, + "breedString": "Greyhound / Mixed", + "breedPrimary": "Greyhound", + "breedPrimaryId": 150, + "isBreedMixed": true, + "isCourtesyListing": false, + "descriptionHtml": "Cassie is a fun loving playful girl whose approx dob is  August 13 2025. We weren't given any info on her parents so our best guess is Greyhound mix due to her very slender body frame and unique markings. Cassie does great with other dogs LOVES to meet people and guve hugs and kisses! She has such a fun spunky personality. Www.barconline.com or call 951 845 1513 we are located in Cherry Valley Ca.\"\"", + "descriptionText": "Cassie is a fun loving playful girl whose approx dob is  August 13 2025. We weren't given any info on her parents so our best guess is Greyhound mix due to her very slender body frame and unique markings. Cassie does great with other dogs LOVES to meet people and guve hugs and kisses! She has such a fun spunky personality. Www.barconline.com or call 951 845 1513 we are located in Cherry Valley Ca.", + "isDogsOk": true, + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "name": "Cassie", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/105/pictures/animals/21907/21907959/101859184.jpg?width=100", + "rescueId": "5738", + "searchString": "Cassie Female 5738 Dogs Greyhound / Mixeds", + "sex": "Female", + "slug": "adopt-cassie-greyhound-dog", + "isSpecialNeeds": false, + "isSponsorable": true, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21907959", + "url": "https://www.barconline.com/animals/detail?AnimalID=21907959", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-09-24T21:43:38Z", + "updatedDate": "2026-01-03T01:03:49Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "150" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000000105" + } + ] + } + } + }, + { + "type": "animals", + "id": "22326727", + "attributes": { + "activityLevel": "Moderately Active", + "adoptionFeeString": "350", + "isAdoptionPending": false, + "adultSexesOk": "All", + "ageGroup": "Adult", + "ageString": "1 Year 5 Months", + "birthDate": "2024-09-14T00:00:00Z", + "isBirthDateExact": false, + "breedString": "American Staffordshire Terrier / Mixed (short coat)", + "breedPrimary": "American Staffordshire Terrier", + "breedPrimaryId": 82, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": true, + "isCurrentVaccinations": true, + "descriptionText": "Polly Pockets is 1.5 year sold, 50 lbs\n\n \n\nShe is a dollbaby Good with all people and dogs. Very sweet. Her owner had openheart surgery and can no longer care for her. He is disrtraught. In danger of going to the shelter and they will kill her.", + "isDogsOk": true, + "energyLevel": "Moderate", + "exerciseNeeds": "Moderate", + "fenceNeeds": "Any Type", + "isFound": false, + "groomingNeeds": "Not Required", + "priority": 10, + "isHousetrained": true, + "indoorOutdoor": "Indoor and Outdoor", + "name": "Polly Pockets", + "newPeopleReaction": "Friendly", + "obedienceTraining": "Well Trained", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6198/pictures/animals/22326/22326727/102819970.jpg?width=100", + "qualities": [ + "affectionate", + "cratetrained", + "eagerToPlease", + "doesWellInCar", + "lap", + "leashtrained", + "playful" + ], + "searchString": "Polly Pockets Tan Female Medium Dogs American Staffordshire Terrier / Mixed (short coat)s", + "sex": "Female", + "sheddingLevel": "Moderate", + "sizeCurrent": 50, + "sizeGroup": "Medium", + "sizePotential": 50, + "sizeUOM": "Pounds", + "slug": "adopt-polly-pockets-american-staffordshire-terrier-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "vocalLevel": "Quiet", + "isYardRequired": true, + "createdDate": "2026-03-13T16:20:17Z", + "updatedDate": "2026-03-13T16:20:18Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "82" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000006198" + } + ] + } + } + }, + { + "type": "animals", + "id": "21388410", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Young", + "isBirthDateExact": false, + "breedString": "Labrador Retriever / Akita / Mixed", + "breedPrimary": "Labrador Retriever", + "breedPrimaryId": 162, + "breedSecondary": "Akita", + "breedSecondaryId": 78, + "isBreedMixed": true, + "isCourtesyListing": false, + "descriptionText": "Meet Snow, a 3-4 year old friendly and well-mannered dog who loves people. He’s fully potty trained and crate trained, making him an easy addition to any home. Snow enjoys car rides and going for walks, so he’s always ready for an adventure or a relaxing drive. With his sweet nature and great temperament, Snow would make a wonderful companion for anyone looking for a loyal, loving friend. ", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "isKidsOk": true, + "name": "Snow", + "pictureCount": 3, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/637/pictures/animals/21388/21388410/100692761.jpg?width=100", + "searchString": "Snow White Male Large Dogs Labrador Retriever / Akita / Mixeds", + "sex": "Male", + "sizeGroup": "Large", + "slug": "adopt-snow-labrador-retriever-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-03-30T05:07:47Z", + "updatedDate": "2025-03-30T05:37:25Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "162" + }, + { + "type": "breeds", + "id": "78" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000000637" + } + ] + } + } + }, + { + "type": "animals", + "id": "22335553", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Senior", + "ageString": "8 Years", + "birthDate": "2018-03-09T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Terrier / Mixed (medium coat)", + "breedPrimary": "Terrier", + "breedPrimaryId": 208, + "isBreedMixed": true, + "coatLength": "Medium", + "isCourtesyListing": false, + "descriptionText": "Hi I'm Frito!I\u00e2\u0080\u0099m a sweet, senior guy who loves nothing more than curling up on a fluffy bed and soaking in the cozy vibes. I enjoy sniffing and exploring when we go on walks, and treats always make my day. Once I warm up to you, I become a little attention-seeker, happily nudging for pets and cuddles. If you\u00e2\u0080\u0099re looking for a calm, loving companion with a big heart, I\u00e2\u0080\u0099d love to meet you!", + "isFound": false, + "priority": 10, + "name": "FRITO", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8846/pictures/animals/22335/22335553/102866911.jpg?width=100", + "rescueId": "A270361", + "searchString": "FRITO White Male Medium A270361 Dogs Terrier / Mixed (medium coat)s", + "sex": "Male", + "sizeGroup": "Medium", + "slug": "adopt-frito-terrier-dog", + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-17T21:52:18Z", + "updatedDate": "2026-03-23T01:27:14Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "208" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000008846" + } + ] + } + } + }, + { + "type": "animals", + "id": "16027333", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "isBirthDateExact": false, + "breedString": "Husky / Pit Bull Terrier (medium coat)", + "breedPrimary": "Husky", + "breedPrimaryId": 152, + "breedSecondary": "Pit Bull Terrier", + "breedSecondaryId": 179, + "isCatsOk": true, + "coatLength": "Medium", + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionHtml": "

COCO came to us after she had a cluster of seizures in her newly adoptive home. She was adopted only a month before from a rescue in Tennessee which did not respond to our inquiries regarding her previous history. Coco is a SWEET girl! She is currently in a foster home with a Chihuahua and several cats and does well with all! She enjoys her walks as much as she enjoys her couch time! Coco is a beautiful red brindle! she is 110% housebroken. Great with meeting new people! Coco is in Keppra and \nphenobarbital \nto control her seizures. She has had a couple of clusters since arriving in her foster home and we are working In fine tuning her meds. Coco will need more frequent vet visits than the average dog her age. With excellent care, we feel she can live a relatively normal life and will make an excellent companion for some lucky family. Coco has been spayed, vaccinated, microchipped and heartworm tested. Adoption $200. Please fill out an application at www.companimals.org if you are interested in adding Coco to your family! 

\"\"", + "descriptionText": "COCO came to us after she had a cluster of seizures in her newly adoptive home. She was adopted only a month before from a rescue in Tennessee which did not respond to our inquiries regarding her previous history. Coco is a SWEET girl! She is currently in a foster home with a Chihuahua and several cats and does well with all! She enjoys her walks as much as she enjoys her couch time! Coco is a beautiful red brindle! she is 110% housebroken. Great with meeting new people! Coco is in Keppra and \nphenobarbital \nto control her seizures. She has had a couple of clusters since arriving in her foster home and we are working In fine tuning her meds. Coco will need more frequent vet visits than the average dog her age. With excellent care, we feel she can live a relatively normal life and will make an excellent companion for some lucky family. Coco has been spayed, vaccinated, microchipped and heartworm tested. Adoption $200. Please fill out an application at www.companimals.org if you are interested in adding Coco to your family! ", + "isDogsOk": true, + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "isKidsOk": true, + "name": "Coco", + "pictureCount": 12, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/4012/pictures/animals/16027/16027333/76723483.jpg?width=100", + "searchString": "Coco Red Female Large Dogs Husky / Pit Bull Terrier (medium coat)s", + "sex": "Female", + "sizeGroup": "Large", + "sizeUOM": "Pounds", + "slug": "adopt-coco-husky-dog", + "isSpecialNeeds": true, + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?16027333", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2020-09-07T09:47:40Z", + "updatedDate": "2023-06-05T01:03:41Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "152" + }, + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000004012" + } + ] + } + } + }, + { + "type": "animals", + "id": "22276752", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Baby", + "isBirthDateExact": false, + "breedString": "Staffordshire Bull Terrier / Mixed (short coat)", + "breedPrimary": "Staffordshire Bull Terrier", + "breedPrimaryId": 207, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": false, + "descriptionHtml": "\"\"", + "isFound": false, + "priority": 10, + "name": "Rei", + "pictureCount": 0, + "searchString": "Rei Luna Rose Black with White Female Medium Dogs Staffordshire Bull Terrier / Mixed (short coat)s", + "sex": "Female", + "sizeGroup": "Medium", + "sizeUOM": "Pounds", + "slug": "adopt-rei-staffordshire-bull-terrier-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22276752", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-02-18T20:24:01Z", + "updatedDate": "2026-02-26T13:31:04Z", + "pictureThumbnailUrl": "https://www.rescuegroups.org/images/photos/22276752/22276752-1-400x400.jpg" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "207" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000003398" + } + ] + } + } + }, + { + "type": "animals", + "id": "17439767", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "ageString": "6 Years 4 Months", + "birthDate": "2019-11-09T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Chinese Shar-Pei / Mixed", + "breedPrimary": "Chinese Shar-Pei", + "breedPrimaryId": 120, + "isBreedMixed": true, + "isCourtesyListing": false, + "descriptionText": "Myrtle is a fun-loving, energetic girl with a sweet spirit and a love for the great outdoors. She enjoys hikes and leisurely walks where she can take in all the sights and smells, making her a wonderful companion for fresh-air adventures.After a day out, Myrtle is happy to relax with her favorite toys and enjoy some well-earned downtime. With her affectionate nature and adventurous heart, Myrtle is ready to share lifeâ\u20ac\u2122s simple joys with a loving home. Ask an Adoption Specialist to meet Myrtle today!", + "isDogsOk": true, + "isFound": false, + "priority": 10, + "name": "Myrtle", + "pictureCount": 9, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6685/pictures/animals/17439/17439767/97865248.jpg?width=100", + "qualities": [ + "protective" + ], + "searchString": "Myrtle Black Female Large Dogs Chinese Shar-Pei / Mixeds", + "sex": "Female", + "sizeCurrent": 54.674598693847656, + "sizeGroup": "Large", + "sizeUOM": "Pounds", + "slug": "adopt-myrtle-chinese-shar-pei-dog", + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2021-11-10T19:02:15Z", + "updatedDate": "2026-03-13T21:08:32Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "120" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000006685" + } + ] + } + } + }, + { + "type": "animals", + "id": "21639940", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Young", + "ageString": "2 Years 1 Month", + "birthDate": "2024-01-01T00:00:00Z", + "isBirthDateExact": false, + "breedString": "American Staffordshire Terrier / Mixed (short coat)", + "breedPrimary": "American Staffordshire Terrier", + "breedPrimaryId": 82, + "isBreedMixed": true, + "isCatsOk": false, + "coatLength": "Short", + "isCourtesyListing": true, + "isCurrentVaccinations": true, + "isDeclawed": false, + "descriptionHtml": "

Part Staffy? Part Adorable? Full on adorable is Thor! He is just not another black dog; if fact, he is so fun that if you meet him, you will see why we love this breed at the shelter (Thor is a courtesy post for the Brooke County Animal Shelter in BeechBottom, WV, about 45 minutes from Pittsburgh). 

\n\n

Thor is a young adult -- about a year or two old who sports a smooth, sleek and easy-to-care for coat and ears that stand up or down according to his mood!

\n\n

Thor likes to bounce and hop when he is happy and his joy is infectious. He is great with other dogs and always has his eyes on people looking for love and attention. Thor promises to bring the best of his breed traits including lots of contact and snuggles as well as being game for going wherever you go. He ADORES toys, especially if they squeak. This boy is medium weight, ready-to-travel size and is game for anything as long as he his with his family! He can be a bit excitable initially but quickly calms down to scoot close for cuddles. Thor is almost impossible to resist when he gazes up at you with eyes filled with love and kindness.

\n\n

Please contact the Brooke County Animal Shelter at 304-394-0800 for more information or to arrange a meet and greet with Thor.

\"\"", + "descriptionText": "Part Staffy? Part Adorable? Full on adorable is Thor! He is just not another black dog; if fact, he is so fun that if you meet him, you will see why we love this breed at the shelter (Thor is a courtesy post for the Brooke County Animal Shelter in BeechBottom, WV, about 45 minutes from Pittsburgh). \n\nThor is a young adult -- about a year or two old who sports a smooth, sleek and easy-to-care for coat and ears that stand up or down according to his mood!\n\nThor likes to bounce and hop when he is happy and his joy is infectious. He is great with other dogs and always has his eyes on people looking for love and attention. Thor promises to bring the best of his breed traits including lots of contact and snuggles as well as being game for going wherever you go. He ADORES toys, especially if they squeak. This boy is medium weight, ready-to-travel size and is game for anything as long as he his with his family! He can be a bit excitable initially but quickly calms down to scoot close for cuddles. Thor is almost impossible to resist when he gazes up at you with eyes filled with love and kindness.\n\nPlease contact the Brooke County Animal Shelter at 304-394-0800 for more information or to arrange a meet and greet with Thor.", + "isDogsOk": true, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "isKidsOk": true, + "killReason": "0", + "name": "Thor (courtesy post)", + "pictureCount": 3, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3365/pictures/animals/21639/21639940/101248442.jpg?width=100", + "rescueId": "25-0086", + "searchString": "Thor (courtesy post) Black Male Medium 25-0086 Dogs American Staffordshire Terrier / Mixed (short coat)s", + "sex": "Male", + "sizeCurrent": 60, + "sizeGroup": "Medium", + "sizeUOM": "Pounds", + "slug": "adopt-thor-courtesy-post-american-staffordshire-terrier-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21639940", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-06-30T14:04:15Z", + "updatedDate": "2025-06-30T14:09:12Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "82" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000003365" + } + ] + } + } + }, + { + "type": "animals", + "id": "22277252", + "attributes": { + "activityLevel": "Moderately Active", + "adoptionFeeString": "350", + "isAdoptionPending": false, + "adultSexesOk": "All", + "ageGroup": "Adult", + "ageString": "2 Years 6 Months", + "birthDate": "2023-08-23T00:00:00Z", + "isBirthDateExact": false, + "breedString": "American Pit Bull Terrier / Mixed (short coat)", + "breedPrimary": "American Pit Bull Terrier", + "breedPrimaryId": 729, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionText": "Meet Gumdrop!\n\nGumdrop is a 1.5–2 year old  45 lb female pittie mix who is as sweet as her name suggests. This adorable girl is the total package — smart, playful, affectionate, and ready to find a family to call her own.\n\nGumdrop is already housebroken and crate trained, which makes the transition into her new home that much easier. She’s also completed 8 weeks of puppy training, so she has a great foundation and loves to keep learning. She’s a bright girl who enjoys engaging with her people and showing off what she knows.\n\nWhen it’s time to play, Gumdrop is happy to have fun and burn off some energy, but when the day winds down her favorite place to be is curled up right next to you for cuddles. She truly loves being close to her people.\n\nGumdrop gets along well with other dogs and would enjoy having a canine companion to play with, though she’d also be perfectly happy soaking up all the attention as your one and only.\n\nThis sweet, affectionate, and well-mannered girl is more than ready to start the next chapter of her life with a loving family.\n\nIf you’re looking for a loyal companion who can play, learn, and snuggle, Gumdrop might just be your perfect match\n\n \n\nIf you are interested in adopting/meeting this pup , please complete our online adoption application that you can find here: https://www.starfishanimalrescue.com/adopt/dog-adoption-application  . Your application will be reviewed by our team and if it is a good fit, we will send to the foster family to set up a meet!\n\nThe adoption fee of $350 will include spay/neuter, microchip, age appropriate/required vaccinations, treatment for heartworm and any other necessary medical treatment to assure a healthy new pup for your family. IMPORTANT DISCLAIMER: Although we do our best to describe breed, since our pups all come from shelters we can not and will not make any guarantee on breed or size. We can tell you that they are 100% RESCUED! And that is the BEST breed!\n\n*please note that we are a foster home based rescue and we do home visits as part of the adoption process - we are unable to adopt to families who live outside of the Chicago and surrounding suburbs. Thank you for your understanding.", + "isDogsOk": true, + "energyLevel": "Moderate", + "exerciseNeeds": "Moderate", + "fenceNeeds": "6 foot", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "indoorOutdoor": "Indoor Only", + "isKidsOk": true, + "name": "Gumdrop", + "newPeopleReaction": "Friendly", + "obedienceTraining": "Needs Training", + "pictureCount": 6, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/8605/pictures/animals/22277/22277252/102786566.jpg?width=100", + "qualities": [ + "affectionate", + "cratetrained", + "eagerToPlease", + "lap", + "olderKidsOnly", + "playful", + "playsToys" + ], + "rescueId": "D260086", + "searchString": "Gumdrop White with Black Female Medium D260086 Dogs American Pit Bull Terrier / Mixed (short coat)s", + "sex": "Female", + "sizeCurrent": 45, + "sizeGroup": "Medium", + "sizeUOM": "Pounds", + "slug": "adopt-gumdrop-american-pit-bull-terrier-dog", + "isSponsorable": true, + "url": "https://Crowe.rescuegroups.org/animals/detail?AnimalID=22277252", + "videoCount": 0, + "videoUrlCount": 0, + "isYardRequired": true, + "createdDate": "2026-02-18T23:08:59Z", + "updatedDate": "2026-03-19T17:25:39Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "729" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000008605" + } + ] + } + } + }, { "type": "animals", "id": "22315844", "attributes": { "isAdoptionPending": false, - "ageGroup": "Adult", - "ageString": "5 Years 11 Months", - "birthDate": "2020-04-17T00:00:00Z", + "ageGroup": "Adult", + "ageString": "5 Years 11 Months", + "birthDate": "2020-04-17T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Pit Bull Terrier (medium coat)", + "breedPrimary": "Pit Bull Terrier", + "breedPrimaryId": 179, + "isBreedMixed": false, + "coatLength": "Medium", + "isCourtesyListing": false, + "isFound": false, + "priority": 10, + "name": "HARLEY", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1991/pictures/animals/22315/22315844/102863279.jpg?width=100", + "rescueId": "A115110", + "searchString": "HARLEY Brown/Chocolate Female Large A115110 Dogs Pit Bull Terrier (medium coat)s", + "sex": "Female", + "sizeGroup": "Large", + "slug": "adopt-harley-pit-bull-terrier-dog", + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-08T16:39:01Z", + "updatedDate": "2026-03-22T02:18:24Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000001991" + } + ] + } + } + }, + { + "type": "animals", + "id": "22308825", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "ageString": "2 Years", + "birthDate": "2024-02-17T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Pit Bull Terrier (medium coat)", + "breedPrimary": "Pit Bull Terrier", + "breedPrimaryId": 179, + "isBreedMixed": false, + "coatLength": "Medium", + "isCourtesyListing": false, + "descriptionHtml": "\"\"", + "isFound": false, + "priority": 10, + "name": "DOS", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/4156/pictures/animals/22308/22308825/102782448.jpg?width=100", + "rescueId": "A071698", + "searchString": "DOS Gray Male Medium A071698 Dogs Pit Bull Terrier (medium coat)s", + "sex": "Male", + "sizeGroup": "Medium", + "slug": "adopt-dos-pit-bull-terrier-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22308825", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-04T22:38:23Z", + "updatedDate": "2026-03-05T23:12:41Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000004156" + } + ] + } + } + }, + { + "type": "animals", + "id": "22323834", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Young", + "isBirthDateExact": false, + "breedString": "Pit Bull Terrier / Mixed (short coat)", + "breedPrimary": "Pit Bull Terrier", + "breedPrimaryId": 179, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": true, + "isCurrentVaccinations": true, + "descriptionText": "This animal is available at:\nSonoma County Animal Services (707) 565-7100\nPLEASE DO NOT CONTACT NORTH BAY\nDaisy A438421\n \nLocated At: Sonoma County Animal Services\n \nDescription: I am a spayed female, white Pit Bull Terrier mix.\n \nAge: I am estimated to be about 1 year and 4 months old.\n \nWeight: I weigh approximately 43 pounds.\n \nMore Info: I have been at the shelter since Nov 29, 2025. \nFor more information about this animal, call:Sonoma County Animal Services at (707) 565-71001247 Century Court, Santa Rosa\nTuesday - Saturday12:00 - 5:00 p.m.Adoptions are processed until 4:30 p.m.", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "name": "Daisy A438421", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3325/pictures/animals/22323/22323834/102812908.jpg?width=100", + "rescueId": "Sonoma County AS", + "searchString": "Daisy A438421 White Female Medium Sonoma County AS Dogs Pit Bull Terrier / Mixed (short coat)s", + "sex": "Female", + "sizeGroup": "Medium", + "slug": "adopt-daisy-a-pit-bull-terrier-dog", + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-12T02:03:23Z", + "updatedDate": "2026-03-12T02:03:23Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "6833" + } + ] + } + } + }, + { + "type": "animals", + "id": "20063917", + "attributes": { + "activityLevel": "Highly Active", + "isAdoptionPending": false, + "adultSexesOk": "All", + "ageGroup": "Adult", + "ageString": "3 Years 6 Months", + "birthDate": "2021-07-22T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Pit Bull Terrier / Mixed (short coat)", + "breedPrimary": "Pit Bull Terrier", + "breedPrimaryId": 179, + "isBreedMixed": true, + "coatLength": "Short", + "colorDetails": "White with Black", + "isCourtesyListing": false, + "isDeclawed": false, + "descriptionHtml": "

AFFECTIONATE AND PLAYFUL

\n\n

My name is Star. My estimated date of birth is 7/22/2021. I am very affectionate! I love to be the center of attention, and won't allow you much personal space. I enjoy lounging and will sit on your lap. I am energetic and enjoy playing. I am a black and white Pit Bull. I am 85 lbs. Due to my size, I would do best in a home without young children or frail adults. I am good with older, considerate children.

\n\n

*** A courtesy post means this cat is NOT a part of Forever Love Rescue. We are trying to help a fellow rescuer find a home for this cat. We accept the adoption application, assist in processing the application, and then all communication will be handled by the private rescuer onwards. We do not attest to the information they provide, or to the health or vet care of the cat. Specific questions about this cat should be emailed to us at foreverloverescue@gmail.com so we can forward them to the appropriate person! ***

\"\"", + "descriptionText": "AFFECTIONATE AND PLAYFUL\n\nMy name is Star. My estimated date of birth is 7/22/2021. I am very affectionate! I love to be the center of attention, and won't allow you much personal space. I enjoy lounging and will sit on your lap. I am energetic and enjoy playing. I am a black and white Pit Bull. I am 85 lbs. Due to my size, I would do best in a home without young children or frail adults. I am good with older, considerate children.\n\n*** A courtesy post means this cat is NOT a part of Forever Love Rescue. We are trying to help a fellow rescuer find a home for this cat. We accept the adoption application, assist in processing the application, and then all communication will be handled by the private rescuer onwards. We do not attest to the information they provide, or to the health or vet care of the cat. Specific questions about this cat should be emailed to us at foreverloverescue@gmail.com so we can forward them to the appropriate person! ***", + "energyLevel": "High", + "exerciseNeeds": "High", + "isFound": false, + "groomingNeeds": "Not Required", + "priority": 10, + "isHousetrained": true, + "indoorOutdoor": "Indoor and Outdoor", + "isKidsOk": true, + "name": "(KS Courtesy Post) Star", + "newPeopleReaction": "Friendly", + "ownerExperience": "Breed", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6552/pictures/animals/20063/20063917/97344032.jpg?width=100", + "qualities": [ + "affectionate", + "lap", + "olderKidsOnly", + "playful", + "playsToys" + ], + "searchString": "(KS Courtesy Post) Star White with Black White with Black Female Large White with Black Dogs Pit Bull Terrier / Mixed (short coat)s", + "sex": "Female", + "sheddingLevel": "Moderate", + "sizeCurrent": 85, + "sizeGroup": "Large", + "sizeUOM": "Pounds", + "slug": "adopt-ks-courtesy-post-star-pit-bull-terrier-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?20063917", + "videoCount": 0, + "videoUrlCount": 0, + "vocalLevel": "Lots", + "isYardRequired": true, + "createdDate": "2023-12-11T18:28:02Z", + "updatedDate": "2025-02-14T23:34:01Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000006552" + } + ] + } + } + }, + { + "type": "animals", + "id": "22087654", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Young", + "ageString": "1 Year 3 Months", + "birthDate": "2024-11-26T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Labrador Retriever / Pit Bull Terrier / Mixed (medium coat)", + "breedPrimary": "Labrador Retriever", + "breedPrimaryId": 162, + "breedSecondary": "Pit Bull Terrier", + "breedSecondaryId": 179, + "isBreedMixed": true, + "coatLength": "Medium", + "isCourtesyListing": false, + "descriptionHtml": "Kairo is a 53-pound boy who was surrendered to the shelter by his owner due to the owner's medical issues. He is known to be active, friendly, playful and loving. He can be an escape artist so he will need a home with a secure yard with room to run around. He's scared of vacuums and loud noises but loves to chase anything that moves. He's crate trained but this young man needs a little more help with house training. This playful pup is looking for his forever home! Could that be yours?\"\"", + "descriptionText": "Kairo is a 53-pound boy who was surrendered to the shelter by his owner due to the owner's medical issues. He is known to be active, friendly, playful and loving. He can be an escape artist so he will need a home with a secure yard with room to run around. He's scared of vacuums and loud noises but loves to chase anything that moves. He's crate trained but this young man needs a little more help with house training. This playful pup is looking for his forever home! Could that be yours?", + "isFound": false, + "priority": 10, + "name": "KAIRO", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1903/pictures/animals/22087/22087654/102787305.jpg?width=100", + "rescueId": "A216619", + "searchString": "KAIRO Black Male Large A216619 Dogs Labrador Retriever / Pit Bull Terrier / Mixed (medium coat)s", + "sex": "Male", + "sizeGroup": "Large", + "slug": "adopt-kairo-labrador-retriever-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22087654", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-11-29T16:27:27Z", + "updatedDate": "2026-03-06T22:16:06Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "162" + }, + { + "type": "breeds", + "id": "179" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000001903" + } + ] + } + } + }, + { + "type": "animals", + "id": "22294941", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Baby", + "ageString": "2 Months 8 Days", + "birthDate": "2026-01-14T00:00:00Z", "isBirthDateExact": false, - "breedString": "Pit Bull Terrier (medium coat)", + "breedString": "Boxer / Labrador Retriever / Mixed", + "breedPrimary": "Boxer", + "breedPrimaryId": 104, + "breedSecondary": "Labrador Retriever", + "breedSecondaryId": 162, + "isBreedMixed": true, + "isCourtesyListing": false, + "descriptionText": "Gilbert and his siblings were rescued with their mom from the streets of Arkansas. These adorable pups are now taking applications. Meet them and fall in love!\n\nApply here: https://projecthopearf.rescuegroups.org/forms/form?formid=6707", + "isFound": false, + "priority": 10, + "name": "Gilbert", + "pictureCount": 4, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10006/pictures/animals/22294/22294941/102813227.jpg?width=100", + "rescueId": "26-0224-1736", + "searchString": "Gilbert Tan Male 26-0224-1736 Dogs Boxer / Labrador Retriever / Mixeds", + "sex": "Male", + "sizeUOM": "Pounds", + "slug": "adopt-gilbert-boxer-dog", + "isSponsorable": false, + "url": "https://projecthopearf.rescuegroups.org/animals/detail?AnimalID=22294941", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-02-26T20:58:00Z", + "updatedDate": "2026-03-23T00:13:18Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "104" + }, + { + "type": "breeds", + "id": "162" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000010006" + } + ] + } + } + }, + { + "type": "animals", + "id": "22058131", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Senior", + "ageString": "10 Years 2 Months", + "birthDate": "2015-11-15T00:00:00Z", + "isBirthDateExact": false, + "breedString": "American Staffordshire Terrier / Mixed (medium coat)", + "breedPrimary": "American Staffordshire Terrier", + "breedPrimaryId": 82, + "isBreedMixed": true, + "isCatsOk": false, + "coatLength": "Medium", + "isCourtesyListing": true, + "isCurrentVaccinations": true, + "isDeclawed": false, + "descriptionHtml": "

Stark is a 10-year old "Pittie" mix neutered male with a personality that instantly wins people over. Don't let his age fool you -- this sweet guy is full of spunk and loves showing off his playful side. He's a big fan of toys and will happily bounce around with them like a much younger pup.

\n\n

When Stark isn't playing, he is the most incredibly affectionate boy and loves soaking up all the attention he can get. And his ears? Truly the cutest ever since one stands straight up while the other flops perfectly to the side giving him a look that melts the heart on sight.

\n\n

Stark may be a senior but he has tons of love, joy and a touch of goofiness left to share. He's just waiting for the right person or family to appreciate his charm and give him the cozy loving home he deserves.

\n\n

Stark is a courtesy post for the Brooke County Animal Shelter in Beechbottom, WV, about 45 minutes from downtown Pittsburgh. Please contact the shelter at 304-394-0800 for more information or to arrange a meet-and-greet with Stark. 

\"\"", + "descriptionText": "Stark is a 10-year old "Pittie" mix neutered male with a personality that instantly wins people over. Don't let his age fool you -- this sweet guy is full of spunk and loves showing off his playful side. He's a big fan of toys and will happily bounce around with them like a much younger pup.\n\nWhen Stark isn't playing, he is the most incredibly affectionate boy and loves soaking up all the attention he can get. And his ears? Truly the cutest ever since one stands straight up while the other flops perfectly to the side giving him a look that melts the heart on sight.\n\nStark may be a senior but he has tons of love, joy and a touch of goofiness left to share. He's just waiting for the right person or family to appreciate his charm and give him the cozy loving home he deserves.\n\nStark is a courtesy post for the Brooke County Animal Shelter in Beechbottom, WV, about 45 minutes from downtown Pittsburgh. Please contact the shelter at 304-394-0800 for more information or to arrange a meet-and-greet with Stark. ", + "isDogsOk": true, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "killReason": "0", + "name": "Stark (courtesy post)", + "pictureCount": 3, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/3365/pictures/animals/22058/22058131/102195876.jpg?width=100", + "rescueId": "25-0126", + "searchString": "Stark (courtesy post) Brindle with White Male Medium 25-0126 Dogs American Staffordshire Terrier / Mixed (medium coat)s", + "sex": "Male", + "sizeCurrent": 50, + "sizeGroup": "Medium", + "sizeUOM": "Pounds", + "slug": "adopt-stark-courtesy-post-american-staffordshire-terrier-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22058131", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-11-16T18:51:44Z", + "updatedDate": "2025-11-16T18:54:20Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "82" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000003365" + } + ] + } + } + }, + { + "type": "animals", + "id": "21839884", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Baby", + "isBirthDateExact": false, + "breedString": "Pit Bull Terrier / Plott Hound / Mixed (short coat)", "breedPrimary": "Pit Bull Terrier", "breedPrimaryId": 179, + "breedSecondary": "Plott Hound", + "breedSecondaryId": 400, + "isBreedMixed": true, + "coatLength": "Short", + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionHtml": "Meet Sasha \u2013 Our Sweet Survivor Ready to be Part of a Single-Dog Loving Home! Sasha is a one-year-old brindle American Terrier mix bursting with love and joyful energy! She adores belly rubs, long walks, splashing around in water, camping, car rides, and most of all, snuggling up with her people. Sasha is the ultimate companion; she\u2019ll keep you company while you cook, settle by your side as you work, and snuggle up close for movie nights.Her journey is a true \u201crags to riches\u201d story. At just six months old she was found starving in a California alley, abandoned in a kennel chained to a pole. With a body score of one, she was skin and bones barely hanging onto life. And to top it off, the initial shelter that picked her up deemed her unadoptable and gave her 72 hours until euthanization. Working with Pack Lyfe Rescue, we quickly stepped up to commit to giving her a better life and gave Sasha her first cross-country road trip to bring this sweet girl home to Minnesota. Today, Sasha\u2019s days are filled with the warmth of a loving home, cozy beds, endless pets, and delicious treats.For the past eight months, Sasha has been sharing her life with three cats, another dog, and all the humans she loves. Sasha is truly part of our family. We love Sasha and wish more than anything that she could stay with us forever. She\u2019s such an amazing girl, however, her wonderful puppy energy has been a bit too much for our resident dog, who\u2019s eight years old and set in her ways. While Sasha and her \u201csister\u201d have bonded beautifully and get along well most of the time, there have been a few incidents where our older pup\u2019s quick temper has triggered intense disagreements between the two. It breaks our hearts, but because of this we believe Sasha will thrive as the only dog in a home where she can soak up all the love and attention she deserves. And there's even more to adore about Sasha! She's fully potty trained and incredibly smart, knowing lots of commands like sit, stay, lay down, turn around, and heel. Sasha is so attentive and well-mannered that she\u2019ll even sit patiently, waiting for the \u201cokay\u201d before diving into her dinner. She\u2019s eager to learn, loves making her people proud, and is always ready to show off her good-girl skills! If you\u2019re looking for a loyal, affectionate, and adventurous friend, Sasha is ready to be the light of your life. Let\u2019s find this amazing girl the forever family she so deserves!\"\"", + "descriptionText": "Meet Sasha \u2013 Our Sweet Survivor Ready to be Part of a Single-Dog Loving Home! Sasha is a one-year-old brindle American Terrier mix bursting with love and joyful energy! She adores belly rubs, long walks, splashing around in water, camping, car rides, and most of all, snuggling up with her people. Sasha is the ultimate companion; she\u2019ll keep you company while you cook, settle by your side as you work, and snuggle up close for movie nights.Her journey is a true \u201crags to riches\u201d story. At just six months old she was found starving in a California alley, abandoned in a kennel chained to a pole. With a body score of one, she was skin and bones barely hanging onto life. And to top it off, the initial shelter that picked her up deemed her unadoptable and gave her 72 hours until euthanization. Working with Pack Lyfe Rescue, we quickly stepped up to commit to giving her a better life and gave Sasha her first cross-country road trip to bring this sweet girl home to Minnesota. Today, Sasha\u2019s days are filled with the warmth of a loving home, cozy beds, endless pets, and delicious treats.For the past eight months, Sasha has been sharing her life with three cats, another dog, and all the humans she loves. Sasha is truly part of our family. We love Sasha and wish more than anything that she could stay with us forever. She\u2019s such an amazing girl, however, her wonderful puppy energy has been a bit too much for our resident dog, who\u2019s eight years old and set in her ways. While Sasha and her \u201csister\u201d have bonded beautifully and get along well most of the time, there have been a few incidents where our older pup\u2019s quick temper has triggered intense disagreements between the two. It breaks our hearts, but because of this we believe Sasha will thrive as the only dog in a home where she can soak up all the love and attention she deserves. And there's even more to adore about Sasha! She's fully potty trained and incredibly smart, knowing lots of commands like sit, stay, lay down, turn around, and heel. Sasha is so attentive and well-mannered that she\u2019ll even sit patiently, waiting for the \u201cokay\u201d before diving into her dinner. She\u2019s eager to learn, loves making her people proud, and is always ready to show off her good-girl skills! If you\u2019re looking for a loyal, affectionate, and adventurous friend, Sasha is ready to be the light of your life. Let\u2019s find this amazing girl the forever family she so deserves!", + "isFound": false, + "priority": 10, + "isHousetrained": true, + "isKidsOk": true, + "killReason": "0", + "name": "Sasha", + "pictureCount": 6, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10308/pictures/animals/21839/21839884/101707821.jpg?width=100", + "searchString": "Sasha Brindle Female Medium Dogs Pit Bull Terrier / Plott Hound / Mixed (short coat)s", + "sex": "Female", + "sizeGroup": "Medium", + "slug": "adopt-sasha-pit-bull-terrier-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21839884", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-09-02T12:45:52Z", + "updatedDate": "2025-09-02T12:46:21Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "179" + }, + { + "type": "breeds", + "id": "400" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000010308" + } + ] + } + } + }, + { + "type": "animals", + "id": "22251807", + "attributes": { + "adoptionFeeString": "300.00", + "isAdoptionPending": false, + "ageGroup": "Young", + "ageString": "8 Months", + "birthDate": "2025-06-02T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Shepherd / Mixed", + "breedPrimary": "Shepherd", + "breedPrimaryId": 411, + "isBreedMixed": true, + "isCatsOk": true, + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionHtml": "

DOGS:   
CATS:    
KIDS:    
IDEAL FAMILY:  
\n

If you're looking for a gorgeous, young, trainable buddy
who will love you forever,
Gaby may be your ideal family member!

\nGABY'S ADOPTION FEE:   $300
This fee covers only part of what we spend to vet, board and rehab the dogs we save. On average we spend over $450 on each dog. We made a decision to keep our adoption fee at the 2005 level even though vet prices have doubled and tripled since then. We are constantly fundraising to cover the deficit. At minimum, your adoption fee includes the dog's spay/neuter, heartworm test, heartworm treatment if needed, rabies shot, distemper/parvo shot, bordatella shot, deworming, monthly heartworm and flea preventives, and microchip. In many cases it also includes surgery and various types of vet treatment for standard issues such as hot spots, ear infections and so on.

INTERESTED IN ADOPTING GABY?
Complete an Adoption Application Now!
\"\"", + "descriptionText": "DOGS:   CATS:    KIDS:    IDEAL FAMILY:  \nIf you're looking for a gorgeous, young, trainable buddy who will love you forever,Gaby may be your ideal family member!\nGABY'S ADOPTION FEE:   $300 This fee covers only part of what we spend to vet, board and rehab the dogs we save. On average we spend over $450 on each dog. We made a decision to keep our adoption fee at the 2005 level even though vet prices have doubled and tripled since then. We are constantly fundraising to cover the deficit. At minimum, your adoption fee includes the dog's spay/neuter, heartworm test, heartworm treatment if needed, rabies shot, distemper/parvo shot, bordatella shot, deworming, monthly heartworm and flea preventives, and microchip. In many cases it also includes surgery and various types of vet treatment for standard issues such as hot spots, ear infections and so on.INTERESTED IN ADOPTING GABY?Complete an Adoption Application Now!", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "name": "Gaby", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/2152/pictures/animals/22251/22251807/102643524.jpg?width=100", + "rescueId": "2026-2.1", + "searchString": "Gaby Gypsy (OS) Black with Brown, Red, Golden, Orange or Chestnut Female Medium 2026-2.1 Dogs Shepherd / Mixeds", + "sex": "Female", + "sizeGroup": "Medium", + "slug": "adopt-gaby-shepherd-dog", + "isSpecialNeeds": false, + "isSponsorable": true, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22251807", + "url": "https://mogsrescue.rescuegroups.org/animals/detail?AnimalID=22251807", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-02-07T01:43:03Z", + "updatedDate": "2026-02-07T01:43:04Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "411" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000002152" + } + ] + } + } + }, + { + "type": "animals", + "id": "22331986", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Baby", + "isBirthDateExact": false, + "breedString": "Terrier / Mixed", + "breedPrimary": "Terrier", + "breedPrimaryId": 208, + "isBreedMixed": true, + "isCatsOk": true, + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionText": "Meet Velma! Breed: Scruffy Terrier Mix Estimated DOB: 11/12/2025 Sex: Female (spayed) Weight: 10-15 lbs Currently up to date on all age appropriate vaccinationsThings To Know about Velma:\tRescued from Mississippi \tLoves people and takes to everyone she meets like they're her new best friend\tEasygoing, affectionate, and full of puppy charm — when she's not busy playing or exploring, she'll launch herself into your lap for a full-on kiss attack\tPlayful and curious with a love for squeaky toys, bones, and balls\tTreat motivated and eager to learn — future star student written all over her\tCrate training is progressing nicely\tCurrently working on housetraining and will require continued, consistent housetraining habits to establish a good routine\tDog friendly\tYoung enough to learn to love cats\tLooking for a loving and committed forever home to provide her with the care and love she deservesLearn more and apply to adopt at TLCrescuePA.org/adoptInterested in fostering? Join our 100% foster-based team at tlcrescuepa.org/foster. #AdoptVelma #ScruffyTerrier #TLCRescuePA", + "isDogsOk": true, + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isKidsOk": true, + "name": "Velma 031426", + "pictureCount": 3, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/10212/pictures/animals/22331/22331986/102832437.png?width=100", + "searchString": "Velma 031426 Black with Tan, Yellow or Fawn Female Medium Dogs Terrier / Mixeds", + "sex": "Female", + "sizeGroup": "Medium", + "slug": "adopt-velma-terrier-dog", + "isSpecialNeeds": false, + "isSponsorable": false, + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2026-03-16T05:32:11Z", + "updatedDate": "2026-03-17T04:42:18Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "208" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000010212" + } + ] + } + } + }, + { + "type": "animals", + "id": "22024006", + "attributes": { + "activityLevel": "Moderately Active", + "adoptionFeeString": "295.00", + "isAdoptionPending": false, + "adultSexesOk": "All", + "ageGroup": "Baby", + "ageString": "10 Months", + "birthDate": "2025-04-17T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Labrador Retriever / Border Collie / Mixed (short coat)", + "breedPrimary": "Labrador Retriever", + "breedPrimaryId": 162, + "breedSecondary": "Border Collie", + "breedSecondaryId": 99, + "isBreedMixed": true, + "isCatsOk": false, + "coatLength": "Short", + "isCourtesyListing": false, + "isCurrentVaccinations": true, + "descriptionHtml": "

\nMeet Dante!\n

\n\n

\nDante is a lovable goofball with charm for days and energy to spare! This clumsy, silly pup is guaranteed to keep you smiling—from his wiggly zoomies (he especially loves being outside) to his playful personality.\n

\n\n

\nDante is crate trained and knows the command “Kennel” like a champ. He’s still working on sit, but proudly does it before meals. He’s also potty trained and confidently uses a doggy door. Dante is smart, eager to please, and making progress every day!\n

\n\n

\nDante gets along wonderfully with his three foster dog siblings and loves having canine friends to play with. He would do best in a home without cats, and because he’s still polishing his manners, he may be better suited for a family with older kids.\n

\n\n

\nIf interested, please  \n complete the  \n Adoption Application \n  on our website at  \n www.NeedyPaws.org \n  and we will contact you.  \n Adopters must live within an hour’s drive of St. Louis, MO. \n

\n\n

\nOur $295 adoption fee includes up-to-date vaccinations, heartworm testing and preventative, spay/neuter and microchip.\n

\n\n

\nWe are a 100% foster-based rescue and all of our dogs live in approved foster homes. We are dedicated to finding the best Forever Homes, and will perform home visits and conduct veterinarian and reference checks for each potential adopter.\n

\"\"", + "descriptionText": "\nMeet Dante!\n\n\n\nDante is a lovable goofball with charm for days and energy to spare! This clumsy, silly pup is guaranteed to keep you smiling—from his wiggly zoomies (he especially loves being outside) to his playful personality.\n\n\n\nDante is crate trained and knows the command “Kennel” like a champ. He’s still working on sit, but proudly does it before meals. He’s also potty trained and confidently uses a doggy door. Dante is smart, eager to please, and making progress every day!\n\n\n\nDante gets along wonderfully with his three foster dog siblings and loves having canine friends to play with. He would do best in a home without cats, and because he’s still polishing his manners, he may be better suited for a family with older kids.\n\n\n\nIf interested, please  \n complete the  \n Adoption Application \n  on our website at  \n www.NeedyPaws.org \n  and we will contact you.  \n Adopters must live within an hour’s drive of St. Louis, MO. \n\n\n\nOur $295 adoption fee includes up-to-date vaccinations, heartworm testing and preventative, spay/neuter and microchip.\n\n\n\nWe are a 100% foster-based rescue and all of our dogs live in approved foster homes. We are dedicated to finding the best Forever Homes, and will perform home visits and conduct veterinarian and reference checks for each potential adopter.\n", + "isDogsOk": true, + "energyLevel": "Moderate", + "exerciseNeeds": "Moderate", + "isNeedingFoster": false, + "isFound": false, + "priority": 10, + "isHousetrained": true, + "indoorOutdoor": "Indoor Only", + "name": "Dante", + "newPeopleReaction": "Friendly", + "obedienceTraining": "Needs Training", + "pictureCount": 10, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/6733/pictures/animals/22024/22024006/102432921.jpg?width=100", + "qualities": [ + "cratetrained", + "doesWellInCar", + "goofy", + "olderKidsOnly", + "playful", + "playsToys" + ], + "rescueId": "251104-404", + "searchString": "Dante Black with White Male Large 251104-404 Dogs Labrador Retriever / Border Collie / Mixed (short coat)s", + "sex": "Male", + "sizeCurrent": 43, + "sizeGroup": "Large", + "sizeUOM": "Pounds", + "slug": "adopt-dante-labrador-retriever-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?22024006", + "url": "https://needypaws.rescuegroups.org/animals/detail?AnimalID=22024006", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-11-05T02:04:57Z", + "updatedDate": "2026-03-01T22:54:36Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "162" + }, + { + "type": "breeds", + "id": "99" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000006733" + } + ] + } + } + }, + { + "type": "animals", + "id": "20391987", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "ageString": "5 Years", + "birthDate": "2019-04-12T00:00:00Z", + "isBirthDateExact": false, + "breedString": "Labrador Retriever (medium coat)", + "breedPrimary": "Labrador Retriever", + "breedPrimaryId": 162, "isBreedMixed": false, "coatLength": "Medium", "isCourtesyListing": false, + "descriptionHtml": "\"\"", "isFound": false, "priority": 10, - "name": "HARLEY", + "name": "FREDERICK", "pictureCount": 1, - "pictureThumbnailUrl": "https://cdn.rescuegroups.org/1991/pictures/animals/22315/22315844/102863279.jpg?width=100", - "rescueId": "A115110", - "searchString": "HARLEY Brown/Chocolate Female Large A115110 Dogs Pit Bull Terrier (medium coat)s", - "sex": "Female", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/7832/pictures/animals/20391/20391987/98176198.jpg?width=100", + "rescueId": "A111253", + "searchString": "FREDERICK Black Male Large A111253 Dogs Labrador Retriever (medium coat)s", + "sex": "Male", "sizeGroup": "Large", - "slug": "adopt-harley-pit-bull-terrier-dog", + "slug": "adopt-frederick-labrador-retriever-dog", "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?20391987", "videoCount": 0, "videoUrlCount": 0, - "createdDate": "2026-03-08T16:39:01Z", - "updatedDate": "2026-03-22T02:18:24Z" + "createdDate": "2024-04-15T00:00:41Z", + "updatedDate": "2024-04-23T12:39:49Z" }, "relationships": { "breeds": { "data": [ { "type": "breeds", - "id": "179" + "id": "162" } ] }, @@ -43,7 +1422,59 @@ "data": [ { "type": "locations", - "id": "1000001991" + "id": "1000007832" + } + ] + } + } + }, + { + "type": "animals", + "id": "21737563", + "attributes": { + "isAdoptionPending": false, + "ageGroup": "Adult", + "ageString": "1 Year 5 Months", + "birthDate": "2024-05-24T00:00:00Z", + "isBirthDateExact": false, + "breedString": "German Shepherd Dog (medium coat)", + "breedPrimary": "German Shepherd Dog", + "breedPrimaryId": 142, + "isBreedMixed": false, + "coatLength": "Medium", + "isCourtesyListing": false, + "descriptionHtml": "\"\"", + "isFound": false, + "priority": 10, + "name": "LUNA", + "pictureCount": 1, + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/2013/pictures/animals/21737/21737563/101546619.jpg?width=100", + "rescueId": "A2199610", + "searchString": "LUNA Tan Female Medium A2199610 Dogs German Shepherd Dog (medium coat)s", + "sex": "Female", + "sizeGroup": "Medium", + "slug": "adopt-luna-german-shepherd-dog-dog", + "isSponsorable": false, + "trackerimageUrl": "https://tracker.rescuegroups.org/pet?21737563", + "videoCount": 0, + "videoUrlCount": 0, + "createdDate": "2025-07-31T19:58:52Z", + "updatedDate": "2025-11-01T08:24:57Z" + }, + "relationships": { + "breeds": { + "data": [ + { + "type": "breeds", + "id": "142" + } + ] + }, + "locations": { + "data": [ + { + "type": "locations", + "id": "1000002013" } ] }