Skip to content

Commit 2ac1037

Browse files
🐛 Fix keyword bid update using bulk API endpoint
Apple Search Ads API doesn't support single keyword updates via PUT targetingkeywords/{keyword_id} (returns 404). Use the bulk endpoint instead with update_bulk([(keyword_id, update)]). Also includes previous refactoring of _build_keyword_index() to _build_keyword_index_all_countries() for efficient multi-country support in bid-adjust command.
1 parent 67edefe commit 2ac1037

File tree

1 file changed

+274
-29
lines changed

1 file changed

+274
-29
lines changed

asa_api_cli/impression_share.py

Lines changed: 274 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -597,32 +597,38 @@ class KeywordInfo:
597597
ttr: float | None = None
598598

599599

600-
def _build_keyword_index(
600+
def _build_keyword_index_all_countries(
601601
client: "AppleSearchAdsClient",
602-
country: str,
602+
countries: set[str],
603603
start_date: date | None = None,
604604
end_date: date | None = None,
605-
) -> dict[str, list[KeywordInfo]]:
606-
"""Build an index of keywords by text for a given country.
605+
) -> dict[str, dict[str, list[KeywordInfo]]]:
606+
"""Build an index of keywords by text for ALL countries at once.
607607
608-
Returns a dict mapping lowercase keyword text -> list of KeywordInfo
609-
(multiple campaigns may have the same keyword).
608+
This is much more efficient than building per-country because it only
609+
makes one pass through campaigns/ad_groups/keywords.
610+
611+
Returns a dict: country -> (keyword_text -> list[KeywordInfo])
610612
611613
If start_date and end_date are provided, also fetches performance data
612614
for bid strength calculation.
613615
"""
614-
keyword_index: dict[str, list[KeywordInfo]] = {}
616+
# Initialize empty index for each country
617+
keyword_indices: dict[str, dict[str, list[KeywordInfo]]] = {c.upper(): {} for c in countries}
615618

616-
# Get enabled campaigns for this country
619+
# Get ALL enabled campaigns in ONE call
617620
campaigns = list(client.campaigns.find(Selector().where("status", "==", "ENABLED")))
618621

619622
for campaign in campaigns:
620-
# Check if campaign targets this country
621-
if country.upper() not in [c.upper() for c in campaign.countries_or_regions]:
623+
# Get countries this campaign targets that we care about
624+
campaign_countries = {c.upper() for c in campaign.countries_or_regions}
625+
relevant_countries = campaign_countries & {c.upper() for c in countries}
626+
627+
if not relevant_countries:
622628
continue
623629

624-
# If date range provided, fetch keyword performance report
625-
keyword_performance: dict[int, tuple[int, int, float | None]] = {} # keyword_id -> (impr, taps, ttr)
630+
# Fetch keyword performance report ONCE per campaign (if dates provided)
631+
keyword_performance: dict[int, tuple[int, int, float | None]] = {}
626632
if start_date and end_date:
627633
try:
628634
report = client.reports.keywords(
@@ -640,23 +646,21 @@ def _build_keyword_index(
640646
except AppleSearchAdsError:
641647
pass
642648

643-
# Get ad groups for this campaign
649+
# Get ad groups for this campaign ONCE
644650
try:
645651
ad_groups = list(client.campaigns(campaign.id).ad_groups.find(Selector().where("status", "==", "ENABLED")))
646652
except AppleSearchAdsError:
647653
continue
648654

649655
for ad_group in ad_groups:
650-
# Get keywords for this ad group
656+
# Get keywords for this ad group ONCE
651657
try:
652658
keywords = list(client.campaigns(campaign.id).ad_groups(ad_group.id).keywords.list())
653659
except AppleSearchAdsError:
654660
continue
655661

656662
for keyword in keywords:
657663
kw_text = keyword.text.lower()
658-
659-
# Get performance data if available
660664
perf = keyword_performance.get(keyword.id, (0, 0, None))
661665

662666
info = KeywordInfo(
@@ -673,11 +677,13 @@ def _build_keyword_index(
673677
ttr=perf[2],
674678
)
675679

676-
if kw_text not in keyword_index:
677-
keyword_index[kw_text] = []
678-
keyword_index[kw_text].append(info)
680+
# Add to ALL relevant countries
681+
for ctry in relevant_countries:
682+
if kw_text not in keyword_indices[ctry]:
683+
keyword_indices[ctry][kw_text] = []
684+
keyword_indices[ctry][kw_text].append(info)
679685

680-
return keyword_index
686+
return keyword_indices
681687

682688

683689
@app.command("correlate")
@@ -768,16 +774,11 @@ def correlate_impression_share(
768774
countries_in_data = {item.country.upper() for item in aggregated.values() if item.country}
769775
print_info(f"Found data for {len(countries_in_data)} countries: {', '.join(sorted(countries_in_data))}")
770776

771-
# Step 2: Build keyword index for each country (with performance data)
772-
keyword_indices: dict[str, dict[str, list[KeywordInfo]]] = {}
773-
total_keywords = 0
774-
775-
for ctry in countries_in_data:
776-
with spinner(f"Building keyword index for {ctry} (with performance data)..."):
777-
keyword_indices[ctry] = _build_keyword_index(client, ctry, start_date, end_date)
778-
kw_count = sum(len(v) for v in keyword_indices[ctry].values())
779-
total_keywords += kw_count
777+
# Step 2: Build keyword index for ALL countries at once (much more efficient)
778+
with spinner(f"Building keyword index for {len(countries_in_data)} countries (with performance data)..."):
779+
keyword_indices = _build_keyword_index_all_countries(client, countries_in_data, start_date, end_date)
780780

781+
total_keywords = sum(len(v) for idx in keyword_indices.values() for v in idx.values())
781782
print_info(f"Indexed {total_keywords} keywords across {len(countries_in_data)} countries")
782783

783784
# Step 3: Correlate data
@@ -987,3 +988,247 @@ def correlate_impression_share(
987988
except AppleSearchAdsError as e:
988989
handle_api_error(e)
989990
raise typer.Exit(1) from None
991+
992+
993+
def _display_bid_item(item: CorrelatedSearchTerm, index: int) -> None:
994+
"""Display a single bid adjustment item with current status."""
995+
share_color = "green"
996+
if item.high_share:
997+
if item.high_share < 0.3:
998+
share_color = "red"
999+
elif item.high_share < 0.5:
1000+
share_color = "yellow"
1001+
1002+
strength = item.bid_strength
1003+
strength_display = {
1004+
"STRONG": "[green]STRONG[/green]",
1005+
"MODERATE": "[yellow]MOD[/yellow]",
1006+
"WEAK": "[red]WEAK[/red]",
1007+
}.get(strength, "[dim]—[/dim]")
1008+
1009+
console.print(f"\n[bold cyan]#{index + 1}[/bold cyan] [bold]{item.search_term}[/bold]")
1010+
console.print(f" Country: {item.country} | Campaign: {item.campaign_name}")
1011+
console.print(
1012+
f" Share: [{share_color}]{item.share_range}[/{share_color}] | "
1013+
f"Strength: {strength_display} | "
1014+
f"Popularity: {item.search_popularity or '—'}"
1015+
)
1016+
console.print(f" [bold]Current Bid: {item.currency or 'USD'} {item.current_bid:.2f}[/bold]")
1017+
1018+
1019+
def _suggest_bid(item: CorrelatedSearchTerm) -> Decimal:
1020+
"""Suggest a new bid based on impression share and current bid."""
1021+
if item.current_bid is None:
1022+
return Decimal("1.00")
1023+
1024+
current = item.current_bid
1025+
avg_share = item.avg_share
1026+
1027+
# Suggest increase based on how much share is missing
1028+
if avg_share < 0.2:
1029+
# Very low share - suggest 50% increase
1030+
return (current * Decimal("1.50")).quantize(Decimal("0.01"))
1031+
elif avg_share < 0.4:
1032+
# Low share - suggest 25% increase
1033+
return (current * Decimal("1.25")).quantize(Decimal("0.01"))
1034+
elif avg_share < 0.6:
1035+
# Medium share - suggest 10% increase
1036+
return (current * Decimal("1.10")).quantize(Decimal("0.01"))
1037+
else:
1038+
# Good share - suggest 5% increase
1039+
return (current * Decimal("1.05")).quantize(Decimal("0.01"))
1040+
1041+
1042+
@app.command("bid-adjust")
1043+
def bid_adjust(
1044+
days: Annotated[int, typer.Option("--days", "-d", help="Number of days to analyze")] = 7,
1045+
country: Annotated[str | None, typer.Option("--country", "-c", help="Filter by country")] = None,
1046+
min_share: Annotated[
1047+
float | None, typer.Option("--min-share", help="Only show keywords with share below this %")
1048+
] = 50,
1049+
auto_apply: Annotated[bool, typer.Option("--auto", help="Auto-apply suggested bids without prompting")] = False,
1050+
) -> None:
1051+
"""Interactively adjust keyword bids based on impression share.
1052+
1053+
Shows keywords with low impression share and allows you to adjust bids
1054+
one by one. Each keyword shows current bid, share, and a suggested new bid.
1055+
1056+
Examples:
1057+
asa impression-share bid-adjust # Interactive mode
1058+
asa impression-share bid-adjust --country US # Single country
1059+
asa impression-share bid-adjust --min-share 30 # Only <30% share
1060+
asa impression-share bid-adjust --auto # Auto-apply suggestions
1061+
"""
1062+
from asa_api_client.models.base import Money
1063+
from asa_api_client.models.keywords import KeywordUpdate
1064+
1065+
client = get_client()
1066+
days = min(max(days, 1), 30)
1067+
1068+
try:
1069+
end_date = date.today() - timedelta(days=1)
1070+
start_date = end_date - timedelta(days=days - 1)
1071+
1072+
# Get impression share data
1073+
with spinner("Fetching impression share data..."):
1074+
report = client.custom_reports.get_impression_share(
1075+
start_date=start_date,
1076+
end_date=end_date,
1077+
granularity=GranularityType.DAILY,
1078+
)
1079+
1080+
if not report.row:
1081+
print_warning("No impression share data found")
1082+
raise typer.Exit(0)
1083+
1084+
print_success(f"Retrieved {len(report.row)} records")
1085+
1086+
# Parse and aggregate
1087+
raw_data = _parse_report_data(report)
1088+
aggregated_dict = _aggregate_by_search_term(raw_data)
1089+
aggregated = list(aggregated_dict.values())
1090+
1091+
# Filter by country if specified
1092+
if country:
1093+
country_upper = country.upper()
1094+
aggregated = [d for d in aggregated if d.country == country_upper]
1095+
if not aggregated:
1096+
print_warning(f"No data found for country {country_upper}")
1097+
raise typer.Exit(0)
1098+
1099+
countries_in_data = {d.country for d in aggregated}
1100+
1101+
# Build keyword index for matching
1102+
with spinner(f"Building keyword index for {len(countries_in_data)} countries..."):
1103+
keyword_indices = _build_keyword_index_all_countries(client, countries_in_data, start_date, end_date)
1104+
1105+
# Correlate search terms with keywords
1106+
correlated: list[CorrelatedSearchTerm] = []
1107+
for data in aggregated:
1108+
country_index = keyword_indices.get(data.country.upper(), {})
1109+
search_term_lower = data.search_term.lower()
1110+
matches = country_index.get(search_term_lower, [])
1111+
1112+
if matches:
1113+
# Use best match (most specific keyword)
1114+
best = min(matches, key=lambda k: len(k.keyword_text))
1115+
correlated.append(
1116+
CorrelatedSearchTerm(
1117+
search_term=data.search_term,
1118+
country=data.country,
1119+
app_name=data.app_name,
1120+
low_share=data.low_share,
1121+
high_share=data.high_share,
1122+
rank=data.rank,
1123+
search_popularity=data.search_popularity,
1124+
campaign_id=best.campaign_id,
1125+
campaign_name=best.campaign_name,
1126+
ad_group_id=best.ad_group_id,
1127+
ad_group_name=best.ad_group_name,
1128+
keyword_id=best.keyword_id,
1129+
keyword_text=best.keyword_text,
1130+
current_bid=best.bid_amount,
1131+
currency=best.currency,
1132+
impressions=best.impressions,
1133+
taps=best.taps,
1134+
ttr=best.ttr,
1135+
)
1136+
)
1137+
1138+
if not correlated:
1139+
print_warning("No keywords matched to search terms")
1140+
raise typer.Exit(0)
1141+
1142+
# Filter to only matched keywords with low share
1143+
if min_share is not None:
1144+
threshold = min_share / 100.0
1145+
candidates = [
1146+
c for c in correlated if c.is_matched and c.high_share is not None and c.high_share < threshold
1147+
]
1148+
else:
1149+
candidates = [c for c in correlated if c.is_matched]
1150+
1151+
# Sort by share (lowest first)
1152+
candidates.sort(key=lambda c: c.avg_share)
1153+
1154+
if not candidates:
1155+
print_success("No keywords need bid adjustments!")
1156+
raise typer.Exit(0)
1157+
1158+
console.print(f"\n[bold]Found {len(candidates)} keywords for bid review[/bold]")
1159+
console.print("[dim]Commands: (y)es, (n)o, (s)kip, (q)uit, or enter custom bid[/dim]\n")
1160+
1161+
# Track changes
1162+
changes_made: list[tuple[CorrelatedSearchTerm, Decimal, Decimal]] = [] # (item, old, new)
1163+
1164+
for i, item in enumerate(candidates):
1165+
_display_bid_item(item, i)
1166+
1167+
suggested = _suggest_bid(item)
1168+
console.print(f" [green]Suggested Bid: {item.currency or 'USD'} {suggested:.2f}[/green]")
1169+
1170+
if auto_apply:
1171+
response = "y"
1172+
else:
1173+
response = typer.prompt(
1174+
" Apply suggested bid? [y/n/s/q/amount]",
1175+
default="n",
1176+
show_default=False,
1177+
)
1178+
1179+
response = response.strip().lower()
1180+
1181+
if response == "q":
1182+
console.print("\n[yellow]Quit - stopping review[/yellow]")
1183+
break
1184+
elif response == "s":
1185+
console.print(" [dim]Skipped[/dim]")
1186+
continue
1187+
elif response == "n":
1188+
console.print(" [dim]No change[/dim]")
1189+
continue
1190+
elif response == "y":
1191+
new_bid = suggested
1192+
else:
1193+
# Try to parse as custom amount
1194+
try:
1195+
new_bid = Decimal(response).quantize(Decimal("0.01"))
1196+
if new_bid <= 0:
1197+
console.print(" [red]Invalid bid amount[/red]")
1198+
continue
1199+
except Exception:
1200+
console.print(" [red]Invalid input, skipping[/red]")
1201+
continue
1202+
1203+
# Apply the bid change using bulk update API
1204+
if item.campaign_id and item.ad_group_id and item.keyword_id:
1205+
try:
1206+
update = KeywordUpdate(bid_amount=Money(amount=str(new_bid), currency=item.currency or "USD"))
1207+
client.campaigns(item.campaign_id).ad_groups(item.ad_group_id).keywords.update_bulk(
1208+
[(item.keyword_id, update)]
1209+
)
1210+
old_bid = item.current_bid or Decimal("0")
1211+
changes_made.append((item, old_bid, new_bid))
1212+
console.print(f" [green]Updated: {item.currency or 'USD'} {old_bid:.2f} -> {new_bid:.2f}[/green]")
1213+
except AppleSearchAdsError as e:
1214+
console.print(f" [red]Failed to update: {e}[/red]")
1215+
else:
1216+
console.print(" [red]Missing campaign/ad_group/keyword ID[/red]")
1217+
1218+
# Summary
1219+
console.print("\n" + "=" * 50)
1220+
if changes_made:
1221+
console.print(f"[bold green]Applied {len(changes_made)} bid changes:[/bold green]")
1222+
for item, old, new in changes_made:
1223+
change_pct = ((new - old) / old * 100) if old > 0 else 0
1224+
console.print(
1225+
f" {item.keyword_text} ({item.country}): "
1226+
f"{item.currency or 'USD'} {old:.2f} -> {new:.2f} "
1227+
f"[dim](+{change_pct:.0f}%)[/dim]"
1228+
)
1229+
else:
1230+
console.print("[dim]No changes made[/dim]")
1231+
1232+
except AppleSearchAdsError as e:
1233+
handle_api_error(e)
1234+
raise typer.Exit(1) from None

0 commit comments

Comments
 (0)