feat(reddit): implement Reddit scraping subagent and associated capabilities

- Added a new `reddit` subagent to scrape structured data from Reddit posts, comments, and users.
- Introduced `reddit.scrape` capability for fetching data using URLs and search queries.
- Implemented tools for scraping and parsing Reddit data, including handling pagination and rate limits.
- Created input/output models for the Reddit scraper to define request and response structures.
- Added documentation for the new Reddit scraping functionality and its usage.
- Integrated the Reddit subagent into the existing multi-agent chat framework.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-04 17:31:11 -07:00
parent 701f888b9e
commit ff2e5f390f
35 changed files with 510 additions and 15 deletions

View file

@ -0,0 +1,5 @@
"""``reddit.*`` namespace: platform-native Reddit data verbs."""
from __future__ import annotations
from app.capabilities.reddit.scrape import definition as _scrape # noqa: F401

View file

@ -0,0 +1,3 @@
"""``reddit.scrape`` verb: Reddit URLs / search terms → posts, comments, users."""
from __future__ import annotations

View file

@ -0,0 +1,24 @@
"""``reddit.scrape`` capability registration (free — see 04-capabilities open item)."""
from __future__ import annotations
from app.capabilities.core import Capability, register_capability
from app.capabilities.reddit.scrape.executor import build_scrape_executor
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
REDDIT_SCRAPE = Capability(
name="reddit.scrape",
description=(
"Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
"user) and/or search terms, and it returns structured items — posts "
"(title, body, score, comment count, subreddit, author), their comments, "
"and community/user metadata. Use search_queries (optionally scoped to a "
"community) to discover posts, or urls to pull a known post/subreddit/user."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=None,
)
register_capability(REDDIT_SCRAPE)

View file

@ -0,0 +1,49 @@
"""``reddit.scrape`` executor: verb input → scraper → Reddit items."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from app.capabilities.core import Executor
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
from app.exceptions import ForbiddenError
from app.proprietary.platforms.reddit import (
RedditAccessBlockedError,
RedditScrapeInput,
scrape_reddit,
)
ScrapeFn = Callable[..., Awaitable[list[dict]]]
def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor:
"""Bind the executor to a scraper fn (defaults to the proprietary actor)."""
scrape_fn = scrape_fn or scrape_reddit
async def execute(payload: ScrapeInput) -> ScrapeOutput:
actor_input = RedditScrapeInput(
startUrls=[{"url": url} for url in payload.urls],
searches=payload.search_queries,
searchCommunityName=payload.community,
sort=payload.sort,
time=payload.time_filter,
includeNSFW=payload.include_nsfw,
skipComments=payload.skip_comments,
maxItems=payload.max_items,
maxPostCount=payload.max_posts,
maxComments=payload.max_comments,
postDateLimit=payload.post_date_limit,
commentDateLimit=payload.comment_date_limit,
)
try:
items = await scrape_fn(actor_input, limit=payload.max_items)
except RedditAccessBlockedError as exc:
# Anonymous-only scraper; a hard block can't be retried with creds.
# Mirror google_maps' SignInRequiredError -> ForbiddenError mapping.
raise ForbiddenError(
f"Reddit refused anonymous access: {exc}",
code="REDDIT_ACCESS_BLOCKED",
) from exc
return ScrapeOutput(items=items)
return execute

View file

@ -0,0 +1,102 @@
"""``reddit.scrape`` I/O contracts.
A lean, agent-friendly surface over ``RedditScrapeInput``
(``app/proprietary/platforms/reddit``). The executor maps this to the full
scraper input; the scraper's ``RedditItem`` is reused verbatim as the output
element.
"""
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
from app.proprietary.platforms.reddit import RedditItem
from app.proprietary.platforms.reddit.schemas import RedditSort, RedditTime
MAX_REDDIT_SOURCES = 20
"""Per-call cap on urls + search_queries: bounds a synchronous request's fan-out."""
MAX_REDDIT_ITEMS = 100
"""Hard ceiling on items returned per call, regardless of the per-target caps."""
class ScrapeInput(BaseModel):
urls: list[str] = Field(
default_factory=list,
max_length=MAX_REDDIT_SOURCES,
description=(
"Reddit URLs to scrape: a post, a subreddit (/r/<name>), a user "
"(/user/<name>), or a search URL. Provide these OR search_queries/"
"community (at least one source is required)."
),
)
search_queries: list[str] = Field(
default_factory=list,
max_length=MAX_REDDIT_SOURCES,
description=(
"Search terms to run on Reddit; each returns up to max_items results. "
"Scope to one subreddit with community."
),
)
community: str | None = Field(
default=None,
description=(
"Subreddit name (without 'r/') to scope search_queries to, e.g. "
"'python'. With no search_queries, its listing is scraped."
),
)
sort: RedditSort = Field(
default="new",
description="Result ordering: relevance, hot, top, new, rising, or comments.",
)
time_filter: RedditTime | None = Field(
default=None,
description="Time window for 'top'/'controversial' sorts: hour, day, week, month, year, all.",
)
include_nsfw: bool = Field(
default=True,
description="Include posts flagged over-18 (NSFW) in the results.",
)
skip_comments: bool = Field(
default=False,
description="Skip fetching comment trees (faster; posts/listings only).",
)
max_items: int = Field(
default=10,
ge=1,
le=MAX_REDDIT_ITEMS,
description="Max total items to return across all sources.",
)
max_posts: int = Field(
default=10,
ge=0,
description="Max posts to pull per subreddit/user/search target.",
)
max_comments: int = Field(
default=10,
ge=0,
description="Max comments to pull per post (0 = none).",
)
post_date_limit: str | None = Field(
default=None,
description="ISO date; only return posts newer than this (incremental scrape).",
)
comment_date_limit: str | None = Field(
default=None,
description="ISO date; only return comments newer than this (incremental scrape).",
)
@model_validator(mode="after")
def _require_a_source(self) -> ScrapeInput:
if not self.urls and not self.search_queries and not self.community:
raise ValueError(
"Provide at least one of 'urls', 'search_queries', or 'community'."
)
return self
class ScrapeOutput(BaseModel):
items: list[RedditItem] = Field(
default_factory=list,
description="One item per result (post/comment/community/user), in emission order.",
)