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
|
|
@ -0,0 +1,96 @@
|
|||
"""``reddit.scrape`` executor: verb input → actor input mapping → typed items.
|
||||
|
||||
Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
|
||||
own payload→RedditScrapeInput mapping and the dict→RedditItem wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities.reddit.scrape.executor import build_scrape_executor
|
||||
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||
from app.exceptions import ForbiddenError
|
||||
from app.proprietary.platforms.reddit import RedditAccessBlockedError, RedditScrapeInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeScraper:
|
||||
"""Records the actor input + limit it was called with; returns canned items."""
|
||||
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
self.calls: list[tuple[RedditScrapeInput, int | None]] = []
|
||||
|
||||
async def __call__(
|
||||
self, actor_input: RedditScrapeInput, *, limit: int | None = None
|
||||
) -> list[dict]:
|
||||
self.calls.append((actor_input, limit))
|
||||
return self._items
|
||||
|
||||
|
||||
async def test_maps_urls_to_start_urls_and_wraps_items():
|
||||
scraper = _FakeScraper([{"dataType": "post", "id": "abc", "title": "Hello"}])
|
||||
execute = build_scrape_executor(scrape_fn=scraper)
|
||||
|
||||
out = await execute(ScrapeInput(urls=["https://www.reddit.com/r/python/"]))
|
||||
|
||||
assert isinstance(out, ScrapeOutput)
|
||||
assert len(out.items) == 1
|
||||
assert out.items[0].id == "abc"
|
||||
assert out.items[0].title == "Hello"
|
||||
assert out.items[0].dataType == "post"
|
||||
|
||||
(actor_input, _limit) = scraper.calls[0]
|
||||
assert [u.url for u in actor_input.startUrls] == ["https://www.reddit.com/r/python/"]
|
||||
assert actor_input.searches == []
|
||||
|
||||
|
||||
async def test_forwards_search_queries_and_community():
|
||||
scraper = _FakeScraper([])
|
||||
execute = build_scrape_executor(scrape_fn=scraper)
|
||||
|
||||
await execute(ScrapeInput(search_queries=["a", "b"], community="python"))
|
||||
|
||||
(actor_input, _limit) = scraper.calls[0]
|
||||
assert actor_input.searches == ["a", "b"]
|
||||
assert actor_input.searchCommunityName == "python"
|
||||
assert actor_input.startUrls == []
|
||||
|
||||
|
||||
async def test_maps_caps_and_passes_limit():
|
||||
scraper = _FakeScraper([])
|
||||
execute = build_scrape_executor(scrape_fn=scraper)
|
||||
|
||||
await execute(
|
||||
ScrapeInput(
|
||||
search_queries=["x"],
|
||||
max_items=25,
|
||||
max_posts=7,
|
||||
max_comments=3,
|
||||
skip_comments=True,
|
||||
sort="top",
|
||||
time_filter="week",
|
||||
)
|
||||
)
|
||||
|
||||
(actor_input, limit) = scraper.calls[0]
|
||||
assert actor_input.maxItems == 25
|
||||
assert actor_input.maxPostCount == 7
|
||||
assert actor_input.maxComments == 3
|
||||
assert actor_input.skipComments is True
|
||||
assert actor_input.sort == "top"
|
||||
assert actor_input.time == "week"
|
||||
# The outer collection limit is the caller's total-item cap.
|
||||
assert limit == 25
|
||||
|
||||
|
||||
async def test_access_blocked_maps_to_forbidden():
|
||||
async def _blocked(actor_input: RedditScrapeInput, *, limit: int | None = None):
|
||||
raise RedditAccessBlockedError("all IPs refused")
|
||||
|
||||
execute = build_scrape_executor(scrape_fn=_blocked)
|
||||
|
||||
with pytest.raises(ForbiddenError):
|
||||
await execute(ScrapeInput(search_queries=["x"]))
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""``reddit.scrape`` input guards: a source is required and the batch is bounded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.capabilities.reddit.scrape.schemas import (
|
||||
MAX_REDDIT_ITEMS,
|
||||
MAX_REDDIT_SOURCES,
|
||||
ScrapeInput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_rejects_input_with_no_source():
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput()
|
||||
|
||||
|
||||
def test_accepts_urls_only():
|
||||
payload = ScrapeInput(urls=["https://www.reddit.com/r/python/"])
|
||||
assert payload.search_queries == []
|
||||
|
||||
|
||||
def test_accepts_search_queries_only():
|
||||
payload = ScrapeInput(search_queries=["notebooklm alternative"])
|
||||
assert payload.urls == []
|
||||
|
||||
|
||||
def test_accepts_community_only():
|
||||
payload = ScrapeInput(community="python")
|
||||
assert payload.community == "python"
|
||||
|
||||
|
||||
def test_defaults_and_bounds():
|
||||
payload = ScrapeInput(search_queries=["x"])
|
||||
assert payload.max_items == 10
|
||||
assert payload.sort == "new"
|
||||
assert payload.include_nsfw is True
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(search_queries=["x"], max_items=0)
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(search_queries=["x"], max_items=MAX_REDDIT_ITEMS + 1)
|
||||
|
||||
|
||||
def test_rejects_more_sources_than_the_cap():
|
||||
too_many = [f"https://redd.it/{i}" for i in range(MAX_REDDIT_SOURCES + 1)]
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(urls=too_many)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"""The reddit namespace registers its verb as one Capability the doors/agent read."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities import (
|
||||
reddit, # noqa: F401 — importing the namespace registers its verbs
|
||||
)
|
||||
from app.capabilities.core.store import get_capability
|
||||
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_reddit_scrape_is_registered_and_free():
|
||||
cap = get_capability("reddit.scrape")
|
||||
|
||||
assert cap.name == "reddit.scrape"
|
||||
assert cap.input_schema is ScrapeInput
|
||||
assert cap.output_schema is ScrapeOutput
|
||||
assert cap.billing_unit is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue