Skip to content

Add Reddit and Bluesky platform support (+ relevance.py None-fix) - #1

Open
billhector wants to merge 8 commits into
bradautomates:mainfrom
billhector:add-reddit-bluesky
Open

billhector wants to merge 8 commits into
bradautomates:mainfrom
billhector:add-reddit-bluesky

Conversation

@billhector

Copy link
Copy Markdown

Summary

Adds two new platforms to the scraper: Reddit (subreddit-centric) and Bluesky (user-centric), plus a small defensive fix in relevance.py. Both new platforms use ScrapeCreators endpoints already available on the same API key — no new dependencies, no new secrets, no changes to existing platform behavior.

Sent because it felt like a gap a lot of users would hit: I just wanted to track a couple of subreddits and one Bluesky creator alongside my IG/YT tracking, and adding both was straightforward following the existing per-platform module pattern.

Commits

fix: tolerate None values in relevance scoring (47d90df)

post.get("description", "") returns the default only when the key is missing — but the platform fetchers can legitimately return posts where description or transcript is explicitly None. That crashes score_relevance mid-pipeline with TypeError: can only concatenate str (not "NoneType") to str. Hit this on a real run before I'd touched any code. One-line change: (post.get(k) or "").

feat: add Reddit and Bluesky platform support (eea427f)

Reddit (scripts/lib/reddit.py)

  • Subreddit-centric tracking — ScrapeCreators does not expose /v1/reddit/user/posts, so handle is a subreddit name (with or without r/ prefix). author on each returned post is the post submitter, used as metadata.
  • Endpoints: /v1/reddit/subreddit (profile), /v1/reddit/post/comments (single post + comments; same endpoint serves both), /v1/reddit/post/transcript (video transcripts, rare).
  • Engagement: ups, downs, comments, score, upvote_ratio. Scoring is score × upvote_ratio + 3×comments so controversial high-vote posts don't dominate.
  • Permalinks constructed as https://www.reddit.com/r/{sub}/comments/{id}/.

Bluesky (scripts/lib/bluesky.py)

  • User-centric, mirrors X / IG / YouTube. handle is a Bluesky handle (e.g. pfrazee.com); leading @ is tolerated.
  • Endpoints: /v1/bluesky/user/posts (profile), /v1/bluesky/post (single).
  • ScrapeCreators does not expose a Bluesky comments endpoint — replyCount is on each post but reply text is not retrievable. No transcript endpoint either (Bluesky is text-only). Both registries intentionally omit bluesky.
  • Engagement: likes, reposts, replies, quotes. Scoring weights reposts + quotes 2× (active amplification) and replies 3×.
  • Post URLs derived from AT-proto URI → https://bsky.app/profile/{handle}/post/{rkey}.

Wiring

  • lib/platforms.py — both registered in PROFILE_FETCHERS + POST_FETCHERS; reddit also in COMMENT_FETCHERS + TRANSCRIPT_FETCHERS.
  • lib/scoring.py — added per-platform branches.
  • lib/urls.pydetect_platform recognises reddit.com / redd.it / bsky.app; extract_handle_from_url returns the subreddit for reddit URLs and the bluesky handle for bsky URLs.
  • lib/__init__.py — module docstring updated.

Test plan

  • Smoke test: from lib.platforms import * clean, all six platforms in registries, URL detection returns expected platform for canonical URLs.
  • Live scrape: r/Solopreneur (24 posts) + rikschennink.com (30 posts) over 7 days. Both populated correctly — subreddit field on reddit posts, bsky.app URLs constructed, outlier detection working, comments fetched on top reddit posts, no exceptions.
  • No regressions on existing platforms: existing pipeline code (pipeline.py, analyze.py) unchanged; new platforms slot in via the registry pattern.

