From 38a26b1ccf0fad75615e25946517d756a1ec3145 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:58:58 +0530 Subject: [PATCH] feat(instagram): add comments capability verb --- .../instagram/comments/__init__.py | 3 + .../instagram/comments/definition.py | 22 +++++++ .../instagram/comments/executor.py | 53 ++++++++++++++++ .../instagram/comments/schemas.py | 61 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 surfsense_backend/app/capabilities/instagram/comments/__init__.py create mode 100644 surfsense_backend/app/capabilities/instagram/comments/definition.py create mode 100644 surfsense_backend/app/capabilities/instagram/comments/executor.py create mode 100644 surfsense_backend/app/capabilities/instagram/comments/schemas.py diff --git a/surfsense_backend/app/capabilities/instagram/comments/__init__.py b/surfsense_backend/app/capabilities/instagram/comments/__init__.py new file mode 100644 index 000000000..84899d758 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/comments/__init__.py @@ -0,0 +1,3 @@ +"""``instagram.comments`` verb: post/reel URLs → comments (and replies).""" + +from __future__ import annotations diff --git a/surfsense_backend/app/capabilities/instagram/comments/definition.py b/surfsense_backend/app/capabilities/instagram/comments/definition.py new file mode 100644 index 000000000..7794228ac --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/comments/definition.py @@ -0,0 +1,22 @@ +"""``instagram.comments`` capability registration (billed per comment; see config +``INSTAGRAM_SCRAPE_MICROS_PER_COMMENT``).""" + +from __future__ import annotations + +from app.capabilities.core import BillingUnit, Capability, register_capability +from app.capabilities.instagram.comments.executor import build_comments_executor +from app.capabilities.instagram.comments.schemas import CommentsInput, CommentsOutput + +INSTAGRAM_COMMENTS = Capability( + name="instagram.comments", + description=( + "Fetch comments (and optionally replies) for Instagram post or reel URLs." + ), + input_schema=CommentsInput, + output_schema=CommentsOutput, + executor=build_comments_executor(), + billing_unit=BillingUnit.INSTAGRAM_COMMENT, + docs_url="/docs/connectors/native/instagram", +) + +register_capability(INSTAGRAM_COMMENTS) diff --git a/surfsense_backend/app/capabilities/instagram/comments/executor.py b/surfsense_backend/app/capabilities/instagram/comments/executor.py new file mode 100644 index 000000000..260ee06c9 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/comments/executor.py @@ -0,0 +1,53 @@ +"""``instagram.comments`` executor: verb input → scraper → comment items.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from app.capabilities.core import Executor +from app.capabilities.core.progress import emit_progress +from app.capabilities.instagram.comments.schemas import CommentsInput, CommentsOutput +from app.exceptions import ForbiddenError +from app.proprietary.platforms.instagram import ( + InstagramAccessBlockedError, + InstagramScrapeInput, + scrape_instagram, +) + +ScrapeFn = Callable[..., Awaitable[list[dict]]] + + +def build_comments_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_instagram + + async def execute(payload: CommentsInput) -> CommentsOutput: + actor_input = InstagramScrapeInput( + resultsType="comments", + directUrls=payload.urls, + resultsLimit=payload.max_comments_per_post, + isNewestComments=payload.newest_first, + includeNestedComments=payload.include_replies, + ) + emit_progress( + "starting", + "Fetching Instagram comments", + total=payload.max_items, + unit="comment", + ) + try: + items = await scrape_fn(actor_input, limit=payload.max_items) + except InstagramAccessBlockedError as exc: + raise ForbiddenError( + f"Instagram refused anonymous access: {exc}", + code="INSTAGRAM_ACCESS_BLOCKED", + ) from exc + emit_progress( + "done", + f"Scraped {len(items)} comment(s)", + current=len(items), + unit="comment", + ) + return CommentsOutput(items=items) + + return execute diff --git a/surfsense_backend/app/capabilities/instagram/comments/schemas.py b/surfsense_backend/app/capabilities/instagram/comments/schemas.py new file mode 100644 index 000000000..fadb83215 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/comments/schemas.py @@ -0,0 +1,61 @@ +"""``instagram.comments`` I/O contracts. + +A lean surface over ``InstagramScrapeInput`` (``resultsType="comments"``). The +scraper's ``InstagramComment`` is reused verbatim as the output element. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.capabilities.instagram.scrape.schemas import ( + MAX_INSTAGRAM_ITEMS, + MAX_INSTAGRAM_SOURCES, +) +from app.proprietary.platforms.instagram import InstagramComment + +MAX_COMMENTS_PER_POST = 50 +"""Anonymous web media pages surface at most ~50 comments per post.""" + + +class CommentsInput(BaseModel): + urls: list[str] = Field( + min_length=1, + max_length=MAX_INSTAGRAM_SOURCES, + description="Post or reel URLs to fetch comments for (shortCode or numeric-ID forms).", + ) + newest_first: bool = Field( + default=False, + description="Return newest comments first.", + ) + include_replies: bool = Field( + default=False, + description="Include nested replies; each reply is a separate billable item.", + ) + max_comments_per_post: int = Field( + default=10, + ge=1, + le=MAX_COMMENTS_PER_POST, + description="Max comments per post (Instagram caps at 50).", + ) + max_items: int = Field( + default=10, + ge=1, + le=MAX_INSTAGRAM_ITEMS, + description="Max total comments to return across all posts.", + ) + + @property + def estimated_units(self) -> int: + return self.max_items + + +class CommentsOutput(BaseModel): + items: list[InstagramComment] = Field( + default_factory=list, + description="One item per comment (or reply), in emission order.", + ) + + @property + def billable_units(self) -> int: + return len(self.items)