1919 GranularityType ,
2020 KeywordCreate ,
2121 KeywordMatchType ,
22+ KeywordUpdate ,
2223 Money ,
2324 NegativeKeywordCreate ,
2425 Selector ,
@@ -1044,6 +1045,10 @@ def review_keyword_bids(
10441045 str | None ,
10451046 typer .Option ("--output" , "-o" , help = "Export to CSV file" ),
10461047 ] = None ,
1048+ interactive : Annotated [
1049+ bool ,
1050+ typer .Option ("--interactive" , "-i" , help = "Interactive mode to adjust bids" ),
1051+ ] = False ,
10471052) -> None :
10481053 """Review keyword bids and their performance.
10491054
@@ -1061,6 +1066,7 @@ def review_keyword_bids(
10611066 asa optimize bid-review --country AU --weak # Focus on weak performers
10621067 asa optimize bid-review --days 14 --min-impressions 100
10631068 asa optimize bid-review --output keywords.csv
1069+ asa optimize bid-review --weak --interactive # Interactively increase weak bids
10641070 """
10651071 client = get_client ()
10661072
@@ -1102,6 +1108,14 @@ def review_keyword_bids(
11021108 for row in report .row :
11031109 if not row .metadata .keyword or not row .total :
11041110 continue
1111+ # Skip if no ad_group_id (needed for updates)
1112+ if not row .metadata .ad_group_id :
1113+ continue
1114+ # Skip paused/deleted keywords and ad groups
1115+ if row .metadata .keyword_status and enum_value (row .metadata .keyword_status ) != "ACTIVE" :
1116+ continue
1117+ if row .metadata .ad_group_status and enum_value (row .metadata .ad_group_status ) != "ENABLED" :
1118+ continue
11051119
11061120 impressions = row .total .impressions or 0
11071121 taps = row .total .taps or 0
@@ -1270,6 +1284,172 @@ def review_keyword_bids(
12701284 f"\n [yellow]💡 { weak } keywords have weak bid strength - consider increasing bids[/yellow]"
12711285 )
12721286
1287+ # Interactive mode for bid adjustments
1288+ if interactive :
1289+ # Filter to weak/moderate keywords for interactive adjustment
1290+ adjustable = [k for k in keyword_analyses if k .bid_strength in ("WEAK" , "MODERATE" )]
1291+
1292+ if not adjustable :
1293+ print_info ("No weak or moderate keywords to adjust" )
1294+ return
1295+
1296+ console .print ()
1297+ console .rule ("[bold]Interactive Bid Adjustment" )
1298+ console .print ()
1299+ console .print ("[dim]For each keyword, choose an action:[/dim]" )
1300+ console .print ("[dim] • Enter a number to set new bid (e.g., '2.50')[/dim]" )
1301+ console .print ("[dim] • +N or +N% to increase (e.g., '+0.50' or '+20%')[/dim]" )
1302+ console .print ("[dim] • 's' to skip, 'q' to quit[/dim]" )
1303+ console .print ()
1304+
1305+ changes_made = 0
1306+ for i , kw in enumerate (adjustable , 1 ):
1307+ # Skip if missing required IDs
1308+ if not kw .ad_group_id or kw .ad_group_id == 0 :
1309+ continue
1310+
1311+ console .rule (f"[bold]{ i } /{ len (adjustable )} " )
1312+ console .print ()
1313+
1314+ # Show keyword details
1315+ strength_color = "red" if kw .bid_strength == "WEAK" else "yellow"
1316+ console .print (f"[bold]Keyword:[/bold] { kw .keyword_text } [dim]ID: { kw .keyword_id } [/dim]" )
1317+ console .print (
1318+ f"[bold]Campaign:[/bold] { kw .campaign_name } ({ kw .country } ) [dim]ID: { kw .campaign_id } [/dim]"
1319+ )
1320+ console .print (f"[bold]Ad Group:[/bold] { kw .ad_group_name } [dim]ID: { kw .ad_group_id } [/dim]" )
1321+ console .print ()
1322+ console .print (
1323+ f" Current bid: [{ strength_color } ]{ kw .current_bid :.2f} { kw .currency } [/{ strength_color } ]"
1324+ )
1325+ console .print (f" Impressions: { kw .impressions :,} " )
1326+ ttr_display = f"{ kw .ttr * 100 :.1f} %" if kw .ttr else "—"
1327+ console .print (f" TTR: { ttr_display } " )
1328+ console .print (f" Strength: [{ strength_color } ]{ kw .bid_strength } [/{ strength_color } ]" )
1329+ if kw .avg_cpt :
1330+ console .print (f" Avg CPT: { kw .avg_cpt :.2f} { kw .currency } " )
1331+ console .print ()
1332+
1333+ # Suggest a new bid (20% increase for weak, 10% for moderate)
1334+ if kw .bid_strength == "WEAK" :
1335+ suggested = round (kw .current_bid * Decimal ("1.20" ), 2 )
1336+ else :
1337+ suggested = round (kw .current_bid * Decimal ("1.10" ), 2 )
1338+
1339+ console .print (f"[bold]Suggested:[/bold] { suggested :.2f} { kw .currency } " )
1340+ console .print ()
1341+
1342+ action = (
1343+ typer .prompt (
1344+ "New bid" ,
1345+ default = str (suggested ),
1346+ show_default = True ,
1347+ )
1348+ .strip ()
1349+ .lower ()
1350+ )
1351+
1352+ if action in ("q" , "quit" ):
1353+ print_info ("Quitting..." )
1354+ break
1355+ elif action in ("s" , "skip" , "" ):
1356+ console .print ("[dim]Skipped[/dim]" )
1357+ console .print ()
1358+ continue
1359+
1360+ # Parse the new bid
1361+ try :
1362+ if action .startswith ("+" ):
1363+ # Relative increase
1364+ if action .endswith ("%" ):
1365+ pct = Decimal (action [1 :- 1 ]) / 100
1366+ new_bid = round (kw .current_bid * (1 + pct ), 2 )
1367+ else :
1368+ new_bid = kw .current_bid + Decimal (action [1 :])
1369+ else :
1370+ new_bid = Decimal (action )
1371+
1372+ if new_bid <= 0 :
1373+ print_warning ("Invalid bid (must be positive), skipping" )
1374+ continue
1375+
1376+ except Exception :
1377+ print_warning (f"Invalid input '{ action } ', skipping" )
1378+ continue
1379+
1380+ # Apply the change
1381+ # Fetch keywords from ad group to find matching keyword by text
1382+ try :
1383+ with spinner ("Fetching keywords from ad group..." ):
1384+ keywords = list (
1385+ client .campaigns (kw .campaign_id ).ad_groups (kw .ad_group_id ).keywords .list (limit = 200 )
1386+ )
1387+ except AppleSearchAdsError as e :
1388+ print_error (
1389+ "Failed to fetch keywords" ,
1390+ f"{ e .message } (campaign={ kw .campaign_id } , adgroup={ kw .ad_group_id } )" ,
1391+ )
1392+ continue
1393+
1394+ # Find matching ACTIVE keyword by text
1395+ actual_keyword = next (
1396+ (
1397+ k
1398+ for k in keywords
1399+ if k .text .lower () == kw .keyword_text .lower () and enum_value (k .status ) == "ACTIVE"
1400+ ),
1401+ None ,
1402+ )
1403+ if not actual_keyword :
1404+ active_keywords = [k .text for k in keywords if enum_value (k .status ) == "ACTIVE" ]
1405+ sample = active_keywords [:10 ]
1406+ ellipsis = "..." if len (active_keywords ) > 10 else ""
1407+ print_warning (
1408+ f"Keyword '{ kw .keyword_text } ' not found in ad group "
1409+ f"(found { len (active_keywords )} active keywords: { sample } { ellipsis } )"
1410+ )
1411+ continue
1412+
1413+ try :
1414+ with spinner (f"Updating keyword { actual_keyword .id } ..." ):
1415+ # Must use bulk endpoint - Apple API doesn't support single keyword PUT
1416+ client .campaigns (kw .campaign_id ).ad_groups (kw .ad_group_id ).keywords .update_bulk (
1417+ [
1418+ (
1419+ actual_keyword .id ,
1420+ KeywordUpdate (
1421+ bid_amount = Money (
1422+ amount = str (new_bid ),
1423+ currency = kw .currency ,
1424+ )
1425+ ),
1426+ )
1427+ ]
1428+ )
1429+ except AppleSearchAdsError as e :
1430+ print_error ("Update failed" , f"{ e .message } (keyword_id={ actual_keyword .id } )" )
1431+ continue
1432+ except Exception as e :
1433+ print_error ("Update failed" , str (e ))
1434+ continue
1435+
1436+ print_success (f"Updated: { kw .current_bid :.2f} → { new_bid :.2f} { kw .currency } " )
1437+ changes_made += 1
1438+ console .print ()
1439+
1440+ # Summary
1441+ console .print ()
1442+ if changes_made > 0 :
1443+ print_result_panel (
1444+ "Bid Review Complete" ,
1445+ {
1446+ "Keywords reviewed" : str (len (adjustable )),
1447+ "Bids updated" : str (changes_made ),
1448+ },
1449+ )
1450+ else :
1451+ print_info ("No changes made" )
1452+
12731453 except AppleSearchAdsError as e :
12741454 handle_api_error (e )
12751455 raise typer .Exit (1 ) from None
0 commit comments