Notes

  • Bluesky comments gap. Since reply text is not retrievable, the "audience asking" ideation signal is weaker for Bluesky than for IG/YT/Reddit. If/when ScrapeCreators adds a Bluesky comments endpoint, slotting it in is a small follow-up — just add a fetch_comments to bluesky.py and register in COMMENT_FETCHERS.
  • Reddit user tracking. If a future ScrapeCreators endpoint surfaces user-centric reddit posts (/v1/reddit/user/posts style), happy to follow up with a second tracking-mode that lets users be tracked alongside subreddits.
  • Happy to iterate on naming, weights, or anything else — pushed as a starting point.

billhector and others added 8 commits June 3, 2026 15:41
`post.get("description", "")` returns the default only when the key is
missing — but the platform fetchers can return posts where `description`
or `transcript` is present with a `None` value (e.g. Instagram captions,
YouTube videos with no transcript yet). That makes the str concatenation
in score_relevance crash with TypeError mid-run.

Use `(post.get(k) or "")` so an explicit None is treated the same as a
missing key. Defensive, no behavior change for valid posts.

Discovered while running a 7-day scrape across IG + YouTube on 2026-06-03;
script aborted in analyze_results before scoring/output.
Two new fetcher modules plus registry / scoring / URL-detection wiring,
covering the two most-requested platforms not previously supported.

**Reddit** (subreddit-centric tracking)
- ScrapeCreators does not expose `/user/posts` for reddit, so reddit
  tracking is by subreddit, not by user. `handle` passed to fetch_profile
  is a subreddit name (with or without `r/` prefix); `author` on each
  returned post is the post submitter.
- Endpoints used: /v1/reddit/subreddit (profile), /v1/reddit/post/comments
  (single post + comments — same endpoint serves both), /v1/reddit/post/transcript.
- Engagement: ups/downs/score/comments/upvote_ratio. Scoring weights
  score × upvote_ratio + 3× comments so controversial high-vote posts
  don't dominate.
- Post URL: canonical https://www.reddit.com/r/{sub}/comments/{id}/

**Bluesky** (user-centric, like X/IG/YT)
- Endpoints used: /v1/bluesky/user/posts (profile), /v1/bluesky/post (single).
- No comment endpoint — replyCount is included on each post but reply
  *text* is not retrievable. No transcript endpoint (Bluesky is
  text-only). Both registries intentionally omit bluesky.
- Engagement: likes / reposts / replies / quotes. Scoring weights
  reposts + quotes 2×, replies 3× (active amplification > passive likes).
- Post URL: derived from AT-proto URI →
  https://bsky.app/profile/{handle}/post/{rkey}

**Wiring**
- platforms.py — both registered in PROFILE_FETCHERS + POST_FETCHERS;
  reddit also in COMMENT_FETCHERS + TRANSCRIPT_FETCHERS.
- scoring.py — added per-platform branches.
- urls.py — detect_platform recognises reddit.com / redd.it / bsky.app;
  extract_handle_from_url returns the subreddit for reddit URLs and the
  bluesky handle for bsky URLs.
- __init__.py — module docstring updated.

Verified via smoke test that all six platforms are now in the registries
and URL detection returns the expected platform for canonical URLs.
ScrapeCreators returns `credits_remaining` on most responses but the
library used to throw it away. Now captured at the http layer and surfaced
at the end of every scrape run — no extra API call, no quota cost.

**lib/http.py**
- Module-level `_last_credits_remaining` updates whenever a response
  contains the field.
- New `get_last_credits_remaining()` reader.

**scripts/scrape.py**
- New `_emit_credits()` runs at end of both profile-mode and urls-mode.
- Logs `[credits] credits_remaining: N` to stderr (visible alongside
  existing progress).
- Writes a tiny single-int sidecar at `$CONTENT_HOME/.last-credits` when
  `CONTENT_HOME` is set, so external wrappers can read post-run quota
  without parsing log output.
- Sidecar write is best-effort; failures log to stderr and don't abort
  the run.

**SKILL.md**
- New "wrapper-first invocation" guidance in Step 3: prefer
  `$CONTENT_HOME/bin/scrape.sh` if present, fall back to bare scrape.py.
  Lets per-user wrappers add credits-tracking / run-logging / alerting
  without forking the skill itself.

