Skip to content

Commit a38d63f

Browse files
✨ Add impression share correlate command
- New 'correlate' command matches search terms to campaigns/keywords - Shows current bid amounts for matched keywords - Filter options: --matched, --unmatched, --min-share - Useful for SKAG campaigns to identify bid optimization opportunities
1 parent c063506 commit a38d63f

File tree

1 file changed

+374
-1
lines changed

1 file changed

+374
-1
lines changed

asa_api_cli/impression_share.py

Lines changed: 374 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
from dataclasses import dataclass
44
from datetime import date, timedelta
5-
from typing import Annotated
5+
from decimal import Decimal
6+
from typing import TYPE_CHECKING, Annotated
7+
8+
if TYPE_CHECKING:
9+
from asa_api_client import AppleSearchAdsClient
610

711
import typer
12+
from asa_api_client.exceptions import AppleSearchAdsError
13+
from asa_api_client.models import Selector
814
from asa_api_client.models.reports import GranularityType, ImpressionShareReport
915
from rich.table import Table
1016

@@ -510,3 +516,370 @@ def share_summary(
510516
console.print(table)
511517

512518
print_info(f"\nTotal: {total_terms} search terms across {len(by_app)} apps and {len(total_countries)} countries")
519+
520+
521+
@dataclass
522+
class CorrelatedSearchTerm:
523+
"""Search term with matched campaign/keyword data."""
524+
525+
search_term: str
526+
country: str
527+
app_name: str
528+
low_share: float | None
529+
high_share: float | None
530+
rank: str | None
531+
search_popularity: int | None
532+
# Matched campaign/keyword data
533+
campaign_id: int | None = None
534+
campaign_name: str | None = None
535+
ad_group_id: int | None = None
536+
ad_group_name: str | None = None
537+
keyword_id: int | None = None
538+
keyword_text: str | None = None
539+
current_bid: Decimal | None = None
540+
currency: str | None = None
541+
542+
@property
543+
def share_range(self) -> str:
544+
"""Format impression share as range string."""
545+
if self.low_share is None and self.high_share is None:
546+
return "N/A"
547+
low = f"{int(self.low_share * 100)}" if self.low_share else "0"
548+
high = f"{int(self.high_share * 100)}" if self.high_share else "?"
549+
return f"{low}-{high}%"
550+
551+
@property
552+
def avg_share(self) -> float:
553+
"""Average of low and high share."""
554+
if self.low_share is None and self.high_share is None:
555+
return 0.0
556+
low = self.low_share or 0.0
557+
high = self.high_share or low
558+
return (low + high) / 2
559+
560+
@property
561+
def is_matched(self) -> bool:
562+
"""Whether this search term was matched to a keyword."""
563+
return self.keyword_id is not None
564+
565+
566+
@dataclass
567+
class KeywordInfo:
568+
"""Cached keyword information for matching."""
569+
570+
keyword_id: int
571+
keyword_text: str
572+
campaign_id: int
573+
campaign_name: str
574+
ad_group_id: int
575+
ad_group_name: str
576+
bid_amount: Decimal
577+
currency: str
578+
579+
580+
def _build_keyword_index(
581+
client: "AppleSearchAdsClient",
582+
country: str,
583+
) -> dict[str, list[KeywordInfo]]:
584+
"""Build an index of keywords by text for a given country.
585+
586+
Returns a dict mapping lowercase keyword text -> list of KeywordInfo
587+
(multiple campaigns may have the same keyword).
588+
"""
589+
keyword_index: dict[str, list[KeywordInfo]] = {}
590+
591+
# Get enabled campaigns for this country
592+
campaigns = list(client.campaigns.find(Selector().where("status", "==", "ENABLED")))
593+
594+
for campaign in campaigns:
595+
# Check if campaign targets this country
596+
if country.upper() not in [c.upper() for c in campaign.countries_or_regions]:
597+
continue
598+
599+
# Get ad groups for this campaign
600+
try:
601+
ad_groups = list(client.campaigns(campaign.id).ad_groups.find(Selector().where("status", "==", "ENABLED")))
602+
except AppleSearchAdsError:
603+
continue
604+
605+
for ad_group in ad_groups:
606+
# Get keywords for this ad group
607+
try:
608+
keywords = list(client.campaigns(campaign.id).ad_groups(ad_group.id).keywords.list())
609+
except AppleSearchAdsError:
610+
continue
611+
612+
for keyword in keywords:
613+
kw_text = keyword.text.lower()
614+
info = KeywordInfo(
615+
keyword_id=keyword.id,
616+
keyword_text=keyword.text,
617+
campaign_id=campaign.id,
618+
campaign_name=campaign.name,
619+
ad_group_id=ad_group.id,
620+
ad_group_name=ad_group.name,
621+
bid_amount=Decimal(keyword.bid_amount.amount) if keyword.bid_amount else Decimal("0"),
622+
currency=keyword.bid_amount.currency if keyword.bid_amount else "USD",
623+
)
624+
625+
if kw_text not in keyword_index:
626+
keyword_index[kw_text] = []
627+
keyword_index[kw_text].append(info)
628+
629+
return keyword_index
630+
631+
632+
@app.command("correlate")
633+
def correlate_impression_share(
634+
days: Annotated[
635+
int,
636+
typer.Option("--days", "-d", help="Number of days to analyze (max 30)"),
637+
] = 7,
638+
country: Annotated[
639+
str | None,
640+
typer.Option("--country", "-c", help="Filter by country code (required for correlation)"),
641+
] = None,
642+
min_share: Annotated[
643+
float | None,
644+
typer.Option(
645+
"--min-share",
646+
help="Only show search terms with share below this % (e.g., 30)",
647+
),
648+
] = None,
649+
unmatched_only: Annotated[
650+
bool,
651+
typer.Option("--unmatched", "-u", help="Only show search terms not matched to keywords"),
652+
] = False,
653+
matched_only: Annotated[
654+
bool,
655+
typer.Option("--matched", "-m", help="Only show search terms matched to keywords"),
656+
] = False,
657+
limit: Annotated[
658+
int,
659+
typer.Option("--limit", "-l", help="Max rows to display (0 for all)"),
660+
] = 50,
661+
output: Annotated[
662+
str | None,
663+
typer.Option("--output", "-o", help="Export to CSV file"),
664+
] = None,
665+
) -> None:
666+
"""Correlate impression share data with your campaigns and keywords.
667+
668+
Matches search terms from impression share reports to your actual
669+
campaign keywords, showing current bid amounts. Useful for identifying
670+
which keywords need bid adjustments.
671+
672+
For SKAG campaigns with single-market targeting, this provides accurate
673+
campaign-level attribution for impression share data.
674+
675+
Examples:
676+
asa impression-share correlate --country US
677+
asa impression-share correlate --country AU --min-share 30
678+
asa impression-share correlate --country US --unmatched # New keyword opportunities
679+
asa impression-share correlate --country US --matched --min-share 40 # Bid increase candidates
680+
"""
681+
if not country:
682+
print_error("Error", "Country is required for correlation. Use --country/-c")
683+
raise typer.Exit(1)
684+
685+
country = country.upper()
686+
client = get_client()
687+
688+
if days > 30:
689+
print_warning("Maximum lookback is 30 days, using 30")
690+
days = 30
691+
692+
end_date = date.today() - timedelta(days=1)
693+
start_date = end_date - timedelta(days=days - 1)
694+
695+
try:
696+
with client:
697+
# Step 1: Get impression share data
698+
with spinner("Fetching impression share data..."):
699+
report = client.custom_reports.get_impression_share(
700+
start_date=start_date,
701+
end_date=end_date,
702+
granularity=GranularityType.DAILY,
703+
country_codes=[country],
704+
poll_interval=3.0,
705+
timeout=120.0,
706+
)
707+
708+
if not report.row:
709+
print_warning(f"No impression share data available for {country}")
710+
return
711+
712+
print_success(f"Retrieved {len(report.row)} impression share records")
713+
714+
# Step 2: Build keyword index for this country
715+
with spinner(f"Building keyword index for {country}..."):
716+
keyword_index = _build_keyword_index(client, country)
717+
718+
total_kw = sum(len(v) for v in keyword_index.values())
719+
print_info(f"Indexed {total_kw} keywords from {len(keyword_index)} unique terms")
720+
721+
# Step 3: Correlate data
722+
share_data = _parse_report_data(report)
723+
aggregated = _aggregate_by_search_term(share_data)
724+
725+
correlated: list[CorrelatedSearchTerm] = []
726+
for item in aggregated.values():
727+
if item.country.upper() != country:
728+
continue
729+
730+
search_term_lower = item.search_term.lower()
731+
matched_keywords = keyword_index.get(search_term_lower, [])
732+
733+
if matched_keywords:
734+
# Use first match (could be multiple campaigns with same keyword)
735+
# TODO: Could show all matches or pick based on criteria
736+
match = matched_keywords[0]
737+
correlated.append(
738+
CorrelatedSearchTerm(
739+
search_term=item.search_term,
740+
country=item.country,
741+
app_name=item.app_name,
742+
low_share=item.low_share,
743+
high_share=item.high_share,
744+
rank=item.rank,
745+
search_popularity=item.search_popularity,
746+
campaign_id=match.campaign_id,
747+
campaign_name=match.campaign_name,
748+
ad_group_id=match.ad_group_id,
749+
ad_group_name=match.ad_group_name,
750+
keyword_id=match.keyword_id,
751+
keyword_text=match.keyword_text,
752+
current_bid=match.bid_amount,
753+
currency=match.currency,
754+
)
755+
)
756+
else:
757+
# Unmatched search term
758+
correlated.append(
759+
CorrelatedSearchTerm(
760+
search_term=item.search_term,
761+
country=item.country,
762+
app_name=item.app_name,
763+
low_share=item.low_share,
764+
high_share=item.high_share,
765+
rank=item.rank,
766+
search_popularity=item.search_popularity,
767+
)
768+
)
769+
770+
# Apply filters
771+
if min_share is not None:
772+
threshold = min_share / 100.0
773+
correlated = [c for c in correlated if c.high_share is not None and c.high_share < threshold]
774+
775+
if unmatched_only:
776+
correlated = [c for c in correlated if not c.is_matched]
777+
elif matched_only:
778+
correlated = [c for c in correlated if c.is_matched]
779+
780+
if not correlated:
781+
print_warning("No search terms match the specified filters")
782+
return
783+
784+
# Sort by share (lowest first)
785+
correlated.sort(key=lambda x: x.avg_share)
786+
787+
# Stats
788+
matched_count = sum(1 for c in correlated if c.is_matched)
789+
unmatched_count = len(correlated) - matched_count
790+
791+
# Export to CSV if requested
792+
if output:
793+
try:
794+
import csv
795+
796+
with open(output, "w", newline="") as f:
797+
writer = csv.writer(f)
798+
writer.writerow(
799+
[
800+
"search_term",
801+
"country",
802+
"app_name",
803+
"low_share",
804+
"high_share",
805+
"rank",
806+
"popularity",
807+
"campaign_name",
808+
"ad_group_name",
809+
"keyword_text",
810+
"current_bid",
811+
"currency",
812+
]
813+
)
814+
for row in correlated:
815+
writer.writerow(
816+
[
817+
row.search_term,
818+
row.country,
819+
row.app_name,
820+
row.low_share,
821+
row.high_share,
822+
row.rank,
823+
row.search_popularity,
824+
row.campaign_name or "",
825+
row.ad_group_name or "",
826+
row.keyword_text or "",
827+
row.current_bid or "",
828+
row.currency or "",
829+
]
830+
)
831+
print_success(f"Exported {len(correlated)} rows to {output}")
832+
except Exception as e:
833+
print_error("Export failed", str(e))
834+
835+
# Display table
836+
display_limit = len(correlated) if limit == 0 else limit
837+
838+
table = Table(title=f"Impression Share Correlation - {country}")
839+
table.add_column("Search Term", style="cyan", max_width=30)
840+
table.add_column("Share", justify="right", width=8)
841+
table.add_column("Campaign", style="magenta", max_width=25)
842+
table.add_column("Keyword", style="dim", max_width=20)
843+
table.add_column("Bid", justify="right", width=10)
844+
table.add_column("Pop", justify="center", width=3)
845+
846+
for row in correlated[:display_limit]:
847+
share_style = "green"
848+
if row.high_share:
849+
if row.high_share < 0.3:
850+
share_style = "red"
851+
elif row.high_share < 0.5:
852+
share_style = "yellow"
853+
854+
bid_display = f"{row.current_bid:.2f} {row.currency}" if row.current_bid else "[dim]—[/dim]"
855+
campaign_display = row.campaign_name[:25] if row.campaign_name else "[dim]Not matched[/dim]"
856+
keyword_display = row.keyword_text[:20] if row.keyword_text else ""
857+
858+
table.add_row(
859+
row.search_term[:30],
860+
f"[{share_style}]{row.share_range}[/{share_style}]",
861+
campaign_display,
862+
keyword_display,
863+
bid_display,
864+
str(row.search_popularity) if row.search_popularity else "—",
865+
)
866+
867+
console.print(table)
868+
869+
if len(correlated) > display_limit:
870+
print_info(f"Showing {display_limit} of {len(correlated)} results. Use --limit 0 to see all.")
871+
872+
# Summary
873+
console.print(f"\n[dim]Total search terms:[/dim] {len(correlated)}")
874+
console.print(f" [green]Matched to keywords:[/green] {matched_count}")
875+
console.print(f" [yellow]Unmatched (opportunities):[/yellow] {unmatched_count}")
876+
877+
low_share_matched = sum(1 for c in correlated if c.is_matched and c.high_share and c.high_share < 0.3)
878+
if low_share_matched > 0:
879+
console.print(
880+
f"\n[red]⚠ {low_share_matched} matched keywords have <30% share - consider bid increases[/red]"
881+
)
882+
883+
except AppleSearchAdsError as e:
884+
handle_api_error(e)
885+
raise typer.Exit(1) from None

0 commit comments

Comments
 (0)