From 17472af43c6aa73d55d038b09d819702a4d7cfbf Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 26 May 2026 21:34:56 -0400 Subject: [PATCH 1/9] spec: MetricCollector abstraction + concrete per-platform collectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scaffolds docs/specs/metric-collector.md covering the per-platform engagement snapshot system: schema evolution of database.json, MetricCollector ABC mirroring SocialPoster, and orchestration that re-polls posts up to 14 days old each run. <|°_°|> --- docs/specs/metric-collector.md | 231 +++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/specs/metric-collector.md diff --git a/docs/specs/metric-collector.md b/docs/specs/metric-collector.md new file mode 100644 index 0000000..2295785 --- /dev/null +++ b/docs/specs/metric-collector.md @@ -0,0 +1,231 @@ +# MetricCollector — per-platform engagement snapshots + +## Summary + +Add an `AbstractMetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll engagement counts (likes, reposts, comments) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. No new persistence layer — same artifact, same workflow. + +## Problem Statement + +We post pets to three platforms but capture zero feedback on how those posts perform. We don't know which pets get traction, which platform performs best for which species, or whether a given post is dead in the water. Without that data, we can't prioritize, A/B, or build a dashboard later. + +## Goals + +- Persist per-platform engagement (likes, reposts/shares, comments) for every post we publish. +- Snapshot metrics on a recurring schedule (each scheduled run) so we have a time series, not just latest counts. +- Reuse the existing `database.json` artifact and GH Actions upload/download pattern — no new storage. +- Mirror the existing `SocialPoster` abstraction so adding a new platform is symmetric: one poster + one collector. + +## Non-Goals + +- **Dashboard / UI.** Out of scope for this MVP — planned as a follow-up (likely served from GH Pages in this repo, consuming the same artifact). +- **Slack digests / alerts on metric trends.** Future work. +- **Impressions / views / reach.** Not all platforms expose these (Bluesky doesn't, Mastodon doesn't, Insta requires extra Insights perms). Core engagement only. +- **Backfilling metrics for posts from before this feature ships.** Going forward only. + +## Background + +Current state (relevant pieces): + +- `abstractions.py` defines `SocialPoster` ABC, `Post`, `PostResult(success, post_id, post_url, error_message)`. Each concrete poster returns a `post_id` (and usually `post_url`) on success. +- `main.py::run()` iterates posters, calls `publish()`, but **does not persist the returned `post_id` anywhere.** That's a blocker for metric collection. +- `main.py::pick_pet()` writes one entry per posted pet to `database.json`: `{name, pet_id, posted_at}`. Entries older than 12 weeks are pruned on write. +- `database.json` is a GitHub Actions artifact: `prod.yml` downloads the previous run's artifact, runs `main.py`, then re-uploads (14-day retention in prod, 1 day in dev). +- Per-platform `post_id` shapes: + - **Bluesky**: `PostResult.post_id` = cid, `post_url` = `at://...` URI. The `at://` URI is what `app.bsky.feed.getPostThread` needs. + - **Mastodon**: `post_id` = status id (string), `post_url` = public URL. The status id is what `GET /api/v1/statuses/:id` needs. + - **Instagram**: `post_id` = media id, `post_url` = generic account URL (not per-post). Media id is what `GET /{media-id}` needs. + +## Proposed Solution + +### Overview + +Three layered changes: + +1. **Schema evolution** of `database.json` to store per-platform post_ids and a list of metric snapshots per pet/platform. +2. **New abstractions** (`MetricCollector` ABC, `PostMetrics` dataclass) added to `abstractions.py`. +3. **New `metric_collectors/` package** mirroring `social_posters/`, with one collector per platform, wired into `main.py` to run after posting. + +### Detailed Design + +#### Schema: `database.json` evolution + +Extend each entry in `posted_pets[]` with a `posts` dict keyed by platform name: + +```json +{ + "posted_pets": [ + { + "name": "Fido", + "pet_id": "rg-12345", + "posted_at": "2026-05-26T12:00:00+00:00", + "posts": { + "Bluesky": { + "post_id": "bafyrei...", + "post_url": "at://did:plc:.../app.bsky.feed.post/3k...", + "metrics": [ + {"collected_at": "2026-05-26T12:00:10+00:00", "likes": 0, "reposts": 0, "comments": 0}, + {"collected_at": "2026-05-26T16:00:10+00:00", "likes": 5, "reposts": 1, "comments": 0} + ] + }, + "Mastodon": { "post_id": "...", "post_url": "...", "metrics": [...] }, + "Instagram": { "post_id": "...", "post_url": "...", "metrics": [...] } + } + } + ] +} +``` + +**Backward compat**: entries without a `posts` key are tolerated — the collector skips them (nothing to poll). Existing prune logic on `posted_at` is unchanged. + +**Failed publishes**: if a poster returned `success=False`, no entry is added under that platform key. The collector iterates only what's there. + +#### `abstractions.py` additions + +```python +@dataclass +class PostMetrics: + collected_at: str # ISO8601 UTC + likes: int | None = None + reposts: int | None = None # "reposts" on Bluesky, "reblogs" on Mastodon, "shares" doesn't apply on Insta -> None + comments: int | None = None # "replies" on Bluesky/Mastodon, "comments" on Insta + + +class MetricCollector(ABC): + @property + @abstractmethod + def platform_name(self) -> str: ... + + @abstractmethod + def fetch_metrics(self, post_id: str, post_url: str | None = None) -> PostMetrics | None: + """ + Return a PostMetrics snapshot for the given post. + Return None if the post is unreachable (deleted, 404, transient error). + Caller logs None results and continues — never raises into the caller. + """ + ... +``` + +Notes: + +- `platform_name` must match the corresponding poster's `platform_name` exactly — that's the join key in `database.json`. +- `post_url` is optional in the signature but required for Bluesky (it needs the `at://` URI). Mastodon and Insta can ignore it. +- Returning `None` (not raising) is the contract — a flaky network shouldn't crash a whole run. + +#### Concrete collectors + +One file each under `metric_collectors/`, matching the `social_posters/` layout: + +| File | Class | API call | Counts | +|---|---|---|---| +| `metric_collectors/bluesky.py` | `CollectorBluesky` | `GET /xrpc/app.bsky.feed.getPostThread?uri={post_url}` (public, no auth) | `thread.post.{likeCount, repostCount, replyCount}` | +| `metric_collectors/mastodon.py` | `CollectorMastodon` | `mastodon.status(id)` via the `Mastodon` SDK | `status.{favourites_count, reblogs_count, replies_count}` | +| `metric_collectors/instagram.py` | `CollectorInstagram` | `GET {GRAPH_API_BASE}/{media-id}?fields=like_count,comments_count&access_token=...` | `like_count`, `comments_count`. `reposts` → `None` (no native repost concept) | + +Each collector: + +- Has `platform_name` returning the same string as its paired poster. +- Reads credentials from the same env vars as its paired poster (Insta needs the access token; Bluesky/Mastodon metric endpoints are public-readable so no creds required). +- Returns `None` on any exception, logs to stdout with platform prefix. + +#### Orchestration: `main.py` changes + +**Refactor `pick_pet`**: split the responsibility so `pick_pet` chooses the pet without writing post info; a new `record_publish_results(pet, results)` writes the pet entry *including* `posts` after `run()` has results. + +Current `pick_pet` writes the entry on selection. Move the write to after publishing so we can include post_ids in the same write. Pruning of >12wk entries still happens at write time. + +New shape of `main()`: + +```python +def main(): + # ...existing arg parsing... + try: + sources = create_sources(...) + posters = create_posters(...) + collectors = create_collectors(...) # new + + pets = fetch_all_pets(sources) + pet = pick_pet(pets, database_path="database.json") # no longer writes + if pet: + results = publish(pet, posters) # existing per-poster loop + record_publish_results(pet, results, database_path="database.json") # writes pet + posts + + collect_metrics(collectors, database_path="database.json", window_days=14) # new + except Exception: + notify_slack_of_exception(traceback.format_exc()) + raise +``` + +`collect_metrics()` responsibilities: + +1. Read `database.json`. +2. For each `posted_pets` entry with a `posts` dict, for each `(platform, info)` where the pet is within the 14-day window from `posted_at`: + - Find the collector whose `platform_name == platform`. Skip if none. + - Call `collector.fetch_metrics(info["post_id"], info.get("post_url"))`. + - If non-None, append the `PostMetrics` (as a dict, with `collected_at` set to now-UTC) to `info["metrics"]`. +3. Write the file back. **Atomic write**: write to `database.json.tmp` then rename, so a mid-write crash doesn't corrupt the artifact. + +`create_collectors(debug=False)`: + +- Mirrors `create_posters`. If debug, returns `[]` (no-op) or a single `CollectorDebug` that just logs. (Lean toward `[]` for simplicity unless we want to exercise the orchestration in dev.) +- Otherwise returns `[CollectorBluesky(), CollectorMastodon(), CollectorInstagram()]`. + +#### File-locking / concurrency + +Not a concern — each GH Actions run is single-process and downloads its own copy of the artifact. No concurrent writers. + +#### GitHub workflow changes + +**None required.** The existing `prod.yml` and `dev.yml` already download `database.json` from the previous run and re-upload it. Metric snapshots ride along. + +Optional polish: bump prod artifact retention from 14 → 30 days so a few weeks of metric history survives even if a run fails. Defer unless we hit the limit in practice. + +#### Error handling + +- A collector exception inside `fetch_metrics` returns `None` and is logged. Never raises. +- `collect_metrics()` wraps the whole loop in try/except per pet/platform — one bad entry doesn't kill the rest. +- `collect_metrics()` itself does NOT raise into `main()`. Metric collection is best-effort; if the artifact write fails, log loudly but don't notify Slack (posting succeeded, metrics are just delayed). + +#### What about deleted/missing posts? + +If a post 404s (user deleted, account suspended), the collector returns `None`. The entry stays in `database.json` with whatever history it has; future runs will keep returning `None` until the pet ages out of the 12-week window. + +Optional: after N consecutive `None` results for the same `(pet_id, platform)`, mark the entry as `unreachable: true` and skip it. Defer — not needed for MVP. + +## Implementation Plan + +Each step independently verifiable. + +1. **Add `PostMetrics` dataclass + `MetricCollector` ABC** to `abstractions.py`. Verify: existing tests still pass; importable. +2. **Refactor `pick_pet`** so it picks but does not write; add `record_publish_results(pet, results, database_path)`. Update `main.py::run()` callers. Verify: existing dev workflow still posts and writes the same shape (with empty `posts: {}` placeholder if no posters succeed, or filled if they do). +3. **Wire `record_publish_results` to write per-platform post_ids** under the new `posts` schema. Verify: post a pet via dev workflow (`--debugposters`) and inspect `database.json` artifact. +4. **Create `metric_collectors/` package + 3 concrete collectors** (`bluesky.py`, `mastodon.py`, `instagram.py`). Each implements `fetch_metrics`. Verify: unit tests with mocked HTTP responses for each platform. +5. **Add `collect_metrics()` orchestration** to `main.py` + `create_collectors()` factory. Wire after `run()`. Verify: end-to-end dev run produces a `metrics` list per post. +6. **End-to-end verification** on the dev workflow: trigger `dev.yml`, confirm `database.json` artifact contains a snapshot, trigger again and confirm a *second* snapshot was appended to existing posts. + +## Testing Strategy + +Unit tests in `tests/`: + +- `tests/test_metric_collector_bluesky.py` — mock `requests.get` for `getPostThread`, assert counts mapped correctly, assert `None` returned on 404 / network error. +- `tests/test_metric_collector_mastodon.py` — mock the `Mastodon` SDK's `status()`, assert mapping + None on exception. +- `tests/test_metric_collector_instagram.py` — mock `requests.get` for the graph media endpoint, assert mapping, assert `reposts is None`. +- `tests/test_collect_metrics_orchestration.py` — fake database.json + fake collectors, assert: only entries within 14d window are polled; new snapshot appended; entries without `posts` are skipped; collector returning None doesn't append. +- `tests/test_record_publish_results.py` — assert `posts` dict is populated only for successful results; assert 12-week prune still applies. + +Edge cases covered: + +- Entry posted >14d ago → skipped by collector. +- Entry with `posts: {}` (publish failed everywhere) → no-op. +- Collector raises → logged, returns None, no snapshot appended, loop continues. +- `database.json` missing or empty → collector no-ops gracefully (same as current `pick_pet`). +- Mid-write crash → atomic rename via `.tmp` file means previous-known-good file stays intact. + +Manual / workflow verification: + +- Run `dev.yml` twice and inspect successive `database.json` artifacts for a growing `metrics` list. +- Eyeball one post's metric history vs the same post in the platform UI to spot-check accuracy. + +## Open Questions + +- **`reposts` for Instagram**: stored as `None` (no native concept). Confirm we want `None` rather than `0` — `None` correctly signals "not applicable" vs `0` which means "zero reposts". Going with `None`. +- **Dev workflow collector behavior**: should `create_collectors(debug=True)` return `[]` or a logging-only stub? Defaulting to `[]` for now; revisit if we want orchestration exercised in dev. From 7d4c6b11fe00a9c0e8d32c94bbe2f3b0cd502651 Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 26 May 2026 21:52:04 -0400 Subject: [PATCH 2/9] spec: switch to flat posts[] schema joined to posted_pets[] by pet_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the nested posted_pets[].posts.{platform} shape with a top-level posts[] table joined on pet_id, with metric snapshots nested inside each post row as a time-series list. Picked C (hybrid) over A (nested) and B (fully flat) for: - minimum byte redundancy (no FK repetition in the dominant 127k snapshot rows; nesting handles that) - cleaner consumption by the planned GH Pages dashboard - two independent prune filters by posted_at, no cascading joins, no orphan-row drift Updates schema example, orchestration responsibilities for record_publish_results and collect_metrics, implementation plan, and testing strategy to match. <|°_°|> --- docs/specs/metric-collector.md | 106 +++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/docs/specs/metric-collector.md b/docs/specs/metric-collector.md index 2295785..37d8a6b 100644 --- a/docs/specs/metric-collector.md +++ b/docs/specs/metric-collector.md @@ -2,7 +2,7 @@ ## Summary -Add an `AbstractMetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll engagement counts (likes, reposts, comments) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. No new persistence layer — same artifact, same workflow. +Add an `AbstractMetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll engagement counts (likes, reposts, comments) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. `database.json` grows a new top-level `posts[]` table (one row per published post, joined to `posted_pets[]` by `pet_id`) with metric snapshots nested inside each post as a time-series list. No new persistence layer — same artifact, same workflow. ## Problem Statement @@ -41,7 +41,7 @@ Current state (relevant pieces): Three layered changes: -1. **Schema evolution** of `database.json` to store per-platform post_ids and a list of metric snapshots per pet/platform. +1. **Schema evolution** of `database.json` to add a new top-level `posts[]` table joined to `posted_pets[]` by `pet_id`, with metric snapshots nested inside each post entry. 2. **New abstractions** (`MetricCollector` ABC, `PostMetrics` dataclass) added to `abstractions.py`. 3. **New `metric_collectors/` package** mirroring `social_posters/`, with one collector per platform, wired into `main.py` to run after posting. @@ -49,7 +49,7 @@ Three layered changes: #### Schema: `database.json` evolution -Extend each entry in `posted_pets[]` with a `posts` dict keyed by platform name: +Add a new top-level `posts[]` array alongside the existing `posted_pets[]`. `posted_pets[]` is unchanged (pet metadata only). Each post entry carries its own `pet_id` (foreign key), `platform`, `post_id`, `post_url`, `posted_at`, and a nested `metrics[]` time-series: ```json { @@ -57,27 +57,51 @@ Extend each entry in `posted_pets[]` with a `posts` dict keyed by platform name: { "name": "Fido", "pet_id": "rg-12345", + "posted_at": "2026-05-26T12:00:00+00:00" + } + ], + "posts": [ + { + "pet_id": "rg-12345", + "platform": "Bluesky", + "post_id": "bafyrei...", + "post_url": "at://did:plc:.../app.bsky.feed.post/3k...", + "posted_at": "2026-05-26T12:00:00+00:00", + "metrics": [ + {"collected_at": "2026-05-26T12:00:10+00:00", "likes": 0, "reposts": 0, "comments": 0}, + {"collected_at": "2026-05-26T16:00:10+00:00", "likes": 5, "reposts": 1, "comments": 0} + ] + }, + { + "pet_id": "rg-12345", + "platform": "Mastodon", + "post_id": "...", + "post_url": "...", "posted_at": "2026-05-26T12:00:00+00:00", - "posts": { - "Bluesky": { - "post_id": "bafyrei...", - "post_url": "at://did:plc:.../app.bsky.feed.post/3k...", - "metrics": [ - {"collected_at": "2026-05-26T12:00:10+00:00", "likes": 0, "reposts": 0, "comments": 0}, - {"collected_at": "2026-05-26T16:00:10+00:00", "likes": 5, "reposts": 1, "comments": 0} - ] - }, - "Mastodon": { "post_id": "...", "post_url": "...", "metrics": [...] }, - "Instagram": { "post_id": "...", "post_url": "...", "metrics": [...] } - } + "metrics": [] } ] } ``` -**Backward compat**: entries without a `posts` key are tolerated — the collector skips them (nothing to poll). Existing prune logic on `posted_at` is unchanged. +**Why `posted_at` is duplicated on the post row**: the collector filters posts by age (14d window) every run. Storing `posted_at` on the post row lets the filter run as a direct scan of `posts[]` without joining back to `posted_pets[]`. The few-bytes redundancy is worth the simpler query and matches how the eventual dashboard will consume the data. + +**Backward compat**: existing `database.json` artifacts only have `posted_pets[]`. The new code: + +- Treats missing `posts` key as `[]` (no posts to poll, no-op for collector). +- Existing pets stay as-is — there are no historical `post_id`s to back-fill anyway. They age out naturally after 12 weeks. +- New pets get written to both `posted_pets[]` AND `posts[]` in the same transaction (see [Orchestration](#orchestration-mainpy-changes)). -**Failed publishes**: if a poster returned `success=False`, no entry is added under that platform key. The collector iterates only what's there. +No migration script needed. + +**Failed publishes**: if a poster returned `success=False`, no row is appended to `posts[]` for that platform. The collector iterates only what's there. + +**Pruning** (both arrays use their own `posted_at` so prune is two independent filters, no join): + +- `posted_pets[]`: drop entries with `posted_at` older than 12 weeks (existing behavior). +- `posts[]`: drop entries with `posted_at` older than 12 weeks (new, mirrors pet prune so no orphans accumulate). + +Both prunes run on every `record_publish_results` write. The collector never prunes (it only appends). #### `abstractions.py` additions @@ -157,12 +181,26 @@ def main(): `collect_metrics()` responsibilities: -1. Read `database.json`. -2. For each `posted_pets` entry with a `posts` dict, for each `(platform, info)` where the pet is within the 14-day window from `posted_at`: - - Find the collector whose `platform_name == platform`. Skip if none. - - Call `collector.fetch_metrics(info["post_id"], info.get("post_url"))`. - - If non-None, append the `PostMetrics` (as a dict, with `collected_at` set to now-UTC) to `info["metrics"]`. -3. Write the file back. **Atomic write**: write to `database.json.tmp` then rename, so a mid-write crash doesn't corrupt the artifact. +1. Read `database.json`. Treat missing `posts` key as `[]`. +2. Build a `{platform_name: collector}` lookup from the `collectors` list. +3. Compute `cutoff = now_utc - timedelta(days=window_days)`. +4. For each entry in `posts[]` where `posted_at >= cutoff`: + - Look up the collector by `entry["platform"]`. Skip if not registered. + - Call `collector.fetch_metrics(entry["post_id"], entry.get("post_url"))`. + - If non-None, append the snapshot (as a dict with `collected_at` set to now-UTC) to `entry["metrics"]`. +5. Write the file back. **Atomic write**: write to `database.json.tmp` then rename, so a mid-write crash doesn't corrupt the artifact. + +Note: `collect_metrics` only appends to `metrics[]` lists; it never adds or removes rows from `posts[]` or `posted_pets[]`. Pruning is `record_publish_results`'s job. + +`record_publish_results(pet, results, database_path)` responsibilities: + +1. Read `database.json`. Treat missing `posts` key as `[]`. +2. Append to `posted_pets[]`: `{name, pet_id, posted_at}` (same shape as today). +3. For each `result` in `results` where `result.success` is True: + - Append to `posts[]`: `{pet_id, platform, post_id, post_url, posted_at, metrics: []}`. + - `platform` is taken from the matching poster's `platform_name`. (`run()` will need to pass `(poster, result)` pairs, not just results.) +4. Prune both arrays: drop entries with `posted_at` older than 12 weeks. +5. Atomic write via `.tmp` rename. `create_collectors(debug=False)`: @@ -196,11 +234,11 @@ Optional: after N consecutive `None` results for the same `(pet_id, platform)`, Each step independently verifiable. 1. **Add `PostMetrics` dataclass + `MetricCollector` ABC** to `abstractions.py`. Verify: existing tests still pass; importable. -2. **Refactor `pick_pet`** so it picks but does not write; add `record_publish_results(pet, results, database_path)`. Update `main.py::run()` callers. Verify: existing dev workflow still posts and writes the same shape (with empty `posts: {}` placeholder if no posters succeed, or filled if they do). -3. **Wire `record_publish_results` to write per-platform post_ids** under the new `posts` schema. Verify: post a pet via dev workflow (`--debugposters`) and inspect `database.json` artifact. -4. **Create `metric_collectors/` package + 3 concrete collectors** (`bluesky.py`, `mastodon.py`, `instagram.py`). Each implements `fetch_metrics`. Verify: unit tests with mocked HTTP responses for each platform. -5. **Add `collect_metrics()` orchestration** to `main.py` + `create_collectors()` factory. Wire after `run()`. Verify: end-to-end dev run produces a `metrics` list per post. -6. **End-to-end verification** on the dev workflow: trigger `dev.yml`, confirm `database.json` artifact contains a snapshot, trigger again and confirm a *second* snapshot was appended to existing posts. +2. **Refactor `pick_pet`** so it picks but does not write. `pick_pet` returns the chosen `AdoptablePet`; no `database.json` writes happen inside it. Verify: existing tests for pick_pet are updated and still pass; dedup logic (skipping previously-posted `pet_id`s) is preserved by reading from `posted_pets[]` in the new pick-only flow. +3. **Add `record_publish_results(pet, results, database_path)`** that writes the pet entry to `posted_pets[]` and one row per successful publish to `posts[]` (with `metrics: []`). Prunes both arrays at 12 weeks. Update `main.py::run()` to pass `(poster, result)` pairs so platform names are available at write time. Verify: post a pet via dev workflow (`--debugposters`) and inspect `database.json` artifact — `posted_pets[]` has one new entry, `posts[]` has N new entries (one per successful publish). +4. **Create `metric_collectors/` package + 3 concrete collectors** (`bluesky.py`, `mastodon.py`, `instagram.py`). Each implements `fetch_metrics(post_id, post_url=None) -> PostMetrics | None`. Verify: unit tests with mocked HTTP responses for each platform. +5. **Add `collect_metrics()` orchestration** to `main.py` + `create_collectors()` factory. Wire after `run()`. Iterates `posts[]` within the 14-day window, appends snapshots to each post's `metrics[]`. Verify: end-to-end dev run shows `posts[i].metrics` growing by one entry per run. +6. **End-to-end verification** on the dev workflow: trigger `dev.yml`, confirm `database.json` artifact contains `posts[]` with one snapshot in `metrics`. Trigger again and confirm a *second* snapshot was appended to the existing post entries (not a new post row). ## Testing Strategy @@ -209,16 +247,18 @@ Unit tests in `tests/`: - `tests/test_metric_collector_bluesky.py` — mock `requests.get` for `getPostThread`, assert counts mapped correctly, assert `None` returned on 404 / network error. - `tests/test_metric_collector_mastodon.py` — mock the `Mastodon` SDK's `status()`, assert mapping + None on exception. - `tests/test_metric_collector_instagram.py` — mock `requests.get` for the graph media endpoint, assert mapping, assert `reposts is None`. -- `tests/test_collect_metrics_orchestration.py` — fake database.json + fake collectors, assert: only entries within 14d window are polled; new snapshot appended; entries without `posts` are skipped; collector returning None doesn't append. -- `tests/test_record_publish_results.py` — assert `posts` dict is populated only for successful results; assert 12-week prune still applies. +- `tests/test_collect_metrics_orchestration.py` — fake `database.json` + fake collectors, assert: only `posts[]` entries within 14d window are polled; snapshot appended to the correct entry's `metrics[]`; entries with no matching registered collector are skipped; collector returning `None` doesn't append; missing top-level `posts` key is treated as empty. +- `tests/test_record_publish_results.py` — assert `posted_pets[]` gets one new entry; `posts[]` gets one row per successful publish (none for failed); `metrics: []` initialized empty; 12-week prune applied to both arrays independently. Edge cases covered: -- Entry posted >14d ago → skipped by collector. -- Entry with `posts: {}` (publish failed everywhere) → no-op. +- Post entry with `posted_at` >14d ago → skipped by collector. +- All publishes for a pet failed → `posted_pets[]` gets the entry, `posts[]` gets nothing for that pet (pet still counts for dedup; no metrics ever collected). - Collector raises → logged, returns None, no snapshot appended, loop continues. -- `database.json` missing or empty → collector no-ops gracefully (same as current `pick_pet`). +- `database.json` missing or empty → both `pick_pet` and `collect_metrics` no-op gracefully. +- `posts[]` key missing on legacy artifact → treated as `[]`, new writes populate it. - Mid-write crash → atomic rename via `.tmp` file means previous-known-good file stays intact. +- Pet pruned at 12wk → corresponding `posts[]` rows pruned in the same write (via independent `posted_at` filter, not a join — so even if `posted_at` somehow drifts between pet and post rows, no orphans accumulate). Manual / workflow verification: From aa3f3db0f14377339b8f8ccb002def689df2c89e Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 21 Jul 2026 20:00:26 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20collect=20post=20engagement=20metri?= =?UTF-8?q?cs=20<|=C2=B0=5F=C2=B0|>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- abstractions.py | 32 +++ main.py | 227 ++++++++++++++------ metric_collectors/__init__.py | 8 + metric_collectors/bluesky.py | 41 ++++ metric_collectors/instagram.py | 51 +++++ metric_collectors/mastodon.py | 35 +++ tests/test_collect_metrics_orchestration.py | 137 ++++++++++++ tests/test_main.py | 49 ++++- tests/test_metric_collector_bluesky.py | 50 +++++ tests/test_metric_collector_instagram.py | 48 +++++ tests/test_metric_collector_mastodon.py | 28 +++ tests/test_record_publish_results.py | 138 ++++++++++++ 12 files changed, 773 insertions(+), 71 deletions(-) create mode 100644 metric_collectors/__init__.py create mode 100644 metric_collectors/bluesky.py create mode 100644 metric_collectors/instagram.py create mode 100644 metric_collectors/mastodon.py create mode 100644 tests/test_collect_metrics_orchestration.py create mode 100644 tests/test_metric_collector_bluesky.py create mode 100644 tests/test_metric_collector_instagram.py create mode 100644 tests/test_metric_collector_mastodon.py create mode 100644 tests/test_record_publish_results.py diff --git a/abstractions.py b/abstractions.py index 870b428..211c040 100644 --- a/abstractions.py +++ b/abstractions.py @@ -138,3 +138,35 @@ def format_post(self, pet: AdoptablePet) -> Post: pet.breed.lower().replace(" ", ""), ], ) + + +# ============================================================================= +# Engagement Metric Collector Interface +# ============================================================================= + + +@dataclass +class PostMetrics: + """Point-in-time engagement counts for a published post.""" + + collected_at: str + likes: int | None = None + reposts: int | None = None + comments: int | None = None + + +class MetricCollector(ABC): + """Interface for collecting engagement metrics from a social platform.""" + + @property + @abstractmethod + def platform_name(self) -> str: + """Return the platform name used in persisted post records.""" + ... + + @abstractmethod + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + """Return a metric snapshot, or None when the post cannot be read.""" + ... diff --git a/main.py b/main.py index 6a05c40..a655896 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,29 @@ -import os -import random import argparse +from dataclasses import asdict +from datetime import datetime, timedelta, timezone import json +import os +from pathlib import Path +import random 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 + parser.add_argument("--debugsources", action="store_true") + parser.add_argument("--debugposters", action="store_true") args = parser.parse_args() try: sources = create_sources(debug=args.debugsources) posters = create_posters(debug=args.debugposters) + collectors = create_collectors(debug=args.debugposters) - run(sources, posters) + run(sources, posters, collectors) except Exception: notify_slack_of_exception(traceback.format_exc()) raise @@ -29,21 +31,26 @@ def main(): def create_posters(debug=False): from social_posters.debug import PosterDebug - - if debug: + if debug: return [PosterDebug()] - from social_posters.instagram import PosterInstagram + from social_posters.bluesky import PosterBluesky + from social_posters.instagram import PosterInstagram from social_posters.mastodon import PosterMastodon - posters = [] - posters.append(PosterMastodon()) - posters.append(PosterBluesky()) - posters.append(PosterInstagram()) - return posters + return [PosterMastodon(), PosterBluesky(), PosterInstagram()] + + +def create_collectors(debug=False): + if debug: + return [] + from metric_collectors.bluesky import CollectorBluesky + from metric_collectors.instagram import CollectorInstagram + from metric_collectors.mastodon import CollectorMastodon + return [CollectorBluesky(), CollectorMastodon(), CollectorInstagram()] def create_sources(debug=False): @@ -59,7 +66,7 @@ def create_sources(debug=False): return sources -def run(sources, posters): +def run(sources, posters, collectors=None, database_path="database.json"): pets = [] for source in sources: try: @@ -68,62 +75,150 @@ def run(sources, posters): raise SystemExit(str(exc)) from exc print("Fetched", len(pets), "records") - pet = pick_pet(pets) + pet = pick_pet(pets, database_path=database_path) + results = [] + publish_results = [] + if not pet: print("No pets available to post.") - return [] - - if not posters: + elif 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.") - + record_publish_results(pet, publish_results, database_path=database_path) + else: + for poster in posters: + post = poster.format_post(pet) + result = poster.publish(post) + results.append(result) + publish_results.append((poster, result)) + if not result.success: + print(f"{poster.platform_name} post failed: {result.error_message}") + else: + print(f"{poster.platform_name} post published.") + + record_publish_results(pet, publish_results, database_path=database_path) + + collect_metrics(collectors or [], database_path=database_path) 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 = {posted_pet["pet_id"] for posted_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: - raise ValueError("No elligible pet found") - - 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, "posted_at": datetime.now(timezone.utc).isoformat()}) - # Remove old pets - cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) - recent_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['posted_at']) > cutoff] - data["posted_pets"] = recent_pets - # Export json - f.seek(0) - json.dump(data, f, indent=4) - f.truncate() - return selected_pet +def pick_pet(pets, database_path="database.json"): + data = _read_database(database_path) + posted_pet_ids = { + posted_pet["pet_id"] for posted_pet in data.get("posted_pets", []) + } + 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: + raise ValueError("No eligible pet found") + + return random.choice(eligible) + + +def record_publish_results(pet, results, database_path="database.json"): + data = _read_database(database_path) + posted_pets = data.setdefault("posted_pets", []) + posts = data.setdefault("posts", []) + posted_at = datetime.now(timezone.utc).isoformat() + + posted_pets.append( + {"name": pet.name, "pet_id": pet.pet_id, "posted_at": posted_at} + ) + for poster, result in results: + if not result.success: + continue + posts.append( + { + "pet_id": pet.pet_id, + "platform": poster.platform_name, + "post_id": result.post_id, + "post_url": result.post_url, + "posted_at": posted_at, + "metrics": [], + } + ) + + cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) + data["posted_pets"] = [ + item + for item in posted_pets + if datetime.fromisoformat(item["posted_at"]) >= cutoff + ] + data["posts"] = [ + item + for item in posts + if datetime.fromisoformat(item["posted_at"]) >= cutoff + ] + _write_database(database_path, data) + + +def collect_metrics(collectors, database_path="database.json", window_days=14): + try: + data = _read_database(database_path) + posts = data.get("posts", []) + if not posts: + return + + collectors_by_platform = { + collector.platform_name: collector for collector in collectors + } + cutoff = datetime.now(timezone.utc) - timedelta(days=window_days) + updated = False + + for entry in posts: + try: + if datetime.fromisoformat(entry["posted_at"]) < cutoff: + continue + + collector = collectors_by_platform.get(entry.get("platform")) + if collector is None: + continue + + metrics = collector.fetch_metrics( + entry["post_id"], entry.get("post_url") + ) + if metrics is None: + continue + + snapshot = asdict(metrics) + snapshot["collected_at"] = datetime.now(timezone.utc).isoformat() + entry.setdefault("metrics", []).append(snapshot) + updated = True + except Exception as exc: + platform = entry.get("platform", "unknown platform") + post_id = entry.get("post_id", "unknown post") + print(f"{platform} metric collection failed for {post_id}: {exc}") + + if updated: + _write_database(database_path, data) + except Exception as exc: + print(f"Metric collection failed: {exc}") + + +def _read_database(database_path): + path = Path(database_path) + if not path.exists() or path.stat().st_size == 0: + return {} + + try: + with path.open() as database_file: + return json.load(database_file) + except (json.JSONDecodeError, ValueError) as exc: + print(f"{type(exc).__name__}:{exc}", file=sys.stderr) + traceback.print_exc() + return {} + + +def _write_database(database_path, data): + path = Path(database_path) + temporary_path = path.with_name(f"{path.name}.tmp") + with temporary_path.open("w") as database_file: + json.dump(data, database_file, indent=4) + temporary_path.replace(path) # Slack incoming-webhook messages have a ~40k-char limit; cap the traceback diff --git a/metric_collectors/__init__.py b/metric_collectors/__init__.py new file mode 100644 index 0000000..a4458e8 --- /dev/null +++ b/metric_collectors/__init__.py @@ -0,0 +1,8 @@ +"""Engagement metric collectors for supported social platforms.""" + +from metric_collectors.bluesky import CollectorBluesky +from metric_collectors.instagram import CollectorInstagram +from metric_collectors.mastodon import CollectorMastodon + + +__all__ = ["CollectorBluesky", "CollectorInstagram", "CollectorMastodon"] diff --git a/metric_collectors/bluesky.py b/metric_collectors/bluesky.py new file mode 100644 index 0000000..703f305 --- /dev/null +++ b/metric_collectors/bluesky.py @@ -0,0 +1,41 @@ +"""Bluesky engagement metric collector.""" + +from datetime import datetime, timezone + +import requests + +from abstractions import MetricCollector, PostMetrics + + +POST_THREAD_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread" + + +class CollectorBluesky(MetricCollector): + @property + def platform_name(self) -> str: + return "Bluesky" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + if not post_url: + print(f"Bluesky metric collection failed for {post_id}: post URL missing") + return None + + try: + response = requests.get( + POST_THREAD_URL, + params={"uri": post_url}, + timeout=20, + ) + response.raise_for_status() + post = response.json()["thread"]["post"] + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=post.get("likeCount"), + reposts=post.get("repostCount"), + comments=post.get("replyCount"), + ) + except Exception as exc: + print(f"Bluesky metric collection failed for {post_id}: {exc}") + return None diff --git a/metric_collectors/instagram.py b/metric_collectors/instagram.py new file mode 100644 index 0000000..d97b22e --- /dev/null +++ b/metric_collectors/instagram.py @@ -0,0 +1,51 @@ +"""Instagram engagement metric collector.""" + +from datetime import datetime, timezone +import os + +import requests + +from abstractions import MetricCollector, PostMetrics +from social_posters.instagram import GRAPH_API_BASE + + +class CollectorInstagram(MetricCollector): + def __init__(self, access_token=None): + self.access_token = access_token or os.environ.get( + "INSTAGRAM_PAGE_ACCESS_TOKEN" + ) + + @property + def platform_name(self) -> str: + return "Instagram" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + if not self.access_token: + print( + f"Instagram metric collection failed for {post_id}: " + "access token missing" + ) + return None + + try: + response = requests.get( + f"{GRAPH_API_BASE}/{post_id}", + params={ + "fields": "like_count,comments_count", + "access_token": self.access_token, + }, + timeout=20, + ) + response.raise_for_status() + media = response.json() + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=media.get("like_count"), + reposts=None, + comments=media.get("comments_count"), + ) + except Exception as exc: + print(f"Instagram metric collection failed for {post_id}: {exc}") + return None diff --git a/metric_collectors/mastodon.py b/metric_collectors/mastodon.py new file mode 100644 index 0000000..91138d8 --- /dev/null +++ b/metric_collectors/mastodon.py @@ -0,0 +1,35 @@ +"""Mastodon engagement metric collector.""" + +from datetime import datetime, timezone +import os + +from mastodon import Mastodon + +from abstractions import MetricCollector, PostMetrics + + +class CollectorMastodon(MetricCollector): + def __init__(self, client=None): + api_base_url = os.environ.get( + "MASTODON_API_BASE_URL", "https://mastodon.social" + ) + self._client = client or Mastodon(api_base_url=api_base_url) + + @property + def platform_name(self) -> str: + return "Mastodon" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + try: + status = self._client.status(post_id) + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=status.get("favourites_count"), + reposts=status.get("reblogs_count"), + comments=status.get("replies_count"), + ) + except Exception as exc: + print(f"Mastodon metric collection failed for {post_id}: {exc}") + return None diff --git a/tests/test_collect_metrics_orchestration.py b/tests/test_collect_metrics_orchestration.py new file mode 100644 index 0000000..e543bdb --- /dev/null +++ b/tests/test_collect_metrics_orchestration.py @@ -0,0 +1,137 @@ +import json +from datetime import datetime, timedelta, timezone + +from abstractions import PostMetrics +from main import collect_metrics + + +class FakeCollector: + platform_name = "Bluesky" + + def __init__(self, responses): + self.responses = responses + self.calls = [] + + def fetch_metrics(self, post_id, post_url=None): + self.calls.append((post_id, post_url)) + response = self.responses[post_id] + if isinstance(response, Exception): + raise response + return response + + +def post(post_id, posted_at, platform="Bluesky", metrics=None): + return { + "pet_id": f"pet-{post_id}", + "platform": platform, + "post_id": post_id, + "post_url": f"at://{post_id}", + "posted_at": posted_at, + "metrics": list(metrics or []), + } + + +def snapshot(likes): + return PostMetrics( + collected_at="replaced-by-orchestrator", + likes=likes, + reposts=2, + comments=3, + ) + + +def test_collects_only_recent_posts_with_registered_collectors(tmp_path): + database_path = tmp_path / "database.json" + now = datetime.now(timezone.utc) + previous = { + "collected_at": (now - timedelta(hours=1)).isoformat(), + "likes": 1, + "reposts": 0, + "comments": 0, + } + database_path.write_text( + json.dumps( + { + "posted_pets": [], + "posts": [ + post("recent", (now - timedelta(days=1)).isoformat(), metrics=[previous]), + post("none", (now - timedelta(days=2)).isoformat()), + post("old", (now - timedelta(days=15)).isoformat()), + post( + "unknown", + (now - timedelta(days=1)).isoformat(), + platform="Unknown", + ), + ], + } + ) + ) + collector = FakeCollector({"recent": snapshot(9), "none": None}) + + collect_metrics([collector], database_path=database_path, window_days=14) + + data = json.loads(database_path.read_text()) + assert collector.calls == [("recent", "at://recent"), ("none", "at://none")] + assert data["posts"][0]["metrics"][0] == previous + assert data["posts"][0]["metrics"][1]["likes"] == 9 + assert data["posts"][0]["metrics"][1]["reposts"] == 2 + assert data["posts"][0]["metrics"][1]["comments"] == 3 + collected_at = datetime.fromisoformat( + data["posts"][0]["metrics"][1]["collected_at"] + ) + assert collected_at.tzinfo == timezone.utc + assert data["posts"][1]["metrics"] == [] + assert data["posts"][2]["metrics"] == [] + assert data["posts"][3]["metrics"] == [] + + +def test_missing_database_is_a_noop(tmp_path): + database_path = tmp_path / "database.json" + + collect_metrics([], database_path=database_path) + + assert not database_path.exists() + + +def test_missing_posts_key_is_a_noop(tmp_path): + database_path = tmp_path / "database.json" + original = {"posted_pets": []} + database_path.write_text(json.dumps(original)) + + collect_metrics([], database_path=database_path) + + assert json.loads(database_path.read_text()) == original + + +def test_collector_error_does_not_stop_other_posts(tmp_path): + database_path = tmp_path / "database.json" + recent = datetime.now(timezone.utc).isoformat() + database_path.write_text( + json.dumps({"posted_pets": [], "posts": [post("bad", recent), post("good", recent)]}) + ) + collector = FakeCollector( + {"bad": RuntimeError("unreachable"), "good": snapshot(4)} + ) + + collect_metrics([collector], database_path=database_path) + + data = json.loads(database_path.read_text()) + assert data["posts"][0]["metrics"] == [] + assert data["posts"][1]["metrics"][0]["likes"] == 4 + + +def test_repeated_collection_appends_snapshots_without_adding_posts(tmp_path): + database_path = tmp_path / "database.json" + recent = datetime.now(timezone.utc).isoformat() + database_path.write_text( + json.dumps({"posted_pets": [], "posts": [post("recent", recent)]}) + ) + collector = FakeCollector({"recent": snapshot(5)}) + + collect_metrics([collector], database_path=database_path) + collector.responses["recent"] = snapshot(7) + collect_metrics([collector], database_path=database_path) + + data = json.loads(database_path.read_text()) + assert len(data["posts"]) == 1 + assert [item["likes"] for item in data["posts"][0]["metrics"]] == [5, 7] diff --git a/tests/test_main.py b/tests/test_main.py index af19a2f..a86bb43 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,10 @@ +import json +from pathlib import Path +from tempfile import TemporaryDirectory import unittest -from abstractions import AdoptablePet, Post, PostResult -from main import create_posters, run +from abstractions import AdoptablePet, Post, PostMetrics, PostResult +from main import create_collectors, create_posters, run class FakeSource: @@ -29,7 +32,27 @@ def format_post(self, pet): def publish(self, post): self.publish_called = True self.posts.append(post) - return PostResult(success=True) + return PostResult( + success=True, + post_id=f"post-{len(self.posts)}", + post_url=f"https://example.com/posts/{len(self.posts)}", + ) + + +class FakeCollector: + platform_name = "FakePoster" + + def __init__(self): + self.calls = [] + + def fetch_metrics(self, post_id, post_url=None): + self.calls.append((post_id, post_url)) + return PostMetrics( + collected_at="set-by-run", + likes=3, + reposts=1, + comments=2, + ) class RunFlowTests(unittest.TestCase): @@ -41,12 +64,22 @@ def test_run_calls_source_and_posters(self): location="Boston, MA", image_url="https://example.com/poppy.jpg", adoption_url="https://example.com/adopt/poppy", + pet_id="pet-poppy", ) source = FakeSource([pet]) poster_one = FakePoster() poster_two = FakePoster() - - results = run([source], [poster_one, poster_two]) + collector = FakeCollector() + + with TemporaryDirectory() as temporary_directory: + database_path = Path(temporary_directory) / "database.json" + results = run( + [source], + [poster_one, poster_two], + collectors=[collector], + database_path=database_path, + ) + data = json.loads(database_path.read_text()) self.assertTrue(source.fetch_called) self.assertTrue(poster_one.format_called) @@ -54,6 +87,10 @@ def test_run_calls_source_and_posters(self): self.assertTrue(poster_two.format_called) self.assertTrue(poster_two.publish_called) self.assertEqual(len(results), 2) + self.assertEqual(len(data["posted_pets"]), 1) + self.assertEqual(len(data["posts"]), 2) + self.assertEqual(len(collector.calls), 2) + self.assertEqual(data["posts"][0]["metrics"][0]["likes"], 3) class CreatePostersTests(unittest.TestCase): @@ -63,6 +100,8 @@ def test_debug_returns_debug_poster(self): self.assertEqual(len(posters), 1) self.assertEqual(posters[0].platform_name, "Debug") + def test_debug_disables_metric_collectors(self): + self.assertEqual(create_collectors(debug=True), []) if __name__ == "__main__": diff --git a/tests/test_metric_collector_bluesky.py b/tests/test_metric_collector_bluesky.py new file mode 100644 index 0000000..2942f55 --- /dev/null +++ b/tests/test_metric_collector_bluesky.py @@ -0,0 +1,50 @@ +from unittest.mock import Mock, patch + +import requests + +from metric_collectors.bluesky import CollectorBluesky, POST_THREAD_URL + + +class TestCollectorBluesky: + @patch("metric_collectors.bluesky.requests.get") + def test_maps_post_thread_counts(self, mock_get): + response = Mock() + response.json.return_value = { + "thread": { + "post": {"likeCount": 8, "repostCount": 3, "replyCount": 2} + } + } + mock_get.return_value = response + + metrics = CollectorBluesky().fetch_metrics( + "cid-123", "at://did:plc:abc/app.bsky.feed.post/xyz" + ) + + assert metrics.likes == 8 + assert metrics.reposts == 3 + assert metrics.comments == 2 + mock_get.assert_called_once_with( + POST_THREAD_URL, + params={"uri": "at://did:plc:abc/app.bsky.feed.post/xyz"}, + timeout=20, + ) + response.raise_for_status.assert_called_once_with() + + @patch("metric_collectors.bluesky.requests.get") + def test_returns_none_on_http_error(self, mock_get): + response = Mock() + response.raise_for_status.side_effect = requests.HTTPError("not found") + mock_get.return_value = response + + metrics = CollectorBluesky().fetch_metrics( + "cid-123", "at://did:plc:abc/app.bsky.feed.post/missing" + ) + + assert metrics is None + + @patch("metric_collectors.bluesky.requests.get") + def test_returns_none_when_post_url_is_missing(self, mock_get): + metrics = CollectorBluesky().fetch_metrics("cid-123") + + assert metrics is None + mock_get.assert_not_called() diff --git a/tests/test_metric_collector_instagram.py b/tests/test_metric_collector_instagram.py new file mode 100644 index 0000000..d8d4e72 --- /dev/null +++ b/tests/test_metric_collector_instagram.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock, patch + +import requests + +from metric_collectors.instagram import CollectorInstagram +from social_posters.instagram import GRAPH_API_BASE + + +class TestCollectorInstagram: + @patch("metric_collectors.instagram.requests.get") + def test_maps_media_counts_and_marks_reposts_not_applicable(self, mock_get): + response = Mock() + response.json.return_value = {"like_count": 13, "comments_count": 5} + mock_get.return_value = response + + metrics = CollectorInstagram(access_token="token").fetch_metrics("media-123") + + assert metrics.likes == 13 + assert metrics.reposts is None + assert metrics.comments == 5 + mock_get.assert_called_once_with( + f"{GRAPH_API_BASE}/media-123", + params={ + "fields": "like_count,comments_count", + "access_token": "token", + }, + timeout=20, + ) + response.raise_for_status.assert_called_once_with() + + @patch("metric_collectors.instagram.requests.get") + def test_returns_none_on_http_error(self, mock_get): + response = Mock() + response.raise_for_status.side_effect = requests.HTTPError("not found") + mock_get.return_value = response + + metrics = CollectorInstagram(access_token="token").fetch_metrics( + "media-123" + ) + + assert metrics is None + + @patch("metric_collectors.instagram.requests.get") + def test_returns_none_without_access_token(self, mock_get): + metrics = CollectorInstagram(access_token="").fetch_metrics("media-123") + + assert metrics is None + mock_get.assert_not_called() diff --git a/tests/test_metric_collector_mastodon.py b/tests/test_metric_collector_mastodon.py new file mode 100644 index 0000000..6cc8463 --- /dev/null +++ b/tests/test_metric_collector_mastodon.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock + +from metric_collectors.mastodon import CollectorMastodon + + +class TestCollectorMastodon: + def test_maps_status_counts(self): + client = Mock() + client.status.return_value = { + "favourites_count": 11, + "reblogs_count": 4, + "replies_count": 6, + } + + metrics = CollectorMastodon(client=client).fetch_metrics("status-123") + + assert metrics.likes == 11 + assert metrics.reposts == 4 + assert metrics.comments == 6 + client.status.assert_called_once_with("status-123") + + def test_returns_none_on_sdk_error(self): + client = Mock() + client.status.side_effect = RuntimeError("mastodon unavailable") + + metrics = CollectorMastodon(client=client).fetch_metrics("status-123") + + assert metrics is None diff --git a/tests/test_record_publish_results.py b/tests/test_record_publish_results.py new file mode 100644 index 0000000..5295185 --- /dev/null +++ b/tests/test_record_publish_results.py @@ -0,0 +1,138 @@ +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from abstractions import AdoptablePet, PostResult +from main import pick_pet, record_publish_results + + +def make_pet(pet_id="pet-123", name="Poppy"): + return AdoptablePet( + name=name, + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/poppy.jpg", + adoption_url="https://example.com/adopt/poppy", + pet_id=pet_id, + ) + + +def test_pick_pet_preserves_dedup_without_writing(tmp_path): + database_path = tmp_path / "database.json" + database_path.write_text( + json.dumps( + { + "posted_pets": [ + { + "name": "Already posted", + "pet_id": "old-pet", + "posted_at": datetime.now(timezone.utc).isoformat(), + } + ] + } + ) + ) + original_contents = database_path.read_text() + + selected = pick_pet( + [make_pet("old-pet", "Old"), make_pet("new-pet", "New")], + database_path=database_path, + ) + + assert selected.pet_id == "new-pet" + assert database_path.read_text() == original_contents + + +def test_pick_pet_does_not_create_a_missing_database(tmp_path): + database_path = tmp_path / "database.json" + + selected = pick_pet([make_pet()], database_path=database_path) + + assert selected.pet_id == "pet-123" + assert not database_path.exists() + + +def test_records_pet_and_one_post_per_successful_publish(tmp_path): + database_path = tmp_path / "database.json" + pet = make_pet() + bluesky = SimpleNamespace(platform_name="Bluesky") + mastodon = SimpleNamespace(platform_name="Mastodon") + + record_publish_results( + pet, + [ + ( + bluesky, + PostResult( + success=True, + post_id="cid-123", + post_url="at://did:plc:abc/app.bsky.feed.post/xyz", + ), + ), + ( + mastodon, + PostResult(success=False, error_message="publish failed"), + ), + ], + database_path=database_path, + ) + + data = json.loads(database_path.read_text()) + assert data["posted_pets"][0]["pet_id"] == "pet-123" + assert data["posts"] == [ + { + "pet_id": "pet-123", + "platform": "Bluesky", + "post_id": "cid-123", + "post_url": "at://did:plc:abc/app.bsky.feed.post/xyz", + "posted_at": data["posted_pets"][0]["posted_at"], + "metrics": [], + } + ] + assert not database_path.with_name("database.json.tmp").exists() + + +def test_records_pet_when_all_publishes_fail(tmp_path): + database_path = tmp_path / "database.json" + poster = SimpleNamespace(platform_name="Mastodon") + + record_publish_results( + make_pet(), + [(poster, PostResult(success=False, error_message="publish failed"))], + database_path=database_path, + ) + + data = json.loads(database_path.read_text()) + assert [pet["pet_id"] for pet in data["posted_pets"]] == ["pet-123"] + assert data["posts"] == [] + + +def test_prunes_old_pets_and_posts_independently(tmp_path): + database_path = tmp_path / "database.json" + old_timestamp = (datetime.now(timezone.utc) - timedelta(weeks=13)).isoformat() + database_path.write_text( + json.dumps( + { + "posted_pets": [ + {"name": "Old pet", "pet_id": "old-pet", "posted_at": old_timestamp} + ], + "posts": [ + { + "pet_id": "different-old-pet", + "platform": "Bluesky", + "post_id": "old-post", + "post_url": "at://old", + "posted_at": old_timestamp, + "metrics": [], + } + ], + } + ) + ) + + record_publish_results(make_pet(), [], database_path=database_path) + + data = json.loads(database_path.read_text()) + assert [pet["pet_id"] for pet in data["posted_pets"]] == ["pet-123"] + assert data["posts"] == [] From 623fdea71e2a8810c7af9ccfccc9514fe07b74d3 Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 21 Jul 2026 20:11:26 -0400 Subject: [PATCH 4/9] test the workflow --- .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 7f6b196..ba1649c 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -60,7 +60,7 @@ jobs: APP_ENV: dev run: | #In order to create posts on the test accounts remove the --debugposters debug flag - python ./main.py --debugsources --debugposters + python ./main.py - name: Upload database artifact uses: actions/upload-artifact@v7 From 8d8652b68d078d51dc0e082b739bcbd584fa6f0c Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 4 Aug 2026 19:42:12 -0400 Subject: [PATCH 5/9] whitespace From f79dbaa3041c6fa572f9b7f95f287ad13c99e34b Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 4 Aug 2026 19:43:40 -0400 Subject: [PATCH 6/9] whitespace From feaae333414e4b2ed963501bb739b549c1b53f19 Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 4 Aug 2026 20:40:27 -0400 Subject: [PATCH 7/9] whoops commit resolution --- .github/workflows/dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f4974c7..9758ae9 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -80,7 +80,7 @@ jobs: DEBUG_SOURCES: ${{ inputs.debugsources != false }} DEBUG_POSTERS: ${{ inputs.debugposters != false }} run: | - #In order to create posts on the test accounts remove the --debugposters debug + #In order to create posts on the test accounts remove the --debugposters debug flag FLAGS="" if [ "$DEBUG_SOURCES" = "true" ]; then FLAGS="$FLAGS --debugsources"; fi if [ "$DEBUG_POSTERS" = "true" ]; then FLAGS="$FLAGS --debugposters"; fi @@ -99,4 +99,4 @@ jobs: with: path: cutepets.log retention-days: 14 - archive: false \ No newline at end of file + archive: false From 41d3486d99c3fa64274a29e7236286fc09adae8c Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 11 Aug 2026 20:09:41 -0400 Subject: [PATCH 8/9] comment/feedback resolution --- docs/specs/metric-collector.md | 28 ++++++++++++++++------------ main.py | 4 ++-- metric_collectors/__init__.py | 8 -------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/docs/specs/metric-collector.md b/docs/specs/metric-collector.md index 37d8a6b..90f886f 100644 --- a/docs/specs/metric-collector.md +++ b/docs/specs/metric-collector.md @@ -2,7 +2,9 @@ ## Summary -Add an `AbstractMetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll engagement counts (likes, reposts, comments) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. `database.json` grows a new top-level `posts[]` table (one row per published post, joined to `posted_pets[]` by `pet_id`) with metric snapshots nested inside each post as a time-series list. No new persistence layer — same artifact, same workflow. +Add a `MetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll normalized engagement counts (`likes`, `reposts`, `comments`) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. `database.json` grows a new top-level `posts[]` table (one row per published post, joined to `posted_pets[]` by `pet_id`) with metric snapshots nested inside each post as a time-series list. No new persistence layer — same artifact, same workflow. + +The collector abstraction uses one stable vocabulary across platforms: native likes/favourites map to `likes`, native reposts/reblogs map to `reposts`, and native replies/comments map to `comments`. A separate `shares` field is not persisted; unavailable metrics are stored as `null`. ## Problem Statement @@ -10,10 +12,10 @@ We post pets to three platforms but capture zero feedback on how those posts per ## Goals -- Persist per-platform engagement (likes, reposts/shares, comments) for every post we publish. +- Persist per-platform engagement (`likes`, `reposts`, `comments`) for every post we publish. A separate `shares` field is intentionally out of scope. - Snapshot metrics on a recurring schedule (each scheduled run) so we have a time series, not just latest counts. - Reuse the existing `database.json` artifact and GH Actions upload/download pattern — no new storage. -- Mirror the existing `SocialPoster` abstraction so adding a new platform is symmetric: one poster + one collector. +- Mirror the existing `SocialPoster` platform layout so adding a new platform is symmetric: one poster + one collector. Collectors share the platform naming and package pattern, but only expose the metric-fetching lifecycle they need. ## Non-Goals @@ -108,10 +110,10 @@ Both prunes run on every `record_publish_results` write. The collector never pru ```python @dataclass class PostMetrics: - collected_at: str # ISO8601 UTC - likes: int | None = None - reposts: int | None = None # "reposts" on Bluesky, "reblogs" on Mastodon, "shares" doesn't apply on Insta -> None - comments: int | None = None # "replies" on Bluesky/Mastodon, "comments" on Insta + collected_at: str # ISO8601 UTC, assigned by orchestration + likes: int | None = None # likes/favourites + reposts: int | None = None # reposts/reblogs; None when unavailable + comments: int | None = None # replies/comments class MetricCollector(ABC): @@ -132,7 +134,8 @@ class MetricCollector(ABC): Notes: - `platform_name` must match the corresponding poster's `platform_name` exactly — that's the join key in `database.json`. -- `post_url` is optional in the signature but required for Bluesky (it needs the `at://` URI). Mastodon and Insta can ignore it. +- `post_id` is the platform identifier returned by the poster. `post_url` is an optional API lookup locator; it is required by the Bluesky collector because the public thread endpoint needs the `at://` URI, while Mastodon and Instagram use `post_id`. +- The orchestration layer owns the persisted `collected_at` timestamp so snapshots from one run share a consistent UTC collection time. - Returning `None` (not raising) is the contract — a flaky network shouldn't crash a whole run. #### Concrete collectors @@ -143,7 +146,7 @@ One file each under `metric_collectors/`, matching the `social_posters/` layout: |---|---|---|---| | `metric_collectors/bluesky.py` | `CollectorBluesky` | `GET /xrpc/app.bsky.feed.getPostThread?uri={post_url}` (public, no auth) | `thread.post.{likeCount, repostCount, replyCount}` | | `metric_collectors/mastodon.py` | `CollectorMastodon` | `mastodon.status(id)` via the `Mastodon` SDK | `status.{favourites_count, reblogs_count, replies_count}` | -| `metric_collectors/instagram.py` | `CollectorInstagram` | `GET {GRAPH_API_BASE}/{media-id}?fields=like_count,comments_count&access_token=...` | `like_count`, `comments_count`. `reposts` → `None` (no native repost concept) | +| `metric_collectors/instagram.py` | `CollectorInstagram` | `GET {GRAPH_API_BASE}/{media-id}?fields=like_count,comments_count&access_token=...` | `like_count`, `comments_count`. `reposts` → `None` (unavailable from this endpoint) | Each collector: @@ -265,7 +268,8 @@ Manual / workflow verification: - Run `dev.yml` twice and inspect successive `database.json` artifacts for a growing `metrics` list. - Eyeball one post's metric history vs the same post in the platform UI to spot-check accuracy. -## Open Questions +## Resolved Decisions -- **`reposts` for Instagram**: stored as `None` (no native concept). Confirm we want `None` rather than `0` — `None` correctly signals "not applicable" vs `0` which means "zero reposts". Going with `None`. -- **Dev workflow collector behavior**: should `create_collectors(debug=True)` return `[]` or a logging-only stub? Defaulting to `[]` for now; revisit if we want orchestration exercised in dev. +- **Metric vocabulary**: persist `likes`, `reposts`, and `comments`. Do not add a separate `shares` field. Platform-native likes/favourites, reposts/reblogs, and replies/comments are normalized into those fields. +- **Unavailable metrics**: persist `null`, not `0`. `null` distinguishes “not exposed by this platform” from a real zero count. Instagram therefore stores `reposts: null`. +- **Dev workflow collector behavior**: `create_collectors(debug=True)` returns `[]`; debug runs exercise posting without making live metric API calls. diff --git a/main.py b/main.py index 46e43bf..188edeb 100644 --- a/main.py +++ b/main.py @@ -40,8 +40,8 @@ def main(): logger.info("Log started") parser = argparse.ArgumentParser() - parser.add_argument("--debugsources", action="store_true") - parser.add_argument("--debugposters", action="store_true") + 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() diff --git a/metric_collectors/__init__.py b/metric_collectors/__init__.py index a4458e8..e69de29 100644 --- a/metric_collectors/__init__.py +++ b/metric_collectors/__init__.py @@ -1,8 +0,0 @@ -"""Engagement metric collectors for supported social platforms.""" - -from metric_collectors.bluesky import CollectorBluesky -from metric_collectors.instagram import CollectorInstagram -from metric_collectors.mastodon import CollectorMastodon - - -__all__ = ["CollectorBluesky", "CollectorInstagram", "CollectorMastodon"] From 58394ba40d548ded235a94a87a6e4aa46cf534f6 Mon Sep 17 00:00:00 2001 From: Jonny Johannes Date: Tue, 11 Aug 2026 20:12:24 -0400 Subject: [PATCH 9/9] extract post publishing helper --- main.py | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 188edeb..1b85ddc 100644 --- a/main.py +++ b/main.py @@ -94,36 +94,42 @@ def run(sources, posters, collectors=None, database_path="database.json"): logger.info("Fetched %d records", len(pets)) pet = pick_pet(pets, database_path=database_path) results = [] - publish_results = [] if not pet: logger.error("No pets available to post.") else: logger.info("Picked pet %s", pprint.pformat(pet)) - - if not posters: - logger.error("No social media credentials set; skipping post.") - else: - for poster in posters: - post = poster.format_post(pet) - result = poster.publish(post) - results.append(result) - publish_results.append((poster, result)) - if not result.success: - logger.error( - "%s post failed: %s", - poster.platform_name, - result.error_message, - ) - else: - logger.info("%s post published.", poster.platform_name) - - record_publish_results(pet, publish_results, database_path=database_path) + results, published_results = publish_posts(pet, posters) + record_publish_results(pet, published_results, database_path=database_path) collect_metrics(collectors or [], database_path=database_path) return results +def publish_posts(pet, posters): + results = [] + published_results = [] + + if not posters: + logger.error("No social media credentials set; skipping post.") + else: + for poster in posters: + post = poster.format_post(pet) + result = poster.publish(post) + results.append(result) + published_results.append((poster, result)) + if not result.success: + logger.error( + "%s post failed: %s", + poster.platform_name, + result.error_message, + ) + else: + logger.info("%s post published.", poster.platform_name) + + return results, published_results + + def pick_pet(pets, database_path="database.json"): data = _read_database(database_path) posted_pet_ids = {