Use case that motivated this: I'm on the paid plan with thousands of
credits and want a daily run to auto-update an `api-usage.md` file with
the new remaining balance and warn me when I drop below a threshold. The
wrapper that does that lives outside the plugin (per-user), but it needs
the credits to be reachable without a redundant probe call.
Adds an opt-in multi-project mode for users running multiple
non-overlapping content lines (a SaaS site + a personal brand +
a side project — each with different niche, pillars, competitors,
goals, and ideally a separate API key for attribution).

**Behavior in the two modes:**

Single-project (existing, default, no breaking change):
- One CONTENT_HOME, one ~/.config/content/.env, one brand/.
- Everything works exactly as before.

Multi-project (new, opt-in):
- /content-ideas <slug>            → that project's feed
- /content-ideas                   → last-used slug from
                                     ~/.config/content/last-project
- Per-project state at
  <CI_ROOT>/projects/<slug>/{brand,research}/
- Per-project key at ~/.config/content/<slug>.env
- Shared wrapper at <CI_ROOT>/bin/scrape.sh (rewritten in the
  user's vault to be slug-aware; the wrapper passes wrapper exit
  codes 3/4/5 back so the skill can distinguish missing-project,
  missing-key, and empty-key states and trigger the right recovery
  flow).

**SKILL.md changes:**

1. New "Resolve the project (multi-project mode)" section before
   "Resolve the content home". Parses slug from $ARGUMENTS, falls
   back to last-project file, discovers wrapper at conventional
   locations (CONTENT_IDEAS_HOME, mise vault, ~/Documents/Content,
   or $CONTENT_HOME/bin).
2. "Resolve the content home" gated on single-project mode (multi-
   project resolves CONTENT_HOME from the slug above).
3. Step 0 now branches: detect first-run for single-project; detect
   new-project for multi-project (project dir + key file existence
   check). Setup writes to the right paths in either mode.
4. Step 3 wrapper-first invocation expanded: shows the
   multi-project signature (`scrape.sh <slug> '<json>' ...`),
   the single-project signature, and the bare-fallback. Documents
   the wrapper's exit codes that callers should handle vs treat
   as generic failures.

**commands/content-ideas.md:**

- argument-hint updated to mention the project slug
- skill invocation prose updated to describe the routing step

**Backward compatibility:**

Users with no slug and no wrapper continue using the original flow
unchanged — the new section short-circuits to "skip ahead to Step 0"
when neither slug nor wrapper is found.

**Note re relevance.py + reddit + bluesky:**

This commit stacks on the previous three on this branch (None-fix +
reddit/bluesky platforms + credits surfacing). Together they form
the full "I want to track multiple projects with proper credit
attribution and not lose work to plugin updates" story.
Adds a "What's new in this fork" section between the install/quick-start
block and "The problem it solves". Strictly additive — Brad's existing
prose is untouched.

The new section walks through the four commits on this PR (bradautomates#1) at a
level appropriate for a casual reader of the GitHub repo page:

1. Reddit + Bluesky platform support — including the subreddit-centric
   vs user-centric tracking distinction, what's missing (no Bluesky
   comments endpoint, no `/v1/reddit/user/posts`), and the engagement
   scoring weights chosen.

2. Credits surfacing — what credits_remaining lookup looks like
   (stderr log line + sidecar file), why it matters (zero extra calls
   for usage tracking), where the sidecar lands.

3. Multi-project mode — slug-aware routing, opt-in via wrapper
   detection, per-project state + per-project API key + shared credit
   pool with attribution. Reference wrapper kept in user-vault layer
   (not the plugin) so per-user logging concerns stay out of the
   plugin's scope.

4. relevance.py None-fix — one-line defensive fix; brief mention.

Anyone landing on the fork on GitHub now sees the additions without
having to read commit messages or the PR diff. Brad sees the docs land
with the code so the PR is self-documenting.
…tent)

Every new install used to silently default to ~/Documents/Content with
no way to know that was the choice until files started landing there.
On macOS in particular this surprises users who keep their durable state
in iCloud Drive, an Obsidian vault, ~/Content, or some XDG-style hidden
location. By the time you notice, you've already accumulated a brand/
profile + a few research/ days in the wrong place and either have to
move them by hand or live with the default.

