@@ -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