Metadata-Version: 2.4
Name: agentberlin
Version: 0.176.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
- **content**: `get()`, `save()` - Read and write the content draft attached to a content action
- **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
- **microsoft_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `share_of_voice()`, `coverage()` - Microsoft Advertising performance reports (read-only, nightly-synced warehouse; money values are decimal, not micros; share-of-voice keeps ~6 months of history)
- **linkedin_ads**: `account()`, `campaign_groups()`, `campaigns()`, `creatives()`, `list_campaigns()` - LinkedIn Ads (paid social) performance insights read live from the connected ad account via the /adAnalytics finder (read-only; no keywords/search terms; `cost_in_local_currency` in account currency, not micros)

## API Reference

### content

Read and write the content draft attached to an action.

Content is always attached to an action — there is no standalone content
library. Only content actions carry a draft (writing or updating a blog
post, or writing a LinkedIn post); asking for one on any other action
returns an error.

Publishing is deliberately not exposed. It puts content on the customer's
own site — or on a named person's LinkedIn profile — and closes an action
on a shared workspace, so it stays a human decision made in the dashboard.

**Where the copy goes.** When you propose a piece of content, the copy
belongs in the draft, written here — never in the action's `description`.
The description is the brief: why this post, what claim it makes, what to
watch out for. The draft is the post. They open in different places, and
only the draft opens in an editor, so copy left in a description is
something a person has to retype before they can publish it.

So proposing content is two steps: create the action with the brief, then
immediately `save()` the copy to its draft. An action whose description
contains the copy is the mistake this is here to prevent.

**A LinkedIn post is the same document**, written as paragraph blocks:

```python
{
    "version": 1,
    "type": "linkedin_post",
    "meta": {"title": "Claim 'AI Orchestrator' as the FDM"},
    "blocks": [
        {"type": "paragraph", "paragraph_text": "The hook line."},
        {"type": "paragraph", "paragraph_text": "The point, and its follow-up."},
    ],
}
```

Only text and images survive publishing — headings, tables, lists and
embeds are flattened or dropped, because LinkedIn's post body is one
plain string. The limit is 3000 characters, counted the way LinkedIn
counts them (an emoji costs two). Line breaks are the only formatting
there is, so write the breaks you want.

Do not use `client.linkedin.post()` for a post that has an action. That
publishes immediately, records nothing against the action and skips human
review; it exists for posts that were never proposed as work. A post
attached to an action is published by a person from the composer, which
is what fills in the post URL and closes the action.

The document is a block structure. A blog post looks like:

```python
{
    "version": 1,
    "type": "blog",
    "meta": {
        "title": "How to choose a data warehouse",
        "meta_description": "...",
        "slug": "how-to-choose-a-data-warehouse",
        "author": {"name": "Ada Lovelace", "bio": "...", "same_as": ["https://x.com/ada"]},
        "primary_keyword": "data warehouse",
        "target_queries": ["what is a data warehouse"],
        "featured_image": {"url": "https://...", "alt": "..."},
    },
    "blocks": [
        {"type": "heading", "heading_level": 2, "heading_text": "What is a data warehouse?"},
        {"type": "paragraph", "paragraph_text": "A data warehouse is <strong>..."},
        {"type": "list", "list_type": "ordered", "list_items": ["First", "Second"], "is_procedure": True},
        {"type": "table", "table_headers": ["Tool", "Price"], "table_rows": [["A", "$10"]]},
        {"type": "faq", "faq_items": [{"question": "Is it hard?", "answer": "No."}]},
        {"type": "callout", "callout_variant": "takeaways", "callout_items": ["Point one"]},
        {"type": "quote", "quote_text": "...", "quote_author": "Ada", "quote_source_url": "https://..."},
        {"type": "image", "image_url": "https://...", "image_alt_text": "...", "is_featured": True},
        {"type": "video", "video_url": "https://...", "video_transcript": "..."},
        {"type": "code", "code_content": "SELECT 1", "code_language": "sql"},
    ],
    "sources": [{"id": "s1", "title": "...", "url": "https://...", "publisher": "..."}],
}
```

Text fields accept a small inline HTML subset — `<strong>`, `<em>`,
`<code>`, `<sup>`, `<a href>`. Anything else is stripped on save.


#### client.content.get()

Get the draft attached to an action.

Signature:
```python
draft = client.content.get(action_id="abc123")
```

Args:
- action_id: The action whose draft to read (required)

Returns ContentDraftResponse with `draft_id`, `action_id`, `status`,
`version`, `doc` and `published_url`. Raises if the action has no draft
yet — call `save()` to create one.

#### client.content.save()

Write a document to an action's draft, creating it if it doesn't exist.

The previous document is snapshotted first, so a person can always get
back what they had before you touched it.

