Metadata-Version: 2.4
Name: brightbean
Version: 1.0.0rc4
Summary: Official Python SDK for the BrightBean API — YouTube packaging scoring.
Project-URL: Homepage, https://brightbean.xyz
Project-URL: Documentation, https://api.brightbean.xyz/docs
Project-URL: Source, https://github.com/brightbean/brightbean
Project-URL: Changelog, https://github.com/brightbean/brightbean/releases
Author-email: BrightBean <support@brightbean.xyz>
License: MIT
Keywords: brightbean,ctr,scoring,thumbnail,title,youtube
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: attrs>=22.2
Requires-Dist: httpx<1,>=0.23
Requires-Dist: python-dateutil>=2.8
Requires-Dist: tenacity<10,>=8.2
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: openapi-python-client>=0.28; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# brightbean — Python SDK

Official Python SDK for the [BrightBean API](https://brightbean.xyz). YouTube
creator analytics: score titles and thumbnails for click-through-rate, analyze
video hooks, benchmark channels and videos against their niche, and surface
ranked content opportunities.

## Install

```bash
pip install brightbean
```

Requires Python 3.10+.

## Quickstart

```python
from brightbean import BrightBean

with BrightBean(api_key="bb_...") as client:
    result = client.score(
        title="10 Tips to Boost Your Coding Productivity",
        thumbnail_url="https://i.ytimg.com/vi/abc123/maxresdefault.jpg",
    )
    print(f"score={result.score:.2f} percentile={result.percentile}")
```

## Async

```python
import asyncio
from brightbean import AsyncBrightBean

async def main():
    async with AsyncBrightBean(api_key="bb_...") as client:
        return await client.score(title="My epic video")

asyncio.run(main())
```

Every method documented below has the same signature on `AsyncBrightBean`; just
`await` the call.

---

## API reference

Response objects are typed dataclasses. Field names match the wire format
(snake_case). Nullable fields are explicitly annotated `… | None`.

### `client.score(...)` — packaging CTR

Score a YouTube packaging (title and/or thumbnail) for predicted click-through
rate. The server auto-detects the mode from your inputs and charges credits
accordingly.

```python
result = client.score(
    title="10 Tips to Boost Your Coding Productivity",
    thumbnail_url="https://i.ytimg.com/vi/abc123/maxresdefault.jpg",
)
print(result.score, result.percentile, result.niche_label)
```

**Parameters**

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `title` | `str \| None` | conditional* | `None` | Max 250 chars. |
| `thumbnail_url` | `str \| None` | conditional* | `None` | Public URL. Max 1024 chars. Mutually exclusive with `thumbnail_base64`. |
| `thumbnail_base64` | `str \| None` | conditional* | `None` | Base64-encoded image. Max 12.5 MB encoded. Mutually exclusive with `thumbnail_url`. |
| `channel_url` | `str \| None` | no | `None` | Reserved — currently accepted but not consumed by the model. |

\* At least one of `title`, `thumbnail_url`, or `thumbnail_base64` is required.

**Returns** `PackagingScoreResponse`

| Field | Type | Description |
|---|---|---|
| `score_id` | `UUID` | Opaque identifier for this scoring call. |
| `score` | `float` (0–1) | Calibrated CTR percentile rank. |
| `percentile` | `int` (0–100) | `round(score * 100)`. |
| `raw_score` | `float \| None` | Continuous pre-calibration sigmoid output. Useful for fine-grained comparisons that the percentile-ranked `score` would round into the same plateau. |
| `mode` | `"title" \| "thumbnail" \| "combined"` | Server-detected scoring mode. |
| `niche_slug` | `str` | Canonical niche slug. Same taxonomy as `/v1/research/*` and `/v1/benchmark/*`. |
| `niche_label` | `str` | Human-readable niche name. |
| `niche_confidence` | `float` | Confidence (cosine similarity) of the niche assignment. |

**Cost**: 1 credit for title-only, 2 credits for thumbnail-only, 3 credits for
combined (the combined mode captures interaction effects the single-input modes
miss).

---

### `client.video_hook(...)` — hook quality of the first ~6 s

Score the first ~6 seconds of a YouTube video for hook quality. Returns a
structured analysis with archetype classification, five dimension scores, and
actionable strengths / weaknesses / suggestions.

```python
result = client.video_hook(
    youtube_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
)
print(result.primary_archetype, result.overall_score)
print(result.scores.clarity, result.scores.tension)
```

**Parameters**

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `youtube_url` | `str` | yes | — | Public YouTube URL, max 1024 chars. Accepts `youtube.com/watch?v=…`, `youtu.be/…`, `youtube.com/shorts/…`, `youtube.com/embed/…`. |

**Returns** `VideoHookScoreResponse`

| Field | Type | Description |
|---|---|---|
| `score_id` | `UUID` | Opaque identifier for this scoring call. |
| `primary_archetype` | `str` | One of 13 archetypes (`curiosity_gap`, `direct_question`, `bold_claim`, `in_medias_res`, `stakes_statement`, `promise`, `pattern_interrupt`, `authority`, `demonstration`, `empathy`, `negative_framing`, `cold_open`, `greetings`). |
| `secondary_archetype` | `str \| None` | Optional second archetype if the hook blends two. |
| `scores` | `Scores` (nested) | Five 0–10 dimension scores. See below. |
| `overall_score` | `int` (0–100) | Holistic hook-quality score. |
| `transcript` | `str` | First-6-seconds transcript. May be blank for music-only or visual-only openings. |
| `visual_summary` | `str` | Short description of the visual content. |
| `strengths` | `list[str]` *(2–4 items)* | What the hook does well. |
| `weaknesses` | `list[str]` *(2–4 items)* | What the hook does poorly. |
| `suggestions` | `list[str]` *(2–4 items)* | Concrete edits the creator could make. |
| `delta_vs_niche_top` | `int` | Difference between this hook's `overall_score` and the niche's top performers. |
| `key_differences_vs_top` | `list[str]` *(1–3 items)* | Specific ways this hook differs from niche-top hooks. |

Nested `scores`:

| Field | Type | Description |
|---|---|---|
| `clarity` | `int` (0–10) | How quickly the viewer understands the topic. |
| `specificity` | `int` (0–10) | How concrete the promise is. |
| `tension` | `int` (0–10) | Stakes / curiosity gap. |
| `visual_energy` | `int` (0–10) | Movement, cuts, visual contrast. |
| `pace` | `int` (0–10) | Words / cuts per second. |

**Cost**: 10 credits per call.

---

### `client.benchmark_channel(...)` — channel vs. niche

Benchmark a YouTube channel against its niche distribution. Fetches the
channel's recent videos, classifies the niche, and percentile-ranks engagement
and title patterns.

```python
result = client.benchmark_channel(
    url="https://www.youtube.com/@MarquesBrownlee",
)
print(result.niche.slug, result.niche.match_strength)
print(result.channel.engagement_percentiles.overall)
```

**Parameters**

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `url` | `str` | yes | — | Channel URL or `@handle`, max 1024 chars. Accepts `youtube.com/@handle`, `/channel/UC…`, `/c/<name>`, `/user/<legacy>`. |

**Returns** `BenchmarkChannelResponse` — two top-level objects, `channel` and `niche`.

#### `channel`

| Field | Type | Description |
|---|---|---|
| `channel_id` | `str` | YouTube channel ID (`UC…`). |
| `title` | `str` | Channel display name. |
| `subscriber_count` | `int` | Public subscriber count at fetch time. |
| `video_count` | `int` | Total public videos. |
| `engagement` | object | Raw ratios. `view_to_sub_ratio`, `like_to_view_ratio`, `comment_to_view_ratio` — each `float \| None`. |
| `engagement_percentiles` | object | The same three ratios plus `overall`, each ranked into `int` (0–100) `\| None` against the niche distribution. |
| `sample_window_days` | `int` | Date span (in days) of the fetched sample, so callers can weight the signal against the channel's posting cadence. |
| `title_patterns` | object | Aggregate stats: `mean_length_chars`, `mean_length_words`, `share_with_question_mark`, `share_with_number`, `median_uppercase_ratio`, `share_with_emoji`. Each `float \| None` (or `int \| None` for the length fields). |

#### `niche`

| Field | Type | Description |
|---|---|---|
| `slug` | `str` | Canonical niche slug. |
| `name` | `str` | Human-readable niche name. |
| `match_score` | `float` | Cosine similarity of the channel to the niche centroid. |
| `match_strength` | `"strong" \| "moderate" \| "weak"` | Categorized confidence. |
| `engagement` | object | Three ratio-keyed distribution objects (`view_to_sub_ratio`, `like_to_view_ratio`, `comment_to_view_ratio`). Each carries `p10`, `p25`, `p50`, `p75`, `p90`, `p95` — `float \| None`. |
| `title_patterns` | object | Same fields as `channel.title_patterns` plus `common_niche_phrases`: list of `{phrase: str, frequency: float \| None, used_by_channel: bool}`. |
| `exemplar_channels` | `list` (up to 5) | `{title: str \| None, subscriber_count: int \| None}`. |

**Cost**: 5 credits per call.

---

### `client.benchmark_video(...)` — single video vs. niche

Benchmark one YouTube video against its niche distribution.

```python
result = client.benchmark_video(
    url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
)
print(result.video.engagement_percentiles.overall)
print(result.video.title_patterns.fits_niche_patterns)
```

**Parameters**

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `url` | `str` | yes | — | YouTube video URL, max 1024 chars. Accepts `youtube.com/watch?v=…`, `youtu.be/…`, `youtube.com/shorts/…`, `youtube.com/embed/…`. |

**Returns** `BenchmarkVideoResponse` — two top-level objects, `video` and `niche`.

#### `video`

| Field | Type | Description |
|---|---|---|
| `video_id` | `str` | 11-character YouTube video ID. |
| `title` | `str` | Video title. May be blank. |
| `channel_title` | `str` | Owning channel's display name. May be blank. |
| `published_at` | `datetime` | UTC publish timestamp. |
| `view_count` | `int` | Public view count at fetch time. |
| `like_count` | `int \| None` | `None` when likes are hidden on the video. |
| `comment_count` | `int \| None` | `None` when comments are disabled. |
| `engagement` | object | `view_to_sub_ratio`, `like_to_view_ratio`, `comment_to_view_ratio` — each `float \| None`. |
| `engagement_percentiles` | object | Same three ratios plus `overall`, each `int` (0–100) `\| None`. |
| `title_patterns` | object | `length_chars` (`int \| None`), `has_question` (`bool`), `has_number` (`bool`), `has_emoji` (`bool`), `fits_niche_patterns` (`bool`). |

#### `niche`

Same shape as `benchmark_channel`'s `niche` — `slug`, `name`, `match_score`,
`match_strength`, ratio-keyed `engagement` distributions, `title_patterns` with
`common_niche_phrases`. The `used_by_channel` flag on each common phrase here
means "did this single video's title use the phrase"; the serializer field name
stays the same across both benchmark endpoints.

**Cost**: 3 credits per call.

---

### `client.niches()` — list catalogued niches

List every catalogued YouTube niche for the active research run. Use the
`slug` values as the `niche` argument to `client.content_gaps(...)`.

```python
result = client.niches()
for n in result.niches:
    print(n.slug, n.gap_count)
```

**Parameters**: none.

**Returns** `NicheListResponse`

| Field | Type | Description |
|---|---|---|
| `niches` | `list[NicheSummary]` | Flat array of catalogued niches. |

Each `NicheSummary`:

| Field | Type | Description |
|---|---|---|
| `slug` | `str` | Canonical niche slug. |
| `name` | `str` | Human-readable name. |
| `gap_count` | `int` | Number of content gaps catalogued for that niche in the active run, for the default gap types. `0` means the niche exists in the taxonomy but no gaps have been ranked yet. |

**Cost**: 1 credit per call.

---

### `client.content_gaps(...)` — ranked content opportunities

Return ranked content opportunities (gaps) for a YouTube niche.

```python
result = client.content_gaps(
    niche="python_programming_tutorials",
    gap_type=["underserved", "stale"],   # or just "underserved"
    limit=10,
    min_score=60,
)
for gap in result.gaps:
    print(gap.opportunity_score, gap.canonical_title)
```

**Parameters**

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `niche` | `str` | yes | — | Slug from `client.niches()`, max 100 chars. |
| `gap_type` | `str \| list[str] \| None` | no | `None` (server defaults to `["underserved", "stale"]`) | One of `"underserved"`, `"stale"`, `"competitive"`, or a list of those. `"competitive"` is opt-in (omit from the list to exclude it). |
| `limit` | `int \| None` | no | `None` (server default: 20) | 1–50. |
| `min_score` | `int \| None` | no | `None` (server default: 0) | Minimum `opportunity_score` (0–100) to include. |

**Returns** `ContentGapsResponse`

| Field | Type | Description |
|---|---|---|
| `niche` | object | `{slug: str, name: str}`. |
| `gaps` | `list[ContentGapsItem]` | Ranked gaps (see below). |

Each gap (`ContentGapsItem`):

| Field | Type | Description |
|---|---|---|
| `canonical_title` | `str` | Suggested canonical title for the gap. |
| `opportunity_score` | `int` (0–100) | Composite ranking score. |
| `gap_type` | `"underserved" \| "stale" \| "competitive"` | Which category the gap falls into. |
| `components` | object | `{demand: float, supply: float, recency: float}` — the three signals that make up `opportunity_score`. |
| `explanation` | `str` | Short rationale for why this gap exists. May be blank. |
| `suggested_angles` | `list[str]` | Concrete video angles the creator could take. |
| `evidence` | object | Supporting signals. See below. |

Nested `evidence`:

| Field | Type | Description |
|---|---|---|
| `newest_quality_video_age_days` | `int \| None` | Age (in days) of the newest high-quality video in this gap; `None` if no qualifying video exists. |
| `trends_appearance_count` | `int` | How many times this topic appeared in trends signals. |
| `autocomplete_rank` | `int \| None` | YouTube autocomplete rank, if observed. |
| `residual_outlier_count` | `int` | Number of outlier signals contributing to the score. |
| `top_competitors` | `list` (up to 5) | `{title: str, channel_title: str, subscriber_count: int \| None, view_count: int, age_days: int, published_at: datetime}`. |
| `related_queries` | `list[str]` (up to 5) | Adjacent search queries. |

**Cost**: 5 credits per call.

---

### `client.me()` — account info

Return account info and plan details for the authenticated API key. Free to call.

```python
me = client.me()
print(me.email, me.plan.name, me.credits_remaining)
```

**Parameters**: none.

**Returns** `MeResponse`

| Field | Type | Description |
|---|---|---|
| `email` | `str` | Account email. |
| `full_name` | `str` | May be blank if the user hasn't set it. |
| `plan` | `Plan` (nested) | `{name: str, slug: str, monthly_credits: int, rate_limit_per_minute: int}`. |
| `credits_remaining` | `int` | Credits left in the current billing period. |
| `period_end` | `datetime` | When the current billing period ends and credits reset. |

**Cost**: free (no credits deducted).

---

### `client.usage()` — credit balance + recent activity

Return the credit balance and recent API activity for the authenticated API
key. Free to call.

```python
usage = client.usage()
print(usage.credits_remaining, "/", usage.credits_total)
for entry in usage.recent_activity:
    print(entry.created_at, entry.endpoint, entry.delta)
```

**Parameters**: none.

**Returns** `UsageResponse`

| Field | Type | Description |
|---|---|---|
| `credits_remaining` | `int` | Credits left in the current billing period. |
| `credits_total` | `int` | Total credits for the current plan (resets at `period_end`). |
| `period_end` | `datetime` | When the current billing period ends. |
| `recent_activity` | `list[CreditLedgerEntry]` | Up to 20 most-recent credit-ledger entries. |

Each `CreditLedgerEntry`:

| Field | Type | Description |
|---|---|---|
| `delta` | `int` | Signed change. Negative for API charges. |
| `balance_after` | `int` | Credit balance immediately after this entry. |
| `reason` | `str` | Free-form reason (e.g. `"api_call"`, `"monthly_reset"`). |
| `endpoint` | `str` | API endpoint path that triggered the entry. May be blank for non-API entries. |
| `created_at` | `datetime` | UTC timestamp. |

**Cost**: free (no credits deducted).

---

## Errors

The SDK raises typed exceptions for each documented status code:

| Status | Exception | Notes |
|---|---|---|
| 400 | `BadRequestError` | Validation failed. |
| 401 | `AuthenticationError` | Missing, invalid, or expired API key. |
| 402 | `InsufficientCreditsError` | Account is out of credits. |
| 429 | `RateLimitError` | Too many requests. `retry_after` (int seconds). |
| 503 | `ServiceUnavailableError` | Scoring service down. `retry_after` (int seconds). |
| 5xx | `ServerError` | Other server-side failure. |
| network | `NetworkError` | DNS, connection refused, TLS, timeout. |

All API errors inherit `BrightBeanAPIError`. Network failures raise
`NetworkError`. Both inherit `BrightBeanError` for catch-all handling.

## Retries

Idempotent GETs (`me()`, `usage()`) retry automatically on 5xx and network
errors with exponential backoff + jitter. Tune via `max_retries`:

```python
client = BrightBean(api_key="bb_...", max_retries=5)
```

POST endpoints and paid GETs are **never** auto-retried because the server may
have charged credits before the network response was lost. Catch
`RateLimitError` or `ServiceUnavailableError` and back off using `retry_after`
if you need retry semantics.

## Configuration

```python
BrightBean(
    api_key="bb_...",                   # required
    base_url="https://api.brightbean.xyz",
    timeout=30.0,                       # seconds, or httpx.Timeout(...)
    max_retries=3,                      # idempotent GETs only
    user_agent=None,                    # override the default UA string
)
```

`base_url` defaults to `https://api.brightbean.xyz`. Override for staging or
local testing.

## Development

```bash
pip install -e .[dev]
openapi-python-client generate \
    --path ../../openapi.yaml \
    --config generator.yaml \
    --meta=none \
    --output-path ./brightbean/_generated \
    --overwrite
pytest && ruff check . && mypy brightbean
```

The `brightbean/_generated/` directory is regenerated from the upstream
OpenAPI spec. The hand-written facade (`brightbean/_client.py`,
`brightbean/errors.py`, `brightbean/__init__.py`) wraps it to give the
ergonomic `BrightBean` import shape.
