mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
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:
parent
701f888b9e
commit
ff2e5f390f
35 changed files with 510 additions and 15 deletions
5
surfsense_backend/app/capabilities/reddit/__init__.py
Normal file
5
surfsense_backend/app/capabilities/reddit/__init__.py
Normal 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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""``reddit.scrape`` verb: Reddit URLs / search terms → posts, comments, users."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -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)
|
||||
49
surfsense_backend/app/capabilities/reddit/scrape/executor.py
Normal file
49
surfsense_backend/app/capabilities/reddit/scrape/executor.py
Normal 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
|
||||
102
surfsense_backend/app/capabilities/reddit/scrape/schemas.py
Normal file
102
surfsense_backend/app/capabilities/reddit/scrape/schemas.py
Normal 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.",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue