diff --git a/README.md b/README.md index 52682d8..d7141c8 100644 --- a/README.md +++ b/README.md @@ -211,9 +211,9 @@ except FopostError as err: | `contacts` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `import_csv`, `list_fields`, `create_field`, `update_field`, `delete_field`, `conversation_analytics` | | `broadcasts` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | | `sequences` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | -| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `authorize_meta`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `goals`, `catalogs`, `create_catalog`, `get_catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set`, `reach_frequency`, `create_reach_frequency`, `get_reach_frequency`, `reserve_reach_frequency`, `cancel_reach_frequency`, `library`, `partnership_creators`, `request_partnership`, `revoke_partnership`, `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `get_study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set` | +| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `goals`, `catalogs`, `create_catalog`, `get_catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set`, `reach_frequency`, `create_reach_frequency`, `get_reach_frequency`, `reserve_reach_frequency`, `cancel_reach_frequency`, `library`, `partnership_creators`, `request_partnership`, `revoke_partnership`, `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `get_study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | | `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` | -| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `authorize_meta`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page` | +| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | | `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | | `validate` | `post`, `length`, `media` | | `activity` | `list` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 30362aa..98bacf5 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -50,6 +50,8 @@ AdIdentity, AdInsights, AdInsightsReport, + AdLibraryPage, + AdProvider, AdSet, AdSetNode, AdSource, @@ -58,6 +60,7 @@ Audience, AudienceFilter, AudiencesResult, + BidPricing, BlueskyLanguages, BoostablePost, Broadcast, @@ -76,6 +79,8 @@ ContentSignal, ConversationAnalytics, ConversationAnalyticsRow, + ConversionMetrics, + ConversionRule, Delivery, DiscordChannel, DiscordIdentity, @@ -162,6 +167,7 @@ SlackMember, SocialAccount, SparkPost, + SupplyForecast, TargetingOption, TelegramBotCommand, TelegramBotCommands, @@ -206,6 +212,8 @@ "AdConnection", "AdCreative", "AdInsights", + "AdLibraryPage", + "AdProvider", "AdInsightsReport", "AdSet", "AdSetNode", @@ -370,5 +378,9 @@ "LeadPageSubscription", "LeadsFeedPage", "NetworkAd", + "BidPricing", + "ConversionMetrics", + "ConversionRule", "ReachEstimate", + "SupplyForecast", ] diff --git a/src/fopost/models.py b/src/fopost/models.py index dab2af9..3cf1f4d 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1093,10 +1093,24 @@ class AdConnection(FopostModel): workspace_id: str | None = None +class AdProvider(FopostModel): + """An ad network from the API's registry. ``configured`` false cannot be connected yet.""" + + id: str + name: str + logo: str | None = None + configured: bool = False + connect_methods: list[str] = [] + capabilities: dict[str, bool] = {} + targeting_facets: list[str] = [] + tracking_macros: list[dict[str, Any]] = [] + + class AdSource(FopostModel): """A connection with the ad accounts and Pages its grant reaches.""" connection_id: str + provider: str | None = None name: str workspace_id: str | None = None ad_accounts: list[dict[str, Any]] = [] @@ -1334,6 +1348,49 @@ class ReachEstimate(FopostModel): ready: bool = False +class BidPricing(FopostModel): + """What the auction costs, in minor units of the ad account currency.""" + + currency: str | None = None + suggested_bid_minor: int | None = None + min_bid_minor: int | None = None + max_bid_minor: int | None = None + daily_budget_floor_minor: int | None = None + + +class SupplyForecast(FopostModel): + """What an audience would deliver at a budget, over the network's own window.""" + + currency: str | None = None + impressions: int | None = None + clicks: int | None = None + spend_minor: int | None = None + window_days: int | None = None + ready: bool = False + + +class ConversionRule(FopostModel): + id: str + name: str + type: str | None = None + attribution: str | None = None + post_click_window_days: int = 30 + view_through_window_days: int = 7 + value_minor: int | None = None + currency: str | None = None + enabled: bool = True + created_at: str | None = None + campaign_ids: list[str] = [] + + +class ConversionMetrics(FopostModel): + conversions: int = 0 + post_click_conversions: int = 0 + view_through_conversions: int = 0 + value_minor: int = 0 + cost_per_conversion_minor: int | None = None + + class LeadFormDetail(LeadForm): page_id: str | None = None privacy_policy_url: str | None = None diff --git a/src/fopost/resources/ads.py b/src/fopost/resources/ads.py index 881691f..b272467 100644 --- a/src/fopost/resources/ads.py +++ b/src/fopost/resources/ads.py @@ -28,15 +28,19 @@ AdInsightsReport, AdLabel, AdLibraryPage, + AdProvider, AdSet, AdSource, AdStudy, Audience, AudiencesResult, + BidPricing, BoostablePost, BulkAdStatusResult, CatalogBatchResult, CatalogProductsPage, + ConversionMetrics, + ConversionRule, ExternalAd, HighDemandPeriod, IosCampaignLimits, @@ -57,6 +61,7 @@ ReachFrequencyPrediction, ReachFrequencyResult, SparkPost, + SupplyForecast, TargetingOption, ValueRuleSet, ) @@ -100,19 +105,34 @@ def sources(self, *, workspace_id: str | None = None) -> builtins.list[AdSource] AdSource, unwrap(self._http.get("/ads/sources", {"workspace_id": workspace_id})) ) - def authorize_meta( - self, *, workspace_id: str, method: str | None = None, return_to: str | None = None + def providers(self) -> builtins.list[AdProvider]: + """The ad networks this deployment knows, with what each one supports.""" + return parse_list(AdProvider, unwrap(self._http.get("/ads/providers"))) + + def authorize( + self, + provider: str, + *, + workspace_id: str, + method: str | None = None, + return_to: str | None = None, ) -> str: - """The Meta login URL; the caller finishes it in their own browser.""" + """The network's login URL; the caller finishes it in their own browser.""" body: dict[str, Any] = {"workspaceId": workspace_id} if method is not None: body["method"] = method if return_to is not None: body["returnTo"] = return_to - result = unwrap(self._http.post("/ads/connections/meta/authorize", body)) + result = unwrap(self._http.post(f"/ads/connections/{provider}/authorize", body)) url = result.get("url") if isinstance(result, dict) else None return str(url) if url else "" + def authorize_meta( + self, *, workspace_id: str, method: str | None = None, return_to: str | None = None + ) -> str: + """Deprecated: use ``authorize("meta", ...)``.""" + return self.authorize("meta", workspace_id=workspace_id, method=method, return_to=return_to) + def authorize_google(self, *, workspace_id: str, return_to: str | None = None) -> str: """The Google login URL; the caller finishes it in their own browser.""" body: dict[str, Any] = {"workspaceId": workspace_id} @@ -705,6 +725,272 @@ def add_audience_users( added = result.get("added") if isinstance(result, dict) else None return int(added) if added is not None else 0 + def add_audience_companies( + self, + audience_id: str, + *, + workspace_id: str, + connection_id: str, + companies: Sequence[Mapping[str, Any]], + ) -> int: + """Add companies to a company-list audience. Returns the count the network took. + + Each row needs a ``name``, ``domain``, ``pageUrl`` or ``ticker``. The rows + travel with the request and are never stored. + """ + result = unwrap( + self._http.request( + "POST", + f"/ads/audiences/{audience_id}/companies", + json={"companies": [dict(c) for c in companies]}, + params={"workspace_id": workspace_id, "connection_id": connection_id}, + ) + ) + added = result.get("added") if isinstance(result, dict) else None + return int(added) if added is not None else 0 + + # ─── Forecasts, conversions and the public ad library ─────────────── + + def bid_pricing( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + goal: str, + targeting: Mapping[str, Any], + placements: Sequence[str] | None = None, + bid_type: str | None = None, + ) -> BidPricing: + """What the auction currently costs for that audience.""" + body: dict[str, Any] = { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "goal": goal, + "targeting": dict(targeting), + } + if placements is not None: + body["placements"] = list(placements) + if bid_type is not None: + body["bidType"] = bid_type + return BidPricing.model_validate(unwrap(self._http.post("/ads/linkedin/bid-pricing", body))) + + def supply_forecast( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + goal: str, + targeting: Mapping[str, Any], + placements: Sequence[str] | None = None, + budget_minor: int | None = None, + ) -> SupplyForecast: + """What that audience would deliver at that budget.""" + body: dict[str, Any] = { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "goal": goal, + "targeting": dict(targeting), + } + if placements is not None: + body["placements"] = list(placements) + if budget_minor is not None: + body["budgetMinor"] = budget_minor + return SupplyForecast.model_validate( + unwrap(self._http.post("/ads/linkedin/supply-forecast", body)) + ) + + def conversion_rules( + self, *, connection_id: str, ad_account_id: str, workspace_id: str | None = None + ) -> builtins.list[ConversionRule]: + return parse_list( + ConversionRule, + unwrap( + self._http.get( + "/ads/linkedin/conversion-rules", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "ad_account_id": ad_account_id, + }, + ) + ), + ) + + def create_conversion_rule( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + name: str, + type: str, + attribution: str, + post_click_window_days: int | None = None, + view_through_window_days: int | None = None, + value_minor: int | None = None, + currency: str | None = None, + ) -> str: + body = drop_unset( + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "name": name, + "type": type, + "attribution": attribution, + "postClickWindowDays": post_click_window_days + if post_click_window_days is not None + else UNSET, + "viewThroughWindowDays": view_through_window_days + if view_through_window_days is not None + else UNSET, + "valueMinor": value_minor if value_minor is not None else UNSET, + "currency": currency if currency is not None else UNSET, + } + ) + result = unwrap(self._http.post("/ads/linkedin/conversion-rules", body)) + rule_id = result.get("id") if isinstance(result, dict) else None + return str(rule_id) if rule_id else "" + + def get_conversion_rule( + self, rule_id: str, *, connection_id: str, workspace_id: str | None = None + ) -> ConversionRule: + return ConversionRule.model_validate( + unwrap(self._rule("GET", rule_id, "", workspace_id, connection_id)) + ) + + def update_conversion_rule( + self, rule_id: str, *, workspace_id: str, connection_id: str, **changes: Any + ) -> ConversionRule: + """Change a rule. Keys are the API's own: ``name``, ``type``, ``attribution``, + ``postClickWindowDays``, ``viewThroughWindowDays``, ``valueMinor``, ``currency``, + ``enabled``.""" + return ConversionRule.model_validate( + unwrap(self._rule("PATCH", rule_id, "", workspace_id, connection_id, changes)) + ) + + def delete_conversion_rule( + self, rule_id: str, *, workspace_id: str, connection_id: str + ) -> None: + """Turns the rule off; the network keeps the history.""" + self._rule("DELETE", rule_id, "", workspace_id, connection_id) + + def attach_conversion_rule( + self, rule_id: str, *, workspace_id: str, connection_id: str, campaign_id: str + ) -> ConversionRule: + return ConversionRule.model_validate( + unwrap( + self._rule( + "POST", + rule_id, + "/associations", + workspace_id, + connection_id, + {"campaignId": campaign_id}, + ) + ) + ) + + def detach_conversion_rule( + self, rule_id: str, *, workspace_id: str, connection_id: str, campaign_id: str + ) -> ConversionRule: + return ConversionRule.model_validate( + unwrap( + self._rule( + "DELETE", + rule_id, + "/associations", + workspace_id, + connection_id, + {"campaignId": campaign_id}, + ) + ) + ) + + def conversion_metrics( + self, + rule_id: str, + *, + connection_id: str, + since: str, + until: str, + workspace_id: str | None = None, + ) -> ConversionMetrics: + return ConversionMetrics.model_validate( + unwrap( + self._http.get( + f"/ads/linkedin/conversion-rules/{rule_id}/metrics", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "since": since, + "until": until, + }, + ) + ) + ) + + def send_conversion_events( + self, + rule_id: str, + *, + workspace_id: str, + connection_id: str, + events: Sequence[Mapping[str, Any]], + ) -> int: + """Send conversions back to the network. Returns how many it took. + + Each event needs ``happenedAt`` in epoch milliseconds and an ``email`` or a + ``clickId``. The address is hashed inside the API and nothing is stored. + """ + result = unwrap( + self._rule( + "POST", + rule_id, + "/events", + workspace_id, + connection_id, + {"events": [dict(e) for e in events]}, + ) + ) + accepted = result.get("accepted") if isinstance(result, dict) else None + return int(accepted) if accepted is not None else 0 + + def ad_library( + self, + *, + connection_id: str, + workspace_id: str | None = None, + keyword: str | None = None, + advertiser: str | None = None, + countries: Sequence[str] | None = None, + since: str | None = None, + until: str | None = None, + cursor: str | None = None, + ) -> AdLibraryPage: + """The network's own public ad library, not the connection's ads.""" + return AdLibraryPage.model_validate( + unwrap( + self._http.get( + "/ads/ad-library", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "keyword": keyword, + "advertiser": advertiser, + "countries": ",".join(countries) if countries else None, + "since": since, + "until": until, + "cursor": cursor, + }, + ) + ) + ) + # ─── Reach and insights ───────────────────────────────────────────── def estimate_reach( @@ -1778,6 +2064,22 @@ def _object( params={"workspace_id": workspace_id, "connection_id": connection_id}, ) + def _rule( + self, + method: str, + rule_id: str, + suffix: str, + workspace_id: str | None, + connection_id: str, + body: Mapping[str, Any] | None = None, + ) -> Any: + return self._http.request( + method, + f"/ads/linkedin/conversion-rules/{rule_id}{suffix}", + json=dict(body) if body is not None else None, + params={"workspace_id": workspace_id, "connection_id": connection_id}, + ) + def _duplicate( self, kind: str, diff --git a/tests/test_ads.py b/tests/test_ads.py index 636a1b4..074c66c 100644 --- a/tests/test_ads.py +++ b/tests/test_ads.py @@ -404,3 +404,51 @@ def test_lead_pages_and_audience_users(client: Fopost) -> None: assert json.loads(users.calls.last.request.content) == { "emails": ["a@yourbrand.com", "b@yourbrand.com"] } + + +@respx.mock +def test_authorize_reaches_whichever_network_the_registry_named(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/connections/linkedin/authorize").mock( + return_value=httpx.Response(200, json={"data": {"url": "https://www.linkedin.com/oauth"}}) + ) + + url = client.ads.authorize("linkedin", workspace_id="ws_1") + + assert url == "https://www.linkedin.com/oauth" + assert json.loads(route.calls.last.request.content) == {"workspaceId": "ws_1"} + + +@respx.mock +def test_company_list_rows_travel_with_the_request(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/audiences/urn:li:adSegment:44/companies").mock( + return_value=httpx.Response(200, json={"data": {"added": 2}}) + ) + + added = client.ads.add_audience_companies( + "urn:li:adSegment:44", + workspace_id="ws_1", + connection_id="conn_1", + companies=[{"domain": "northwind.example"}, {"name": "Contoso"}], + ) + + assert added == 2 + assert json.loads(route.calls.last.request.content) == { + "companies": [{"domain": "northwind.example"}, {"name": "Contoso"}] + } + + +@respx.mock +def test_conversion_events_send_the_identity_the_api_hashes(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/linkedin/conversion-rules/urn:li:conversion:9/events").mock( + return_value=httpx.Response(200, json={"data": {"accepted": 1}}) + ) + + accepted = client.ads.send_conversion_events( + "urn:li:conversion:9", + workspace_id="ws_1", + connection_id="conn_1", + events=[{"happenedAt": 1758326400000, "email": "buyer@example.test"}], + ) + + assert accepted == 1 + assert route.calls.last.request.url.params["connection_id"] == "conn_1"