|
2 | 2 |
|
3 | 3 | from dataclasses import dataclass |
4 | 4 | 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 |
6 | 10 |
|
7 | 11 | import typer |
| 12 | +from asa_api_client.exceptions import AppleSearchAdsError |
| 13 | +from asa_api_client.models import Selector |
8 | 14 | from asa_api_client.models.reports import GranularityType, ImpressionShareReport |
9 | 15 | from rich.table import Table |
10 | 16 |
|
@@ -510,3 +516,370 @@ def share_summary( |
510 | 516 | console.print(table) |
511 | 517 |
|
512 | 518 | 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