Metadata-Version: 2.4
Name: agentberlin
Version: 0.165.0
Summary: Python SDK for Agent Berlin - AI-powered SEO and AEO automation
Project-URL: Homepage, https://agentberlin.ai
Project-URL: Documentation, https://docs.agentberlin.ai/sdk/python
Project-URL: Repository, https://github.com/boat-builder/agentberlin
Author-email: Agent Berlin <support@agentberlin.ai>
License: MIT
License-File: LICENSE
Keywords: aeo,ai,analytics,optimization,search,seo
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.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: click>=8.0.0
Requires-Dist: mypy>=1.8.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: requests>=2.28.0
Provides-Extra: dev
Requires-Dist: black>=24.0.0; extra == 'dev'
Requires-Dist: isort>=5.13.0; extra == 'dev'
Requires-Dist: mypy>=1.8.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: responses>=0.25.0; extra == 'dev'
Requires-Dist: types-requests>=2.31.0; extra == 'dev'
Description-Content-Type: text/markdown

# Agent Berlin Python SDK

## Installation

```bash
pip install agentberlin
```

Agent Berlin Python SDK provides AI-powered SEO automation.

### Quick Start

```python
from agentberlin import AgentBerlin
client = AgentBerlin()
```

### Available APIs

- **ga4**: `query()` - Query GA4 analytics with custom metrics, dimensions, and filters
- **pages**: `list()`, `search()`, `get()`, `get_detailed()` - List pages, search pages, get page info, and get page details with similarity analysis
- **keywords**: `list()`, `search()`, `list_clusters()`, `get_cluster_keywords()` - List/search keywords, explore clusters
- **brand**: `get_profile()`, `update_profile()`, `get_stats()` - Brand profile management and precomputed aggregate stats
- **crawls**: `versions()`, `diff()`, `diff_url()`, `list_issues()`, `get_issue()` - Crawl run timeline, URL-by-URL diffs between two crawls, and the latest crawl's SEO issue report
- **google_search**: `web()`, `news()`, `images()`, `shopping()`, `ai_overview()`, `ai_mode()` - Google Search with all result types
- **bing_search**: `web()` - Bing web search
- **google_maps**: `search()`, `reviews()` - Google Maps places and reviews
- **google_trends**: `interest_over_time()`, `related_queries()` - Google Trends data
- **ai_search**: `query()` - Grounded AI-powered web search with citations (Perplexity, ChatGPT, Gemini)
- **gsc**: `query()`, `get_site()`, `list_sitemaps()`, `get_sitemap()`, `inspect_url()` - Search analytics, sitemaps, URL inspection
- **reddit**: `search()`, `get_posts()`, `get_subreddit_posts()`, `get_post_comments()` - Reddit posts, comments, and subreddit feeds (SLOW: 30-120s per call, batch your inputs)
- **linkedin**: `post()`, `status()`, `list_posts()` - Publish posts to the connected user's personal LinkedIn profile (write-only; token expires every ~60 days — check `status()` and surface expiry warnings to the user)
- **bing_webmaster**: `query_stats()`, `page_stats()`, `traffic_stats()`, `crawl_stats()`, `get_site()` - Bing Webmaster Tools analytics
- **amplitude**: `query()`, `list_events()`, `list_cohorts()`, `funnel()`, `retention()` - Product analytics (sessions, users, events, funnels, retention)
- **posthog**: `query()` - Run any PostHog query kind (`HogQLQuery`, `TrendsQuery`, `FunnelsQuery`, `RetentionQuery`, …) against a connected PostHog project
- **pagespeed**: `analyze()` - Google PageSpeed Insights Lighthouse audit (performance score + Core Web Vitals)
- **market**: `ranked_keywords()`, `keyword_ideas()`, `domain_overview()`, `historical_rank_overview()`, `bulk_traffic_estimation()`, `top_pages()`, `technologies()` - Competitive organic-search intelligence about any domain (yours or a competitor's)
- **backlinks**: `summary()`, `list()`, `referring_domains()`, `competitors()` - Off-page/authority profile of any domain
- **ai_mentions**: `search()`, `summary()`, `top_domains()`, `top_pages()` - How a brand/domain/keyword is mentioned inside AI answers (ChatGPT & Google AI)
- **email_marketing**: `capabilities()`, `list_audiences()`, `add_to_audience()`, `remove_from_audience()`, `get_contact()`, `upsert_contact()`, `create_campaign()`, `update_campaign()`, `get_campaign()`, `list_campaigns()`, `campaign_metrics()`, `schedule_campaign()`, `send_campaign()`, `cancel_scheduled_campaign()`, `create_segment()`, `list_templates()`, `create_template()`, `update_template()`, `track_event()`, `list_suppressed()` - The project's connected email service provider (e.g. Klaviyo); campaigns are created as DRAFTS for human review in the ESP's own UI
- **google_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `coverage()` - Google Ads performance reports (read-only, nightly-synced warehouse; cost values in micros)
- **openai_ads**: `account()`, `campaigns()`, `ad_groups()`, `ads()`, `list_campaigns()`, `record_hint_set()`, `list_hint_sets()` - OpenAI (ChatGPT) Ads performance insights read live from the connected Advertiser API key (read-only; six metrics only; no keywords/search terms; insights money in account currency, not micros), plus recording/recall of generated ad-group context_hints for history

## API Reference

### ga4

Direct access to Google Analytics 4 Data API for flexible reporting with custom metrics, dimensions, and filters.

#### client.ga4.query()

Query GA4 analytics data using the GA4 Data API.

This method maps directly to Google Analytics 4's `runReport` API,
providing full flexibility for analytics queries.

Signature:
```python
result = client.ga4.query(
    metrics=["totalUsers", "sessions"],      # Required: list of metric names
    start_date="2024-01-01",                 # Required: YYYY-MM-DD format
    end_date="2024-01-31",                   # Required: YYYY-MM-DD format
    dimensions=["country", "date"],          # Optional: breakdown dimensions
    dimension_filter={                       # Optional: filter results
        "field_name": "sessionDefaultChannelGroup",
        "string_filter": {"match_type": "EXACT", "value": "Organic Search"}
    },
    order_bys=[{"metric": "totalUsers", "desc": True}],  # Optional: sorting
    limit=100,                               # Optional: max rows
    offset=0,                                # Optional: pagination offset
    date_ranges=[                            # Optional: date comparison
        {"start_date": "2024-01-01", "end_date": "2024-01-31", "name": "current"},
        {"start_date": "2023-12-01", "end_date": "2023-12-31", "name": "previous"}
    ],
    currency_code="USD"                      # Optional: for monetary metrics
)
```

Args:
- metrics: List of metric names to retrieve (required). Examples: "totalUsers", "sessions", "ecommercePurchases", "screenPageViews"
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- dimensions: Optional list of dimension names. Examples: "country", "sessionDefaultChannelGroup", "date", "deviceCategory"
- dimension_filter: Optional filter to restrict results:
  - Simple filter: {"field_name": "country", "string_filter": {"match_type": "EXACT", "value": "US"}}
  - In-list filter: {"field_name": "country", "in_list_filter": {"values": ["US", "CA"]}}
  - AND group: {"and_group": {"expressions": [filter1, filter2]}}
  - OR group: {"or_group": {"expressions": [filter1, filter2]}}
  - NOT: {"not_expression": filter}
- order_bys: Optional list of ordering. Example: [{"metric": "totalUsers", "desc": True}]
- limit: Maximum number of rows to return
- offset: Number of rows to skip (for pagination)
- date_ranges: Optional list of date ranges for comparison
- currency_code: Optional currency code for monetary metrics (e.g., "USD")

Returns GA4QueryResponse with:
- rows - List of GA4Row objects
- row_count - Total number of rows returned
- totals - Aggregate totals for all metrics

Each GA4Row has:
- dimensions - Dict mapping dimension names to values
- metrics - Dict mapping metric names to values

Note: Item-scoped dimensions (e.g., itemName, itemId, itemBrand) cannot be
combined with event-scoped metrics (e.g., totalUsers, sessions).
Use item-scoped metrics (itemsViewed, itemRevenue) instead.

Example:
```python
# Get organic traffic by country
result = client.ga4.query(
    metrics=["totalUsers", "sessions"],
    dimensions=["country"],
    start_date="2024-01-01",
    end_date="2024-01-31",
    dimension_filter={
        "field_name": "sessionDefaultChannelGroup",
        "string_filter": {"match_type": "EXACT", "value": "Organic Search"}
    },
    order_bys=[{"metric": "totalUsers", "desc": True}],
    limit=10
)
for row in result.rows:
    print(f"{row.dimensions['country']}: {row.metrics['totalUsers']} users")
```

### pages

A powerful search engine backed by yours and your competitors' crawled pages.
The dataset is produced by crawl runs — see `client.crawls` for the run
timeline, diffs between two crawls, and the latest crawl's issue report.


#### client.pages.list()

List pages with pagination and optional filtering.

Signature:
```python
result = client.pages.list(
    limit=100,           # Max pages (default: 100, max: 1000)
    offset=0,            # Pagination offset (default: 0)
    domain="example.com", # Optional: filter by domain
    own_only=True        # Optional: exclude competitor pages
)
```

Args:
- limit: Maximum pages to return (default: 100, max: 1000)
- offset: Pagination offset (default: 0)
- domain: Filter by domain (e.g., "competitor.com")
- own_only: If True, only return your own pages

Returns PageListResponse with:
- pages - List of PageListItem objects
- total - Number of pages in response
- limit - Limit used
- offset - Offset used

Each PageListItem has:
- url - Page URL
- title - Page title
- meta_description - Meta description (optional)
- h1s - List of H1 headings (empty if none)
- domain - Domain name (optional)
- status_code / redirect_url / redirect_type / content_type / canonical / meta_robots / x_robots_tag / indexability / indexability_status / blocked_by_robots / word_count / response_time / last_modified - Per-page crawl-health summary (None for pages indexed before these fields existed)
- structured_data_types / schema_errors / schema_warnings - Structured data (schema) summary (None if the page has no schema)

#### client.pages.search()

Search for similar pages with multiple input modes.

This unified method supports two input modes (exactly one required):
- query: Simple text query for semantic search across all pages
- content: Find similar pages to provided raw content

Additionally, `contains` can filter by URL or content pattern (regex supported):
- Standalone: Returns all pages matching the pattern, sorted by URL
- Combined: Filters semantic search results by the pattern

Note: For chunk-level analysis, use pages.get_detailed(url) instead.

Signature:
```python
# Query mode - semantic search
results = client.pages.search(
    query="SEO best practices",   # Text query for semantic search
    count=10,                      # Max results (default: 10)
    offset=0,                      # Pagination offset (default: 0)
    similarity_threshold=0.60      # Min similarity 0-1 (default: 0.60)
)

# Content mode - find similar pages to raw content
results = client.pages.search(
    content="This is my page content about SEO..."
)

# Query mode with filters
results = client.pages.search(
    query="SEO guide",
    domain="competitor.com",       # Filter by domain
    status_code="200",             # Filter by HTTP status
    topic="SEO",                   # Filter by topic name
    page_type="pillar"             # Filter by page type
)

# Contains-only mode - filter by URL pattern
results = client.pages.search(
    contains="/blog/.*"            # Regex pattern for URL or content
)

# Combine semantic search with contains filter
results = client.pages.search(
    query="pricing",
    contains="enterprise"          # Also filter by pattern in URL/content
)
```

Args:
- query: Text query for semantic search (mutually exclusive with content)
- content: Raw content string (mutually exclusive with query)
- contains: Filter by URL or content pattern (regex supported, case-insensitive).
            Matches if page URL or content contains the pattern.
            Can be used alone or with query/content.
- count: Max recommendations (default: 10, max: 50)
- offset: Pagination offset (default: 0)
- similarity_threshold: Min similarity 0-1 (default: 0.60)
- domain: Filter by domain (e.g., "competitor.com")
- own_only: If True, filter to only your own pages (excludes competitors)
- status_code: Filter by HTTP status code ("200", "404", "error", "redirect", "success")
- topic: Filter by topic name
- page_type: Filter by page type ("pillar" or "landing")

Returns PageSearchResponse with:
- similar_pages - List of SimilarPage objects (always includes evidence and topic_info)

Each SimilarPage has:
- target_page_url - URL of recommended page
- similarity_score - Similarity score 0-1
- match_type - "page_to_page"
- page_type - "pillar", "landing", or None
- assigned_topic - Topic if target is assigned as pillar/landing
- title - Page title
- h1s - List of H1 headings (empty if none)
- meta_description - Meta description
- evidence - List of Evidence objects (h_path, text) for top chunks
- domain - Page domain
- status_code - HTTP status code
- topic_info - TopicInfo object (topics, topic_scores, page_type, assigned_topic)

#### client.pages.get()

Get page information.

For similarity analysis (similar_pages and similar_chunks), use
pages.get_detailed instead.

Signature:
```python
# Basic page details (metadata only)
page = client.pages.get(
    url="https://example.com/blog/article"
)

# Include page content (truncated to 500 chars)
page = client.pages.get(
    url="https://example.com/blog/article",
    content_length=500
)

# With the page's raw JSON-LD structured data
page = client.pages.get(
    url="https://example.com/blog/article",
    include_schema=True
)
```

Args:
- url: The page URL to look up
- content_length: Max characters of content to return (default: 0, no content). There is no upper limit - pass a large value (e.g., 999999) to get full content. Note: pages can have large content (thousands of words), so large values will significantly increase response size.
- include_schema: If True, also return the page's raw JSON-LD bodies in structured_data_jsonld (can be large). The schema summary fields are always returned.

Returns PageDetailResponse with:
- url - Page URL
- title - Page title
- meta_description - Meta description
- h1s - List of H1 headings (empty if none)
- domain - Domain name
- links.inlinks - List of incoming links (PageLink objects)
- links.outlinks - List of outgoing links (PageLink objects)
- topic_info.topics - List of topic names
- topic_info.topic_scores - List of topic relevance scores
- topic_info.page_type - "pillar" or "landing"
- topic_info.assigned_topic - Primary assigned topic
- content - Page content truncated to content_length (only returned if content_length > 0)
- content_length - Total content length in characters
- status_code / redirect_url / redirect_type / content_type / canonical / meta_robots / x_robots_tag / indexability / indexability_status / blocked_by_robots / word_count / response_time / last_modified - Per-page crawl-health and indexability fields (None for pages indexed before these fields existed)
- structured_data_types - Detected schema.org types, e.g. ["Organization", "WebSite"] (None if the page has no schema)
- schema_errors - Schema validation error count (None if no schema)
- schema_warnings - Schema validation warning count (None if no schema)
- structured_data_jsonld - Raw JSON-LD script bodies (only when include_schema=True)

#### client.pages.get_detailed()

Get page details with similarity analysis.

Like get, but additionally computes similar_pages and similar_chunks
(expensive, 1-10s).

Signature:
```python
page = client.pages.get_detailed(
    url="https://example.com/blog/article"
)
page.similar_pages    # List of SimilarPage objects
page.similar_chunks   # List of SimilarChunkSet objects
```

Args:
- url: The page URL to look up
- content_length: Max characters of content to return (default: 0, no content). There is no upper limit - pass a large value (e.g., 999999) to get full content. Note: pages can have large content (thousands of words), so large values will significantly increase response size.
- include_schema: If True, also return the page's raw JSON-LD bodies in structured_data_jsonld (can be large). The schema summary fields are always returned.

Returns PageDetailResponse with all the fields get returns, plus:
- similar_pages - List of SimilarPage objects
- similar_chunks - List of SimilarChunkSet objects

### keywords

Search for keywords and explore keyword clusters (powered by SEMRush & DataForSEO)

#### client.keywords.list()

List all keywords with pagination.

Signature:
```python
result = client.keywords.list(
    limit=100,    # Max keywords (default: 100, max: 1000)
    offset=0      # Pagination offset (default: 0)
)
```

Returns KeywordListResponse with:
- keywords - List of KeywordResult objects
- total - Total number of keywords

Each KeywordResult has:
- keyword - The keyword text
- intent - Search intent (optional)
- locations - List of LocationMetrics objects with location-specific data

Each LocationMetrics has:
- code - Location code (e.g., "us", "uk", "de")
- volume - Monthly search volume (optional)
- difficulty - Difficulty score 0-100 (optional)
- cpc - Cost per click (optional)
- position - SERP position for your domain (optional)

Example:
```python
# Get first 100 keywords
result = client.keywords.list()
for kw in result.keywords:
    # Access location-specific metrics
    for loc in kw.locations:
        print(f"{kw.keyword} ({loc.code}): volume={loc.volume}, difficulty={loc.difficulty}")

# Paginate through all keywords
all_keywords = []
offset = 0
while True:
    result = client.keywords.list(limit=1000, offset=offset)
    all_keywords.extend(result.keywords)
    if len(result.keywords) < 1000:
        break
    offset += 1000
```

#### client.keywords.search()

Search for keywords.

Signature:
```python
keywords = client.keywords.search(
    query="digital marketing",  # Search query
    limit=10,                   # Max results (default: 10)
    match_type="semantic"       # "semantic", "exact", or "contains" (default: "semantic")
)
```

Parameters:
- query - Search query string (required)
- limit - Maximum results to return (default: 10, max: 50)
- match_type - Search mode (default: "semantic"):
  - "semantic" - Hybrid AI search using embeddings and BM25
  - "exact" - Case-insensitive exact string match
  - "contains" - Case-insensitive substring/contains match

Returns KeywordSearchResponse with keywords list and total count.
Each keyword has:
- keyword - The keyword text
- intent - Search intent: "informational", "commercial", "transactional", "navigational"
- locations - List of LocationMetrics with location-specific data:
  - code - Location code (e.g., "us", "uk")
  - volume - Monthly search volume
  - difficulty - Difficulty score (0-100)
  - cpc - Cost per click
  - position - SERP position (optional)

Examples:
```python
# Semantic search (default) - finds related keywords
results = client.keywords.search(query="running shoes")

# Exact match - finds only "running shoes" exactly
results = client.keywords.search(query="running shoes", match_type="exact")

# Substring match - finds keywords containing "running"
results = client.keywords.search(query="running", match_type="contains")
```

#### client.keywords.list_clusters()

List all keyword clusters with representative keywords.

Keywords are grouped into clusters using HDBSCAN clustering based on semantic similarity.
This method returns a summary of all clusters for topic exploration.

Signature:
```python
clusters = client.keywords.list_clusters(
    representative_count=3  # Keywords per cluster (default: 3, max: 10)
)
```

Returns ClusterListResponse with:
- clusters - List of ClusterSummary objects
- noise_count - Number of unclustered keywords (cluster_id=-1)
- total_keywords - Total keywords in the index
- cluster_count - Number of clusters (excluding noise)

Each ClusterSummary has:
- cluster_id - Unique cluster identifier
- size - Number of keywords in cluster
- representative_keywords - Top keywords by volume

Example:
```python
clusters = client.keywords.list_clusters()
for cluster in clusters.clusters:
    print(f"Cluster {cluster.cluster_id}: {cluster.size} keywords")
    print(f"  Topics: {', '.join(cluster.representative_keywords)}")
```

#### client.keywords.get_cluster_keywords()

Get keywords belonging to a specific cluster.

Use this to explore keywords within a cluster. Supports pagination for large clusters.
Use cluster_id=-1 to get noise (unclustered) keywords.

Signature:
```python
result = client.keywords.get_cluster_keywords(
    cluster_id=0,   # Required: cluster ID (-1 for noise)
    limit=50,       # Max keywords (default: 50, max: 1000)
    offset=0        # Pagination offset (default: 0)
)
```

Returns ClusterKeywordsResponse with:
- keywords - List of ClusterKeywordResult objects
- total - Total keywords in this cluster
- cluster_id - The requested cluster ID
- limit - Limit used
- offset - Offset used

Each ClusterKeywordResult has:
- keyword - The keyword text
- cluster_id - Cluster ID
- intent - Search intent (optional)
- locations - List of LocationMetrics with location-specific data:
  - code - Location code (e.g., "us", "uk")
  - volume - Monthly search volume (optional)
  - difficulty - Difficulty score 0-100 (optional)
  - cpc - Cost per click (optional)
  - position - SERP position (optional)

Example:
```python
# Get top keywords from cluster 0
result = client.keywords.get_cluster_keywords(0, limit=20)
for kw in result.keywords:
    for loc in kw.locations:
        print(f"{kw.keyword} ({loc.code}): volume={loc.volume}")

# Get noise keywords (unclustered)
noise = client.keywords.get_cluster_keywords(-1, limit=100)
```

### brand

Your brand profile including company info, competitors, industries, and target markets.

#### client.brand.get_profile()

Get the complete brand profile including all company information, competitors, target industries, business models, and geographic scope.

Signature:
```python
profile = client.brand.get_profile()
```

Returns BrandProfileResponse with:
- domain - Domain name
- name - Brand name
- brand_context - Structured markdown brand context (What we do, USP, ICP, etc.)
- domain_authority - Domain authority score
- competitors - List of competitor domains
- industries - List of industries
- business_models - List of business models
- company_size - Company size
- target_customer_segments - Target segments
- geographies - Target geographies
- topics - List of Topic objects (value, pillar_page_url, landing_page_url)
- sitemaps - Sitemap URLs
- profile_urls - Profile URLs

Example:
```python
profile = client.brand.get_profile()
print(f"Domain: {profile.domain}")
print(f"Topics: {[(t.value, t.pillar_page_url) for t in profile.topics]}")
```

#### client.brand.update_profile()

Update brand profile fields.

**Important behavior:**
- Only provided fields are updated
- Fields set to None/undefined are IGNORED (not cleared)
- To clear a list field, pass an empty list []
- To clear a string field, pass an empty string ""

Signature:
```python
result = client.brand.update_profile(
    name="Project Name",                          # Optional: Project name
    brand_context="Business description...",      # Optional: Brand context (markdown)
    competitors=["competitor1.com"],              # Optional: Competitor domains
    industries=["SaaS", "Technology"],            # Optional: Industries
    business_models=["B2B", "Enterprise"],        # Optional: Business models
    company_size="startup",                       # Optional: solo, early_startup, startup, smb, mid_market, enterprise
    target_customer_segments=["Enterprise"],      # Optional: Target segments
    geographies=["US", "EU"],                     # Optional: Geographic regions
    topics=["SEO", "Content Marketing"]           # Optional: Topic values
)
```

Returns the full updated BrandProfileResponse (same shape as
get_profile), reflecting the applied changes.

Examples:
```python
# Update multiple fields at once
result = client.brand.update_profile(
    brand_context="We are a B2B SaaS company...",
    competitors=["competitor1.com", "competitor2.com"],
    topics=["SEO", "Content Marketing", "Analytics"]
)

# Only update one field (others unchanged)
result = client.brand.update_profile(industries=["SaaS", "MarTech"])

# Clear a list field
result = client.brand.update_profile(competitors=[])
```

#### client.brand.get_stats()

Get precomputed aggregate statistics for the project's brand.

These stats are generated at build time from the crawl (ScreamingFrog)
and keyword (SEMrush / DataForSEO) data, and served from disk — no
worker machine is provisioned for the call, making it cheap to poll
for dashboards.

Signature:
```python
stats = client.brand.get_stats()
```

Returns BrandStatsResponse. Every top-level section is optional — when
the underlying source data was missing at build time, that section is
absent.

Top-level fields:
- brand_name - Brand identifier
- generated_at - Unix timestamp when the stats were produced
- pages - PagesStats (crawl health, content structure, URL shape, redirects, performance)
- links - LinksStats (in/out link totals, orphan/dead-end pages, top-linked pages, broken outlinks, classification & type counts)
- keywords - KeywordsStats (volume & CPC rollups, difficulty buckets, SERP ranking buckets, intent distribution)
- cross - CrossStats (keyword-to-page matches, top landing pages, topic coverage)

Example:
```python
stats = client.brand.get_stats()
if stats.pages and stats.pages.crawl_health:
    sc = stats.pages.crawl_health.status_class_counts
    print(f"4xx pages: {sc.four_xx}, 5xx pages: {sc.five_xx}")
if stats.keywords and stats.keywords.ranking:
    print(f"Top-3 ranking keywords: {stats.keywords.ranking.top_3_count}")
if stats.cross:
    for lp in stats.cross.top_landing_pages[:5]:
        print(f"{lp.url}: {lp.keyword_count} kws, {lp.total_volume} vol")
```

### crawls

The project's crawl runs and their artifacts. Crawl runs are the pipeline
that produces the pages dataset queryable via `client.pages` (one run crawls
your own domain plus competitor domains). This resource covers the run axis:
the version timeline, URL-by-URL diffs of the dataset between two runs, and
the SEO/structural issue report of the latest run (own domain only). While
project data is still being collected, these methods raise an error with
HTTP 503.


#### client.crawls.versions()

List the project's completed crawl versions, newest first.

Each version is one crawl run over your own domain and competitor domains;
its pages are what `client.pages` serves once the run is indexed.

Signature:
```python
result = client.crawls.versions()
```

Returns CrawlVersionsResponse with:
- versions - List of CrawlVersion objects

Each CrawlVersion has:
- version_id - The identifier to pass to diff()/diff_url()
- run_id - The crawl run it came from (equal to version_id for current runs)
- page_count - Number of pages in that crawl (optional)
- trigger - What started the crawl (e.g., "scheduled", "manual")
- created_at - When the crawl started
- finished_at - When the crawl finished (optional)

#### client.crawls.diff()

Diff the page dataset between two crawl versions, URL-by-URL.

The diff is symmetric over the union of URLs in both versions: every URL is
reported as changed, new (only in target), deleted (only in base), or unchanged.

Signature:
```python
diff = client.crawls.diff(
    base="<older version_id>",
    target="<newer version_id>",
    include_unchanged=False,  # Optional: also list unchanged URLs
    limit=100,                # Optional: max URL rows
    offset=0                  # Optional: pagination offset
)
```

Returns CrawlDiffResponse with:
- summary - CrawlDiffSummary with base, target, changed, new, deleted, unchanged, total
- urls - List of CrawlURLChange objects (url, status, changed_fields)

Example:
```python
versions = client.crawls.versions().versions
diff = client.crawls.diff(base=versions[1].version_id, target=versions[0].version_id)
print(f"{diff.summary.changed} changed, {diff.summary.new} new, {diff.summary.deleted} deleted")
for change in diff.urls:
    print(change.url, change.status, change.changed_fields)
```

#### client.crawls.diff_url()

Get the before/after text + similarity for one URL across two crawl versions.

Signature:
```python
detail = client.crawls.diff_url(
    base="<older version_id>",
    target="<newer version_id>",
    url="https://example.com/pricing"
)
```

Returns CrawlURLDiffResponse with:
- url, base, target - Echo of the request
- fields - List of CrawlFieldDiff objects (field, before, after, similarity 0-1, before_missing, after_missing)
- overall_similarity - Similarity of the page across the two versions (0-1)

Raises a not-found error if the URL is in neither version.

#### client.crawls.list_issues()

List the latest crawl's SEO/structural issue report (summaries only, no URL lists).

Issues cover the project's own domain and are a snapshot, not a history:
every crawl replaces the previous report, and an empty report after a clean
crawl is meaningful. crawled_at/crawl_run_id identify the producing crawl.

Signature:
```python
report = client.crawls.list_issues(
    type="Issue",       # Optional: "Issue" | "Warning" | "Opportunity" (case-insensitive)
    priority="High",    # Optional: "High" | "Medium" | "Low" (case-insensitive)
    limit=50,           # Optional: max issues
    offset=0            # Optional: pagination offset
)
```

Returns CrawlIssuesListResponse with:
- issues - List of CrawlIssueSummary objects
- total - Total matching issues
- crawled_at / crawl_run_id - Provenance of the snapshot (absent before the first crawl)

Each CrawlIssueSummary has:
- name - Exact issue name (pass to get_issue() for the affected URLs)
- type - "Issue" | "Warning" | "Opportunity"
- priority - "High" | "Medium" | "Low"
- description - What the issue means
- how_to_fix - Remediation guidance
- help_url - Documentation link (optional)
- url_count - Total number of affected URLs

#### client.crawls.get_issue()

Get one issue by name with a paginated window of its affected URLs.

Signature:
```python
detail = client.crawls.get_issue(
    name="Canonicals: Missing",  # Exact name from list_issues()
    limit=200,                   # Optional: max URLs per page
    offset=0                     # Optional: offset into the URL list
)
```

Returns CrawlIssueDetailResponse with all CrawlIssueSummary fields plus:
- urls - The requested window of affected URLs
- urls_total - Total affected URLs
- crawled_at / crawl_run_id - Provenance of the snapshot

Raises a not-found error for an unknown issue name.

Example:
```python
report = client.crawls.list_issues(priority="High")
for issue in report.issues:
    detail = client.crawls.get_issue(name=issue.name, limit=200)
    print(issue.name, detail.urls[:5])
```

### google_search

Search the web using Google - includes web results, news, images, shopping, and AI features.

**`web()` surfaces classic SERP features.** In addition to organic results (with sitelinks),
the web response exposes — when Google actually returns them for the query — the featured
snippet (`answer_box`), People Also Ask (`related_questions`), knowledge panel
(`knowledge_graph`), local pack (`local_results`), inline images / videos, top stories,
inline AI Overview (`ai_overview`), and `related_searches`. Absent surfaces are omitted.

**Three AI surfaces, three different things.** (1) `web().features.ai_overview` is the
AI Overview block embedded in a normal SERP, set only when Google attaches one to the
query — useful for "would an organic searcher see an AI block?". (2) `ai_overview()`
always asks Google for the AI Overview block specifically; returns the same shape as
the embedded one without the surrounding SERP. (3) `ai_mode()` is Google's distinct
AI Mode product (the `udm=50` conversational tab); returns a richer payload with full
markdown and structured blocks/citations.

**Vertical methods** (`news()`, `images()`, `shopping()`) surface only the
result list for their own surface — they do not carry SERP features.


#### client.google_search.web()

Search the web using Google. Returns organic results plus classic SERP surfaces when
Google includes them for the query.

Signature:
```python
results = client.google_search.web(
    query="best seo tools",
    max_results=10,      # Max results (1-100, default: 10)
    country="us",        # Optional: ISO 3166-1 alpha-2 country code
    language="en",       # Optional: ISO 639-1 language code
    location="Austin,Texas,United States"  # Optional: canonical name for city-level targeting
)
```

Returns GoogleSearchWebResponse with:
- query - The search query
- results - List of GoogleSearchResult objects (organic results)
- total - Organic results count
- answer_box - GoogleSearchAnswerBox or None (featured snippet)
- related_questions - List of GoogleSearchRelatedQuestion (People Also Ask)
- knowledge_graph - GoogleSearchKnowledgeGraph or None (knowledge panel)
- local_results - List of GoogleSearchLocalResult (local pack)
- inline_images - List of GoogleSearchInlineImage (inline carousel)
- inline_videos - List of GoogleSearchInlineVideo (inline carousel)
- top_stories - List of GoogleSearchTopStory (top stories carousel)
- ai_overview - GoogleSearchAIOverview or None (inline AI Overview)
- related_searches - List of GoogleSearchRelatedSearch
- search_metadata - Optional metadata (id, status, total_time_taken)

Each organic result has: title, url, snippet, displayed_link, date, thumbnail,
sitelinks (list of {title, link, snippet}).

All SERP-feature fields default to empty / None when Google did not include that
surface for the query. Detect AI Overview with `if results.ai_overview:`, PAA with
`if results.related_questions:`, etc.

#### client.google_search.news()

Search Google News.

Signature:
```python
results = client.google_search.news(
    query="AI technology",
    max_results=10,      # Max results (1-100, default: 10)
    country="us",        # Optional: ISO 3166-1 alpha-2 country code
    language="en"        # Optional: ISO 639-1 language code
)
```

Returns GoogleSearchNewsResponse with:
- query - The search query
- results - List of GoogleSearchNewsResult objects
- total - Total results count
Each result has: title, url, snippet, source, date, thumbnail

#### client.google_search.images()

Search Google Images.

Signature:
```python
results = client.google_search.images(
    query="modern website design",
    max_results=10,      # Max results (1-100, default: 10)
    country="us"         # Optional: ISO 3166-1 alpha-2 country code
)
```

Returns GoogleSearchImagesResponse with:
- query - The search query
- results - List of GoogleSearchImageResult objects
- total - Total results count
Each result has: title, url, original, thumbnail, source, width, height

#### client.google_search.shopping()

Search Google Shopping.

Signature:
```python
results = client.google_search.shopping(
    query="wireless headphones",
    max_results=10,      # Max results (1-100, default: 10)
    country="us"         # Optional: ISO 3166-1 alpha-2 country code
)
```

Returns GoogleSearchShoppingResponse with:
- query - The search query
- results - List of GoogleSearchShoppingResult objects
- total - Total results count
Each result has: title, url, price, source, thumbnail, rating, reviews

#### client.google_search.ai_overview()

Fetch Google's inline AI Overview block — the AI summary that sometimes
appears above the organic results on a regular SERP. For Google's
standalone AI Mode product (different surface, richer response), see
`ai_mode()`.

**Latency:** typically 2–6.5 s (measured via SerpAPI, the routed
provider). AI Overview is best-effort upstream — it does not appear
for every query — so budget for occasional empty results.

Signature:
```python
results = client.google_search.ai_overview(
    query="What is SEO?",
    country="us",        # Optional: ISO 3166-1 alpha-2 country code
    language="en",       # Optional: ISO 639-1 language code
    location="Austin,Texas,United States"  # Optional: canonical name for city-level targeting
)
```

Returns GoogleSearchAIOverviewResponse with:
- query - The search query
- answer - AI-generated answer (joined paragraph text)
- sources - List of GoogleSearchResult objects (source citations)
- search_metadata - Optional metadata

#### client.google_search.ai_mode()

Query Google's dedicated AI Mode product (the udm=50 conversational
AI tab). Distinct from `ai_overview()` — AI Mode is a separate
product with a richer payload.

