Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,30 @@ uv run mypy
MIT

Questions or a problem: [fopost.com/contact](https://fopost.com/contact).

### Google Ads

Campaigns, ad groups, ads, audiences and insights are on `client.ads` and dispatch by
connection. What only Google has is under `client.ads.google`:

```python
keywords = client.ads.google.keywords(
connection_id="c4d5e6f7-…",
customer_id="1234567890",
)

client.ads.google.create_keyword(
workspace_id="7d2b8c11-…",
connection_id="c4d5e6f7-…",
customer_id="1234567890",
ad_group_id="1234567890~adGroup~77",
text="running shoes",
match_type="EXACT",
)
```

Also `keyword_ideas`, `keyword_metrics`, `search_terms`, `bid_strategies`,
`ad_schedule` and `set_ad_schedule`, the negative keyword lists, `assets` and
`asset_groups`, `local_services_leads`, the conversion methods, and `query` for a raw
read-only GAQL SELECT. Changes need the `publish` scope as well as `ads`; `customer_id`
has to name an account the connection's grant reaches.
24 changes: 24 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@
Enrollment,
ExternalAd,
FeedLead,
GoogleAdScheduleSlot,
GoogleAsset,
GoogleAssetGroup,
GoogleAssetLink,
GoogleAssetsResult,
GoogleBidStrategy,
GoogleConversionAction,
GoogleKeyword,
GoogleKeywordIdea,
GoogleLocalServicesLead,
GoogleSearchTerm,
GoogleSharedSet,
InboxAccount,
InboxAccountRef,
InboxApproval,
Expand Down Expand Up @@ -286,4 +298,16 @@
"LeadsFeedPage",
"NetworkAd",
"ReachEstimate",
"GoogleAdScheduleSlot",
"GoogleAsset",
"GoogleAssetGroup",
"GoogleAssetLink",
"GoogleAssetsResult",
"GoogleBidStrategy",
"GoogleConversionAction",
"GoogleKeyword",
"GoogleKeywordIdea",
"GoogleLocalServicesLead",
"GoogleSearchTerm",
"GoogleSharedSet",
]
109 changes: 109 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1661,3 +1661,112 @@ def __len__(self) -> int:

def __getitem__(self, index: int) -> ActivityEvent:
return self.items[index]


# ─── Google Ads ────────────────────────────────────────────────────


class GoogleKeyword(FopostModel):
"""``<customer_id>~keyword~<ad_group_id>~<criterion_id>`` as ``id``."""

id: str
ad_group_id: str
text: str
match_type: str
status: str
#: Account currency, minor units.
cpc_bid_minor: int | None = None
negative: bool = False


class GoogleKeywordIdea(FopostModel):
text: str
avg_monthly_searches: int = 0
competition: str | None = None
low_top_of_page_bid_minor: int | None = None
high_top_of_page_bid_minor: int | None = None


class GoogleSearchTerm(FopostModel):
term: str
ad_group_id: str | None = None
status: str | None = None
metrics: dict[str, Any] = {}


class GoogleBidStrategy(FopostModel):
id: str
name: str
type: str
status: str
campaign_count: int = 0


class GoogleAdScheduleSlot(FopostModel):
id: str
day_of_week: str
start_hour: int = 0
end_hour: int = 0
bid_modifier: float | None = None


class GoogleSharedSet(FopostModel):
id: str
name: str
type: str
member_count: int = 0


class GoogleAsset(FopostModel):
id: str
name: str | None = None
type: str
#: What a sitelink, callout or snippet renders.
text: str | None = None
final_url: str | None = None


class GoogleAssetLink(FopostModel):
"""Where an asset is attached; an asset with no links serves nowhere."""

id: str
asset_id: str
level: str
owner_id: str | None = None
field_type: str
status: str


class GoogleAssetsResult(FopostModel):
assets: list[GoogleAsset] = []
links: list[GoogleAssetLink] = []


class GoogleAssetGroup(FopostModel):
id: str
campaign_id: str
name: str
status: str
final_urls: list[str] = []


class GoogleLocalServicesLead(FopostModel):
id: str
category: str | None = None
service: str | None = None
contact_name: str | None = None
phone: str | None = None
email: str | None = None
status: str | None = None
type: str | None = None
created_at: str | None = None


class GoogleConversionAction(FopostModel):
id: str
name: str
category: str
status: str
type: str
counting_type: str | None = None
value_minor: int | None = None
19 changes: 18 additions & 1 deletion src/fopost/resources/ads.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""``client.ads`` — Meta ads, catalogs, audiences, the ad archive and lead forms.
"""``client.ads`` — ads, catalogs, audiences, the ad archive and lead forms.

Meta is what this module covers; the Google-only surface is ``client.ads.google``.

Every method needs the ``ads`` scope; ``boost``, ``create``, ``set_status``,
``delete``, ``bulk_set_status`` and every create, update, delete or duplicate on
Expand Down Expand Up @@ -54,11 +56,17 @@
ValueRuleSet,
)
from ._base import UNSET, Resource, drop_unset, parse_list
from .google_ads import GoogleAdsResource

__all__ = ["AdsResource"]


class AdsResource(Resource):
def __init__(self, http: Any) -> None:
super().__init__(http)
#: The Search surface no other network has: keywords, assets, conversions, GAQL.
self.google = GoogleAdsResource(http)

def list(self, *, workspace_id: str | None = None) -> builtins.list[Ad]:
"""Boosts and ads created through FoPost, with insights from their last refresh."""
return parse_list(Ad, unwrap(self._http.get("/ads", {"workspace_id": workspace_id})))
Expand Down Expand Up @@ -100,6 +108,15 @@ def authorize_meta(
url = result.get("url") if isinstance(result, dict) else None
return str(url) if url else ""

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}
if return_to is not None:
body["returnTo"] = return_to
result = unwrap(self._http.post("/ads/connections/google/authorize", body))
url = result.get("url") if isinstance(result, dict) else None
return str(url) if url else ""

def delete_connection(self, connection_id: str, *, workspace_id: str) -> None:
"""Also deletes every ad record created through the connection."""
self._http.request(
Expand Down
Loading
Loading