From 71c73f8fea4e478db2a1573dd7d54d63bfce44cb Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:20:13 +0200 Subject: [PATCH] feat(ads): support a second ad network providers() and authorize(provider, ...) reach whichever networks a deployment knows; authorize_meta stays as a deprecated alias. Company-list audiences, LinkedIn's forecasts, conversion rules and the public ad library are wrapped, and AdSource carries its provider. --- README.md | 2 +- src/fopost/__init__.py | 14 ++ src/fopost/models.py | 80 +++++++++ src/fopost/resources/ads.py | 313 +++++++++++++++++++++++++++++++++++- tests/test_ads.py | 48 ++++++ 5 files changed, 451 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 828b424..613caf9 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ except FopostError as err: | `media` | `presign`, `complete`, `upload_direct` | | `ai` | `credits`, `generate_caption`, `rewrite`, `repurpose_url` | | `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` | -| `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`, `ad_library` | | `validate` | `post`, `length`, `media` | For an endpoint the SDK does not wrap yet, `client.request` sends an diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 6ec2553..b9bf392 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -42,6 +42,9 @@ AdCreative, AdInsights, AdInsightsReport, + AdLibraryAd, + AdLibraryPage, + AdProvider, AdSet, AdSetNode, AdSource, @@ -49,11 +52,14 @@ AiCredits, Audience, AudiencesResult, + BidPricing, BoostablePost, BulkAdStatusResult, CaptionResult, ContentBlock, ContentSignal, + ConversionMetrics, + ConversionRule, Delivery, ExternalAd, FeedLead, @@ -97,6 +103,7 @@ SlackIdentity, SlackMember, SocialAccount, + SupplyForecast, TargetingOption, TelegramBotCommand, TelegramBotCommands, @@ -128,6 +135,9 @@ "Ad", "AdConnection", "AdInsights", + "AdLibraryAd", + "AdLibraryPage", + "AdProvider", "AdSource", "AiCreditBalance", "AiCredits", @@ -207,5 +217,9 @@ "LeadPageSubscription", "LeadsFeedPage", "NetworkAd", + "BidPricing", + "ConversionMetrics", + "ConversionRule", "ReachEstimate", + "SupplyForecast", ] diff --git a/src/fopost/models.py b/src/fopost/models.py index 7a0b4ec..251ac76 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -723,10 +723,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]] = [] @@ -914,6 +928,72 @@ 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 AdLibraryAd(FopostModel): + """A public ad from the network's own library, never a connection's own data.""" + + id: str + advertiser_name: str | None = None + advertiser_url: str | None = None + headline: str | None = None + body: str | None = None + type: str | None = None + thumbnail_url: str | None = None + first_impression_at: str | None = None + last_impression_at: str | None = None + countries: list[str] = [] + details_url: str | None = None + payer: str | None = None + impressions_range: str | None = None + + +class AdLibraryPage(FopostModel): + ads: list[AdLibraryAd] = [] + next_cursor: str | 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 a9fd434..b492ebc 100644 --- a/src/fopost/resources/ads.py +++ b/src/fopost/resources/ads.py @@ -1,4 +1,4 @@ -"""``client.ads`` — Meta ads, audiences and lead forms. +"""``client.ads`` — ads, audiences and lead forms on a connected network. Every method needs the ``ads`` scope; ``boost``, ``create``, ``set_status``, ``delete``, ``bulk_set_status`` and every create, update, delete or duplicate on @@ -19,12 +19,17 @@ AdConnection, AdCreative, AdInsightsReport, + AdLibraryPage, + AdProvider, AdSet, AdSource, Audience, AudiencesResult, + BidPricing, BoostablePost, BulkAdStatusResult, + ConversionMetrics, + ConversionRule, ExternalAd, LeadFormDetail, LeadFormSource, @@ -34,6 +39,7 @@ LeadsPage, NetworkAd, ReachEstimate, + SupplyForecast, TargetingOption, ) from ._base import UNSET, Resource, drop_unset, parse_list @@ -70,19 +76,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 delete_connection(self, connection_id: str, *, workspace_id: str) -> None: """Also deletes every ad record created through the connection.""" self._http.request( @@ -657,6 +678,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( @@ -831,6 +1118,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"