**Latency:** typically ~6 s (measured via SerpAPI's google_ai_mode
engine, the routed provider), returning full markdown plus references.

Signature:
```python
results = client.google_search.ai_mode(
    query="compare next.js and remix for a small saas",
    country="us",        # Optional: ISO 3166-1 alpha-2 country code
    language="en",       # Optional: ISO 639-1 language code
    location="Austin,Texas,United States"  # Optional: canonical name for city-level targeting
)
```

Returns GoogleSearchAIModeResponse with:
- query - The search query
- markdown - Full answer rendered as markdown (the primary signal)
- text_blocks - List of GoogleSearchAIModeTextBlock for structured rendering
  Each block has: snippet, type (heading/paragraph/list/expandable/comparison),
  reference_indexes (point into references[])
- references - List of GoogleSearchAIModeReference with index, title, link, source, snippet
- search_metadata - Optional metadata

### bing_search

Search the web using Microsoft Bing (web search only).

**`web()` surfaces classic SERP features.** In addition to organic results (with sitelinks),
the web response exposes — when Bing actually returns them for the query — the answer box
(`answer_box`), People Also Ask (`related_questions`), and `related_searches`. Absent
surfaces are omitted.


#### client.bing_search.web()

Search the web using Bing. Returns organic results plus classic SERP surfaces when
Bing includes them for the query.

