feat(instagram): add details capability verb

This commit is contained in:
Anish Sarkar 2026-07-09 16:01:38 +05:30
parent 38a26b1ccf
commit 7ea474be93
4 changed files with 162 additions and 0 deletions

View file

@ -0,0 +1,3 @@
"""``instagram.details`` verb: profile/hashtag/place URLs or search → metadata."""
from __future__ import annotations

View file

@ -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)

View file

@ -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

View file

@ -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)