This change makes the first invocation ask explicitly.

**SKILL.md — new Step 0a / 0b / 0c**

Step 0 now begins with two short prompts BEFORE the existing API-key
flow:

  0a. "Where should content-ideas store your files?" — only fires on a
      truly fresh install (no ~/.config/content/.env, no <slug>.env
      files, no CONTENT_IDEAS_HOME set anywhere). Offers
      ~/Documents/Content (default), ~/Content, ~/.local/share/
      content-ideas, or paste-a-path. The chosen path is written as
      CONTENT_IDEAS_HOME=... in ~/.config/content/.env so it persists
      across shells/sessions without modifying the user's rc files.

  0b. "What should we call this project?" — slug name. This is the
      project identifier you pass as /content-ideas <slug>. Solo-feed
      users pick anything that fits their brand; the multi-project
      layout (projects/<slug>/) is a no-cost upgrade path for when
      they add a second feed later. Validates slug shape.

  0c. Existing-install / new-project detection — branches between
      "skip to Step 1" (existing slug + key) and "run 0d-0g" (need
      to set up this project).

The old 0a-0d (Welcome+API-key / Manual / Profile / Tracked accounts)
become 0d-0g, with refs across the file updated.

**Resolve-the-project section** updated to describe the new mode
defaults (multi-project for fresh installs, single-project legacy for
pre-multi-project installs) and to note that CONTENT_IDEAS_HOME is
resolved from BOTH the env var AND the .env file (env.py:
content_ideas_home).

**env.py — new resolver chain**

  - `content_ideas_home()` — new public function. Resolves the
    install root: env var → .env file → default ~/Documents/Content.
    Used by Step 0a to find the persisted path; also used as
    content_home()'s fallback so single-project users get the right
    behavior automatically.
  - `content_home()` — now respects CONTENT_HOME env (per-invocation
    in multi-project mode) but falls back through
    `content_ideas_home()` instead of the hard-coded default. No
    behavior change for users who don't set CONTENT_IDEAS_HOME.
  - Extracted `_read_env_file_value()` helper so both the new path
    resolver and the existing API-key loader share one .env-parse
    implementation.
  - Updated module docstring to explain the two concepts (install
    root vs per-run base dir) and how they relate to single vs
    multi-project mode.

**Backward compat:** users whose install already exists at
~/Documents/Content/ (or whose CONTENT_HOME env var was already set)
are not re-prompted — the install-path check in Step 0a only fires when
there's NO sign of an existing install. They keep working unchanged.

**Why land this in PR bradautomates#1 instead of a separate PR:** continues the
pattern set by the multi-project-routing commit two commits back. The
two changes are the "set up + use multiple projects from day 1" story
together. If reviewers prefer to split later, the individual commits
are clean to cherry-pick.
Adds section 5 "Install-path setup on first run" under "What's new in
this fork", documenting:

- the day-1 pain point upstream creates (silent default to
  ~/Documents/Content, only noticed once files already accumulated)
- the new Step 0a prompt — when it fires, what it offers
  (~/Documents/Content default, ~/Content, ~/.local/share/
  content-ideas, paste-a-path), and where the choice is persisted
  (CONTENT_IDEAS_HOME=... in ~/.config/content/.env, durable across
  shells without touching rc files)
- the auto-continuation into multi-project mode (Step 0b — name the
  first project)
- the new content_ideas_home() resolver chain
- the backward-compat guarantee (existing installs never re-prompted)

Also: section intro updated from "the four commits" to "the commits" so
it doesn't drift as the PR grows.
…ences/

Token-cache optimization. SKILL.md was 29.4KB; Step 0 (7 substeps for
install-path + slug + API key + brand-profile + competitor seeding) is
~7.5KB of bootstrap content that only fires on the first run per
project, not on the daily-feed common path.

New: references/first-run-setup.md — full 0a-0g flow.
SKILL.md: 29.4KB → 22.5KB. Saves ~7KB per daily-run fire after first
run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant