|
20 | 20 | from datetime import datetime, time, timezone |
21 | 21 | from typing import Any |
22 | 22 |
|
23 | | -from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, LineItem, Objective |
| 23 | +import pycountry |
| 24 | + |
| 25 | +from yieldagent.domain import Audience, Campaign, CreativeAsset, Flight, Objective |
24 | 26 |
|
25 | 27 | # LinkedIn objectiveType values for Sponsored Content campaigns. |
26 | 28 | OBJECTIVE_TO_LINKEDIN: dict[Objective, str] = { |
|
32 | 34 | Objective.sales: "WEBSITE_CONVERSIONS", |
33 | 35 | } |
34 | 36 |
|
35 | | -# LinkedIn requires geo URNs (urn:li:geo:{id}) — ISO codes are not accepted. |
36 | | -# Production use should resolve via the geo typeahead endpoint; the few entries |
37 | | -# below cover the common-case countries so simple briefs work out of the box. |
38 | | -ISO_TO_LINKEDIN_GEO_URN: dict[str, str] = { |
39 | | - "US": "urn:li:geo:103644278", |
40 | | - "GB": "urn:li:geo:101165590", |
41 | | - "CA": "urn:li:geo:101174742", |
42 | | - "DE": "urn:li:geo:101282230", |
43 | | - "FR": "urn:li:geo:105015875", |
44 | | - "AU": "urn:li:geo:101452733", |
45 | | - "IN": "urn:li:geo:102713980", |
46 | | - "IL": "urn:li:geo:101620260", |
47 | | - "BR": "urn:li:geo:106057199", |
48 | | - "JP": "urn:li:geo:101355337", |
49 | | - "NL": "urn:li:geo:102890719", |
50 | | - "ES": "urn:li:geo:105646813", |
51 | | - "IT": "urn:li:geo:103350119", |
52 | | - "SE": "urn:li:geo:105117694", |
53 | | - "SG": "urn:li:geo:102454443", |
54 | | -} |
55 | | - |
56 | 37 | # LinkedIn campaign type for standard image / single-share Sponsored Content. |
57 | 38 | DEFAULT_CAMPAIGN_TYPE = "SPONSORED_UPDATES" |
58 | 39 |
|
@@ -86,68 +67,18 @@ def campaign_run_schedule(flights: list[Flight]) -> dict[str, int]: |
86 | 67 | return flight_to_run_schedule(Flight(start_date=earliest, end_date=latest)) |
87 | 68 |
|
88 | 69 |
|
89 | | -def audience_to_targeting(audience: Audience) -> dict[str, Any]: |
90 | | - """Build a geo-only LinkedIn `targetingCriteria` payload. |
91 | | -
|
92 | | - This is the static, client-free baseline (locations only). Live B2B facet |
93 | | - resolution — industries, job functions, titles, seniorities, company sizes, |
94 | | - skills — needs API lookups and lives in `targeting.TargetingResolver`, which |
95 | | - the publish flow uses. This helper remains for inspection/test scaffolding. |
96 | | - """ |
97 | | - includes: list[str] = [] |
98 | | - for code in audience.geos: |
99 | | - urn = ISO_TO_LINKEDIN_GEO_URN.get(code.upper()) |
100 | | - if urn: |
101 | | - includes.append(urn) |
102 | | - if not includes: |
103 | | - # LinkedIn requires at least one location; default to US. |
104 | | - includes.append(ISO_TO_LINKEDIN_GEO_URN["US"]) |
105 | | - |
106 | | - return { |
107 | | - "include": { |
108 | | - "and": [ |
109 | | - { |
110 | | - "or": { |
111 | | - "urn:li:adTargetingFacet:locations": includes, |
112 | | - } |
113 | | - } |
114 | | - ] |
115 | | - } |
116 | | - } |
117 | | - |
118 | | - |
119 | 70 | def line_item_locale(audience: Audience) -> dict[str, str]: |
120 | 71 | """LinkedIn campaigns require a `locale` (country + language). |
121 | 72 |
|
122 | | - Derived from the first audience geo if available; defaults to en/US. |
| 73 | + Derived from the first audience geo if it is a valid ISO 3166-1 alpha-2 |
| 74 | + code; defaults to en/US otherwise. |
123 | 75 | """ |
124 | | - country = (audience.geos[0].upper() if audience.geos else "US") |
125 | | - if country not in ISO_TO_LINKEDIN_GEO_URN: |
| 76 | + country = audience.geos[0].upper() if audience.geos else "US" |
| 77 | + if pycountry.countries.get(alpha_2=country) is None: |
126 | 78 | country = "US" |
127 | 79 | return {"country": country, "language": "en"} |
128 | 80 |
|
129 | 81 |
|
130 | | -def line_item_payload( |
131 | | - line_item: LineItem, |
132 | | - *, |
133 | | - campaign_group_urn: str, |
134 | | - objective_type: str, |
135 | | -) -> dict[str, Any]: |
136 | | - """Strict-typed snapshot of the create_campaign call for testing/inspection.""" |
137 | | - return { |
138 | | - "campaignGroup": campaign_group_urn, |
139 | | - "name": line_item.name, |
140 | | - "objectiveType": objective_type, |
141 | | - "type": DEFAULT_CAMPAIGN_TYPE, |
142 | | - "totalBudget": money_to_linkedin_amount( |
143 | | - line_item.budget.amount, line_item.budget.currency |
144 | | - ), |
145 | | - "runSchedule": flight_to_run_schedule(line_item.flight), |
146 | | - "targetingCriteria": audience_to_targeting(line_item.targeting.audience), |
147 | | - "locale": line_item_locale(line_item.targeting.audience), |
148 | | - } |
149 | | - |
150 | | - |
151 | 82 | def post_article_content(creative: CreativeAsset) -> dict[str, Any]: |
152 | 83 | """Build the `article` block for a Posts API dark post. |
153 | 84 |
|
@@ -181,15 +112,12 @@ def creative_content_reference(post_urn: str) -> dict[str, Any]: |
181 | 112 |
|
182 | 113 | __all__ = [ |
183 | 114 | "DEFAULT_CAMPAIGN_TYPE", |
184 | | - "ISO_TO_LINKEDIN_GEO_URN", |
185 | 115 | "OBJECTIVE_TO_LINKEDIN", |
186 | | - "audience_to_targeting", |
187 | 116 | "campaign_objective", |
188 | 117 | "campaign_run_schedule", |
189 | 118 | "creative_content_reference", |
190 | 119 | "flight_to_run_schedule", |
191 | 120 | "line_item_locale", |
192 | | - "line_item_payload", |
193 | 121 | "money_to_linkedin_amount", |
194 | 122 | "post_article_content", |
195 | 123 | "post_commentary", |
|
0 commit comments