Signature:
```python
results = client.bing_search.web(
    query="best seo tools",
    max_results=10,      # Max results (1-100, default: 10)
    country="us"         # Optional: ISO 3166-1 alpha-2 country code
)
```

Returns BingSearchWebResponse with:
- query - The search query
- results - List of BingSearchResult objects (organic results)
- total - Organic results count
- answer_box - BingSearchAnswerBox or None
- related_questions - List of BingSearchRelatedQuestion (People Also Ask)
- related_searches - List of BingSearchRelatedSearch
- search_metadata - Optional metadata

Each organic result has: title, url, snippet, displayed_link, date, sitelinks
(list of {title, link, snippet}).

All SERP-feature fields default to empty / None when Bing did not include that surface
for the query.

### google_maps

Search for places and businesses on Google Maps, and retrieve reviews.

#### client.google_maps.search()

Search for places and businesses on Google Maps.

Signature:
```python
results = client.google_maps.search(
    query="coffee shops",
    location="New York, NY",   # Optional: text location (mutually exclusive with ll)
    ll="@40.7128,-74.0060,15z", # Optional: GPS coords (mutually exclusive with location)
    type="cafe",               # Optional: place type filter
    language="en",             # Optional: ISO 639-1 language code
    country="us"               # Optional: ISO 3166-1 alpha-2 country code
)
```

Returns GoogleMapsSearchResponse with:
- query - The search query
- results - List of GoogleMapsPlace objects
- total - Total results count
- search_metadata - Optional metadata

Each GoogleMapsPlace has:
- title - Place name
- place_id - Google place ID
- data_id - Google data ID (use this for reviews)
- address - Full address
- phone - Phone number
- website - Website URL
- rating - Average rating (1-5)
- reviews - Number of reviews
- type - Place type
- thumbnail - Thumbnail image URL
- gps_coordinates - GPS coordinates dict
- hours - Operating hours
- price_level - Price level indicator

#### client.google_maps.reviews()

Get reviews for a Google Maps place.

Signature:
```python
reviews = client.google_maps.reviews(
    data_id="0x89c259a...",    # Preferred: data_id from search result
    place_id="ChIJ...",        # Alternative: Google place_id
    sort_by="most_relevant",   # Optional: most_relevant, newest, highest_rating, lowest_rating
    num=10,                    # Optional: number of reviews (default: 10)
    language="en"              # Optional: ISO 639-1 language code
)
```

Note: You must provide either data_id or place_id.

Returns GoogleMapsReviewsResponse with:
- place_id - Google place ID
- data_id - Google data ID
- title - Place name
- address - Full address
- rating - Average rating
- total_reviews - Total review count
- reviews - List of GoogleMapsReview objects
- search_metadata - Optional metadata

Each GoogleMapsReview has:
- rating - Review rating (1-5)
- text - Review text
- author - Reviewer name
- date - Review date
- likes - Number of likes
- response - Owner response (if any)

### google_trends

Explore search interest over time and discover related queries using Google Trends.

#### client.google_trends.interest_over_time()

Get search interest over time for a query.

**Latency:** typically 5–20 s. Trends is fetched via SerpAPI's
`google_trends` engine; usually fast but can spike when Google rate
limits widget rendering.

Signature:
```python
interest = client.google_trends.interest_over_time(
    query="artificial intelligence",
    geo="US",                  # Optional: region code (empty for worldwide)
    timeframe="today 12-m"     # Optional: time range
)
```

Timeframe options:
- 'now 1-H': Past hour
- 'now 4-H': Past 4 hours
- 'now 1-d': Past day
- 'now 7-d': Past 7 days
- 'today 1-m': Past month
- 'today 3-m': Past 3 months
- 'today 12-m': Past year
- 'today 5-y': Past 5 years
- 'all': Since 2004
- '2020-01-01 2020-12-31': Custom date range

Returns GoogleTrendsInterestOverTimeResponse with:
- query - The search query
- geo - Geographic region
- timeframe - Time range used
- timeline - List of GoogleTrendsTimelinePoint objects
- average_interest - Average interest score
- search_metadata - Optional metadata

Each GoogleTrendsTimelinePoint has:
- date - Date string
- value - Interest value (0-100, relative)
- formatted_value - Formatted display value

#### client.google_trends.related_queries()

Get related queries for a search term.

**Latency:** typically 5–20 s, same notes as `interest_over_time()`.

Signature:
```python
related = client.google_trends.related_queries(
    query="machine learning",
    geo="US"                   # Optional: region code (empty for worldwide)
)
```