Signature:
```python
draft = client.content.get(action_id="abc123")
draft.doc["blocks"].append({"type": "paragraph", "paragraph_text": "One more point."})

client.content.save(
    action_id="abc123",
    doc=draft.doc,
    version=draft.version,     # optional but strongly recommended
    note="added a closing paragraph",
)
```

Args:
- action_id: The action to attach the draft to (required)
- doc: The content document (required)
- version: The version you read. When supplied, the write is rejected
  with a conflict if the draft changed in the meantime. Always pass it
  when you are editing rather than replacing, so you don't overwrite a
  change someone made while you were working — re-read and re-apply on
  conflict.
- note: Short description of the change, recorded in the draft history

Returns ContentDraftResponse with the saved document and its new version.

### 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.6-terra, 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.6-terra
- 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)
    images=["chart.png"],              # Optional: image FILES (max 9); see below
)
```

Images are local file paths, uploaded for you before the post is
created. JPEG, PNG or GIF, 8MB each. A post is a link share or an
image share — `article_url` and `images` are mutually exclusive.

Give each image alt text: it is the only description a screen reader
gets on a public post, and it cannot be added afterwards.
```python
images=[{"path": "revenue.png", "alt": "Revenue by segment, Q1-Q3"}]
```

A URL is not accepted. LinkedIn will not fetch an image from one, and
Berlin publishes only bytes it stored itself — so download the file
here in the sandbox first and pass its path. If any image fails to
upload, NOTHING is published.

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

### microsoft_ads

Daily Microsoft Advertising performance for the project's connected account: search terms, keywords (with quality score), campaigns, ad groups, landing pages, and keyword share of voice, 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 Microsoft Ads (check `meta.data_as_of` on every response for freshness; it can be 24h+ stale). Unlike Google Ads, all money values are DECIMAL in the account currency in `meta.currency_code` (spend=1.50 with currency_code="USD" is $1.50) — not micros, so no division is needed; all IDs are strings. Microsoft redacts below-threshold search terms, so pair `search_terms()` with `coverage()` for the honest spend denominator. Share of voice keeps only ~6 months of history.


#### client.microsoft_ads.search_terms()

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

Microsoft 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.microsoft_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 MicrosoftAdsSearchTermsResponse with:
- rows - List of MicrosoftAdsSearchTermRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsSearchTermRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / campaign_name / ad_group_id / ad_group_name
- search_term - The actual user query
- keyword_id / keyword_text / match_type - The keyword the query matched
- currency_code
- impressions / clicks / spend / conversions / revenue - spend and
  revenue are decimal amounts in the account currency (not micros)

Example:
```python
result = client.microsoft_ads.search_terms(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
cov = client.microsoft_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:
    print(f"{row.search_term}: {row.clicks} clicks, {row.spend:.2f} {result.meta.currency_code}")
```

#### client.microsoft_ads.keywords()

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

Unlike Google Ads, the quality columns (quality_score, expected_ctr,
ad_relevance, landing_page_experience) are genuine per-day history:
Microsoft populates them for backfilled months too, not just from the
connect date. Metrics columns are historical everywhere.

Signature:
```python
result = client.microsoft_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 MicrosoftAdsKeywordsResponse with:
- rows - List of MicrosoftAdsKeywordRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsKeywordRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / ad_group_id / keyword_id
- keyword_text / status / currency_code
- quality_score / expected_ctr / ad_relevance / landing_page_experience
  - genuine per-day history (populated for backfilled months too)
- impressions / clicks / spend / conversions / revenue - decimal, not micros
- impression_share / impression_lost_to_rank_share /
  impression_lost_to_budget_share - None when Microsoft reports no value

Example:
```python
result = client.microsoft_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.microsoft_ads.campaigns()

Get daily campaign rollups including campaign type and
impression-share metrics.

Signature:
```python
result = client.microsoft_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 MicrosoftAdsCampaignsResponse with:
- rows - List of MicrosoftAdsCampaignRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsCampaignRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / campaign_name / status / campaign_type / currency_code
- impressions / clicks / spend / conversions / revenue - decimal, not micros
- impression_share / impression_lost_to_budget_share /
  impression_lost_to_rank_share - None when Microsoft reports no value

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

#### client.microsoft_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.microsoft_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 MicrosoftAdsAdGroupsResponse with:
- rows - List of MicrosoftAdsAdGroupRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsAdGroupRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / ad_group_id / ad_group_name / status / currency_code
- impressions / clicks / spend / conversions / revenue - decimal, not micros
- impression_share / impression_lost_to_rank_share /
  impression_lost_to_budget_share - None when Microsoft reports no value

Example:
```python
result = client.microsoft_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.microsoft_ads.landing_pages()

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

Signature:
```python
result = client.microsoft_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 MicrosoftAdsLandingPagesResponse with:
- rows - List of MicrosoftAdsLandingPageRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsLandingPageRow has:
- date - Day (YYYY-MM-DD)
- final_url - The final URL ads sent traffic to
- currency_code
- impressions / clicks / spend / conversions - spend is decimal, not micros

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

#### client.microsoft_ads.share_of_voice()

Get daily keyword share-of-voice (auction competitiveness): impression
and click share, plus how much share was lost to rank or budget.

Microsoft retains only ~6 months of share-of-voice history, so a
start_date further back than that returns no rows for the older dates
even when the rest of the account has deeper history.

Signature:
```python
result = client.microsoft_ads.share_of_voice(
    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 MicrosoftAdsShareOfVoiceResponse with:
- rows - List of MicrosoftAdsShareOfVoiceRow objects
- row_count - Number of rows returned
- truncated - True when the row limit cut rows off
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

Each MicrosoftAdsShareOfVoiceRow has:
- date - Day (YYYY-MM-DD)
- campaign_id / ad_group_id / keyword_id / keyword_text / match_type
- impression_share / click_share / exact_match_impression_share /
  impression_lost_to_rank_share / impression_lost_to_budget_share /
  absolute_top_impression_share / top_impression_share - None when
  Microsoft reports no value

Example:
```python
result = client.microsoft_ads.share_of_voice(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in result.rows:
    if row.impression_share is not None and row.impression_share < 0.5:
        print(f"Losing impressions: {row.keyword_text}")
```

#### client.microsoft_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. Microsoft 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.microsoft_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 MicrosoftAdsCoverageResponse with:
- coverage - MicrosoftAdsCoverage object
- meta - MicrosoftAdsMeta (source, account_id, currency_code,
  data_as_of, backfill_complete, integration_error)

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

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

### linkedin_ads

LinkedIn Ads (paid social) performance for the project's connected ad account: account, campaign-group, campaign, and creative insights, plus a campaign listing. Read-only — the SDK never mutates campaigns or any other account state. Data is read LIVE via LinkedIn's /adAnalytics finder on every call (no warehouse, so no `data_as_of` staleness). This is paid SOCIAL, not paid search: there are NO keywords and NO search terms. Money (`cost_in_local_currency`) is a DECIMAL in the account currency in `meta.currency_code` — not micros, so no division is needed; all IDs are URNs kept as strings.


#### client.linkedin_ads.account()

Get account-level performance totals (one row per time bucket).

Signature:
```python
result = client.linkedin_ads.account(
    start_date="2026-06-01",  # Optional: YYYY-MM-DD (default 30 days ago)
    end_date="2026-06-30",    # Optional: YYYY-MM-DD (default today)
    granularity="daily",      # Optional: daily (default) | monthly | yearly | all
)
```

Returns LinkedInAdsInsightsResponse with:
- rows - List of LinkedInAdsInsightRow objects
- row_count - Number of rows returned
- meta - LinkedInAdsMeta (source, account_id, currency_code)

Each LinkedInAdsInsightRow has:
- pivot_value - URN of the pivoted entity (empty at account level)
- start / end - Time-bucket bounds (YYYY-MM-DD)
- impressions / clicks
- cost_in_local_currency / cost_in_usd - decimal, not micros
- conversions / one_click_leads / likes / comments / shares / follows

#### client.linkedin_ads.campaign_groups()

Per-campaign-group performance (one row per group per time bucket). Same
args and shape as `account()`; each row's `pivot_value` is the campaign
group URN.

#### client.linkedin_ads.campaigns()

Per-campaign performance (one row per campaign per time bucket). Same
args and shape as `account()`; each row's `pivot_value` is the campaign
URN. Pair with `list_campaigns()` for names, budgets, and type.

Example:
```python
report = client.linkedin_ads.campaigns(
    start_date="2026-06-01",
    end_date="2026-06-30",
)
for row in report.rows:
    print(f"{row.pivot_value}: {row.clicks} clicks, "
          f"{row.cost_in_local_currency:.2f} {report.meta.currency_code}")
```

#### client.linkedin_ads.creatives()

Per-creative performance (one row per creative per time bucket). Same
args and shape as `account()`; each row's `pivot_value` is the creative
URN.

#### client.linkedin_ads.list_campaigns()

List the connected account's campaigns with metadata for interpreting
the insight rows.

Signature:
```python
result = client.linkedin_ads.list_campaigns()
```

Returns LinkedInAdsCampaignsResponse with:
- rows - List of LinkedInAdsCampaignRow objects
- row_count - Number of campaigns returned
- meta - LinkedInAdsMeta (source, account_id, currency_code)

Each LinkedInAdsCampaignRow has:
- id / name / status
- type - SPONSORED_UPDATES | TEXT_AD | SPONSORED_INMAILS | DYNAMIC
- cost_type - CPC | CPM | CPV
- daily_budget / total_budget / unit_cost - decimal-string money objects
  in the account currency (amount + currency_code)
- campaign_group - the parent campaign group URN
- audience_expansion_enabled / run_schedule / targeting_criteria

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