diff --git a/surfsense_backend/app/capabilities/instagram/details/__init__.py b/surfsense_backend/app/capabilities/instagram/details/__init__.py new file mode 100644 index 000000000..8e4c85258 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/details/__init__.py @@ -0,0 +1,3 @@ +"""``instagram.details`` verb: profile/hashtag/place URLs or search → metadata.""" + +from __future__ import annotations diff --git a/surfsense_backend/app/capabilities/instagram/details/definition.py b/surfsense_backend/app/capabilities/instagram/details/definition.py new file mode 100644 index 000000000..fa7f912d8 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/details/definition.py @@ -0,0 +1,23 @@ +"""``instagram.details`` capability registration (billed per item; see config +``INSTAGRAM_SCRAPE_MICROS_PER_ITEM``).""" + +from __future__ import annotations + +from app.capabilities.core import BillingUnit, Capability, register_capability +from app.capabilities.instagram.details.executor import build_details_executor +from app.capabilities.instagram.details.schemas import DetailsInput, DetailsOutput + +INSTAGRAM_DETAILS = Capability( + name="instagram.details", + description=( + "Fetch Instagram profile, hashtag, or place metadata by URL or discovery " + "search. Each item carries a detailKind discriminator." + ), + input_schema=DetailsInput, + output_schema=DetailsOutput, + executor=build_details_executor(), + billing_unit=BillingUnit.INSTAGRAM_ITEM, + docs_url="/docs/connectors/native/instagram", +) + +register_capability(INSTAGRAM_DETAILS) diff --git a/surfsense_backend/app/capabilities/instagram/details/executor.py b/surfsense_backend/app/capabilities/instagram/details/executor.py new file mode 100644 index 000000000..f9a152f50 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/details/executor.py @@ -0,0 +1,50 @@ +"""``instagram.details`` executor: verb input → scraper → detail 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.details.schemas import DetailsInput, DetailsOutput +from app.exceptions import ForbiddenError +from app.proprietary.platforms.instagram import ( + InstagramAccessBlockedError, + InstagramScrapeInput, + scrape_instagram, +) + +ScrapeFn = Callable[..., Awaitable[list[dict]]] + + +def build_details_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: DetailsInput) -> DetailsOutput: + actor_input = InstagramScrapeInput( + resultsType="details", + directUrls=payload.urls, + search=",".join(payload.search_queries), + searchType=payload.search_type, + searchLimit=payload.search_limit, + ) + emit_progress( + "starting", + "Resolving Instagram detail targets", + total=payload.max_items, + unit="item", + ) + 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)} item(s)", current=len(items), unit="item" + ) + return DetailsOutput(items=items) + + return execute diff --git a/surfsense_backend/app/capabilities/instagram/details/schemas.py b/surfsense_backend/app/capabilities/instagram/details/schemas.py new file mode 100644 index 000000000..31972b095 --- /dev/null +++ b/surfsense_backend/app/capabilities/instagram/details/schemas.py @@ -0,0 +1,86 @@ +"""``instagram.details`` I/O contracts. + +A lean surface over ``InstagramScrapeInput`` (``resultsType="details"``). Each +output item is a profile / hashtag / place, discriminated by the synthesized +``detailKind`` field (a SurfSense addition; every other field mirrors the actor). +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, model_validator + +from app.capabilities.instagram.scrape.schemas import ( + MAX_INSTAGRAM_ITEMS, + MAX_INSTAGRAM_SOURCES, +) +from app.proprietary.platforms.instagram import ( + InstagramHashtag, + InstagramPlace, + InstagramProfile, +) + +InstagramDetailItem = Annotated[ + InstagramProfile | InstagramHashtag | InstagramPlace, + Field(discriminator="detailKind"), +] + + +class DetailsInput(BaseModel): + urls: list[str] = Field( + default_factory=list, + max_length=MAX_INSTAGRAM_SOURCES, + description=( + "Profile / hashtag / place URLs (or bare profile IDs). The URL type " + "determines the detail kind. Provide these OR search_queries." + ), + ) + search_queries: list[str] = Field( + default_factory=list, + max_length=MAX_INSTAGRAM_SOURCES, + description="Discovery keywords. Provide these OR urls (never both).", + ) + search_type: Literal["hashtag", "profile", "place"] = Field( + default="hashtag", + description="What to discover from search_queries (no 'user' — use instagram.scrape).", + ) + search_limit: int = Field( + default=10, + ge=1, + le=MAX_INSTAGRAM_ITEMS, + description="Max discovered entities per query.", + ) + max_items: int = Field( + default=10, + ge=1, + le=MAX_INSTAGRAM_ITEMS, + description="Max total detail items to return.", + ) + + @model_validator(mode="after") + def _exactly_one_source(self) -> DetailsInput: + if not self.urls and not self.search_queries: + raise ValueError( + "Provide at least one of 'urls' or 'search_queries'." + ) + if self.urls and self.search_queries: + raise ValueError( + "Provide 'urls' OR 'search_queries', not both (they cannot be combined)." + ) + return self + + @property + def estimated_units(self) -> int: + return self.max_items + + +class DetailsOutput(BaseModel): + items: list[InstagramDetailItem] = Field( + default_factory=list, + description="One item per profile/hashtag/place, keyed by detailKind.", + ) + + @property + def billable_units(self) -> int: + return len(self.items)