Returns GoogleTrendsRelatedQueriesResponse with:
- query - The search query
- geo - Geographic region
- top - List of top related queries (by volume)
- rising - List of rising queries (fastest growing)
- search_metadata - Optional metadata

Each GoogleTrendsRelatedQuery has:
- query - The related query text
- value - Interest value or percentage
- link - Link to explore the query
- extracted_value - Extracted numeric value

### ai_search

Grounded AI-powered web search using Perplexity, ChatGPT, or Gemini. Ask an
answer engine a question and get back a citation-backed answer grounded in
a live web search. This is an answer engine, not a SERP reader — it returns
no rank/position data (use `google_search` for that).

**IMPORTANT - Usage Restriction:**
Only use this method when the user has **explicitly requested** AI-powered search.
Do NOT use it proactively. Almost always, what you're trying to achieve can be done
using sub-agents instead, which is the preferred approach.


#### client.ai_search.query()

Perform an AI-powered web search with citations.

**IMPORTANT:** Only use this method when the user explicitly requests AI-powered search.
For most tasks, prefer using sub-agents instead of calling this API directly.

Signature:
```python
result = client.ai_search.query(
    model="sonar",                    # Required: sonar, sonar-pro, gpt-5.4-mini, gemini-3.5-flash, etc.
    user_query="What is SEO?",        # Required: search query
    system_prompt="Be concise.",      # Optional: system prompt
    max_tokens=1000,                  # Optional: max response length
    user_location={"city": "Austin", "country": "US"}  # Optional: localize results (OpenAI & Perplexity only; Gemini ignores)
)
```

Supported models:
- Perplexity: sonar, sonar-pro, sonar-reasoning-pro, sonar-deep-research
- OpenAI: gpt-5.4-mini, gpt-5.4-nano
- Gemini: gemini-3.5-flash, gemini-3.1-pro-preview

user_location accepts any of: city, region, country, timezone.

Returns AISearchResponse with:
- id - Response identifier
- model - Model used
- content - Generated response text
- citations - List of AISearchCitation(url, title, text)
- search_results - List of AISearchResult(title, url, snippet) - Perplexity only
- usage.input_tokens, usage.output_tokens, usage.total_tokens

### gsc

Access your Google Search Console data including search analytics, sitemaps, and URL inspection.

#### client.gsc.query()

Query search analytics data.

Signature:
```python
result = client.gsc.query(
    start_date="2024-01-01",
    end_date="2024-01-31",
    dimensions=["query", "page"],  # Optional: "query", "page", "country", "device", "searchAppearance", "date"
    search_type="web",             # Optional: "web", "image", "video", "news", "discover", "googleNews"
    row_limit=100,                 # Optional: max rows (default 1000, max 25000)
    start_row=0,                   # Optional: pagination offset
    aggregation_type="auto",       # Optional: "auto", "byPage", "byProperty"
    data_state="final"             # Optional: "final", "all"
)
```

Returns SearchAnalyticsResponse with:
- rows - List of SearchAnalyticsRow objects
- response_aggregation_type - How data was aggregated
Each row has: keys (list), clicks, impressions, ctr, position

### Pagination Example

The Search Console API returns max 25,000 rows per request. For large datasets:

```python
def get_all_search_analytics(start_date: str, end_date: str, dimensions: list):
    """Fetch all search analytics data with automatic pagination."""
    all_rows = []
    start_row = 0
    row_limit = 25000  # Maximum allowed by the API

    while True:
        result = client.gsc.query(
            start_date=start_date,
            end_date=end_date,
            dimensions=dimensions,
            row_limit=row_limit,
            start_row=start_row,
        )

        all_rows.extend(result.rows)

        # If we got fewer rows than requested, we've reached the end
        if len(result.rows) < row_limit:
            break

        start_row += row_limit

    return all_rows
```

Note: The API has daily quota limits. For large datasets, consider reducing the date range or using fewer dimensions.

#### client.gsc.get_site()

Get site information.

Signature:
```python
site = client.gsc.get_site()
```

Returns SiteInfo with:
- site_url - The site URL
- permission_level - Permission level (e.g., "siteOwner")

#### client.gsc.list_sitemaps()

List all sitemaps.

Signature:
```python
sitemaps = client.gsc.list_sitemaps()
```

Returns SitemapListResponse with:
- sitemap - List of Sitemap objects
Each sitemap has: path, last_submitted, is_pending, is_sitemaps_index, last_downloaded, warnings, errors, contents

#### client.gsc.get_sitemap()

Get specific sitemap details.

Signature:
```python
sitemap = client.gsc.get_sitemap("https://example.com/sitemap.xml")
```

Returns Sitemap with full details.

#### client.gsc.inspect_url()

Inspect a URL's index status.

Signature:
```python
inspection = client.gsc.inspect_url(
    url="https://example.com/page",
    language_code="en-US"  # Optional: for localized results
)
```

Returns UrlInspectionResponse. All nested fields are typed attributes
(snake_case) — no dictionary access needed. Top-level shape:

- `inspection_result.inspection_result_link` — link to the GSC report.
- `inspection_result.index_status_result` — `IndexStatusResult` or None.
    Fields: `verdict`, `coverage_state`, `robots_txt_state`,
    `indexing_state`, `last_crawl_time`, `page_fetch_state`,
    `google_canonical`, `user_canonical`, `crawled_as`,
    `referring_urls` (list), `sitemap` (list).
- `inspection_result.mobile_usability_result` — `MobileUsabilityResult`
    or None. Fields: `verdict`, `issues` (list of `MobileUsabilityIssue`
    with `issue_type`, `message`, `severity`).
- `inspection_result.rich_results_result` — `RichResultsResult` or None.
    Fields: `verdict`, `detected_items` (list of `DetectedItem` with
    `rich_result_type` and `items`, each with `name` and `issues`).

### reddit

Read Reddit posts, comments, keyword search results, and subreddit feeds.
Always available — no integration or Reddit account required.

**IMPORTANT — these calls are SLOW.** Every method triggers a fresh
data-collection job on the provider side and blocks until it finishes:
typically **30–120 seconds per call** (worst case a few minutes), not
milliseconds. Design scripts accordingly:
- Batch inputs: every method accepts up to 20 URLs / keywords /
  subreddits in ONE call. One call with 10 URLs is ~10x faster and
  cheaper than 10 calls with 1 URL. Never call these methods in a loop
  over items you could batch.
- Fetch Reddit data once, early in the script, then work on the results.
- Responses include `duration_ms` (how long collection took) and
  `error_count` (inputs that failed, e.g. deleted posts) — a partial
  result does not raise.

Field names follow the provider's schema: post body is `description`,
score is `num_upvotes`, author is `user_posted`, and each post embeds its
subreddit's metadata as `community_*` fields (name, url, description,
members count). Unmodeled fields are still present on the objects.


#### client.reddit.search()

Search Reddit posts by keyword. SLOW: typically 1-2 minutes.

Signature:
```python
results = client.reddit.search(
    keywords=["ai seo tools", "answer engine optimization"],  # Required: str or list of str (max 20)
    date="Past month",           # Optional: "Past week", "Past month", "Past year", "All time"
    num_of_posts=10,             # Optional: max posts per keyword (1-100)
    wait_seconds=300,            # Optional: server-side job wait (default 300, max 600)
)
```

Returns RedditPostsResponse with:
- posts - List of RedditPost objects
- count - Records collected
- error_count - Inputs that failed upstream
- duration_ms - Collection job runtime

Each RedditPost has:
- post_id, url, title
- description - Post body text (markdown variant in description_markdown)
- user_posted - Author username
- date_posted - ISO-8601 timestamp string
- num_upvotes - Net score
- num_comments - Comment count
- tag - Post flair (may be None)
- photos / videos / embedded_links - Media attachments
- comments - Truncated embedded comment sample (full tree via get_post_comments)
- community_name / community_url / community_description / community_members_num - Subreddit metadata

#### client.reddit.get_posts()

Fetch Reddit posts (content + engagement stats) by permalink URL.
SLOW: typically 30-60 seconds. Batch up to 20 URLs per call.

Signature:
```python
response = client.reddit.get_posts([
    "https://www.reddit.com/r/golang/comments/abc123/some_post/",
    "https://www.reddit.com/r/webdev/comments/def456/other_post/",
])
```

Returns RedditPostsResponse (same shape as search). Use this to check
engagement (num_upvotes, num_comments) on posts you are tracking — a
deleted/unreachable post shows up in error_count instead of raising.

#### client.reddit.get_subreddit_posts()

Fetch recent posts from subreddits. SLOW: typically 1-2 minutes.

Signature:
```python
feed = client.reddit.get_subreddit_posts(
    subreddits=["golang", "r/webdev"],  # Required: names or full URLs (max 20)
    sort_by="New",               # Optional: "Hot", "New", "Top", "Rising"
    num_of_posts=25,             # Optional: max posts per subreddit (1-100)
)
```

Returns RedditPostsResponse. Subreddit metadata (members count,
description) is embedded on each post as community_* fields.

#### client.reddit.get_post_comments()

Fetch the comments of Reddit posts. SLOW: typically 1-2 minutes.

Signature:
```python
response = client.reddit.get_post_comments(
    urls="https://www.reddit.com/r/golang/comments/abc123/some_post/",  # str or list (max 20)
    days_back=30,                # Optional: only comments from the last N days
    load_all_replies=True,       # Optional: expand nested reply trees (slower)
    comment_limit=100,           # Optional: max comments per post
)
```

Returns RedditCommentsResponse with:
- comments - List of RedditComment objects (one per comment)
- count / error_count / duration_ms

Each RedditComment has:
- comment_id, url, post_id, post_url
- comment - Comment body text
- user_posted - Author username
- date_posted - ISO-8601 timestamp string
- num_upvotes, num_replies
- replies - Nested replies (when load_all_replies=True)
- parent_comment_id / root_comment_id - Thread position
- community_name - Subreddit

### linkedin

Publish posts to the connected user's personal LinkedIn profile. The
user connects their LinkedIn account once from the workspace
Integrations page; `post()` then publishes AS THAT PERSON through
LinkedIn's official API.

**IMPORTANT — the 60-day token wall.** LinkedIn grants this integration
a ~60-day access token with NO refresh token. There is no silent
renewal: when the token lapses, `post()` fails until the user clicks
through LinkedIn's consent screen again (workspace → Integrations →
LinkedIn → Reconnect; one click if they're logged in to LinkedIn).
Every response carries `token_days_remaining` plus a `warnings` list
once expiry is ≤14 days away — ALWAYS relay those warnings to the user
so they renew before posting breaks. Call `status()` at the start of
any posting workflow.

**Write-only.** LinkedIn's API cannot read a member's posts back —
engagement (likes, comments, reshares) is invisible to it. `list_posts()`
returns Berlin's own record of what was published (post URNs + URLs).

**Posts publicly as a real person.** Only publish content the user has
asked for or approved; there is no unpublish through this API.


#### client.linkedin.status()

Check the LinkedIn connection and token-expiry countdown. Call this
before posting workflows.

Signature:
```python
status = client.linkedin.status()
```

Returns LinkedInStatus with:
- connected - Whether a LinkedIn account is connected for this project
- member_name - Display name of the connected person
- person_urn - Their LinkedIn identity (urn:li:person:...)
- token_issued_at / token_expires_at - Current token lifetime
- token_days_remaining - Whole days until the token lapses
- token_expired - True once posting is disabled pending reconnect
- warnings - Actionable messages to relay to the user (e.g. "token
  expires in 9 days — reconnect from the Integrations page")

`connected=False` means no account is connected — ask the user to
connect LinkedIn from the workspace Integrations page. This method
never raises for a missing integration.

#### client.linkedin.post()

Publish a post to the connected user's LinkedIn profile.

Signature:
```python
result = client.linkedin.post(
    text="Excited to share...",        # Required: post text (max 3000 chars)
    visibility="PUBLIC",               # Optional: "PUBLIC" (default) or "CONNECTIONS"
    article_url="https://example.com/blog/launch",  # Optional: attach a link share
    article_title="Launch post",       # Optional: link preview title (needs article_url)
    article_description="...",         # Optional: link preview description (needs article_url)
)
```

Returns LinkedInPostResult with:
- post_urn - LinkedIn's id for the post; keep it (engagement join key)
- post_url - Public URL (https://www.linkedin.com/feed/update/{urn}/)
- posted_at - Publish timestamp
- author_name - Who the post was published as
- token_expires_at / token_days_remaining - Token countdown
- warnings - Non-fatal advisories; when present, RELAY THEM TO THE
  USER (they are usually the token-expiry countdown)

Raises AgentBerlinNotFoundError when no LinkedIn account is connected,
and AgentBerlinAPIError with a reconnect instruction when the token
has expired or been revoked — pass that instruction on to the user,
only they can re-authorize.

#### client.linkedin.list_posts()

List posts published through this integration, newest first. This is
Berlin's own publish record — LinkedIn offers no engagement read-back.

Signature:
```python
history = client.linkedin.list_posts(limit=20)  # limit optional, default 50
```

Returns LinkedInPostsList with:
- posts - List of LinkedInPostRecord (post_urn, post_url, text,
  visibility, media_category, article_url, posted_at)
- count - Number of records returned

### bing_webmaster

Access Bing Webmaster Tools data including search analytics, page performance, traffic stats, and crawl information.

#### client.bing_webmaster.query_stats()

Get search query statistics.

Returns search query analytics including impressions, clicks,
average position, and CTR for queries that triggered your site.

Signature:
```python
result = client.bing_webmaster.query_stats()
```

Returns QueryStatsResponse with:
- rows - List of QueryStatsRow objects

Each QueryStatsRow has:
- query - The search query
- impressions - Number of impressions
- clicks - Number of clicks
- avg_position - Average position in search results
- avg_ctr - Average click-through rate
- date - Date of the data (optional)

#### client.bing_webmaster.page_stats()

Get page-level statistics.

Returns page-level analytics including impressions, clicks,
average position, and CTR for your pages.

Signature:
```python
result = client.bing_webmaster.page_stats()
```

Returns PageStatsResponse with:
- rows - List of PageStatsRow objects

Each PageStatsRow has:
- url - The page URL
- impressions - Number of impressions
- clicks - Number of clicks
- avg_position - Average position in search results
- avg_ctr - Average click-through rate

#### client.bing_webmaster.traffic_stats()

Get overall traffic statistics.

Returns aggregate traffic metrics for your site.

Signature:
```python
traffic = client.bing_webmaster.traffic_stats()
```

Returns TrafficStats with:
- date - Date of the data (optional)
- impressions - Total impressions
- clicks - Total clicks
- avg_ctr - Average click-through rate
- avg_imp_rank - Average impression rank
- avg_click_position - Average click position

#### client.bing_webmaster.crawl_stats()

Get crawl statistics.

Returns crawl metrics including pages crawled, errors,
indexing status, and various crawl issues.

Signature:
```python
crawl = client.bing_webmaster.crawl_stats()
```

Returns CrawlStats with:
- date - Date of the data (optional)
- crawled_pages - Number of pages crawled
- crawl_errors - Number of crawl errors
- in_index - Number of pages in the index
- in_links - Number of inbound links
- blocked_by_robots_txt - Pages blocked by robots.txt
- contains_malware - Pages flagged for malware
- http_code_error - Pages with HTTP errors

#### client.bing_webmaster.get_site()

Get site information.

Returns the site URL and verification status for the site
associated with the current project.

Signature:
```python
site = client.bing_webmaster.get_site()
```

Returns SiteInfo with:
- site_url - The site URL
- is_verified - Whether the site is verified
- is_in_index - Whether the site is in the Bing index

### amplitude

Product analytics from Amplitude including sessions, users, events, funnels, and retention.

#### client.amplitude.query()

Get core analytics data (sessions and users).

Signature:
```python
analytics = client.amplitude.query(
    start_date="20240101",   # Optional: YYYYMMDD format (default: 30 days ago)
    end_date="20240131"      # Optional: YYYYMMDD format (default: today)
)
```

Returns AmplitudeQueryResponse with:
- sessions.total - Total sessions
- sessions.average_length - Average session length in seconds
- sessions.per_user - Sessions per user
- users.active - Active users count
- users.new - New users count

Example:
```python
analytics = client.amplitude.query()
print(f"Active users: {analytics.users.active}")
print(f"New users: {analytics.users.new}")
print(f"Avg session length: {analytics.sessions.average_length}s")
```

#### client.amplitude.list_events()

List all trackable events.

Signature:
```python
events = client.amplitude.list_events()
```

Returns EventsListResponse with:
- events - List of AmplitudeEvent objects

Each AmplitudeEvent has:
- name - Event name
- totals - Weekly total count

Example:
```python
events = client.amplitude.list_events()
for event in events.events:
    print(f"{event.name}: {event.totals} weekly")
```

#### client.amplitude.list_cohorts()

List all behavioral cohorts.

Signature:
```python
cohorts = client.amplitude.list_cohorts()
```

Returns CohortsListResponse with:
- cohorts - List of AmplitudeCohort objects

Each AmplitudeCohort has:
- id - Cohort ID
- name - Cohort name
- size - Number of users in cohort
- description - Cohort description (optional)

Example:
```python
cohorts = client.amplitude.list_cohorts()
for cohort in cohorts.cohorts:
    print(f"{cohort.name}: {cohort.size} users")
```

#### client.amplitude.funnel()

Analyze funnel conversion for a sequence of events.

Signature:
```python
funnel = client.amplitude.funnel(
    events=["Sign Up", "Complete Profile", "Make Purchase"],  # Required: min 2 events
    start_date="20240101",   # Optional: YYYYMMDD format
    end_date="20240131"      # Optional: YYYYMMDD format
)
```

Returns FunnelResponse with:
- steps - List of FunnelStep objects
- overall_conversion - Overall funnel conversion rate (percentage)

Each FunnelStep has:
- event - Event name
- users_entered - Users who reached this step
- users_completed - Users who completed this step
- conversion_rate - Conversion rate (percentage)
- drop_off_rate - Drop-off rate (percentage)

Example:
```python
funnel = client.amplitude.funnel(
    events=["Sign Up", "Complete Profile", "Make Purchase"]
)
print(f"Overall conversion: {funnel.overall_conversion}%")
for step in funnel.steps:
    print(f"{step.event}: {step.conversion_rate}% conversion")
```

#### client.amplitude.retention()

Get retention analysis.

Signature:
```python
retention = client.amplitude.retention(
    start_date="20240101",   # Optional: YYYYMMDD format
    end_date="20240131"      # Optional: YYYYMMDD format
)
```

Returns RetentionResponse with:
- cohort_size - Size of the cohort
- retention - List of RetentionDay objects

Each RetentionDay has:
- day - Day number (0, 1, 7, 14, 30)
- retained - Number of users retained
- retention_rate - Retention rate (percentage)

Example:
```python
retention = client.amplitude.retention()
print(f"Cohort size: {retention.cohort_size}")
for day in retention.retention:
    print(f"Day {day.day}: {day.retention_rate}% retained")
```

### posthog

Run queries against a connected PostHog project. Exposes a single
generic `query()` method that forwards the request body to PostHog's
`/api/projects/:id/query/` endpoint — any query `kind` PostHog supports
works without backend changes.


#### client.posthog.query()

Run a query against the connected PostHog project.

Signature:
```python
result = client.posthog.query({
    "kind": "HogQLQuery",                          # Required: query kind
    "query": "SELECT ... FROM events ...",        # Body shape depends on kind
})
```

Args:
- query (dict, required): The PostHog query body — the *inner* object,
  not wrapped in `{"query": ...}`. Must include a `"kind"` field. Common
  kinds:
  - `HogQLQuery` — raw SQL-like queries over events (set `"query"` to the SQL string)
  - `TrendsQuery` — time-series of events with `series`, `dateRange`, `interval`
  - `FunnelsQuery` — step-by-step conversion analysis
  - `RetentionQuery` — cohort retention tables
  - `EventsQuery`, `PathsQuery`, ... — see PostHog's docs for the full set

Raises:
- ValueError: If `query` is empty or missing a `"kind"` field.
- AgentBerlinNotFoundError: If no PostHog integration is connected for the project.
- AgentBerlinAPIError: If PostHog rejects the query.

Returns PostHogQueryResponse with:
- result - Raw PostHog response body as a dict. Shape depends on the query kind:
  - `HogQLQuery` → `result` typically contains `results` (rows), `columns`, `types`, `hogql`, `is_cached`, ...
  - `TrendsQuery` → `result.results` is a list of series with `data`, `labels`, ...
  - `FunnelsQuery` → `result.results` describes step-by-step conversion
  - `RetentionQuery` → `result.results` is the retention cohort table

Examples:
```python
# Top pages by pageviews (HogQL)
result = client.posthog.query({
    "kind": "HogQLQuery",
    "query": (
        "SELECT properties.$pathname AS path, count() AS pageviews "
        "FROM events WHERE event = '$pageview' "
        "AND timestamp >= now() - INTERVAL 30 DAY "
        "GROUP BY path ORDER BY pageviews DESC LIMIT 20"
    ),
})
for row in result.result.get("results", []):
    path, pageviews = row[0], row[1]
    print(f"{path}: {pageviews}")

# Daily active users trend (TrendsQuery)
result = client.posthog.query({
    "kind": "TrendsQuery",
    "series": [{"event": "$pageview", "math": "dau"}],
    "dateRange": {"date_from": "-30d"},
})
```

### pagespeed

Google PageSpeed Insights Lighthouse analysis — returns performance score, Core Web Vitals (lab + real-user CrUX data), actionable opportunities with estimated savings, diagnostics, and third-party cost breakdown. System-wide; no user integration required.

#### client.pagespeed.analyze()

Run a Lighthouse audit on a URL via Google PageSpeed Insights and get
back a workflow-friendly summary: performance score, lab + real-user
(CrUX) Core Web Vitals, ranked opportunities with estimated LCP/FCP
savings, diagnostics, and a third-party resource rollup.

Signature:
```python
result = client.pagespeed.analyze(
    url="https://example.com",
    strategy="mobile",   # Optional: "mobile" (default) or "desktop"
    locale="en-US"       # Optional: BCP 47 locale (default "en-US")
)
```

Returns PageSpeedAnalyzeResponse with:
- url - The analyzed URL (echoed from the request)
- strategy - Form factor used ("mobile" or "desktop")
- analysis_utc_timestamp - When Google ran the analysis
- lighthouse_version - Lighthouse version that produced the report
- performance_score - Overall Lighthouse performance score (0.0–1.0, lab-based). None if not computed.
- core_web_vitals - PageSpeedCoreWebVitals (lab metrics) with:
  - largest_contentful_paint_ms (LCP)
  - first_contentful_paint_ms (FCP)
  - cumulative_layout_shift (CLS, unitless)
  - total_blocking_time_ms (TBT)
  - speed_index_ms
  - time_to_interactive_ms (TTI)
  - server_response_time_ms (TTFB proxy)
- field_data - PageSpeedFieldData (real-user CrUX data, None when site lacks traffic) with:
  - url_metrics - CrUX for this exact URL (PageSpeedCruxMetrics)
  - origin_metrics - CrUX aggregated across the origin (PageSpeedCruxMetrics)
  Each PageSpeedCruxMetrics has overall_category ("FAST" | "AVERAGE" | "SLOW") plus per-metric
  PageSpeedCruxMetric (percentile + category) for LCP, FCP, CLS, INP, TTFB.
  Google ranks sites on CrUX data, not lab data — this is the most important signal for SEO.
- opportunities - List of PageSpeedOpportunity, sorted by estimated LCP savings desc. Each has:
  - id, title, description
  - score (0.0–1.0, lower = worse)
  - display_value (human-readable savings summary)
  - estimated_savings_ms.{lcp, fcp} - predicted improvement if fixed
  - offenders - up to 10 contributing URLs with wasted_bytes / wasted_ms
- diagnostics - List of PageSpeedDiagnostic for issues without a precise savings number
  (e.g. "DOM too large: 811 elements"). Each has id, title, numeric_value, numeric_unit.
- third_parties - List of PageSpeedThirdParty (entity, transfer_size_bytes, main_thread_time_ms),
  sorted by transfer size desc. Useful for identifying heavy third-party scripts.

Important notes:
- PSI is synchronous — each call blocks 10–30s typically, longer for heavy sites. The backend enforces a 90s timeout.
- Quota is 50 analyses/day per org by default (shared Google API key cap of 25,000/day divided across brands). Exceeding returns HTTP 429.
- Mobile and desktop are separate calls — PSI returns CrUX and lab data only for the form factor you request. Call `analyze` twice (once per strategy) if you want both (each call consumes one quota unit).
- If analysis times out, the response is a 504 with a message that the target site is too slow. Retrying won't help — it runs Lighthouse from scratch with the same outcome likely.
- If Lighthouse itself fails (unreachable target, bad response), the response is a 502 with a Lighthouse error code (e.g. ERRORED_DOCUMENT).
- Response payload is typically 5–15 KB after trimming (screenshots + internal Lighthouse UI fields are dropped).

Example — top-line performance + CrUX check:
```python
result = client.pagespeed.analyze(url="https://example.com", strategy="mobile")

if result.performance_score is not None:
    print(f"Lab score: {result.performance_score * 100:.0f}/100")

if result.field_data and result.field_data.url_metrics:
    lcp = result.field_data.url_metrics.largest_contentful_paint_ms
    if lcp:
        print(f"Real-user LCP p75: {lcp.percentile:.0f}ms — {lcp.category}")
```

Example — surface top opportunities for an SEO writeup:
```python
for op in result.opportunities[:5]:
    savings = op.estimated_savings_ms
    print(f"- {op.title}: save {savings.lcp:.0f}ms LCP")
    for offender in op.offenders[:3]:
        print(f"    * {offender.url} (wasted {offender.wasted_bytes:.0f} bytes)")
```

Example — check third-party weight:
```python
for tp in result.third_parties[:5]:
    kb = tp.transfer_size_bytes / 1024
    print(f"- {tp.entity}: {kb:.0f} KB, {tp.main_thread_time_ms:.0f}ms main-thread")
```

### market

Competitive organic-search intelligence about any target domain (yours or a competitor's): the keywords a domain ranks for, related keyword ideas, a ranking/traffic overview, monthly rank history, bulk traffic estimates, top pages, and tech stack. The outside-world counterpart to `pages` and `keywords`, which serve your project's own data from Berlin storage. Paid third-party data, metered per request. System-wide; no user integration required.


#### client.market.ranked_keywords()

Get the keywords a domain ranks for in Google organic search, each with
search volume, CPC, competition, SERP position, the ranking page, and
estimated traffic value.

Signature:
```python
result = client.market.ranked_keywords(
    target="stripe.com",          # Required: domain to analyze
    location_code=2840,            # Optional: location code (2840 = USA)
    language_code="en",            # Optional: language code
    limit=100,                     # Optional: max results (default 100, max 1000)
    offset=0,                      # Optional: pagination offset
    order_by=["keyword_data.keyword_info.search_volume,desc"],  # Optional: sort directives
    filters=None,                  # Optional: filter expression (passed through)
)
```

Returns MarketRankedKeywordsResponse with a `keywords` list of
MarketRankedKeyword (keyword, search_volume, cpc, competition,
competition_level, rank, rank_group, url, domain, title, intent, etv).

Example:
```python
result = client.market.ranked_keywords(target="stripe.com", limit=50)
for kw in result.keywords:
    print(f"{kw.keyword}: rank={kw.rank}, volume={kw.search_volume}")
```

#### client.market.keyword_ideas()

Expand 1–200 seed keywords into semantically related keyword ideas, each
with search volume, CPC, competition, and keyword difficulty.

Signature:
```python
result = client.market.keyword_ideas(
    keywords=["payment processing", "online payments"],  # Required: seed keywords (1-200)
    location_code=2840,            # Optional
    language_code="en",            # Optional
    limit=100,                     # Optional: max results (default 100, max 1000)
    offset=0,                      # Optional
    order_by=["keyword_info.search_volume,desc"],  # Optional
    filters=None,                  # Optional
)
```

Returns MarketKeywordIdeasResponse with a `keywords` list of
MarketKeywordIdea (keyword, search_volume, cpc, competition,
competition_level, difficulty).

#### client.market.domain_overview()

Get a domain's organic + paid ranking and traffic snapshot.

Signature:
```python
overview = client.market.domain_overview(
    target="stripe.com",           # Required
    location_code=2840,            # Optional
    language_code="en",            # Optional
)
```

Returns MarketDomainOverview with `target`, `organic`, and `paid`,
where organic/paid are MarketTrafficMetrics (count, etv,
paid_traffic_cost, is_new/is_up/is_down/is_lost; pos_* buckets are absent
on this surface).

#### client.market.historical_rank_overview()

Get a domain's ranking/traffic metrics as a monthly time series — the
momentum signal.

Signature:
```python
result = client.market.historical_rank_overview(
    target="stripe.com",           # Required
    location_code=2840,            # Optional
    language_code="en",            # Optional
    date_from="2025-01-01",        # Optional: YYYY-MM-DD
    date_to="2025-12-31",          # Optional: YYYY-MM-DD
)
```

Returns MarketHistoricalRankOverviewResponse with a `history` list of
MarketRankHistoryPoint (year, month, organic, paid). On this surface
the organic/paid MarketTrafficMetrics include the pos_1 / pos_2_3 /
pos_4_10 / pos_11_plus rank buckets.

#### client.market.bulk_traffic_estimation()

Estimate organic/paid traffic for up to 1,000 domains in a single call —
for scoring a whole prospect or competitor list at once.

Signature:
```python
result = client.market.bulk_traffic_estimation(
    targets=["stripe.com", "adyen.com", "checkout.com"],  # Required (1-1000)
    location_code=2840,            # Optional
    language_code="en",            # Optional
    item_types=["organic", "paid"],  # Optional: SERP element types
)
```

Returns MarketBulkTrafficEstimationResponse with an `estimates` list of
MarketTrafficEstimate (target, organic, paid).

#### client.market.top_pages()

Get a domain's top pages ranked by organic traffic — what content actually
performs.

Signature:
```python
result = client.market.top_pages(
    target="stripe.com",           # Required
    location_code=2840,            # Optional
    language_code="en",            # Optional
    limit=100,                     # Optional: max results (default 100, max 1000)
    offset=0,                      # Optional
    order_by=["metrics.organic.etv,desc"],  # Optional
    filters=None,                  # Optional
)
```

Returns MarketTopPagesResponse with a `pages` list of
MarketTopPage (page_address, organic, paid).

#### client.market.technologies()

Detect the technology stack a domain runs on (CMS, analytics,
marketing/SEO tooling, frameworks) plus contact/identity signals.

Signature:
```python
stack = client.market.technologies(target="stripe.com")  # Required
```

Returns MarketTechStack with target, domain_rank, last_visited,
country_iso_code, emails, phone_numbers, social_graph_urls, and
technologies (category → subcategory → list of detected technologies).

### backlinks

Off-page / authority profile of any target domain (yours or a competitor's): the aggregate backlink summary, individual backlinks, referring domains, and link competitors. Paid third-party data, metered per request. System-wide; no user integration required.


#### client.backlinks.summary()

Get a target's aggregate backlink/authority profile — referring-domain
counts, total backlinks, backlink rank, spam score, and link-mix
distributions.

Signature:
```python
summary = client.backlinks.summary(
    target="stripe.com",            # Required: domain, subdomain, or page
    include_subdomains=True,        # Optional (default True)
    backlinks_status_type="live",   # Optional: "live" (default) | "lost" | "all"
)
```

Returns BacklinksSummary (target, rank, backlinks,
backlinks_spam_score, referring_domains, referring_main_domains,
referring_pages, referring_ips, broken_backlinks, and the open-ended
referring_links_types / _attributes / _platform_types / _countries maps).

#### client.backlinks.list()

List the individual backlinks pointing at a target, each with its source
page, anchor, dofollow flag, and rank.

Signature:
```python
result = client.backlinks.list(
    target="stripe.com",            # Required
    mode="as_is",                   # Optional: "as_is" | "one_per_domain" | "one_per_anchor"
    limit=100,                      # Optional: max results (default 100, max 1000)
    offset=0,                       # Optional
    order_by=["rank,desc"],         # Optional: sort directives
    filters=None,                   # Optional: filter expression
    include_subdomains=True,        # Optional (default True)
    backlinks_status_type="live",   # Optional: "live" (default) | "lost" | "all"
)
```

Returns BacklinksListResponse with a `backlinks` list of
Backlink (domain_from, url_from, domain_to, url_to, anchor,
item_type, dofollow, is_broken, rank, page_from_rank, domain_from_rank,
tld_from, domain_from_country, first_seen, last_seen).

#### client.backlinks.referring_domains()

Get the domains linking to a target, one aggregate row per linking domain
— the "who links to me" breakdown.

Signature:
```python
result = client.backlinks.referring_domains(
    target="stripe.com",                # Required
    limit=100,                          # Optional (default 100, max 1000)
    offset=0,                           # Optional
    order_by=["rank,desc"],             # Optional
    filters=None,                       # Optional
    include_subdomains=True,            # Optional (default True)
    exclude_internal_backlinks=True,    # Optional (default True)
    backlinks_status_type="live",       # Optional: "live" (default) | "lost" | "all"
)
```

Returns ReferringDomainsResponse with a `referring_domains` list
of ReferringDomain (domain, rank, backlinks, referring_domains,
referring_main_domains, referring_pages, backlinks_spam_score,
broken_backlinks, first_seen, lost_date).

#### client.backlinks.competitors()

Find the domains that share part of a target's backlink profile, ranked by
the number of intersecting referring domains — the off-page competitive set.

Signature:
```python
result = client.backlinks.competitors(
    target="stripe.com",                # Required
    limit=100,                          # Optional (default 100, max 1000)
    offset=0,                           # Optional
    order_by=["intersections,desc"],    # Optional
    filters=None,                       # Optional
    main_domain=True,                   # Optional (default True)
    exclude_large_domains=True,         # Optional: omit facebook.com etc. (default True)
    exclude_internal_backlinks=True,    # Optional (default True)
)
```

Returns BacklinkCompetitorsResponse with a `competitors` list of
BacklinkCompetitor (domain, rank, intersections, backlinks,
referring_domains).

### ai_mentions

AI-answer-engine visibility (AEO/GEO): how a brand, domain, or keyword is mentioned inside AI answers (ChatGPT & Google AI) — raw mention records, aggregate breakdowns, and the most-mentioned domains/pages. Paid third-party data, metered per request. System-wide; no user integration required. Distinct from `ai_search`, which asks an answer engine a question; ai_mentions measures who gets mentioned in AI answers.


#### client.ai_mentions.search()

Track how a brand, domain, or keyword surfaces inside AI answers (ChatGPT &
Google AI). Returns the raw mention records: the question asked, the
answer, the cited sources, and the AI search volume. Provide at least one
keyword or domain (up to 10 targets total).

NOTE: the upstream data supplier gates this API behind a monthly account
commitment; calls fail until it is active on the account.

Signature:
```python
result = client.ai_mentions.search(
    keywords=["best payment processor"],  # Provide keywords and/or domains
    domains=["stripe.com"],               # (at least one required)
    platform="google",                    # Optional: "google" (default) | "chat_gpt"
    location_code=2840,                   # Optional
    language_code="en",                   # Optional
    search_filter="include",              # Optional: "include" (default) | "exclude"
    search_scope=["question", "answer"],  # Optional: where to look
    match_type="word_match",              # Optional: "word_match" (default) | "partial_match"
    include_subdomains=False,             # Optional (domain targets)
    filters=None,                         # Optional
    order_by=None,                        # Optional
    limit=100,                            # Optional (default 100, max 1000)
    offset=0,                             # Optional
)
```

Returns AIMentionsResponse with a `mentions` list of
AIMention (platform, model_name, question, answer,
ai_search_volume, first_response_at, last_response_at, sources, brand_entities,
fan_out_queries).

#### client.ai_mentions.summary()

Get the aggregate AI-visibility profile for your keywords/domains — the
total mention count plus breakdowns by platform, cited source domain,
brand entity, location, and language. Provide at least one keyword or
domain (up to 10 targets total).

Signature:
```python
summary = client.ai_mentions.summary(
    domains=["stripe.com"],       # Provide keywords and/or domains
    platform="google",            # Optional: "google" (default) | "chat_gpt"
    location_code=2840,           # Optional
    language_code="en",           # Optional
    internal_list_limit=10,       # Optional: elements per breakdown, 1-20 (default 10)
)
```

Returns AIMentionsSummary (total_mentions, and the by_platform,
by_sources_domain, by_search_results_domain, by_brand_entity, by_location,
by_language breakdown lists of AIMentionBreakdown: key, mentions,
ai_search_volume).

#### client.ai_mentions.top_domains()

Get the domains most frequently mentioned in AI answers for your
keywords/domains — who gets surfaced instead of (or alongside) you.
Provide at least one keyword or domain (up to 10 targets total).

Signature:
```python
result = client.ai_mentions.top_domains(
    keywords=["best crm for startups"],  # Provide keywords and/or domains
    platform="google",                   # Optional: "google" (default) | "chat_gpt"
    location_code=2840,                   # Optional
    language_code="en",                   # Optional
    links_scope="sources",               # Optional: "sources" (default) | "search_results"
    items_list_limit=5,                  # Optional: ranked domains, 1-10 (default 5)
    internal_list_limit=5,               # Optional
)
```

Returns AIMentionTopDomainsResponse with a `domains` list of
AIMentionTopItem (key, mentions, ai_search_volume).

#### client.ai_mentions.top_pages()

Get the pages most frequently mentioned in AI answers for your
keywords/domains. Arguments mirror `top_domains`.

Returns AIMentionTopPagesResponse with a `pages` list of
AIMentionTopItem (key, mentions, ai_search_volume).

### email_marketing

The project's connected email service provider (ESP), e.g. Klaviyo — audiences, contacts, campaigns, templates, events, and suppressions. Requires an ESP integration on the project. Providers differ widely: call `capabilities()` first and branch on its flags. The intended campaign workflow is DRAFT-FIRST — `create_campaign()` creates a draft and returns `vendor_edit_url` so a human reviews and approves it in the ESP's own UI; prefer that over `send_campaign()`. Every method accepts an optional `provider` keyword, only needed when the project has more than one ESP integration connected.


#### client.email_marketing.capabilities()

Get the feature matrix of the connected ESP. Call this first and
branch on the flags instead of assuming a capability exists.

Signature:
```python
caps = client.email_marketing.capabilities(
    provider="klaviyo",  # Optional: only when multiple ESPs are connected
)
```

Returns EmailCapabilitiesResponse with:
- provider - The connected ESP, e.g. "klaviyo"
- capabilities - ESPCapabilities flags, including:
  - supports_lists / supports_segments / supports_tags - Available audience kinds
  - membership_write_kinds - Audience kinds that accept add/remove contacts
  - supports_segment_create / segment_create_manual_only - Segment creation support
  - supports_campaign_create_draft / supports_campaign_update - Campaign writes
  - supports_schedule / schedule_granularity_minutes / supports_send_now / supports_cancel_scheduled - Sending
  - supports_raw_html_content / requires_template / strips_style_tags - Content constraints
  - supports_campaign_metrics / metrics_include_bounces / metrics_include_revenue - Reporting
  - supports_suppression_read / supports_event_ingestion / supports_webhooks - Misc

Raises AgentBerlinNotFoundError if no ESP integration is connected.

Example:
```python
caps = client.email_marketing.capabilities()
if caps.capabilities.supports_raw_html_content:
    print(f"{caps.provider} accepts raw HTML campaigns")
```

#### client.email_marketing.list_audiences()

List audiences (lists, segments, and tags).

Signature:
```python
result = client.email_marketing.list_audiences(
    kinds=["list", "segment"],  # Optional: filter by kind ("list" | "segment" | "tag")
    cursor=None,                # Optional: pagination cursor
    page_size=50,               # Optional
    provider="klaviyo",         # Optional: only when multiple ESPs are connected
)
```

Returns EmailAudiencesListResponse with:
- provider - The connected ESP
- audiences - List of EmailAudience objects
- next_cursor - Cursor for the next page (None when exhausted)

Each EmailAudience has:
- external_id - Provider-side audience ID
- name - Audience name
- kind - "list", "segment", or "tag"
- member_count - Number of members (-1 when unknown)

Example:
```python
result = client.email_marketing.list_audiences(kinds=["list"])
for a in result.audiences:
    print(f"{a.name} ({a.external_id}): {a.member_count} members")
```

#### client.email_marketing.add_to_audience()

Add contacts to an audience. Check
`capabilities().capabilities.membership_write_kinds` — not every
audience kind accepts membership writes on every provider.

Signature:
```python
result = client.email_marketing.add_to_audience(
    "list_123",                     # Required: audience external_id
    ["a@example.com"],              # Required: emails to add
    mark_as_subscribed=True,        # Optional: also mark as subscribed (consent)
    provider="klaviyo",             # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.remove_from_audience()

Remove contacts from an audience.

Signature:
```python
result = client.email_marketing.remove_from_audience(
    "list_123",             # Required: audience external_id
    ["a@example.com"],      # Required: emails to remove
    provider="klaviyo",     # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.get_contact()

Look up a contact by email or provider-side ID. At least one
identifier is required (raises ValueError otherwise).

Signature:
```python
contact = client.email_marketing.get_contact(
    email="a@example.com",   # Provide email and/or external_id
    external_id=None,        # (at least one required)
    provider="klaviyo",      # Optional
)
```

Returns EmailContact with:
- provider - The connected ESP
- external_id - Provider-side contact ID
- email - Email address
- first_name / last_name - Names (optional)
- status - "subscribed" | "unsubscribed" | "non_subscribed" | "suppressed" | "unknown"
- custom_fields - Provider-side custom properties (optional)

Raises AgentBerlinNotFoundError if the contact does not exist.

#### client.email_marketing.upsert_contact()

Create or update a contact by email (the upsert key).

Signature:
```python
contact = client.email_marketing.upsert_contact(
    "a@example.com",                  # Required: email
    first_name="Ada",                 # Optional
    last_name="Lovelace",             # Optional
    custom_fields={"plan": "pro"},    # Optional
    provider="klaviyo",               # Optional
)
```

Returns EmailContact (same shape as get_contact).

#### client.email_marketing.create_campaign()

Create a campaign as a DRAFT in the ESP. Nothing is sent — a human
reviews and approves the draft in the ESP's own UI via the returned
`campaign.vendor_edit_url`. Prefer this draft + human-approval flow
over `send_campaign()`.

Signature:
```python
result = client.email_marketing.create_campaign(
    "July product update",            # Required: subject
    "Acme",                           # Required: from_name
    "news@acme.com",                  # Required: from_email
    ["list_123"],                     # Required: include_audience_ids
    name="2026-07 newsletter",        # Optional: internal campaign name
    preview_text="What's new",        # Optional
    reply_to="support@acme.com",      # Optional
    html="<html>...</html>",          # Optional: raw HTML body
    plain_text=None,                  # Optional
    template_external_id=None,        # Optional: use a template instead of raw HTML
    exclude_audience_ids=["seg_9"],   # Optional
    provider_options=None,            # Optional: provider-native extras
    provider="klaviyo",               # Optional
)
```

Returns EmailCampaignResponse with:
- provider - The connected ESP
- campaign - EmailCampaign object

Each EmailCampaign has:
- external_id - Provider-side campaign ID
- name - Campaign name
- status - "draft" | "scheduled" | "sending" | "sent" | "canceled"
- content - EmailCampaignContent (subject, preview_text, from_name, from_email, reply_to, html, plain_text, template_external_id)
- include_audience_ids / exclude_audience_ids - Audience targeting
- scheduled_at / sent_at - RFC3339 timestamps (optional)
- vendor_edit_url - Deep link to review/edit the draft in the ESP UI

Raises ValueError if subject, from_name, from_email, or
include_audience_ids is empty. Invalid drafts fail with a 422
carrying the provider's validation_errors.

Example:
```python
result = client.email_marketing.create_campaign(
    "July product update", "Acme", "news@acme.com", ["list_123"],
    html="<html>...</html>",
)
print(f"Draft ready for review: {result.campaign.vendor_edit_url}")
```

#### client.email_marketing.update_campaign()

Update an existing campaign; only provided fields change. Check
capabilities: supports_campaign_update and
supports_audience_update_after_create.

Signature:
```python
result = client.email_marketing.update_campaign(
    "cmp_456",                        # Required: campaign external_id
    subject="New subject",            # Optional content fields
    html="<html>...</html>",          # Optional
    include_audience_ids=["list_1"],  # Optional: replacement audiences
    provider="klaviyo",               # Optional
)
```

Returns EmailCampaignResponse (same shape as create_campaign).

#### client.email_marketing.get_campaign()

Get a single campaign.

Signature:
```python
result = client.email_marketing.get_campaign(
    "cmp_456",           # Required: campaign external_id
    provider="klaviyo",  # Optional
)
```

Returns EmailCampaignResponse (same shape as create_campaign).

#### client.email_marketing.list_campaigns()

List campaigns.

Signature:
```python
result = client.email_marketing.list_campaigns(
    status="draft",      # Optional: "draft" | "scheduled" | "sending" | "sent" | "canceled"
    cursor=None,         # Optional: pagination cursor
    page_size=50,        # Optional
    provider="klaviyo",  # Optional
)
```

Returns EmailCampaignsListResponse with:
- provider - The connected ESP
- campaigns - List of EmailCampaign objects
- next_cursor - Cursor for the next page (None when exhausted)

#### client.email_marketing.campaign_metrics()

Get performance metrics for a campaign. Open counts are inflated by
Apple Mail privacy protection — prefer clicks as the engagement
signal. Klaviyo caps this endpoint at 225 requests/day; cache
results instead of polling.

Signature:
```python
metrics = client.email_marketing.campaign_metrics(
    "cmp_456",           # Required: campaign external_id
    provider="klaviyo",  # Optional
)
```

Returns EmailCampaignMetrics with:
- provider - The connected ESP
- recipients / delivered - Delivery counts
- opens / unique_opens - Opens (inflated by Apple Mail privacy)
- clicks / unique_clicks - Clicks (the reliable engagement signal)
- bounces / unsubscribes / spam_complaints - Negative signals
- revenue - Attributed revenue where supported

Example:
```python
metrics = client.email_marketing.campaign_metrics("cmp_456")
ctr = metrics.unique_clicks / metrics.delivered if metrics.delivered else 0
print(f"CTR: {ctr:.2%}")
```

#### client.email_marketing.schedule_campaign()

Schedule a campaign for a future send time. Check capabilities:
schedule_granularity_minutes is the smallest increment the provider
accepts.

Signature:
```python
result = client.email_marketing.schedule_campaign(
    "cmp_456",                  # Required: campaign external_id
    "2026-08-01T09:00:00Z",     # Required: send_at (RFC3339)
    provider="klaviyo",         # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.send_campaign()

Send a campaign immediately to its audience. WARNING: this sends
real email right away — prefer the draft + human-approval flow
(create_campaign + review via vendor_edit_url) or schedule_campaign
so a human can still intervene.

Signature:
```python
result = client.email_marketing.send_campaign(
    "cmp_456",           # Required: campaign external_id
    provider="klaviyo",  # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.cancel_scheduled_campaign()

Cancel a scheduled campaign, reverting it to a draft.

Signature:
```python
result = client.email_marketing.cancel_scheduled_campaign(
    "cmp_456",           # Required: campaign external_id
    provider="klaviyo",  # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.create_segment()

Create a segment from an explicit email list OR a provider-native
definition — exactly one of the two (raises ValueError otherwise).
Check capabilities: segment_create_manual_only means only the emails
form is accepted.

Signature:
```python
audience = client.email_marketing.create_segment(
    "VIP customers",                       # Required: name
    emails=["a@example.com"],              # Either explicit emails...
    definition=None,                       # ...or a provider-native definition
    provider="klaviyo",                    # Optional
)
```

Returns EmailAudience for the newly created segment:
- external_id - Provider-side audience ID
- name - Segment name
- kind - "segment"
- member_count - Number of members (-1 when unknown)

Example:
```python
# Klaviyo-native definition (condition_groups JSON)
audience = client.email_marketing.create_segment(
    "Recent buyers",
    definition={"condition_groups": [...]},
)
```

#### client.email_marketing.list_templates()

List email templates.

Signature:
```python
result = client.email_marketing.list_templates(
    cursor=None,         # Optional: pagination cursor
    provider="klaviyo",  # Optional
)
```

Returns EmailTemplatesListResponse with:
- provider - The connected ESP
- templates - List of EmailTemplate objects (external_id, name)
- next_cursor - Cursor for the next page (None when exhausted)

#### client.email_marketing.create_template()

Create an email template. Check capabilities:
supports_template_create.

Signature:
```python
template = client.email_marketing.create_template(
    "Newsletter base",     # Required: name
    "<html>...</html>",    # Required: html
    provider="klaviyo",    # Optional
)
```

Returns EmailTemplate with:
- external_id - Provider-side template ID
- name - Template name

#### client.email_marketing.update_template()

Update an email template's HTML (and optionally its name).

Signature:
```python
template = client.email_marketing.update_template(
    "tpl_789",             # Required: template external_id
    "<html>...</html>",    # Required: new html
    name="Newsletter v2",  # Optional
    provider="klaviyo",    # Optional
)
```

Returns EmailTemplate (same shape as create_template).

#### client.email_marketing.track_event()

Track a custom event for a contact into the ESP. Check capabilities:
supports_event_ingestion.

Signature:
```python
result = client.email_marketing.track_event(
    "a@example.com",                   # Required: contact email
    "Viewed pricing",                  # Required: event name
    properties={"plan": "pro"},        # Optional
    timestamp="2026-07-17T10:00:00Z",  # Optional: RFC3339 (default: now)
    provider="klaviyo",                # Optional
)
```

Returns EmailMarketingActionResponse with:
- provider - The connected ESP
- success - Whether the operation succeeded

#### client.email_marketing.list_suppressed()

List suppressed contacts (excluded from all sends). Check
capabilities: supports_suppression_read.

Signature:
```python
result = client.email_marketing.list_suppressed(
    cursor=None,         # Optional: pagination cursor
    page_size=100,       # Optional
    provider="klaviyo",  # Optional
)
```

Returns SuppressedListResponse with:
- provider - The connected ESP
- contacts - List of SuppressedContact objects
- next_cursor - Cursor for the next page (None when exhausted)

Each SuppressedContact has:
- email - Email address
- reason - "unsubscribed" | "hard_bounce" | "soft_bounce" | "spam_complaint" | "manual" | "unknown"
- suppressed_at - RFC3339 timestamp (optional)

### google_ads

Daily Google Ads performance for the project's connected account: search terms, keywords (with quality score), campaigns, ad groups, and landing pages, plus the search-term coverage honesty metric. Read-only — the SDK never mutates campaigns or any other account state. Data is served from Berlin's warehouse, synced nightly from Google Ads (check `meta.data_as_of` on every response for freshness; it can be 24h+ stale). All cost values are micros: millionths of one unit of the account currency in `meta.currency_code` (cost_micros=1500000 with currency_code="USD" is $1.50). Google redacts below-threshold search terms, so pair `search_terms()` with `coverage()` for the honest spend denominator.


#### client.google_ads.search_terms()

Get daily search-term performance — the actual queries ads matched,
with the keyword and match type each query triggered.

Google redacts below-threshold search terms (a large share of spend
can be hidden), so always pair this with `coverage()` for the same
period to know how much spend the visible terms actually represent.

Signature:
```python
result = client.google_ads.search_terms(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
    limit=500,                # Optional: max rows (default 10000, max 100000)
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- limit: Maximum rows to return (default 10000, max 100000). The
  response sets truncated=True when the cap cut rows off.

Returns GoogleAdsSearchTermsResponse with:
- rows - List of GoogleAdsSearchTermRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each GoogleAdsSearchTermRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / campaign_name / ad_group_id / ad_group_name
- search_term - The actual user query
- status / match_type / keyword_text - The keyword the query matched
- impressions / clicks / cost_micros / conversions / conversions_value

Example:
```python
result = client.google_ads.search_terms(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
cov = client.google_ads.coverage(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
print(f"Visible terms cover {cov.coverage.coverage_ratio:.0%} of spend")
for row in result.rows:
    cost = row.cost_micros / 1_000_000
    print(f"{row.search_term}: {row.clicks} clicks, {cost:.2f} {result.meta.currency_code}")
```

#### client.google_ads.keywords()

Get daily bid-keyword performance with quality score and impression
share.

Quality-score fields (quality_score, creative_quality_score,
post_click_quality_score, search_predicted_ctr) are None for dates
before the integration was connected: Google exposes no quality-score
history, so it only accrues from the connect date via the nightly
sync. Metrics columns are genuinely historical everywhere.

Signature:
```python
result = client.google_ads.keywords(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
    limit=500,                # Optional: max rows (default 10000, max 100000)
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- limit: Maximum rows to return (default 10000, max 100000). The
  response sets truncated=True when the cap cut rows off.

Returns GoogleAdsKeywordsResponse with:
- rows - List of GoogleAdsKeywordRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each GoogleAdsKeywordRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / ad_group_id / criterion_id
- keyword_text / match_type / status
- quality_score / creative_quality_score / post_click_quality_score /
  search_predicted_ctr - None for dates before the integration connected
- impressions / clicks / cost_micros / conversions / conversions_value
- search_impression_share / search_rank_lost_impression_share - None
  when Google reports no value

Example:
```python
result = client.google_ads.keywords(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in result.rows:
    if row.quality_score is not None and row.quality_score < 5:
        print(f"Low QS {row.quality_score}: {row.keyword_text}")
```

#### client.google_ads.campaigns()

Get daily campaign rollups including budget, channel type, bidding
strategy, and impression-share metrics.

Signature:
```python
result = client.google_ads.campaigns(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
    limit=500,                # Optional: max rows (default 10000, max 100000)
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- limit: Maximum rows to return (default 10000, max 100000). The
  response sets truncated=True when the cap cut rows off.

Returns GoogleAdsCampaignsResponse with:
- rows - List of GoogleAdsCampaignRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each GoogleAdsCampaignRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / campaign_name / status
- advertising_channel_type / bidding_strategy_type
- budget_amount_micros - Daily budget in micros
- impressions / clicks / cost_micros / conversions / conversions_value
- search_impression_share / search_budget_lost_impression_share /
  search_rank_lost_impression_share - None when Google reports no value

Example:
```python
result = client.google_ads.campaigns(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in result.rows:
    spend = row.cost_micros / 1_000_000
    print(f"{row.date} {row.campaign_name}: {spend:.2f} {result.meta.currency_code}")
```

#### client.google_ads.ad_groups()

Get daily ad-group rollups. Unlike search terms, these totals are NOT
redacted — they are the denominator behind the `coverage()` metric.

Signature:
```python
result = client.google_ads.ad_groups(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
    limit=500,                # Optional: max rows (default 10000, max 100000)
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- limit: Maximum rows to return (default 10000, max 100000). The
  response sets truncated=True when the cap cut rows off.

Returns GoogleAdsAdGroupsResponse with:
- rows - List of GoogleAdsAdGroupRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each GoogleAdsAdGroupRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / ad_group_id / ad_group_name / status
- impressions / clicks / cost_micros / conversions / conversions_value
- search_impression_share / search_rank_lost_impression_share - None
  when Google reports no value

Example:
```python
result = client.google_ads.ad_groups(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in result.rows:
    print(f"{row.ad_group_name}: {row.impressions} impressions")
```

#### client.google_ads.landing_pages()

Get daily landing-page performance keyed by real (expanded) final
URLs, joinable against the crawled page index (`client.pages`).

Signature:
```python
result = client.google_ads.landing_pages(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
    limit=500,                # Optional: max rows (default 10000, max 100000)
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)
- limit: Maximum rows to return (default 10000, max 100000). The
  response sets truncated=True when the cap cut rows off.

Returns GoogleAdsLandingPagesResponse with:
- rows - List of GoogleAdsLandingPageRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each GoogleAdsLandingPageRow has:
- date - Day (YYYY-MM-DD)
- expanded_final_url - The real final URL ads sent traffic to
- impressions / clicks / cost_micros / conversions

Example:
```python
result = client.google_ads.landing_pages(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in result.rows:
    print(f"{row.expanded_final_url}: {row.clicks} clicks")
```

#### client.google_ads.coverage()

Get the search-term visibility ratio for a period: how much of the
account's total spend the (redacted) search-term report actually
covers. Google hides below-threshold terms, so visible search terms
can represent anywhere from a small fraction to nearly all of spend —
surface this next to any `search_terms()` analysis so conclusions are
honest about their denominator.

Signature:
```python
cov = client.google_ads.coverage(
    start_date="2026-06-01",  # Required: YYYY-MM-DD format
    end_date="2026-06-30",    # Required: YYYY-MM-DD format
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (required)
- end_date: End date in YYYY-MM-DD format (required)

Returns GoogleAdsCoverageResponse with:
- coverage - GoogleAdsCoverage object
- meta - GoogleAdsMeta (source, customer_id, currency_code,
  data_as_of, backfill_complete, integration_error)

The GoogleAdsCoverage object has:
- visible_cost_micros - Spend attributed to visible search terms
- total_cost_micros - Total spend (from unredacted ad-group rollups)
- coverage_ratio - visible/total in [0, 1]; 0 when there is no spend

Example:
```python
cov = client.google_ads.coverage(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
print(f"Search terms cover {cov.coverage.coverage_ratio:.0%} of spend")
```

### openai_ads

Performance for the project's connected OpenAI (ChatGPT) Ads account — ads shown inside ChatGPT. Read-only — the SDK never mutates campaigns or any account state. Data is read LIVE from the customer's Advertiser API key on each call (there is no warehouse; the OpenAI insights API is synchronous REST with 5-year retention), so there is no `data_as_of` staleness. OpenAI Ads has NO keywords and NO search terms — reporting exposes only six metrics: impressions, clicks, spend, ctr, cpc, cpm. Insights money (spend/cpc/cpm) is in the account currency in `meta.currency_code`, NOT micros (campaign budget limits ARE micros). The insight methods mirror the platform's aggregation levels — `account()`, `campaigns()`, `ad_groups()`, `ads()` — and `list_campaigns()` returns campaign metadata (names, statuses, budgets, bidding type).


#### client.openai_ads.account()

Get account-level performance totals (one row per time bucket), read
live from the connected Advertiser API key.

Signature:
```python
totals = client.openai_ads.account(
    start_date="2026-07-01",  # Optional: YYYY-MM-DD (default: 30 days ago)
    end_date="2026-07-31",    # Optional: YYYY-MM-DD (default: today)
    granularity="daily",      # Optional: daily | hourly | monthly | none
    segment="country",        # Optional: product | country | device
)
```

Args:
- start_date: Start date in YYYY-MM-DD format (optional; defaults to 30 days ago)
- end_date: End date in YYYY-MM-DD format (optional; defaults to today)
- granularity: Time bucket — daily (default), hourly, monthly, or none
- segment: Optional single breakdown — product, country, or device

Returns OpenAIAdsInsightsResponse with:
- rows - List of OpenAIAdsInsightRow objects
- row_count - Number of rows returned
- meta - OpenAIAdsMeta (source, account_id, currency_code, timezone)

Each OpenAIAdsInsightRow has:
- entity_id / entity_name - The entity at the report level (empty at account level)
- status / review_status - Entity states where applicable
- start_time / end_time / readable_time - The time bucket
- segment / segment_value - Present when a segment was requested
- impressions / clicks / spend / ctr / cpc / cpm - Money in meta.currency_code (not micros)

Example:
```python
totals = client.openai_ads.account(start_date="2026-07-01", end_date="2026-07-31")
for row in totals.rows:
    print(f"{row.readable_time}: {row.spend:.2f} {totals.meta.currency_code}")
```

#### client.openai_ads.campaigns()

Get per-campaign performance (one row per campaign per time bucket),
read live. Use `list_campaigns()` for budgets and bidding type.

Signature:
```python
report = client.openai_ads.campaigns(
    start_date="2026-07-01",  # Optional: YYYY-MM-DD (default: 30 days ago)
    end_date="2026-07-31",    # Optional: YYYY-MM-DD (default: today)
    granularity="daily",      # Optional: daily | hourly | monthly | none
    segment="device",         # Optional: product | country | device
)
```

Args:
- start_date / end_date: YYYY-MM-DD (optional; default last 30 days)
- granularity: daily (default), hourly, monthly, or none
- segment: Optional single breakdown — product, country, or device

Returns OpenAIAdsInsightsResponse (rows, row_count, meta). Each row's
entity_id/entity_name identify the campaign; money is in
meta.currency_code, not micros.

Example:
```python
report = client.openai_ads.campaigns(start_date="2026-07-01", end_date="2026-07-31")
for row in report.rows:
    print(f"{row.entity_name}: {row.clicks} clicks, {row.spend:.2f} {report.meta.currency_code}")
```

#### client.openai_ads.ad_groups()

Get per-ad-group performance (one row per ad group per time bucket),
read live.

Signature:
```python
report = client.openai_ads.ad_groups(
    start_date="2026-07-01",  # Optional: YYYY-MM-DD (default: 30 days ago)
    end_date="2026-07-31",    # Optional: YYYY-MM-DD (default: today)
    granularity="daily",      # Optional: daily | hourly | monthly | none
    segment=None,             # Optional: product | country | device
)
```

Args:
- start_date / end_date: YYYY-MM-DD (optional; default last 30 days)
- granularity: daily (default), hourly, monthly, or none
- segment: Optional single breakdown — product, country, or device

Returns OpenAIAdsInsightsResponse (rows, row_count, meta).

Example:
```python
for row in client.openai_ads.ad_groups().rows:
    print(f"{row.entity_name}: {row.impressions} impressions")
```

#### client.openai_ads.ads()

Get per-ad performance (one row per ad per time bucket), read live.
Each row includes the ad's review_status — where an OAI-AdsBot crawl
rejection surfaces per ad.

Signature:
```python
report = client.openai_ads.ads(
    start_date="2026-07-01",  # Optional: YYYY-MM-DD (default: 30 days ago)
    end_date="2026-07-31",    # Optional: YYYY-MM-DD (default: today)
    granularity="daily",      # Optional: daily | hourly | monthly | none
    segment=None,             # Optional: product | country | device
)
```

Args:
- start_date / end_date: YYYY-MM-DD (optional; default last 30 days)
- granularity: daily (default), hourly, monthly, or none
- segment: Optional single breakdown — product, country, or device

Returns OpenAIAdsInsightsResponse (rows, row_count, meta).

Example:
```python
for row in client.openai_ads.ads().rows:
    print(f"{row.entity_name} [{row.review_status}]: {row.clicks} clicks")
```

#### client.openai_ads.list_campaigns()

List the connected account's campaigns with their metadata (names,
statuses, bidding type, budgets) — the context for interpreting the
insight rows. Read live.

Signature:
```python
campaigns = client.openai_ads.list_campaigns()
```

Args: none (uses the project's connected OpenAI Ads account).

Returns OpenAIAdsCampaignsResponse with:
- rows - List of OpenAIAdsCampaignRow objects
- row_count - Number of campaigns returned
- meta - OpenAIAdsMeta (source, account_id, currency_code, timezone)

Each OpenAIAdsCampaignRow has:
- id / name / description / status
- bidding_type - "impressions" (CPM) or "clicks" (CPC); immutable
- mode - "product_feed" for feed campaigns
- budget - OpenAIAdsBudget (lifetime_spend_limit_micros or
  daily_spend_limit_micros; these ARE micros)
- start_time / end_time / created_at / updated_at - unix seconds

Example:
```python
for c in client.openai_ads.list_campaigns().rows:
    print(f"{c.name} [{c.status}] bidding={c.bidding_type}")
```

#### client.openai_ads.record_hint_set()

Record a generated set of ad-group `context_hints` (and the topics they
came from) for history. OpenAI keeps NO history of past context_hints
and its insights API never reports them, so this is the only record of
which hints an ad group carried — persist each set you generate so you
can later attribute ad-group performance to it. This writes to Berlin
only; it does NOT apply the hints to the ad group (applying is a
product-mediated action outside the SDK).

Signature:
```python
hs = client.openai_ads.record_hint_set(
    hints=["trail running shoes", "gore-tex boots"],  # Required, non-empty
    source_topics=["trail-running", "waterproof-footwear"],  # Optional: provenance
    ad_group_id="adgrp_301",  # Optional: set once applied
    account_id="adacct_123",  # Optional: defaults to the connected account
    label="Trail Q3",         # Optional
)
```

Args:
- hints: The context_hints (free-text topic phrases). Required, non-empty.
- source_topics: Tracked topics the hints were derived from (provenance).
- ad_group_id: The ad group the hints were applied to, if any.
- account_id: Connected ad account id (defaults to the project's account).
- label: Optional human label.

Returns the stored OpenAIAdsHintSet (id, project_domain, account_id,
ad_group_id, label, hints, source_topics, created_at).

Example:
```python
hs = client.openai_ads.record_hint_set(
    hints=["trail running shoes"],
    source_topics=["trail-running"],
    ad_group_id="adgrp_301",
)
print(hs.id, hs.created_at)
```

#### client.openai_ads.list_hint_sets()

List recorded hint sets for the project, newest first. Join these
against `ad_groups()` insights to see which hint sets performed.

Signature:
```python
result = client.openai_ads.list_hint_sets(
    ad_group_id="adgrp_301",  # Optional: filter to one ad group
    limit=20,                 # Optional
)
```

Args:
- ad_group_id: Restrict to hint sets applied to one ad group.
- limit: Maximum records to return (default: all).

Returns OpenAIAdsHintSetsResponse with `hint_sets` (List[OpenAIAdsHintSet])
and `count`.

Example:
```python
for hs in client.openai_ads.list_hint_sets().hint_sets:
    print(hs.label, hs.hints)
```
