diff --git a/.agents/skills/openaso-aso/SKILL.md b/.agents/skills/openaso-aso/SKILL.md index 3d3eb62..b7f5d49 100644 --- a/.agents/skills/openaso-aso/SKILL.md +++ b/.agents/skills/openaso-aso/SKILL.md @@ -21,13 +21,21 @@ Use OpenASO as the evidence layer for ASO recommendations. Do not copy generic A 4. Keep refreshes bounded. Prefer narrow storefronts, small limits, and partial results over broad crawls. 5. Separate verified evidence from hypotheses. Label unsupported or missing inputs instead of inventing them. +## Keyword Discovery + +For keyword research, expansion, or high-impact opportunities, follow [references/keyword-discovery.md](references/keyword-discovery.md). Use fresh popularity to choose branches, explore distinct feature seeds and additional pages, then verify a relevant shortlist. Report coverage and the remaining frontier so a bounded search is not mistaken for exhaustive market coverage. + +## Metadata Targeting + +For title, subtitle, private keyword-field audits, drafts, or release comparisons, follow [references/metadata-targeting.md](references/metadata-targeting.md). Read owned metadata with saved App Store Connect access, compare a relevant discovery shortlist, and validate alternatives before any publishing step. + ## Unsupported Or User-Provided Data OpenASO public/local evidence does not prove: - Downloads, revenue, LTV, retention, or paid campaign performance. - Exact App Store Connect impressions, product page views, conversion rate, or test results. -- The current hidden App Store keyword field unless the user provides it. +- The current hidden App Store keyword field unless authenticated metadata tools return it or the user provides it. - Exact competitor conversion rates or revenue. Ask for these inputs when required, or mark them as missing. @@ -35,7 +43,7 @@ Ask for these inputs when required, or mark them as missing. ## ASO Rubrics To Apply - Title and subtitle are 30 characters each. -- Hidden iOS keyword field is 100 characters when user-provided. +- Hidden iOS keyword field is 100 characters when available from authenticated reads or user input. - Promotional text is 170 characters; description and release notes are 4000 characters. - Avoid repeating keyword words across title, subtitle, and keyword field. - Prefer comma-separated keyword-field terms without spaces. diff --git a/.agents/skills/openaso-aso/references/keyword-discovery.md b/.agents/skills/openaso-aso/references/keyword-discovery.md new file mode 100644 index 0000000..ca04f75 --- /dev/null +++ b/.agents/skills/openaso-aso/references/keyword-discovery.md @@ -0,0 +1,28 @@ +# Popularity-guided keyword discovery + +Find relevant, incremental keywords with measured demand. A high popularity index supports a demand hypothesis; relevance, ranking evidence and competition determine whether it is an opportunity for this app. + +## Establish scope + +Use the app overview and existing tracks to identify its actual features, target audience, tracked terms and evidence freshness. If a broad workspace read times out, continue with narrow tool calls and disclose the missing context. Keep a ledger keyed by normalized keyword/storefront/platform, plus seeds and page offsets already requested. + +Research one storefront at a time. Start with an app-based `suggest_keywords` call (omit `seed_keyword`) and two or three distinct feature/use-case seeds. Use `limit: 50` and `ranking_limit: 0` during expansion so the response budget goes to discovery. A default budget of eight suggestion requests per app is a useful starting point; increase it when the user requests broader coverage and new useful terms continue to appear. + +## Expand with evidence + +- `suggest_keywords` looks up fresh market popularity automatically when available. Retain `popularity.popularityScore`, its source and observation time separately from contextual `suggestionScore`. The latter can change with the seed and should be compared only within that request context. +- Use `get_keyword_popularity` to check untracked terms, synonyms, or evidence that is missing or stale, in batches of at most 50 terms. The service uses saved official credentials and the saved web-session fallback automatically. If access fails, retain candidates and report the reason instead of repeatedly retrying authentication. +- Prefer feature-relevant terms with market popularity above 5 as branch seeds. Keep scores of 0...5 and unknown scores in separate exploratory groups; missing data is not evidence of zero demand. Retain a low-score term when its feature coverage makes it worth testing. +- Exclude unrelated intent, unsupported features, wrong-language phrases and competitor brands from the organic opportunity shortlist before choosing branches. A popular competitor can supply research context without becoming a metadata recommendation. +- Re-query promising untried terms as `seed_keyword` values. Also follow `suggestionPages.nextOffset` with the same seed and limit to inspect another page. Deduplicate terms across pages and seeds while retaining every request context; flag already tracked terms using `isTracked`. +- Count the new, relevant above-floor terms returned by each expansion. Stop after two successive expansion calls add none, when the frontier is exhausted, or at the request budget. Return untried seeds/pages when the budget stops a productive search. Treat a partial page as provider pagination evidence, not proof that every possible keyword has been found. + +## Verify and select + +Use `get_ranked_apps_for_keyword` on the strongest shortlist to inspect target rank, competing apps, search intent and ranking provenance. Native App Store results and an iTunes fallback have different evidential strength; identify the fallback when used. Popularity alone does not establish downloads, exact search volume, conversion, or an achievable rank. + +Prioritize by feature relevance, fresh market popularity, current rank and competitive evidence. Return a coverage summary with seeds/pages tried, unique candidates, above-floor relevant terms, new versus tracked counts, provider failures, and the remaining frontier. For each recommendation include its rationale, popularity source/time, seed context and ranking gaps. Keep low/unknown-demand hypotheses separate from the primary shortlist. + +When keyword additions are authorized, use `add_keywords` for the selected new terms and preserve source context with `update_keyword_notes`. Keep contextual suggestion scores out of stored market-popularity fields. Use `refresh_keyword_metrics` with its `keywords` filter to persist fresh market popularity for the chosen terms in batches of at most 50. Refresh rankings in bounded batches when needed; distinguish adding a track from refreshing it. + +Retire tracked keywords only when removal is authorized. Use `remove_keywords` with the exact app, platform and storefronts; review removed/skipped results. Shared query observations and market popularity survive, while track-specific history and statuses are removed. Do not prune automatically based only on floor or missing popularity. diff --git a/.agents/skills/openaso-aso/references/metadata-targeting.md b/.agents/skills/openaso-aso/references/metadata-targeting.md new file mode 100644 index 0000000..30c2bb8 --- /dev/null +++ b/.agents/skills/openaso-aso/references/metadata-targeting.md @@ -0,0 +1,37 @@ +# Keyword targeting and metadata proposals + +Connect keyword discovery to owned metadata and measured performance. Popularity is demand evidence; feature relevance, search intent, competition, existing visibility and evidence freshness determine whether a term is worth targeting. + +## Read the actual baseline + +Use `get_app_store_metadata` with the app ID, exact App Store locale, search platform and `scope: live` or `editable`. App Store Connect credentials are separate from Apple Ads access. The tool reads title/subtitle and version-specific private keywords using saved credentials, returns version/state and a content hash, and fails instead of silently substituting another language or mixing a draft with live metadata. If authenticated access is unavailable, request the missing fields or mark the audit partial. + +Keep locale and storefront explicit: locale selects metadata language, storefront selects demand and rankings. Repeat research for each intended market instead of translating US keyword choices. + +## Audit opportunities and current targeting + +Run the keyword-discovery workflow first, then pass a curated shortlist to `audit_keyword_targeting`. Include promising new terms and valuable existing terms that a draft must protect. The tool defaults to at most 50 tracked terms ordered by stored popularity; this default is a bounded sample, not the complete keyword market. + +The audit combines fresh market popularity, app-specific metadata overlap, lexical coverage and up to eight bounded ranking samples. Its relevance score is a heuristic; review actual feature fit, search-result intent, brands, language and unsupported claims. A popular phrase with no app relevance is a review candidate rather than an automatic opportunity. + +For sampled keywords, inspect `competition`: its score is an OpenASO native-result heuristic with confidence, source/time and bounded competitor evidence. A missing score or fallback/insufficient-data reason means unknown competition. Compare demand and relevance with observed competitor authority and metadata saturation; do not treat a lower score as guaranteed rankability or an Apple metric. Audits calculate this evidence without persisting it. + +Distinguish literal phrase presence, word combinations across fields, English plural variants, and missing components. Apple advises avoiding filler such as "to" and "the" in the private keyword field. Token presence and variant combinations do not prove indexing. A well-covered term with weak rankings needs a different diagnosis from a term with missing metadata components. + +Use native App Store ranking evidence when available. Preserve source, observation time and sample size; iTunes fallback or an absent target in a bounded sample leaves native rank unknown. Do not turn a missing rank into a numeric position. + +## Produce reviewable alternatives + +Draft a conservative option and a broader positioning option. Preserve the app's brand, readable product promise and useful existing targets. Use `compare_metadata_draft` with proposed fields, the same keyword shortlist, and the baseline `expected_hash`. + +Inspect 30/30/100 character validation, duplicate/plural/filler warnings and gained/lost lexical coverage. Explain which terms each field targets and which existing terms lose prominence. Review exact phrases as well as inferred combinations; avoid unsupported claims about title/subtitle ranking weights or predicted rank lift. Return character counts, source/time, before/after fields, tradeoffs and remaining evidence gaps. + +These tools do not publish App Store changes. Any later apply step needs the user's authorized publishing scope and a fresh baseline check. + +## Record and measure a release + +Use `record_metadata_baseline` before a metadata release to save the live version, private fields, fixed keyword set and ranking/popularity evidence locally. Keep its ID and a release-oriented label. `list_metadata_baselines` finds prior records for an app. + +Use `compare_metadata_baseline` after the release to compare the same app, locale, storefront, platform and queries. It repeats only previously sampled rank queries and computes deltas only from native positions present in both observations. Note version changes, coverage gains/losses, demand changes and source failures. + +Optional App Store Search impression/download counts require explicit reporting periods and are labeled caller-provided. Match market and comparable periods, account for paid Apple Ads and seasonality, and keep aggregate acquisition separate from keyword attribution. The comparison is observational, not a randomized title/subtitle/keyword experiment. Schedule later measurement only when the user requests it. diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index fa3f345..0462a95 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -349,10 +349,19 @@ F008A0000000000000000001 /* Networking/ProviderRequestGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008A0000000000000000011 /* Networking/ProviderRequestGate.swift */; }; F008A0000000000000000002 /* ProviderRequestGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008A0000000000000000012 /* ProviderRequestGateTests.swift */; }; F008B0000000000000000001 /* Networking/ProviderHTTPClientPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008B0000000000000000011 /* Networking/ProviderHTTPClientPipeline.swift */; }; + B4AA3442D89B4D80A20E9975 /* AppStoreConnect/AppStoreConnectMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F24E654014FC982B31117 /* AppStoreConnect/AppStoreConnectMetadataService.swift */; }; + CF4F50E477D94EDB82CDC893 /* AppStoreConnect/AppStoreConnectMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F24E654014FC982B31117 /* AppStoreConnect/AppStoreConnectMetadataService.swift */; }; + 2A64179D162340B9972B7620 /* MCP/KeywordTargetingAnalysis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0103F7D80DA84290B2497908 /* MCP/KeywordTargetingAnalysis.swift */; }; + 49D3E2B90078465ABC5F5C34 /* MCP/KeywordTargetingAnalysis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0103F7D80DA84290B2497908 /* MCP/KeywordTargetingAnalysis.swift */; }; + B2C70B453F624EA08C697A5C /* Persistence/MetadataBaselineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB0FE05C81D4B6CA56EA3BD /* Persistence/MetadataBaselineStore.swift */; }; + F241DBBDCE904A65BD3D0887 /* Persistence/MetadataBaselineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB0FE05C81D4B6CA56EA3BD /* Persistence/MetadataBaselineStore.swift */; }; + 4C9E597C5496436BAC2878AE /* AppStoreConnect/AppStoreConnectCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5C00000000000000000002 /* AppStoreConnect/AppStoreConnectCredentials.swift */; }; + 180607995081435EBFEB3F76 /* KeywordTargetingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B977F79EB964A5F8B069EED /* KeywordTargetingTests.swift */; }; 40A7F458B27249B89EDDCC41 /* Persistence/RatingHistoryStore+Reading.swift in Sources */ = {isa = PBXBuildFile; fileRef = E182C2554EA7417792642340 /* Persistence/RatingHistoryStore+Reading.swift */; }; D45DEBF8843440B384689D6A /* Persistence/RatingHistoryStore+Reading.swift in Sources */ = {isa = PBXBuildFile; fileRef = E182C2554EA7417792642340 /* Persistence/RatingHistoryStore+Reading.swift */; }; 04FE3BB52BA4436A8E8A7B36 /* Persistence/StoragePreparationProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382A12B0A246434D826A43F9 /* Persistence/StoragePreparationProgress.swift */; }; 195FABB04F3A4F39925CFA43 /* Persistence/StoragePreparationProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382A12B0A246434D826A43F9 /* Persistence/StoragePreparationProgress.swift */; }; + 37EFA674B49B4F7C95F2435E /* SearchRanking/KeywordDifficultyEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20A00000000000000000011 /* SearchRanking/KeywordDifficultyEstimator.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -641,6 +650,10 @@ F008A0000000000000000011 /* Networking/ProviderRequestGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking/ProviderRequestGate.swift; sourceTree = ""; }; F008A0000000000000000012 /* ProviderRequestGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderRequestGateTests.swift; sourceTree = ""; }; F008B0000000000000000011 /* Networking/ProviderHTTPClientPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking/ProviderHTTPClientPipeline.swift; sourceTree = ""; }; + 890F24E654014FC982B31117 /* AppStoreConnect/AppStoreConnectMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreConnect/AppStoreConnectMetadataService.swift; sourceTree = ""; }; + 0103F7D80DA84290B2497908 /* MCP/KeywordTargetingAnalysis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCP/KeywordTargetingAnalysis.swift; sourceTree = ""; }; + 4FB0FE05C81D4B6CA56EA3BD /* Persistence/MetadataBaselineStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/MetadataBaselineStore.swift; sourceTree = ""; }; + 7B977F79EB964A5F8B069EED /* KeywordTargetingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTargetingTests.swift; sourceTree = ""; }; E182C2554EA7417792642340 /* Persistence/RatingHistoryStore+Reading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence/RatingHistoryStore+Reading.swift"; sourceTree = ""; }; 382A12B0A246434D826A43F9 /* Persistence/StoragePreparationProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Persistence/StoragePreparationProgress.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -733,6 +746,7 @@ B7000000000000000000001B /* KeywordWorkspaceProjectionTests.swift */, B7000000000000000000001C /* KeywordRankingListLoaderTests.swift */, 4D4350545345525600000001 /* OpenASOMCPServerTests.swift */, + 7B977F79EB964A5F8B069EED /* KeywordTargetingTests.swift */, 4D4350544553545300000001 /* OpenASOMCPServiceTests.swift */, A0BEEF000000000000000015 /* ScreenshotDownloadServiceTests.swift */, A63CBBCCA79CA32CCB5BFE4F /* TrackedAppKeywordIdentityTests.swift */, @@ -856,6 +870,9 @@ E25000000000000000000011 /* SearchRanking/VisibleProductPricingService.swift */, 5C5530D41B3742698F02134C /* Credentials/KeychainService.swift */, C0DEFACE0000000000000101 /* MCP/OpenASOMCPDTOs.swift */, + 890F24E654014FC982B31117 /* AppStoreConnect/AppStoreConnectMetadataService.swift */, + 0103F7D80DA84290B2497908 /* MCP/KeywordTargetingAnalysis.swift */, + 4FB0FE05C81D4B6CA56EA3BD /* Persistence/MetadataBaselineStore.swift */, C0DEFACE0000000000000103 /* MCP/OpenASOMCPService.swift */, C0DEFACE0000000000000105 /* MCP/OpenASOMCPValidation.swift */, C0DEFACE0000000000000107 /* MCP/OpenASOMCPServer.swift */, @@ -1414,6 +1431,9 @@ A30000000000000000000003 /* Persistence/ModelContainerFactory.swift in Sources */, C0DEFACE0000000000000102 /* MCP/OpenASOMCPDTOs.swift in Sources */, C0DEC0DE0000000000000012 /* MCPServerSheet.swift in Sources */, + B4AA3442D89B4D80A20E9975 /* AppStoreConnect/AppStoreConnectMetadataService.swift in Sources */, + 2A64179D162340B9972B7620 /* MCP/KeywordTargetingAnalysis.swift in Sources */, + B2C70B453F624EA08C697A5C /* Persistence/MetadataBaselineStore.swift in Sources */, C0DEFACE0000000000000104 /* MCP/OpenASOMCPService.swift in Sources */, C0DEFACE0000000000000106 /* MCP/OpenASOMCPValidation.swift in Sources */, C0DEFACE0000000000000108 /* MCP/OpenASOMCPServer.swift in Sources */, @@ -1527,6 +1547,7 @@ B7000000000000000000000B /* KeywordWorkspaceProjectionTests.swift in Sources */, B7000000000000000000000C /* KeywordRankingListLoaderTests.swift in Sources */, 4D4350545345525600000002 /* OpenASOMCPServerTests.swift in Sources */, + 180607995081435EBFEB3F76 /* KeywordTargetingTests.swift in Sources */, 4D4350544553545300000002 /* OpenASOMCPServiceTests.swift in Sources */, A0BEEF000000000000000014 /* ScreenshotDownloadServiceTests.swift in Sources */, FE86E03B40393EF38C032A45 /* TrackedAppKeywordIdentityTests.swift in Sources */, @@ -1549,6 +1570,11 @@ AD5100000000000000000002 /* AppleAds/AppleAdsPlatformService.swift in Sources */, C15A0000000000000000000A /* MCP/OpenASOMCPMain.swift in Sources */, C15A00000000000000000101 /* MCP/OpenASOMCPDTOs.swift in Sources */, + CF4F50E477D94EDB82CDC893 /* AppStoreConnect/AppStoreConnectMetadataService.swift in Sources */, + 49D3E2B90078465ABC5F5C34 /* MCP/KeywordTargetingAnalysis.swift in Sources */, + 37EFA674B49B4F7C95F2435E /* SearchRanking/KeywordDifficultyEstimator.swift in Sources */, + F241DBBDCE904A65BD3D0887 /* Persistence/MetadataBaselineStore.swift in Sources */, + 4C9E597C5496436BAC2878AE /* AppStoreConnect/AppStoreConnectCredentials.swift in Sources */, C15A00000000000000000102 /* MCP/OpenASOMCPService.swift in Sources */, C15A00000000000000000103 /* MCP/OpenASOMCPValidation.swift in Sources */, C15A00000000000000000104 /* MCP/OpenASOMCPServer.swift in Sources */, @@ -1655,7 +1681,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 85BF4D5D6B; GENERATE_INFOPLIST_FILE = NO; @@ -1674,7 +1700,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; POSTHOG_HOST = ""; POSTHOG_PROJECT_TOKEN = ""; PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso.dev; @@ -1884,7 +1910,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 85BF4D5D6B; ENABLE_HARDENED_RUNTIME = YES; @@ -1904,7 +1930,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; POSTHOG_HOST = ""; POSTHOG_PROJECT_TOKEN = ""; PRODUCT_BUNDLE_IDENTIFIER = com.thirdtech.openaso; diff --git a/OpenASO/App/AppServices.swift b/OpenASO/App/AppServices.swift index 3aaedb5..fe451df 100644 --- a/OpenASO/App/AppServices.swift +++ b/OpenASO/App/AppServices.swift @@ -457,7 +457,14 @@ final class AppServices { appleAdsPlatformAPI: appleAdsPlatformAPI, appleAdsCredentialsProvider: { appleAdsCredentialStore.apiCredentials - } + }, + appStoreConnectCredentialsProvider: { + appStoreConnectCredentialStore.credentials + }, + metadataBaselineStore: MetadataBaselineStore( + directory: try namespace.applicationSupportDirectoryURL() + .appendingPathComponent("Metadata Baselines", isDirectory: true) + ) ) return await OpenASOMCPServerFactory( diff --git a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift index 4cfb82f..581aed4 100644 --- a/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift +++ b/OpenASO/Features/AppDetail/AppDetailToolbarViews.swift @@ -4,29 +4,42 @@ import SwiftUI struct AppDetailRefreshToolbarButton: View { let isRefreshing: Bool let isDisabled: Bool + var isStopping = false + var stopAction: (() -> Void)? = nil let action: () -> Void let refreshAllStorefrontsAction: () -> Void let refreshAllAppsAction: () -> Void let help: String var body: some View { - Menu { - Button(action: refreshAllStorefrontsAction) { - Label("Refresh All Storefronts", systemImage: "globe") - } - .disabled(isDisabled) + HStack(spacing: 6) { + Menu { + Button(action: refreshAllStorefrontsAction) { + Label("Refresh All Storefronts", systemImage: "globe") + } + .disabled(isDisabled) - Button(action: refreshAllAppsAction) { - Label("Refresh All Apps (Focused Countries)", systemImage: "arrow.triangle.2.circlepath") + Button(action: refreshAllAppsAction) { + Label("Refresh All Apps (Focused Countries)", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(isDisabled) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } primaryAction: { + action() } .disabled(isDisabled) - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } primaryAction: { - action() + .help(help) + if isRefreshing, let stopAction { + if isStopping { + ProgressView().controlSize(.small) + .accessibilityLabel("Stopping refresh") + } else { + Button("Stop", systemImage: "stop.fill", action: stopAction) + .help("Stop this manual refresh") + } + } } - .disabled(isDisabled) - .help(help) } } diff --git a/OpenASO/Features/AppDetail/AppDetailView.swift b/OpenASO/Features/AppDetail/AppDetailView.swift index 5b1b8cd..a9c935c 100644 --- a/OpenASO/Features/AppDetail/AppDetailView.swift +++ b/OpenASO/Features/AppDetail/AppDetailView.swift @@ -21,6 +21,8 @@ struct AppDetailView: View { @State private var isPresentingAddKeywords = false @State private var isRefreshingApp = false + @State private var activeRefreshTask: Task? + @State private var isStoppingRefresh = false @State private var errorMessage: String? @State private var searchText = "" @State private var selectedWorkspaceView = AppDetailWorkspaceView.keywords @@ -36,6 +38,7 @@ struct AppDetailView: View { @State private var keywordRefreshToken = 0 @State private var queuedKeywordAdds: [KeywordAddRequest] = [] @State private var isFlushingQueuedKeywordAdds = false + @State private var skipsQueuedKeywordRefresh = false @State private var isRefreshingQueuedKeywordAdds = false init(trackedApp: TrackedApp) { @@ -92,6 +95,8 @@ struct AppDetailView: View { AppDetailRefreshToolbarButton( isRefreshing: isRefreshingApp, isDisabled: isRefreshDisabled, + isStopping: isStoppingRefresh, + stopAction: stopRefresh, action: refreshApp, refreshAllStorefrontsAction: refreshAllStorefronts, refreshAllAppsAction: refreshAllApps, @@ -228,6 +233,7 @@ struct AppDetailView: View { } private func refreshApp(scope: AppDetailManualRefreshScope) { + guard !isRefreshingApp else { return } isRefreshingApp = true errorMessage = nil let activeWorkspaceView = selectedWorkspaceView @@ -253,11 +259,11 @@ struct AppDetailView: View { "Refresh tapped appStoreID=\(appStoreID, privacy: .public) appName=\(appName, privacy: .public) view=\(activeWorkspaceView.title, privacy: .public) selectedStorefront=\(selectedStorefrontFilter.title, privacy: .public) storefrontCount=\(request.storefrontSelection.codes.count, privacy: .public) keywords=\(request.trackIdentityKeys.count, privacy: .public)" ) - Task(priority: .userInitiated) { + activeRefreshTask = Task(priority: .userInitiated) { let result = await refreshService.refresh(request) await MainActor.run { - if let firstError = result.firstError { + if !Task.isCancelled, !result.wasCancelled, let firstError = result.firstError { errorMessage = firstError.localizedDescription } keywordRefreshToken += 1 @@ -267,12 +273,15 @@ struct AppDetailView: View { "Refresh finished appStoreID=\(appStoreID, privacy: .public) ratingSuccesses=\(result.ratingOutcomes.filter { $0.error == nil }.count, privacy: .public) ratingFailures=\(result.ratingOutcomes.filter { $0.error != nil }.count, privacy: .public) reviewSuccesses=\(result.reviewOutcomes.filter { $0.error == nil }.count, privacy: .public) reviewFailures=\(result.reviewOutcomes.filter { $0.error != nil }.count, privacy: .public) keywordFailures=\(result.keywordOutcomes.filter { $0.error != nil }.count, privacy: .public)" ) isRefreshingApp = false - flushQueuedKeywordAdds() + activeRefreshTask = nil + isStoppingRefresh = false + flushQueuedKeywordAdds(refreshNewTracks: !Task.isCancelled && !result.wasCancelled) } } } private func refreshAllApps() { + guard !isRefreshingApp else { return } isRefreshingApp = true errorMessage = nil let activeWorkspaceView = selectedWorkspaceView @@ -295,7 +304,7 @@ struct AppDetailView: View { "Refresh all tapped startingAppStoreID=\(appStoreID, privacy: .public) view=\(activeWorkspaceView.title, privacy: .public) selectedStorefront=\(selectedStorefrontFilter.title, privacy: .public) appCount=\(requests.count, privacy: .public)" ) - Task(priority: .userInitiated) { + activeRefreshTask = Task(priority: .userInitiated) { var firstError: OpenASOError? var ratingSuccesses = 0 var ratingFailures = 0 @@ -304,7 +313,9 @@ struct AppDetailView: View { var keywordFailures = 0 for request in requests { + guard !Task.isCancelled else { break } let result = await refreshService.refresh(request) + if result.wasCancelled || Task.isCancelled { break } if firstError == nil { firstError = result.firstError } @@ -323,7 +334,7 @@ struct AppDetailView: View { } await MainActor.run { - if let firstError { + if !Task.isCancelled, let firstError { errorMessage = firstError.localizedDescription } keywordRefreshToken += 1 @@ -333,12 +344,22 @@ struct AppDetailView: View { "Refresh all finished startingAppStoreID=\(appStoreID, privacy: .public) appCount=\(requests.count, privacy: .public) ratingSuccesses=\(ratingSuccesses, privacy: .public) ratingFailures=\(ratingFailures, privacy: .public) reviewSuccesses=\(reviewSuccesses, privacy: .public) reviewFailures=\(reviewFailures, privacy: .public) keywordFailures=\(keywordFailures, privacy: .public)" ) isRefreshingApp = false - flushQueuedKeywordAdds() + activeRefreshTask = nil + isStoppingRefresh = false + flushQueuedKeywordAdds(refreshNewTracks: !Task.isCancelled) } } } + private func stopRefresh() { + guard let activeRefreshTask else { return } + isStoppingRefresh = true + if !queuedKeywordAdds.isEmpty { skipsQueuedKeywordRefresh = true } + activeRefreshTask.cancel() + } + private func queueKeywordAdd(_ request: KeywordAddRequest) { + if isStoppingRefresh { skipsQueuedKeywordRefresh = true } queuedKeywordAdds.append(request) services.refreshProgressStore.queuePendingKeywordAddition( appStoreID: appStoreID, @@ -347,7 +368,7 @@ struct AppDetailView: View { flushQueuedKeywordAdds() } - private func flushQueuedKeywordAdds() { + private func flushQueuedKeywordAdds(refreshNewTracks: Bool = true) { guard !queuedKeywordAdds.isEmpty, !isRefreshInProgress, !isFlushingQueuedKeywordAdds else { return } @@ -356,11 +377,13 @@ struct AppDetailView: View { let requests = queuedKeywordAdds queuedKeywordAdds.removeAll() services.refreshProgressStore.clearPendingKeywordAdditions(appStoreID: appStoreID) - addQueuedKeywordTracks(requests) + let shouldRefresh = refreshNewTracks && !skipsQueuedKeywordRefresh + skipsQueuedKeywordRefresh = false + addQueuedKeywordTracks(requests, refreshNewTracks: shouldRefresh) isFlushingQueuedKeywordAdds = false } - private func addQueuedKeywordTracks(_ requests: [KeywordAddRequest]) { + private func addQueuedKeywordTracks(_ requests: [KeywordAddRequest], refreshNewTracks: Bool) { let existingKeys: Set do { existingKeys = try existingKeywordDuplicateKeys() @@ -431,7 +454,7 @@ struct AppDetailView: View { storefrontCount: requestedStorefrontCodes.count )) - guard let refreshService = services.appDetailRefreshService else { + guard refreshNewTracks, let refreshService = services.appDetailRefreshService else { return } diff --git a/OpenASO/Services/AppStoreConnect/AppStoreConnectMetadataService.swift b/OpenASO/Services/AppStoreConnect/AppStoreConnectMetadataService.swift new file mode 100644 index 0000000..e2ed50c --- /dev/null +++ b/OpenASO/Services/AppStoreConnect/AppStoreConnectMetadataService.swift @@ -0,0 +1,234 @@ +import CryptoKit +import Foundation + +struct AppStoreMetadataFields: Codable, Equatable, Sendable { + var title: String + var subtitle: String + /// nil means the private keyword field was not available, rather than empty. + var keywords: String? +} + +struct AppStoreMetadataSnapshot: Codable, Equatable, Sendable { + let appStoreID: Int64 + let locale: String + let platform: String + let scope: String + let version: String + let versionID: String + let versionState: String + let appInfoID: String + let fields: AppStoreMetadataFields + let observedAt: Date + let source: String + var descriptionText: String? = nil + + var contentHash: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + // Only metadata and its identity determine the hash, not observation time. + let identity = [ + String(appStoreID), locale.lowercased(), platform, versionID, appInfoID, + fields.title, fields.subtitle, fields.keywords == nil ? "unavailable" : "available", fields.keywords ?? "", + ] + let data = (try? encoder.encode(identity)) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +extension AppStoreMetadataSnapshot { + private enum CodingKeys: String, CodingKey { + case appStoreID, locale, platform, scope, version, versionID, versionState, appInfoID + case fields, observedAt, source, descriptionText, contentHash + } + + init(from decoder: Decoder) throws { + let value = try decoder.container(keyedBy: CodingKeys.self) + self.init( + appStoreID: try value.decode(Int64.self, forKey: .appStoreID), + locale: try value.decode(String.self, forKey: .locale), + platform: try value.decode(String.self, forKey: .platform), + scope: try value.decode(String.self, forKey: .scope), + version: try value.decode(String.self, forKey: .version), + versionID: try value.decode(String.self, forKey: .versionID), + versionState: try value.decode(String.self, forKey: .versionState), + appInfoID: try value.decode(String.self, forKey: .appInfoID), + fields: try value.decode(AppStoreMetadataFields.self, forKey: .fields), + observedAt: try value.decode(Date.self, forKey: .observedAt), + source: try value.decode(String.self, forKey: .source), + descriptionText: try value.decodeIfPresent(String.self, forKey: .descriptionText) + ) + } + + func encode(to encoder: Encoder) throws { + var value = encoder.container(keyedBy: CodingKeys.self) + try value.encode(appStoreID, forKey: .appStoreID) + try value.encode(locale, forKey: .locale) + try value.encode(platform, forKey: .platform) + try value.encode(scope, forKey: .scope) + try value.encode(version, forKey: .version) + try value.encode(versionID, forKey: .versionID) + try value.encode(versionState, forKey: .versionState) + try value.encode(appInfoID, forKey: .appInfoID) + try value.encode(fields, forKey: .fields) + try value.encode(observedAt, forKey: .observedAt) + try value.encode(source, forKey: .source) + try value.encodeIfPresent(descriptionText, forKey: .descriptionText) + try value.encode(contentHash, forKey: .contentHash) + } +} + +/// Reads one exact locale/version through the existing credential and HTTP seams. +/// It has no App Store write operations and does not fall back to a different locale. +struct AppStoreConnectMetadataService: Sendable { + let httpClient: any HTTPClient + + func snapshot( + appStoreID: Int64, + locale: String, + platform: String, + scope: String, + credentials: AppStoreConnectCredentials, + now: Date + ) async throws -> AppStoreMetadataSnapshot { + guard credentials.isComplete else { + throw OpenASOError.providerUnavailable("Connect App Store Connect in Settings to read the private keyword field.") + } + guard scope == "live" || scope == "editable" else { + throw OpenASOError.providerUnavailable("Metadata scope must be live or editable.") + } + let token = try AppStoreConnectJWT( + issuerID: credentials.trimmed.issuerID, + keyID: credentials.trimmed.keyID, + privateKey: credentials.trimmed.privateKey + ).signed() + let versions = try await resources( + path: "/v1/apps/\(appStoreID)/appStoreVersions", + query: [.init(name: "filter[platform]", value: platform)], + token: token + ) + guard let version = versions.filter({ matches($0.attributes.state, scope: scope) }) + .sorted(by: Self.newestFirst).first else { + throw OpenASOError.providerUnavailable("No \(scope) \(platform) App Store version is available.") + } + let infos = try await resources(path: "/v1/apps/\(appStoreID)/appInfos", token: token) + let matchingInfos = infos.filter { matches($0.attributes.state, scope: scope) } + // App infos do not have a reliable version/date relation. Ambiguity must + // remain visible instead of mixing live and draft names with keywords. + guard matchingInfos.count == 1, let info = matchingInfos.first else { + throw OpenASOError.providerUnavailable("Expected one \(scope) app-info record; found \(matchingInfos.count). Resolve the App Store Connect metadata state before auditing.") + } + let names = try await resources(path: "/v1/appInfos/\(info.id)/appInfoLocalizations", token: token) + let texts = try await resources(path: "/v1/appStoreVersions/\(version.id)/appStoreVersionLocalizations", token: token) + let normalizedLocale = locale.replacingOccurrences(of: "_", with: "-").lowercased() + guard let name = names.first(where: { + $0.attributes.locale?.replacingOccurrences(of: "_", with: "-").lowercased() == normalizedLocale + }), let text = texts.first(where: { + $0.attributes.locale?.replacingOccurrences(of: "_", with: "-").lowercased() == normalizedLocale + }), let title = name.attributes.name, !title.isEmpty else { + throw OpenASOError.providerUnavailable("The exact \(locale) localization is missing from the selected metadata. No other language was substituted.") + } + return AppStoreMetadataSnapshot( + appStoreID: appStoreID, + locale: name.attributes.locale ?? locale, + platform: platform, + scope: scope, + version: version.attributes.versionString ?? "", + versionID: version.id, + versionState: version.attributes.state, + appInfoID: info.id, + fields: .init(title: title, subtitle: name.attributes.subtitle ?? "", keywords: text.attributes.keywords), + observedAt: now, + source: "app_store_connect", + descriptionText: text.attributes.description + ) + } + + private func matches(_ state: String, scope: String) -> Bool { + if scope == "live" { return ["READY_FOR_DISTRIBUTION", "READY_FOR_SALE"].contains(state) } + return [ + "PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", + "METADATA_REJECTED", "INVALID_BINARY", "READY_FOR_REVIEW", + ].contains(state) + } + + private static func newestFirst(_ left: Resource, _ right: Resource) -> Bool { + let dates = [left.attributes.createdDate, right.attributes.createdDate].map { raw -> Date in + guard let raw else { return .distantPast } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: raw) ?? ISO8601DateFormatter().date(from: raw) ?? .distantPast + } + if dates[0] != dates[1] { return dates[0] > dates[1] } + let order = (left.attributes.versionString ?? "").compare(right.attributes.versionString ?? "", options: .numeric) + return order == .orderedSame ? left.id < right.id : order == .orderedDescending + } + + private func resources( + path: String, + query: [URLQueryItem] = [], + token: String + ) async throws -> [Resource] { + var components = URLComponents() + components.scheme = "https" + components.host = "api.appstoreconnect.apple.com" + components.path = path + components.queryItems = query + [.init(name: "limit", value: "200")] + var next = components.url + var visited = Set() + var result: [Resource] = [] + while let url = next { + try Task.checkCancellation() + guard url.scheme == "https", url.host == "api.appstoreconnect.apple.com", + url.port == nil || url.port == 443, url.user == nil, url.password == nil, + url.path == path, visited.insert(url).inserted, visited.count <= 5 else { + throw OpenASOError.providerUnavailable("Unsafe, repeated, or unbounded App Store Connect pagination.") + } + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let data = try await validatedData(for: request, using: httpClient) + let page = try JSONDecoder().decode(Page.self, from: data) + result.append(contentsOf: page.data) + if let raw = page.links?.next { + guard let parsed = URL(string: raw, relativeTo: url)?.absoluteURL else { + throw OpenASOError.unexpectedResponse + } + next = parsed + } else { + next = nil + } + } + return result + } + + private struct Page: Decodable { + let data: [Resource] + let links: Links? + } + + private struct Links: Decodable { let next: String? } + private struct Resource: Decodable { + let id: String + let attributes: Attributes + } + private struct Attributes: Decodable { + let appVersionState: String? + let appStoreState: String? + let stateValue: String? + let versionString: String? + let createdDate: String? + let locale: String? + let name: String? + let subtitle: String? + let keywords: String? + let description: String? + + // Prefer the current state attribute over a stale legacy state. + var state: String { appVersionState ?? stateValue ?? appStoreState ?? "" } + + enum CodingKeys: String, CodingKey { + case appVersionState, appStoreState, versionString, createdDate, locale, name, subtitle, keywords, description + case stateValue = "state" + } + } +} diff --git a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift index 9678679..0436ca4 100644 --- a/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift +++ b/OpenASO/Services/AppleAds/AppleAdsPlatformService.swift @@ -61,6 +61,12 @@ struct AppleAdsSearchTermPopularity: Codable, Equatable, Identifiable, Sendable } } +/// Apple's suggestion-context score is distinct from country-wide search popularity. +struct AppleAdsKeywordSuggestion: Codable, Equatable, Sendable { + let keyword: String + let suggestionScore: Int? +} + struct AppleAdsSearchTermPopularityWindow: Codable, Equatable, Sendable { let start: String let end: String @@ -153,11 +159,100 @@ protocol AppleAdsPlatformAPI: Sendable { window: AppleAdsSearchTermPopularityWindow, using credentials: AppleAdsCredentials ) async throws -> [AppleAdsSearchTermPopularity] + + func keywordSuggestions( + appStoreID: Int64, + countryOrRegion: String, + seedKeyword: String?, + limit: Int, + offset: Int, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsKeywordSuggestion] } struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI { let coverage = AppleAdsPlatformCoverage.current + func keywordSuggestions( + appStoreID: Int64, + countryOrRegion: String, + seedKeyword: String?, + limit: Int, + offset: Int, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsKeywordSuggestion] { + guard appStoreID > 0 else { throw OpenASOError.invalidAppStoreID } + let credentials = try await credentialsWithAccount(try validated(credentials)) + let accountID = try requiredAdAccountID(from: credentials) + let countryCode = countryOrRegion.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard countryCode.utf8.count == 2, + countryCode.utf8.allSatisfy({ (65...90).contains($0) }) else { + throw OpenASOError.providerUnavailable("Enter a two-letter Apple Ads country or region code.") + } + let limit = max(1, min(limit, 50)) + let request = Self.keywordSuggestionsRequest( + appStoreID: appStoreID, + countryCode: countryCode, + seedKeyword: seedKeyword, + limit: limit, + offset: offset + ) + return try await perform(credentials: credentials) { client in + try Task.checkCancellation() + let payload = try await client.queryKeywordSuggestions( + headers: .init(xApContext: XApContext(adAccountID: accountID).rawValue), + body: .json(request) + ).ok.body.json + if let error = payload.value1.error { + throw OpenASOError.providerUnavailable( + "Apple Ads keyword suggestions: \(error.message ?? "The provider returned an error.")" + ) + } + var seen = Set() + return (payload.value2.result ?? []).compactMap { row in + guard let keyword = row.text?.trimmingCharacters(in: .whitespacesAndNewlines), + !keyword.isEmpty, + seen.insert(AppleAdsSearchTermPopularity.normalized(keyword)).inserted else { return nil } + return AppleAdsKeywordSuggestion( + keyword: keyword, + suggestionScore: row.popularity.map(Int.init) + ) + } + .sorted { left, right in + if left.suggestionScore != right.suggestionScore { + return (left.suggestionScore ?? -1) > (right.suggestionScore ?? -1) + } + return left.keyword < right.keyword + } + .prefix(limit) + .map { $0 } + } + } + + static func keywordSuggestionsRequest( + appStoreID: Int64, + countryCode: String, + seedKeyword: String?, + limit: Int, + offset: Int = 0 + ) -> Components.Schemas.RecommendationQueryRequest { + var filters: [Components.Schemas.RecommendationFilterCondition] = [ + .init(field: "promotedObjectId", _operator: .equals, value: [String(appStoreID)]), + .init(field: "promotedObjectType", _operator: .equals, value: ["APPSTORE_APP"]), + .init(field: "countriesOrRegions", _operator: ._in, value: [countryCode]), + ] + if let seed = seedKeyword?.trimmingCharacters(in: .whitespacesAndNewlines), !seed.isEmpty { + filters.append(.init(field: "terms", _operator: ._in, value: [seed])) + } + // The live suggestions endpoint rejects sorting by popularity. Sort the + // bounded returned page locally, retaining the seed/request context. + return .init( + pagination: .init(offset: Int32(max(0, min(offset, 1_000))), pageSize: Int32(max(1, min(limit, 50)))), + sorting: nil, + filters: filters + ) + } + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { let credentials = try validated(credentials) diff --git a/OpenASO/Services/AppleAds/KeywordMetricsService.swift b/OpenASO/Services/AppleAds/KeywordMetricsService.swift index c9d6662..6da62c0 100644 --- a/OpenASO/Services/AppleAds/KeywordMetricsService.swift +++ b/OpenASO/Services/AppleAds/KeywordMetricsService.swift @@ -79,6 +79,36 @@ final class KeywordMetricsService: Sendable { try await fetchResolvedPopularityMetrics(for: targets, now: now) } + /// Looks up untracked discovery terms without creating tracks or persisting metrics. + /// Keeps the official API and authenticated web fallback distinguishable. + func lookupPopularity( + for terms: [String], + storefront: String, + now: @Sendable () -> Date = { Date() } + ) async throws -> [KeywordPopularityLookup] { + let terms = Self.orderedUniqueTerms(terms) + let resolution = try await fetchPopularityScores( + for: terms, + countryOrRegion: storefront, + credentials: await preferredAppleAdsCredentials(), + asOf: now() + ) + try Task.checkCancellation() + let observedAt = now() + return terms.map { term in + let key = AppleAdsSearchTermPopularity.normalized(term) + return KeywordPopularityLookup( + keyword: term, + popularityScore: resolution.scores[key], + source: resolution.sources[key], + observedAt: observedAt, + unavailableReason: resolution.scores[key] == nil + ? resolution.missingTermErrorMessage ?? "Apple returned no popularity for this keyword." + : nil + ) + } + } + /// Compatibility entry point for callers that still carry the retired /// browser-session context in their refresh request. func fetchPopularityMetrics( @@ -194,8 +224,9 @@ final class KeywordMetricsService: Sendable { let missingTerms = Self.orderedUniqueTerms(searchTerms).filter { term in officialScores[AppleAdsSearchTermPopularity.normalized(term)] == nil } + let officialSources = officialScores.mapValues { _ in "apple_ads_platform" } guard !missingTerms.isEmpty else { - return AppleAdsPopularityResolution(scores: officialScores) + return AppleAdsPopularityResolution(scores: officialScores, sources: officialSources) } let fallbackAccess = await campaignManagementAccess() @@ -206,11 +237,13 @@ final class KeywordMetricsService: Sendable { case .requiresReconnect: return AppleAdsPopularityResolution( scores: officialScores, + sources: officialSources, missingTermErrorMessage: AppleAdsWebSessionExpiredError.message ) case .unconfigured: return AppleAdsPopularityResolution( scores: officialScores, + sources: officialSources, missingTermErrorMessage: AppleAdsPopularityFallbackMessaging.webConnectionRequired ) } @@ -223,16 +256,19 @@ final class KeywordMetricsService: Sendable { ) var scores = officialScores + var sources = officialSources for (term, score) in fallbackScores where scores[term] == nil { scores[term] = score + sources[term] = "apple_ads_web" } - return AppleAdsPopularityResolution(scores: scores) + return AppleAdsPopularityResolution(scores: scores, sources: sources) } catch { if Self.isCancellation(error) { throw error } return AppleAdsPopularityResolution( scores: officialScores, + sources: officialSources, missingTermErrorMessage: OpenASOError.map(error).localizedDescription ) } @@ -271,7 +307,10 @@ final class KeywordMetricsService: Sendable { countryOrRegion: countryOrRegion, context: context ) - return AppleAdsPopularityResolution(scores: scores) + return AppleAdsPopularityResolution( + scores: scores, + sources: scores.mapValues { _ in "apple_ads_web" } + ) } private func fetchCampaignManagementPopularityScores( @@ -1365,9 +1404,18 @@ private enum CampaignManagementAccess: Sendable { private struct AppleAdsPopularityResolution: Sendable { let scores: [String: Int] + var sources: [String: String] = [:] var missingTermErrorMessage: String? = nil } +struct KeywordPopularityLookup: Sendable { + let keyword: String + let popularityScore: Int? + let source: String? + let observedAt: Date + let unavailableReason: String? +} + private struct KeywordMetricsPayload: Sendable { let popularityScore: Int? let difficultyScore: Int? diff --git a/OpenASO/Services/MCP/KeywordTargetingAnalysis.swift b/OpenASO/Services/MCP/KeywordTargetingAnalysis.swift new file mode 100644 index 0000000..c26bf18 --- /dev/null +++ b/OpenASO/Services/MCP/KeywordTargetingAnalysis.swift @@ -0,0 +1,359 @@ +import Foundation + +enum KeywordTargetingText { + static func tokens(_ text: String) -> [String] { + text.folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX")) + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init) + } + + static func filler(_ locale: String) -> Set { + locale.lowercased().hasPrefix("en") ? ["a", "an", "and", "for", "the", "to", "of", "with", "in"] : [] + } + + /// Conservative English plural matching is reported as a variant, never an + /// exact phrase or proof that Apple indexes a query. + static func variant(_ word: String, locale: String) -> String { + guard locale.lowercased().hasPrefix("en"), word.count > 3, + word.hasSuffix("s"), !["ss", "us", "is"].contains(where: word.hasSuffix), + !["news", "series", "species"].contains(word) else { return word } + return String(word.dropLast()) + } +} + +struct KeywordAppContext: Sendable { + let name: String + let featureText: String + var locale = "en-US" + + var brandTokens: [String] { + var prefix = name + for separator in [" - ", " – ", " — ", ": ", " | "] { + if let range = prefix.range(of: separator) { prefix = String(prefix[.. KeywordRelevance { + let words = KeywordTargetingText.tokens(keyword).filter { + !KeywordTargetingText.filler(locale).contains($0) + } + let keywordTokens = KeywordTargetingText.tokens(keyword) + let brand = !brandTokens.isEmpty && keywordTokens.windowsContain(brandTokens) + let featureTokens = Set(KeywordTargetingText.tokens(name + " " + featureText).map { + KeywordTargetingText.variant($0, locale: locale) + }) + let matched = words.filter { featureTokens.contains(KeywordTargetingText.variant($0, locale: locale)) } + return .init( + score: brand ? 1 : (words.isEmpty ? 0 : Double(matched.count) / Double(words.count)), + matchedTerms: Array(Set(matched)).sorted(), + isOwnBrand: brand, + basis: "app_metadata_token_overlap" + ) + } +} + +struct KeywordRelevance: Codable, Sendable { + let score: Double + let matchedTerms: [String] + let isOwnBrand: Bool + let basis: String +} + +struct KeywordMetadataToken: Codable, Sendable { + let term: String + let fields: [String] + let variantFields: [String] + let isFiller: Bool +} + +struct KeywordMetadataCoverage: Codable, Sendable { + let status: String + let literalPhraseFields: [String] + let components: [KeywordMetadataToken] + let missingTerms: [String] +} + +struct MetadataFieldValidation: Codable, Sendable { + let characterCounts: [String: Int] + let limits: [String: Int] + let errors: [String] + let warnings: [String] +} + +struct KeywordTargetingRanking: Codable, Sendable { + let rank: Int? + let source: String + let observedAt: Date + let sampledResults: Int + var isNative: Bool { source == RankingSource.appStoreWeb.rawValue } +} + +struct KeywordTargetingItem: Codable, Sendable { + let keyword: String + let popularity: OpenASOMCPKeywordPopularity + let relevance: KeywordRelevance + let coverage: KeywordMetadataCoverage + let ranking: KeywordTargetingRanking? + let opportunity: String + let rationale: [String] + var competition: KeywordTargetingCompetition? = nil +} + +/// Ephemeral analysis of the same bounded native sample used for rank checks. +/// It is never written into Apple's popularity or legacy difficulty fields. +struct KeywordTargetingCompetition: Codable, Sendable { + let score: Int? + let confidence: String? + let confidenceScore: Int? + let unavailableReason: String? + let source: String + let observedAt: Date + let algorithmIdentifier: String + let algorithmVersion: Int + let evidence: KeywordDifficultyEvidence? + let notes: [String] + + static func analyze(_ row: OpenASOMCPKeywordRankingEvidence) -> Self { + guard row.source == RankingSource.appStoreWeb.rawValue else { + return .init(score: nil, confidence: nil, confidenceScore: nil, + unavailableReason: "native_ranking_required", source: row.source, + observedAt: row.observedAt, algorithmIdentifier: KeywordDifficultyEstimator.algorithmIdentifier, + algorithmVersion: KeywordDifficultyEstimator.algorithmVersion, evidence: nil, + notes: ["Fallback results cannot establish native App Store competition."]) + } + let results = row.topApps.compactMap { app -> KeywordDifficultyRankedResult? in + guard let id = Int64(app.appStoreID) else { return nil } + return .init(position: app.position, appStoreID: id, title: app.name, + subtitle: app.subtitle, ratingCount: app.ratingCount) + } + switch KeywordDifficultyEstimator.estimate(keyword: row.keyword, rankedResults: results) { + case .estimated(let value): + return .init(score: value.score, confidence: value.confidence.rawValue, + confidenceScore: value.confidenceScore, unavailableReason: nil, + source: row.source, observedAt: row.observedAt, + algorithmIdentifier: value.algorithmIdentifier, algorithmVersion: value.algorithmVersion, + evidence: value.evidence, notes: value.notes) + case .unavailable(let value): + return .init(score: nil, confidence: nil, confidenceScore: nil, + unavailableReason: value.reason.rawValue, source: row.source, + observedAt: row.observedAt, algorithmIdentifier: value.algorithmIdentifier, + algorithmVersion: value.algorithmVersion, evidence: value.evidence, notes: value.notes) + } + } +} + +struct KeywordTargetingAudit: Codable, Sendable { + let metadata: AppStoreMetadataSnapshot + let metadataHash: String + let storefront: String + let searchPlatform: String + let generatedAt: Date + let items: [KeywordTargetingItem] + let validation: MetadataFieldValidation + let providerErrors: [OpenASOMCPKeywordSuggestionProviderError] + let notes: [String] +} + +struct MetadataDraftComparison: Codable, Sendable { + let baselineHash: String + let baselineMetadata: AppStoreMetadataSnapshot + let proposedFields: AppStoreMetadataFields + let validation: MetadataFieldValidation + let gainedCoverage: [String] + let lostCoverage: [String] + let items: [MetadataDraftCoverageChange] + let visibilityChanges: [MetadataDraftVisibilityChange] + let notes: [String] +} + +struct MetadataDraftVisibilityChange: Codable, Sendable { + let keyword: String + let beforeFields: [String] + let afterFields: [String] +} + +struct MetadataDraftCoverageChange: Codable, Sendable { + let keyword: String + let popularityScore: Int? + let before: KeywordMetadataCoverage + let after: KeywordMetadataCoverage +} + +enum KeywordTargetingAnalysis { + static func coverage(_ keyword: String, fields: AppStoreMetadataFields, locale: String) -> KeywordMetadataCoverage { + let allFields = [ + "title": fields.title, + "subtitle": fields.subtitle, + "keywords": fields.keywords ?? "", + ] + let byField = allFields.mapValues(KeywordTargetingText.tokens) + let words = KeywordTargetingText.tokens(keyword) + let components = words.map { word in + KeywordMetadataToken( + term: word, + fields: byField.keys.filter { byField[$0, default: []].contains(word) }.sorted(), + variantFields: byField.keys.filter { key in + byField[key, default: []].contains { + $0 != word && KeywordTargetingText.variant($0, locale: locale) + == KeywordTargetingText.variant(word, locale: locale) + } + }.sorted(), + isFiller: KeywordTargetingText.filler(locale).contains(word) + ) + } + let required = components.filter { !$0.isFiller } + let missing = required.filter { $0.fields.isEmpty && $0.variantFields.isEmpty }.map(\.term) + let literal = byField.keys.filter { byField[$0, default: []].windowsContain(words) }.sorted() + let status: String + if !literal.isEmpty { status = "literal_phrase" } + else if fields.keywords == nil && !missing.isEmpty { status = "private_field_unknown" } + else if required.isEmpty { status = "uncovered" } + else if missing.isEmpty { + status = required.contains { $0.fields.isEmpty } ? "variant_combination" : "component_combination" + } else if missing.count == required.count { status = "uncovered" } + else { status = "partial" } + return .init(status: status, literalPhraseFields: literal, components: components, missingTerms: missing) + } + + static func covered(_ coverage: KeywordMetadataCoverage) -> Bool { + ["literal_phrase", "component_combination", "variant_combination"].contains(coverage.status) + } + + static func validate(_ fields: AppStoreMetadataFields, locale: String) -> MetadataFieldValidation { + let counts = ["title": fields.title.count, "subtitle": fields.subtitle.count, "keywords": fields.keywords?.count ?? 0] + let limits = ["title": 30, "subtitle": 30, "keywords": 100] + var errors = counts.keys.sorted().compactMap { key in + counts[key, default: 0] > limits[key, default: 0] ? "\(key) exceeds \(limits[key, default: 0]) characters." : nil + } + if fields.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append("Title must not be empty.") + } + var warnings: [String] = [] + let titleTerms = Set(KeywordTargetingText.tokens(fields.title).map { + KeywordTargetingText.variant($0, locale: locale) + }) + for word in KeywordTargetingText.tokens(fields.subtitle) + where !KeywordTargetingText.filler(locale).contains(word) + && titleTerms.contains(KeywordTargetingText.variant(word, locale: locale)) { + warnings.append("Subtitle repeats a title word or plural variant: \(word).") + } + guard let keywords = fields.keywords else { + return .init(characterCounts: counts, limits: limits, errors: errors, + warnings: warnings + ["Private keywords are unavailable; coverage is incomplete."]) + } + let visible = Set(KeywordTargetingText.tokens(fields.title + " " + fields.subtitle).map { + KeywordTargetingText.variant($0, locale: locale) + }) + var seen = Set() + for word in KeywordTargetingText.tokens(keywords) { + let normalized = KeywordTargetingText.variant(word, locale: locale) + if visible.contains(normalized) || !seen.insert(normalized).inserted { + warnings.append("Keyword field repeats \(word) or a plural variant already present.") + } + if KeywordTargetingText.filler(locale).contains(word) { + warnings.append("Keyword field spends space on filler: \(word).") + } + if ["app", "apps", "free"].contains(word) { + warnings.append("Review generic or pricing term in keyword field: \(word).") + } + } + if keywords.contains(", ") { warnings.append("Remove spaces after commas to recover keyword-field capacity.") } + warnings.append("Check competitor names, feature claims, and semantic relevance before publishing.") + return .init(characterCounts: counts, limits: limits, errors: errors, warnings: Array(Set(warnings)).sorted()) + } + + static func item( + popularity: OpenASOMCPKeywordPopularity, + metadata: AppStoreMetadataSnapshot, + context: KeywordAppContext, + ranking: KeywordTargetingRanking? + ) -> KeywordTargetingItem { + let relevance = context.relevance(popularity.keyword) + let coverage = coverage(popularity.keyword, fields: metadata.fields, locale: metadata.locale) + let aboveFloor = (popularity.popularityScore ?? 0) > 5 + let opportunity: String + if relevance.isOwnBrand { opportunity = "own_brand" } + else if relevance.score < 0.35 { opportunity = "relevance_review" } + else if popularity.popularityScore == nil { opportunity = "demand_unknown" } + else if !aboveFloor { opportunity = "weak_demand" } + else if coverage.status == "private_field_unknown" { opportunity = "private_metadata_unknown" } + else if !covered(coverage) { opportunity = "metadata_gap" } + else if let ranking, ranking.isNative, let rank = ranking.rank { + opportunity = rank <= 3 ? "maintain" : "ranking_opportunity" + } else { opportunity = "ranking_needs_verification" } + var rationale = [ + "App relevance uses metadata overlap; semantic fit and achievable rank still require review.", + "Metadata presence describes lexical coverage, not confirmed indexing.", + ] + if !coverage.missingTerms.isEmpty { rationale.append("Missing components: \(coverage.missingTerms.joined(separator: ", ")).") } + if let ranking, !ranking.isNative { + rationale.append("Ranking evidence is \(ranking.source); native organic rank remains unverified.") + } + if let ranking, ranking.rank == nil { + rationale.append("Target was absent from a \(ranking.sampledResults)-result sample; this does not prove non-indexing.") + } + return .init(keyword: popularity.keyword, popularity: popularity, relevance: relevance, coverage: coverage, + ranking: ranking, opportunity: opportunity, rationale: rationale) + } + + static func compare(_ audit: KeywordTargetingAudit, proposed: AppStoreMetadataFields) -> MetadataDraftComparison { + let changes = audit.items.map { + MetadataDraftCoverageChange( + keyword: $0.keyword, popularityScore: $0.popularity.popularityScore, before: $0.coverage, + after: coverage($0.keyword, fields: proposed, locale: audit.metadata.locale) + ) + } + let visibility = changes.compactMap { change -> MetadataDraftVisibilityChange? in + let before = change.before.literalPhraseFields.filter { $0 != "keywords" } + let after = change.after.literalPhraseFields.filter { $0 != "keywords" } + guard before != after else { return nil } + return .init(keyword: change.keyword, beforeFields: before, afterFields: after) + } + let baseValidation = validate(proposed, locale: audit.metadata.locale) + var warnings = baseValidation.warnings + let context = KeywordAppContext(name: audit.metadata.fields.title, featureText: "", locale: audit.metadata.locale) + if !context.brandTokens.isEmpty && !context.relevance(proposed.title).isOwnBrand { + warnings.append("The proposed title removes the existing brand; review this positioning change.") + } + let lostWinners = audit.items.filter { item in + item.ranking?.isNative == true && (item.ranking?.rank ?? Int.max) <= 10 + && changes.contains { + $0.keyword == item.keyword && $0.after.status != "private_field_unknown" && !covered($0.after) + } + } + for item in lostWinners { + warnings.append("The draft loses component coverage for an observed native top-ten term: \(item.keyword).") + } + let validation = MetadataFieldValidation(characterCounts: baseValidation.characterCounts, limits: baseValidation.limits, + errors: baseValidation.errors, warnings: warnings) + return .init( + baselineHash: audit.metadataHash, + baselineMetadata: audit.metadata, + proposedFields: proposed, + validation: validation, + gainedCoverage: changes.filter { + $0.before.status != "private_field_unknown" && !covered($0.before) && covered($0.after) + }.map(\.keyword), + lostCoverage: changes.filter { + $0.after.status != "private_field_unknown" && covered($0.before) && !covered($0.after) + }.map(\.keyword), + items: changes, + visibilityChanges: visibility, + notes: [ + "Coverage gains are lexical hypotheses, not predicted rank or download lift.", + "Review lost coverage and changes in visible positioning before choosing a draft.", + "Unavailable private keyword data is not treated as an empty field or a verified coverage gain/loss.", + "This comparison does not publish metadata. Re-read the baseline and compare its hash before any later apply step.", + ] + ) + } +} + +private extension Array where Element == String { + func windowsContain(_ phrase: [String]) -> Bool { + guard !phrase.isEmpty, phrase.count <= count else { return false } + return (0...(count - phrase.count)).contains { start in + Array(self[start..<(start + phrase.count)]) == phrase + } + } +} diff --git a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift index 25611b6..273aa64 100644 --- a/OpenASO/Services/MCP/OpenASOMCPDTOs.swift +++ b/OpenASO/Services/MCP/OpenASOMCPDTOs.swift @@ -47,6 +47,7 @@ struct OpenASOMCPMutationSummary: Codable, Sendable, Equatable { var skipped: Int var refreshed: Int var failed: Int + var removed: Int = 0 static let empty = OpenASOMCPMutationSummary( inserted: 0, @@ -57,6 +58,20 @@ struct OpenASOMCPMutationSummary: Codable, Sendable, Equatable { ) } +extension OpenASOMCPMutationSummary { + private enum CodingKeys: String, CodingKey { case inserted, updated, skipped, refreshed, failed, removed } + + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + inserted = try values.decode(Int.self, forKey: .inserted) + updated = try values.decode(Int.self, forKey: .updated) + skipped = try values.decode(Int.self, forKey: .skipped) + refreshed = try values.decode(Int.self, forKey: .refreshed) + failed = try values.decode(Int.self, forKey: .failed) + removed = try values.decodeIfPresent(Int.self, forKey: .removed) ?? 0 + } +} + struct OpenASOMCPAppSummary: Codable, Identifiable, Sendable { let id: String let appStoreID: String @@ -279,6 +294,18 @@ struct OpenASOMCPAddKeywordsResult: Codable, Sendable { let skipped: [OpenASOMCPSkippedKeyword] } +struct OpenASOMCPRemovedKeyword: Codable, Sendable, Equatable { + let keyword: String + let storefront: String + let platform: String +} + +struct OpenASOMCPRemoveKeywordsResult: Codable, Sendable { + let summary: OpenASOMCPMutationSummary + let removed: [OpenASOMCPRemovedKeyword] + let skipped: [OpenASOMCPSkippedKeyword] +} + struct OpenASOMCPSkippedKeyword: Codable, Sendable, Equatable { let keyword: String let storefront: String @@ -530,6 +557,51 @@ struct OpenASOMCPKeywordSuggestionResult: Codable, Sendable { let generatedAt: Date let candidates: [OpenASOMCPKeywordCandidate] let errors: [OpenASOMCPKeywordVerificationError] + let appleAdsSuggestions: [OpenASOMCPAppleAdsKeywordSuggestion] + let providerErrors: [OpenASOMCPKeywordSuggestionProviderError] + let notes: [String] + var suggestionPages: [OpenASOMCPKeywordSuggestionPage] = [] + var metadataSeeds: [String] = [] +} + +struct OpenASOMCPAppleAdsKeywordSuggestion: Codable, Sendable { + let keyword: String + let suggestionScore: Int? + let requestedStorefront: String + let seedKeyword: String? + let observedAt: Date + var popularity: OpenASOMCPKeywordPopularity? = nil + var isTracked: Bool = false +} + +struct OpenASOMCPKeywordSuggestionPage: Codable, Sendable { + let requestedStorefront: String + let seedKeyword: String? + let offset: Int + let limit: Int + let returnedCount: Int + let nextOffset: Int? +} + +struct OpenASOMCPKeywordPopularity: Codable, Sendable { + let keyword: String + let storefront: String + let platform: String + let popularityScore: Int? + let source: String? + let observedAt: Date + let unavailableReason: String? +} + +struct OpenASOMCPKeywordPopularityResult: Codable, Sendable { + let items: [OpenASOMCPKeywordPopularity] + let providerErrors: [OpenASOMCPKeywordSuggestionProviderError] + let notes: [String] +} + +struct OpenASOMCPKeywordSuggestionProviderError: Codable, Sendable { + let storefront: String + let error: OpenASOMCPErrorDTO } struct OpenASOMCPReviewRefreshOutcomeDTO: Codable, Sendable { diff --git a/OpenASO/Services/MCP/OpenASOMCPServer.swift b/OpenASO/Services/MCP/OpenASOMCPServer.swift index 8ad8cce..dcd478d 100644 --- a/OpenASO/Services/MCP/OpenASOMCPServer.swift +++ b/OpenASO/Services/MCP/OpenASOMCPServer.swift @@ -234,6 +234,64 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "get_app_store_metadata": + return try Self.toolResult(try await service.getAppStoreMetadata( + appStoreID: arguments.requiredInt64("appStoreID"), + locale: arguments.optionalString("locale") ?? "en-US", + platform: arguments.optionalString("platform"), + scope: arguments.optionalString("scope") ?? "live" + )) + + case "audit_keyword_targeting": + return try Self.toolResult(try await service.auditKeywordTargeting( + appStoreID: arguments.requiredInt64("appStoreID"), + locale: arguments.optionalString("locale") ?? "en-US", + storefront: arguments.optionalString("storefront") ?? "us", + platform: arguments.optionalString("platform"), + scope: arguments.optionalString("scope") ?? "live", + keywords: arguments.optionalStringArray("keywords"), + rankingLimit: arguments.optionalInt("ranking_limit") ?? 5 + )) + + case "compare_metadata_draft": + return try Self.toolResult(try await service.compareMetadataDraft( + appStoreID: arguments.requiredInt64("appStoreID"), + locale: arguments.optionalString("locale") ?? "en-US", + storefront: arguments.optionalString("storefront") ?? "us", + platform: arguments.optionalString("platform"), + scope: arguments.optionalString("scope") ?? "live", + keywords: arguments.optionalStringArray("keywords"), + proposedTitle: arguments.optionalString("proposed_title"), + proposedSubtitle: arguments.optionalString("proposed_subtitle"), + proposedKeywords: arguments.optionalString("proposed_keywords"), + expectedHash: arguments.optionalString("expected_hash"), + rankingLimit: arguments.optionalInt("ranking_limit") ?? 3 + )) + + case "record_metadata_baseline": + return try Self.toolResult(try await service.recordMetadataBaseline( + appStoreID: arguments.requiredInt64("appStoreID"), + locale: arguments.optionalString("locale") ?? "en-US", + storefront: arguments.optionalString("storefront") ?? "us", + platform: arguments.optionalString("platform"), + keywords: arguments.optionalStringArray("keywords"), + rankingLimit: arguments.optionalInt("ranking_limit") ?? 5, + label: arguments.requiredString("label"), + acquisition: arguments.acquisitionEvidence() + )) + + case "list_metadata_baselines": + return try Self.toolResult(try await service.listMetadataBaselines( + appStoreID: arguments.requiredInt64("appStoreID"), limit: arguments.optionalInt("limit") ?? 20 + )) + + case "compare_metadata_baseline": + let rawID = try arguments.requiredString("baseline_id") + guard let id = UUID(uuidString: rawID) else { throw MCPError.invalidParams("baseline_id must be a UUID.") } + return try Self.toolResult(try await service.compareMetadataBaseline( + baselineID: id, acquisition: arguments.acquisitionEvidence() + )) + case "add_keywords": let result = try await service.addKeywords( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -243,6 +301,14 @@ struct OpenASOMCPServerFactory: Sendable { ) return try Self.toolResult(result) + case "remove_keywords": + return try Self.toolResult(try await service.removeKeywords( + appStoreID: arguments.requiredInt64("appStoreID"), + keywords: arguments.requiredStringArray("keywords"), + storefronts: arguments.requiredStringArray("storefronts"), + platform: arguments.optionalString("platform") + )) + case "update_keyword_notes": let result = try await service.updateKeywordNotes( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -320,10 +386,20 @@ struct OpenASOMCPServerFactory: Sendable { storefronts: arguments.stringArray("storefronts"), platform: arguments.string("platform"), limit: arguments.int("limit"), - websiteMarkdown: arguments.string("website_markdown") + websiteMarkdown: arguments.string("website_markdown"), + seedKeyword: try arguments.optionalString("seed_keyword"), + offset: try arguments.optionalInt("offset") ?? 0, + rankingLimit: try arguments.optionalInt("ranking_limit") ) return try Self.toolResult(result) + case "get_keyword_popularity": + return try Self.toolResult(try await service.getKeywordPopularity( + keywords: try arguments.requiredStringArray("keywords"), + storefronts: try arguments.optionalStringArray("storefronts"), + platform: try arguments.optionalString("platform") + )) + case "refresh_keyword_rankings": let result = try await service.refreshKeywordRankings( appStoreID: try arguments.requiredInt64("appStoreID"), @@ -386,7 +462,8 @@ struct OpenASOMCPServerFactory: Sendable { let result = try await service.refreshKeywordMetrics( appStoreID: try arguments.requiredInt64("appStoreID"), storefronts: arguments.stringArray("storefronts"), - platform: arguments.string("platform") + platform: arguments.string("platform"), + keywords: try arguments.optionalStringArray("keywords") ) return try Self.toolResult(result) @@ -458,6 +535,14 @@ struct OpenASOMCPServerFactory: Sendable { } private extension OpenASOMCPServerFactory { + static var metadataFilters: [String: JSONSchemaType] { + ["appStoreID": .integer, "locale": .string, "storefront": .string, "platform": .string, "scope": .string] + } + + static var acquisitionFilters: [String: JSONSchemaType] { + ["search_impressions": .integer, "search_downloads": .integer, + "analytics_date_from": .string, "analytics_date_to": .string] + } static let jsonMimeType = "application/json" static func toolResult(_ value: T) throws -> CallTool.Result { @@ -570,14 +655,41 @@ private extension OpenASOMCPServerFactory { required: ["appStoreID"], optional: commonAppFilters.merging(["limit": .integer, "cursor": .string]) { current, _ in current } ), readOnly: true), - tool("score_keywords", "Classify tracked keywords into defend, attack, long-tail, brand, experimental, or noisy buckets using rank, popularity, and phrase-quality heuristics.", schema( + tool("score_keywords", "Triage tracked keywords using this app's localized metadata and own brand, stored popularity and ranking provenance. Relevance is metadata overlap, not a universal category vocabulary or calibrated ranking forecast. High popularity alone does not make a term relevant; fallback ranks do not establish native positions.", schema( required: ["appStoreID"], optional: commonAppFilters ), readOnly: true), + tool("get_app_store_metadata", "Read owned title, subtitle and private keywords with saved App Store Connect credentials. Select exact locale, search platform and scope live/editable. Returns version/state, fields, source/time and contentHash; fails on ambiguous state or missing locale instead of substituting another version/language. Read-only; never publishes.", schema( + required: ["appStoreID"], optional: metadataFilters.filter { $0.key != "storefront" } + ), readOnly: true, openWorld: true), + tool("audit_keyword_targeting", "Compare 1...50 candidate keywords with authenticated owned metadata, fresh market popularity, app-specific relevance and up to eight live rank samples (ranking_limit defaults to five; zero skips ranks). Includes native-sample estimated competition, confidence, provenance and competitor evidence; fallback or insufficient ratings remain unknown. Distinguishes literal phrases, component/plural combinations, missing words and ranking gaps. Default candidates are tracked terms ordered by stored popularity; pass your discovery shortlist for broader opportunities. Coverage is not proof of indexing; fallback ranks are unverified native evidence. Review semantic fit, competitors, readability and existing winners before recommending edits.", schema( + required: ["appStoreID"], optional: metadataFilters.merging(["keywords": .stringArray, "ranking_limit": .integer]) { current, _ in current } + ), readOnly: true, openWorld: true), + tool("compare_metadata_draft", "Evaluate an agent-written proposed_title, proposed_subtitle and/or proposed_keywords against current owned metadata and a bounded keyword shortlist. Returns 30/30/100 validation, duplicates/filler, gained/lost coverage, visible phrase changes, protected native winner warnings and baseline hash. Ranking checks default to three, cap at eight; ranking_limit: 0 skips them. Pass expected_hash to reject a stale baseline. Generate conservative and broader alternatives while preserving brand and useful current targets; rank lift is a hypothesis. Does not save or publish drafts.", schema( + required: ["appStoreID"], optional: metadataFilters.merging([ + "keywords": .stringArray, "proposed_title": .string, "proposed_subtitle": .string, + "proposed_keywords": .string, "expected_hash": .string, "ranking_limit": .integer + ]) { current, _ in current } + ), readOnly: true, openWorld: true), + tool("record_metadata_baseline", "Save an immutable local baseline of live owned metadata, a fixed keyword set, fresh popularity and bounded ranking samples before a metadata release. Returns baseline id. Optional App Store Search aggregate counts need analytics_date_from/to and are labeled caller_provided; they are not per-keyword attribution. Stores private metadata locally; never publishes or schedules work.", schema( + required: ["appStoreID", "label"], optional: metadataFilters.merging([ + "keywords": .stringArray, "ranking_limit": .integer, "label": .string + ]) { current, _ in current }.merging(acquisitionFilters) { current, _ in current }.filter { $0.key != "scope" } + ), readOnly: false, destructive: false, idempotent: false, openWorld: true), + tool("list_metadata_baselines", "List up to 50 local metadata baseline summaries for one app. Use their ids with compare_metadata_baseline.", schema( + required: ["appStoreID"], optional: ["appStoreID": .integer, "limit": .integer] + ), readOnly: true), + tool("compare_metadata_baseline", "Compare a saved baseline with current live metadata using the same app/locale/storefront/platform and keyword set. Rechecks only previously sampled rank queries; rank deltas require native positions on both observations. Reports coverage changes, version change, popularity and optional caller-provided search acquisition counts. Before/after changes are observations, not a causal metadata A/B test. Does not publish or schedule anything.", schema( + required: ["baseline_id"], optional: ["baseline_id": .string].merging(acquisitionFilters) { current, _ in current } + ), readOnly: true, openWorld: true), tool("add_keywords", "Add missing keyword/storefront/platform tracks and skip duplicates.", schema( required: ["appStoreID", "keywords", "storefronts"], optional: ["appStoreID": .integer, "keywords": .stringArray, "storefronts": .stringArray, "platform": .string] ), readOnly: false, destructive: false, idempotent: true), + tool("remove_keywords", "Untrack 1...50 explicitly selected keywords in 1...3 storefronts for one app/platform (default iPhone). Removes track-specific history and refresh status atomically, preserves shared ranking observations, popularity and other apps/markets/platforms. Missing tracks are skipped; returns persisted keyword text for re-adding. Use only when keyword removal is authorized; low popularity alone is not authorization to prune.", schema( + required: ["appStoreID", "keywords", "storefronts"], + optional: commonAppFilters.merging(["keywords": .stringArray]) { current, _ in current } + ), readOnly: false, destructive: true, idempotent: true), tool("update_keyword_notes", "Update notes for one tracked keyword.", schema( required: ["appStoreID", "keyword", "storefront", "notes"], optional: ["appStoreID": .integer, "keyword": .string, "storefront": .string, "platform": .string, "notes": .string] @@ -625,9 +737,13 @@ private extension OpenASOMCPServerFactory { "limit": .integer ] ), readOnly: true, openWorld: true), - tool("suggest_keywords", "Generate candidate keywords from app metadata and verify a bounded live-ranking sample; returns partial candidates and errors when verification is capped.", schema( + tool("get_keyword_popularity", "Look up fresh market popularity for 1...50 keywords in up to three storefronts, including untracked terms. Uses valid saved Apple Ads API credentials and the active web session fallback automatically. Returns score, source, observation time and unavailability reasons; missing is unknown, not zero. Use this to validate candidates and choose relevant above-floor (>5) seeds for suggest_keywords. Creates no tracks and stores no metrics.", schema( + required: ["keywords"], + optional: ["keywords": .stringArray, "storefronts": .stringArray, "platform": .string] + ), readOnly: true, openWorld: true), + tool("suggest_keywords", "Discover keywords with saved Apple Ads credentials, fresh market-popularity lookup, and metadata/website fallback. Returns appleAdsSuggestions ordered by market popularity with source/time, isTracked and separate contextual suggestionScore. Start with distinct feature seeds (or omit seed_keyword), branch using relevant terms with popularity >5, and page using suggestionPages.nextOffset with the same seed/limit. Set ranking_limit: 0 for compact expansion; verify shortlisted terms with get_ranked_apps_for_keyword. Filter irrelevant/brand terms before choosing seeds or additions. Missing popularity is unknown; contextual scores are not market popularity or volume. Each call covers at most three storefronts, 50 suggestions per page and 16 ranking checks. Use get_keyword_popularity to recheck evidence and stop expansion when new relevant above-floor terms stop appearing. Does not add tracks; use add_keywords for authorized selections.", schema( required: ["appStoreID"], - optional: commonAppFilters.merging(["limit": .integer, "website_markdown": .string]) { current, _ in current } + optional: commonAppFilters.merging(["limit": .integer, "website_markdown": .string, "seed_keyword": .string, "offset": .integer, "ranking_limit": .integer]) { current, _ in current } ), readOnly: true, openWorld: true), tool("refresh_keyword_rankings", "Refresh and persist rankings for tracked keywords so keyword and competitor tools share the same evidence. The limit defaults to 20 and is clamped to 1...25 tracks. Never-attempted tracks are selected first, then by least-recent activity using successful and durable attempt times; attempt rotation survives OpenASO relaunches and in-flight reservations are shared across MCP connections.", schema( required: ["appStoreID"], @@ -670,9 +786,9 @@ private extension OpenASOMCPServerFactory { "cursor": .string ] ), readOnly: true), - tool("refresh_keyword_metrics", "Refresh keyword popularity metrics when Apple Ads is configured; otherwise return actionable setup status per keyword.", schema( + tool("refresh_keyword_metrics", "Refresh and save market popularity for tracked keywords using saved Apple Ads access. Optional keywords selects 1...50 terms for a bounded refresh after discovery/addition; omit it to refresh the whole app/storefront/platform scope. Returns actionable setup status when access is unavailable.", schema( required: ["appStoreID"], - optional: commonAppFilters + optional: commonAppFilters.merging(["keywords": .stringArray]) { current, _ in current } ), readOnly: false, destructive: false, idempotent: false, openWorld: true), tool("refresh_reviews", "Fetch and store a bounded most-recent public App Store review sample. Defaults to the US storefront.", schema( required: ["appStoreID"], @@ -764,10 +880,12 @@ private extension OpenASOMCPServerFactory { """ case "keyword_research_brief": body = """ - For appStoreID \(appStoreID), inspect get_app_overview, list_keywords, score_keywords, list_competitors, list_reviews, list_screenshots, and any available website markdown before recommending keywords. - Use staged, bounded discovery: start with current evidence, then run suggest_keywords or discover_keyword_landscape for the top 1-2 storefronts or a small seed limit before broadening. Continue with partial results when a tool returns verification_budget_exceeded or per-keyword errors. + For appStoreID \(appStoreID), start with get_app_overview and a bounded list_keywords/score_keywords read for \(storefronts). Use metadata, review themes and competitors as needed to establish feature relevance and intent. If a broad workspace read times out, continue with narrower calls and disclose the gap. + Use popularity-guided discovery in the requested storefronts. Run suggest_keywords with distinct feature/use-case seeds and ranking_limit: 0 during expansion; inspect fresh popularity, source/time, isTracked, and separate contextual suggestionScore. Check untracked or missing evidence with get_keyword_popularity when Apple Ads access is available. Missing scores are unknown, not zero; popularity is an index, not exact search volume. + Filter brands, unrelated terms, unsupported features and wrong-language phrases before branching. Prefer relevant market-popularity scores above 5; retain low/unknown terms separately when they cover an important feature. Reuse promising terms as new seeds and explore suggestionPages.nextOffset with the same seed/limit. Deduplicate by keyword/storefront/platform and skip repeated seeds/pages. Use an explicit request budget; stop after two successive expansion calls yield no new relevant above-floor terms, or when the budget is reached. Return the unexplored frontier when stopping at the budget. + Verify the strongest shortlist with get_ranked_apps_for_keyword, then weigh relevance, popularity, target rank, competition and evidence freshness. Rank evidence from iTunesFallback is weaker than native App Store ranking. Return a shortlist and coverage report with seeds/pages tried, new/tracked counts, provider errors and remaining gaps. Add keywords only within the user's authorized scope, then use refresh_keyword_metrics with a keywords filter to save their market popularity. Continue with partial results on verification_budget_exceeded or provider/ranking failures. Classify keywords as defend, attack, long-tail, brand, experimental, or noisy. Drop generic fragments and irrelevant phrases unless ranking evidence proves relevance. - Apply opportunity scoring as a rubric, not a substitute for evidence: weigh popularity, rankability/difficulty or result count, relevance, current rank, competitor overlap, and review-language support. + Use audit_keyword_targeting competition evidence when sampled, including confidence and missing-data reasons. This is an app-derived heuristic, not an Apple metric. Apply opportunity scoring as a rubric, not a substitute for evidence: weigh popularity, rankability/difficulty or result count, relevance, current rank, competitor overlap, and review-language support. Recommend keywords only after separating hypotheses from verified ranking/popularity evidence. Prioritize terms with direct download intent and avoid broad phrases that do not describe the app's core value. For iOS metadata recommendations, avoid repeating words across title, subtitle, and hidden keyword field, prefer singular keyword-field terms, use comma-separated values without spaces, and do not include competitor brands, app name, category names, or unsupported claims. """ @@ -809,11 +927,14 @@ private extension OpenASOMCPServerFactory { """ case "metadata_optimization_package": body = """ - For appStoreID \(appStoreID), build metadata recommendations from OpenASO evidence. Inspect get_app_overview, list_keywords, score_keywords, list_competitors, list_reviews, and any available website markdown for \(storefronts). Use suggest_keywords or discover_keyword_landscape only as a bounded expansion step after reviewing existing evidence. + For appStoreID \(appStoreID), start with get_app_overview and get_app_store_metadata for the exact locale, search platform and live/editable state. Saved App Store Connect access can read the private keyword field; Apple Ads access supplies separate popularity evidence. Keep locale and storefront explicit and fail visibly on missing private fields instead of inferring them. + Use popularity-guided suggest_keywords discovery for \(storefronts), then pass a curated shortlist to audit_keyword_targeting. Include high-value existing targets as well as promising new phrases. Distinguish literal phrases, cross-field word/plural combinations, missing components and well-covered terms with weak or unknown rankings. Relevance is app-aware metadata overlap; review semantic feature fit, brands, unsupported claims and competition before recommending a term. + Generate a conservative and a broader positioning alternative, preserving the brand and useful current targets. Call compare_metadata_draft with each proposed field set, the same shortlist and expected_hash from the baseline. Review field validation and gained/lost coverage; coverage is not confirmed indexing and metadata does not guarantee a rank increase. Produce title, subtitle, keyword-field, description, promotional-text, and What's New recommendations where evidence supports them. Use 30 characters for title, 30 for subtitle, 100 for the iOS keyword field when the user wants hidden keywords, 170 for promotional text, and 4000 for description and release notes. Build a keyword coverage matrix. Avoid repeating keyword words across title, subtitle, and keyword field; prefer singular keyword-field terms; use comma-separated keyword-field values without spaces; avoid app name, category names, competitor brands, "app", "free", and unsupported claims in the keyword field. Use review language and competitor positioning to improve conversion copy, but keep keyword recommendations tied to verified rankings, popularity metrics, competitor overlap, direct user intent, or clearly labeled hypotheses. - If current hidden keyword field, conversion rate, downloads, revenue, or ASC product-page metrics are unavailable, ask the user for them or label them missing rather than inferring them from public metadata. + Use record_metadata_baseline before an authorized metadata release, then compare_metadata_baseline afterward to keep the keyword and market comparison fixed. Only known native ranks support rank deltas. Optional acquisition counts are caller-provided search aggregates with reporting periods, not per-keyword attribution or causal proof. These tools do not publish metadata or schedule follow-ups. + If authenticated private metadata or conversion/download/product-page metrics are unavailable, ask for the missing inputs or label them missing. Preserve provider failures and iTunes fallback provenance instead of claiming a native organic rank. Return a recommended metadata package, two alternatives, character counts, keyword coverage, before/after comparison, rationale, and evidence gaps. """ case "screenshot_optimization_plan": @@ -969,6 +1090,21 @@ private struct OpenASOMCPAppResource: Sendable { } private extension Dictionary where Key == String, Value == MCP.Value { + func acquisitionEvidence() throws -> MetadataAcquisitionEvidence? { + let keys = ["search_impressions", "search_downloads", "analytics_date_from", "analytics_date_to"] + guard keys.contains(where: { self[$0] != nil }) else { return nil } + guard let start = try date("analytics_date_from"), let end = try date("analytics_date_to") else { + throw MCPError.invalidParams("Acquisition evidence needs analytics_date_from and analytics_date_to.") + } + let evidence = MetadataAcquisitionEvidence( + periodStart: start, periodEnd: end, + searchImpressions: try optionalInt("search_impressions"), + searchDownloads: try optionalInt("search_downloads"), source: "caller_provided" + ) + try evidence.validate() + return evidence + } + func string(_ key: String) -> String? { self[key]?.stringValue } diff --git a/OpenASO/Services/MCP/OpenASOMCPService.swift b/OpenASO/Services/MCP/OpenASOMCPService.swift index 2d9d18c..6d2779b 100644 --- a/OpenASO/Services/MCP/OpenASOMCPService.swift +++ b/OpenASO/Services/MCP/OpenASOMCPService.swift @@ -491,6 +491,9 @@ final class OpenASOMCPService: Sendable { private let appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? private let appleAdsCredentialsProvider: @MainActor @Sendable () -> AppleAdsCredentials? + private let appStoreConnectCredentialsProvider: + @MainActor @Sendable () -> AppStoreConnectCredentials? + private let metadataBaselineStore: MetadataBaselineStore? private let now: @Sendable () -> Date init( @@ -511,6 +514,9 @@ final class OpenASOMCPService: Sendable { appleAdsPlatformAPI: (any AppleAdsPlatformAPI)? = nil, appleAdsCredentialsProvider: @escaping @MainActor @Sendable () -> AppleAdsCredentials? = { nil }, + appStoreConnectCredentialsProvider: + @escaping @MainActor @Sendable () -> AppStoreConnectCredentials? = { nil }, + metadataBaselineStore: MetadataBaselineStore? = nil, now: @escaping @Sendable () -> Date = { Date() } ) { self.backgroundModelStore = backgroundModelStore @@ -543,6 +549,8 @@ final class OpenASOMCPService: Sendable { ?? VisibleProductPricingService(httpClient: httpClient, now: now) self.appleAdsPlatformAPI = appleAdsPlatformAPI self.appleAdsCredentialsProvider = appleAdsCredentialsProvider + self.appStoreConnectCredentialsProvider = appStoreConnectCredentialsProvider + self.metadataBaselineStore = metadataBaselineStore self.now = now } @@ -1380,6 +1388,244 @@ final class OpenASOMCPService: Sendable { } } + func getAppStoreMetadata( + appStoreID: Int64, + locale: String = "en-US", + platform: String? = nil, + scope: String = "live" + ) async throws -> AppStoreMetadataSnapshot { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + guard locale.utf8.count <= 32, + locale.range(of: "^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8})*$", options: .regularExpression) != nil else { + throw OpenASOError.providerUnavailable("Provide an exact App Store locale, such as en-US or de-DE.") + } + guard ["live", "editable"].contains(scope) else { + throw OpenASOError.providerUnavailable("Metadata scope must be live or editable.") + } + let searchPlatform = try OpenASOMCPValidation.platform(platform) + guard let credentials = await appStoreConnectCredentialsProvider(), credentials.isComplete else { + throw OpenASOError.providerUnavailable("Connect App Store Connect in Settings to read owned metadata and the private keyword field.") + } + return try await Self.withRankingSearchTimeout(operationName: "App Store metadata") { + try await AppStoreConnectMetadataService(httpClient: self.httpClient).snapshot( + appStoreID: appStoreID, locale: locale, + platform: searchPlatform == .mac ? "MAC_OS" : "IOS", scope: scope, credentials: credentials, now: self.now() + ) + } + } + + func auditKeywordTargeting( + appStoreID: Int64, locale: String = "en-US", storefront: String = "us", + platform: String? = nil, scope: String = "live", keywords: [String]? = nil, + rankingLimit: Int = 5 + ) async throws -> KeywordTargetingAudit { + let metadata = try await getAppStoreMetadata(appStoreID: appStoreID, locale: locale, platform: platform, scope: scope) + let platform = try OpenASOMCPValidation.platform(platform) + let storefront = try OpenASOMCPValidation.storefront(storefront) + let keywords = try await targetingKeywords(keywords, metadata: metadata, storefront: storefront, platform: platform) + return try await makeTargetingAudit( + metadata: metadata, storefront: storefront, platform: platform, keywords: keywords, rankingLimit: rankingLimit + ) + } + + func compareMetadataDraft( + appStoreID: Int64, locale: String = "en-US", storefront: String = "us", platform: String? = nil, + scope: String = "live", keywords: [String]? = nil, proposedTitle: String? = nil, + proposedSubtitle: String? = nil, proposedKeywords: String? = nil, expectedHash: String? = nil, + rankingLimit: Int = 3 + ) async throws -> MetadataDraftComparison { + guard proposedTitle != nil || proposedSubtitle != nil || proposedKeywords != nil else { + throw OpenASOError.providerUnavailable("Provide at least one proposed metadata field.") + } + let metadata = try await getAppStoreMetadata(appStoreID: appStoreID, locale: locale, platform: platform, scope: scope) + if let expectedHash, expectedHash != metadata.contentHash { + throw OpenASOError.providerUnavailable("Metadata changed since the proposal baseline. Re-read and review the new snapshot.") + } + let platform = try OpenASOMCPValidation.platform(platform) + let storefront = try OpenASOMCPValidation.storefront(storefront) + let keywords = try await targetingKeywords(keywords, metadata: metadata, storefront: storefront, platform: platform) + let audit = try await makeTargetingAudit( + metadata: metadata, storefront: storefront, platform: platform, keywords: keywords, rankingLimit: rankingLimit + ) + return KeywordTargetingAnalysis.compare(audit, proposed: .init( + title: proposedTitle ?? metadata.fields.title, + subtitle: proposedSubtitle ?? metadata.fields.subtitle, + keywords: proposedKeywords ?? metadata.fields.keywords + )) + } + + func recordMetadataBaseline( + appStoreID: Int64, locale: String = "en-US", storefront: String = "us", platform: String? = nil, + keywords: [String]? = nil, rankingLimit: Int = 5, label: String, + acquisition: MetadataAcquisitionEvidence? = nil + ) async throws -> MetadataBaseline { + guard let metadataBaselineStore else { + throw OpenASOError.providerUnavailable("Metadata baseline storage is unavailable in this runtime.") + } + let label = try OpenASOMCPValidation.nonEmpty(label, fieldName: "label", maximumLength: 200) + try acquisition?.validate() + let audit = try await auditKeywordTargeting( + appStoreID: appStoreID, locale: locale, storefront: storefront, platform: platform, + keywords: keywords, rankingLimit: rankingLimit + ) + let record = MetadataBaseline(id: UUID(), label: label, capturedAt: now(), audit: audit, acquisition: acquisition) + try await metadataBaselineStore.save(record) + return record + } + + func listMetadataBaselines(appStoreID: Int64, limit: Int = 20) async throws -> [MetadataBaselineSummary] { + guard let metadataBaselineStore else { + throw OpenASOError.providerUnavailable("Metadata baseline storage is unavailable in this runtime.") + } + return try await metadataBaselineStore.list(appStoreID: OpenASOMCPValidation.appStoreID(appStoreID), limit: limit) + } + + func compareMetadataBaseline( + baselineID: UUID, acquisition: MetadataAcquisitionEvidence? = nil + ) async throws -> MetadataBaselineComparison { + guard let metadataBaselineStore else { + throw OpenASOError.providerUnavailable("Metadata baseline storage is unavailable in this runtime.") + } + try acquisition?.validate() + let baseline = try await metadataBaselineStore.load(baselineID) + let before = baseline.audit + let metadata = try await getAppStoreMetadata( + appStoreID: before.metadata.appStoreID, locale: before.metadata.locale, platform: before.searchPlatform + ) + let after = try await makeTargetingAudit( + metadata: metadata, storefront: before.storefront, + platform: OpenASOMCPValidation.platform(before.searchPlatform), + keywords: before.items.map(\.keyword), rankingLimit: 8, + rankingKeywords: before.items.filter { $0.ranking != nil }.map(\.keyword) + ) + let coverage = KeywordTargetingAnalysis.compare(before, proposed: metadata.fields) + let afterByKeyword = Dictionary(uniqueKeysWithValues: after.items.map { ($0.keyword, $0) }) + let rankChanges = before.items.map { item in + let left = item.ranking + let right = afterByKeyword[item.keyword]?.ranking + let comparable = left?.isNative == true && right?.isNative == true + && (right?.observedAt ?? .distantPast) >= (left?.observedAt ?? .distantFuture) + let improvement: Int? + if comparable, let old = left?.rank, let current = right?.rank { improvement = old - current } + else { improvement = nil } + return MetadataRankChange(keyword: item.keyword, before: left, after: right, nativeRankImprovement: improvement) + } + var notes = [ + "The same keyword/storefront/platform set is compared before and after. Metadata coverage does not prove indexing.", + "Rank deltas require native App Store observations with known positions in both samples. Fallback or absent ranks remain unknown.", + "This is a before/after observation, not a randomized metadata experiment or proof of causality.", + "Acquisition counts are caller-provided App Store Search aggregates, not per-keyword conversions; paid Apple Ads may be included.", + ] + if let old = baseline.acquisition, let current = acquisition, + abs(old.periodEnd.timeIntervalSince(old.periodStart) - current.periodEnd.timeIntervalSince(current.periodStart)) > 1 { + notes.append("Acquisition periods have different lengths; raw count changes are not directly comparable.") + } + let visibleChanged = before.metadata.fields.title != after.metadata.fields.title + || before.metadata.fields.subtitle != after.metadata.fields.subtitle + let metadataChanged: Bool? = visibleChanged ? true + : (before.metadata.fields.keywords != nil && after.metadata.fields.keywords != nil + ? before.metadata.fields.keywords != after.metadata.fields.keywords : nil) + if metadataChanged == nil { + notes.append("Private keyword availability is incomplete; a metadata change cannot be established from these snapshots.") + } + return .init( + baselineID: baseline.id, label: baseline.label, before: before, after: after, + metadataChanged: metadataChanged, + versionChanged: before.metadata.versionID != after.metadata.versionID, + gainedCoverage: coverage.gainedCoverage, lostCoverage: coverage.lostCoverage, + rankChanges: rankChanges, acquisitionBefore: baseline.acquisition, acquisitionAfter: acquisition, notes: notes + ) + } + + private func targetingKeywords( + _ requested: [String]?, metadata: AppStoreMetadataSnapshot, storefront: String, platform: AppPlatform + ) async throws -> [String] { + let keywords: [String] + if let requested { keywords = try OpenASOMCPValidation.keywords(requested) } + else { + keywords = try await backgroundModelStore.read { context in + let appID = metadata.appStoreID + let platformRaw = platform.rawValue + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.appStoreID == appID && $0.storefront == storefront && $0.platformRaw == platformRaw + }) + let tracks = try context.fetch(descriptor) + let metrics = try Self.metricValuesByQueryKey(queryKeys: tracks.map(\.queryKey), in: context) + return tracks.sorted { + let left = metrics[$0.queryKey]?.popularityScore ?? -1 + let right = metrics[$1.queryKey]?.popularityScore ?? -1 + return left == right ? $0.term < $1.term : left > right + }.prefix(50).map(\.term) + } + } + let selected = keywords.isEmpty && requested == nil + ? try OpenASOMCPValidation.keywords([metadata.fields.title, metadata.fields.subtitle].filter { !$0.isEmpty }) + : keywords + guard !selected.isEmpty, selected.count <= 50 else { + throw OpenASOError.providerUnavailable("Audit between 1 and 50 distinct keywords.") + } + for term in selected { _ = try OpenASOMCPValidation.nonEmpty(term, fieldName: "keyword", maximumLength: 200) } + return selected + } + + private func makeTargetingAudit( + metadata: AppStoreMetadataSnapshot, storefront: String, platform: AppPlatform, + keywords: [String], rankingLimit: Int, rankingKeywords: [String]? = nil + ) async throws -> KeywordTargetingAudit { + let popularity = try await getKeywordPopularity(keywords: keywords, storefronts: [storefront], platform: platform.rawValue) + let context = KeywordAppContext( + name: metadata.fields.title, + featureText: metadata.fields.subtitle + " " + (metadata.descriptionText ?? ""), locale: metadata.locale + ) + var items = popularity.items.map { + KeywordTargetingAnalysis.item(popularity: $0, metadata: metadata, context: context, ranking: nil) + }.sorted { + let left = $0.relevance.score >= 0.35 + let right = $1.relevance.score >= 0.35 + if left != right { return left } + let leftScore = $0.popularity.popularityScore ?? -1 + let rightScore = $1.popularity.popularityScore ?? -1 + return leftScore == rightScore ? $0.keyword < $1.keyword : leftScore > rightScore + } + let selected = rankingKeywords ?? items.filter { + $0.relevance.score >= 0.35 && (($0.popularity.popularityScore ?? 0) > 5 || $0.relevance.isOwnBrand) + }.prefix(max(0, min(rankingLimit, 8))).map(\.keyword) + var errors = popularity.providerErrors + for keyword in selected.prefix(8) { + try Task.checkCancellation() + do { + let row = try await getRankedAppsForKeyword( + keyword: keyword, storefront: storefront, platform: platform.rawValue, + targetAppStoreID: metadata.appStoreID, limit: 10 + ) + if let index = items.firstIndex(where: { $0.keyword == keyword }) { + items[index] = KeywordTargetingAnalysis.item( + popularity: items[index].popularity, metadata: metadata, context: context, + ranking: .init(rank: row.targetRank, source: row.source, observedAt: row.observedAt, sampledResults: row.resultCount) + ) + items[index].competition = KeywordTargetingCompetition.analyze(row) + } + } catch { + try Task.checkCancellation() + if let urlError = error as? URLError, urlError.code == .cancelled { throw CancellationError() } + let mapped = OpenASOMCPErrorDTO(OpenASOError.map(error)) + errors.append(.init(storefront: storefront, error: .init(code: mapped.code, message: "\(keyword): \(mapped.message)"))) + } + } + return .init( + metadata: metadata, metadataHash: metadata.contentHash, storefront: storefront, + searchPlatform: platform.rawValue, generatedAt: now(), items: items, + validation: KeywordTargetingAnalysis.validate(metadata.fields, locale: metadata.locale), providerErrors: errors, + notes: [ + "Candidates are app-aware lexical hypotheses, not calibrated traffic or rank forecasts. Review semantic fit and competitor brands.", + "Default candidates are the first 50 tracked terms ordered by stored popularity. Pass an explicit discovery shortlist to change this universe.", + "Live rank checks sample ten results each; iTunes fallback never establishes native organic rank.", + "Competition is a bounded native-result heuristic with confidence and evidence, not an Apple metric or rank forecast. Missing evidence is not low competition.", + "Title/subtitle/keyword word presence and plural variants do not prove indexing. No metadata is published by this audit.", + ] + ) + } + func scoreKeywords( appStoreID: Int64, storefronts: [String]? = nil, @@ -1392,7 +1638,20 @@ final class OpenASOMCPService: Sendable { platform: platform, page: OpenASOMCPPageRequest(limit: 200, cursor: nil) ) - let scores = page.items.map(Self.keywordScore) + let contexts: [String: KeywordAppContext] = try await backgroundModelStore.read { modelContext in + guard let app = try Self.fetchStoreApp(appStoreID: appStoreID, in: modelContext) else { return [:] } + return Dictionary(uniqueKeysWithValues: Set(page.items.map(\.storefront)).map { storefront in + let localized = app.storefrontMetadata.first { $0.storefront == storefront } + return (storefront, KeywordAppContext( + name: localized?.name ?? app.name, + featureText: [localized?.subtitle ?? app.subtitle, localized?.descriptionText].compactMap { $0 }.joined(separator: " "), + locale: localized?.servedLanguageCode ?? localized?.storefrontLanguageCode ?? "und" + )) + }) + } + let scores = page.items.map { + Self.keywordScore($0, context: contexts[$0.storefront] ?? KeywordAppContext(name: "", featureText: "", locale: "und")) + } let summary = OpenASOMCPKeywordScoreSummary( totalCount: scores.count, defendCount: scores.filter { $0.priority == "defend" }.count, @@ -1409,7 +1668,8 @@ final class OpenASOMCPService: Sendable { items: scores, summary: summary, notes: [ - "Scores are deterministic heuristics for triage, not search-volume forecasts.", + "Scores use this app's localized metadata and own brand, not a category-specific keyword dictionary.", + "Metadata overlap is a relevance hypothesis. Semantic fit and competitor intent still need review; popularity is not a relevance substitute.", "Use noisy keywords as pruning candidates unless separate ranking evidence proves relevance.", "Use defend keywords to monitor existing visibility and attack keywords to prioritize competitor opportunities.", ] @@ -1500,6 +1760,54 @@ final class OpenASOMCPService: Sendable { } } + func removeKeywords( + appStoreID: Int64, keywords: [String], storefronts: [String], platform: String? = nil + ) async throws -> OpenASOMCPRemoveKeywordsResult { + let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) + let keywords = try OpenASOMCPValidation.keywords(keywords) + let storefronts = try OpenASOMCPValidation.storefronts(storefronts) + let platform = try OpenASOMCPValidation.platform(platform) + guard !keywords.isEmpty, keywords.count <= 50, !storefronts.isEmpty, storefronts.count <= 3 else { + throw OpenASOError.providerUnavailable("Provide 1...50 keywords and 1...3 explicit storefronts to untrack.") + } + return try await backgroundModelStore.write { modelContext in + guard let app = try Self.fetchTrackedApp(appStoreID: appStoreID, in: modelContext) else { + throw OpenASOError.appNotFound + } + var removed: [OpenASOMCPRemovedKeyword] = [] + var skipped: [OpenASOMCPSkippedKeyword] = [] + let tracks = Dictionary(uniqueKeysWithValues: app.keywordTracks.map { ($0.identityKey, $0) }) + for storefront in storefronts { + for keyword in keywords { + try Task.checkCancellation() + let identity = TrackedAppKeyword.makeIdentityKey(appStoreID: appStoreID, term: keyword, + storefront: storefront, platform: platform) + guard let track = tracks[identity] else { + skipped.append(.init(keyword: keyword, storefront: storefront, platform: platform.rawValue, + reason: "not_tracked")) + continue + } + removed.append(.init(keyword: track.term, storefront: track.storefront, platform: track.platformRaw)) + // Sweep deterministic keys rather than relationships, which can omit + // orphaned legacy rows. Shared crawls/facts/query metrics are retained. + let prefix = identity + "::" + try modelContext.delete(model: TrackedKeywordRankedResult.self, where: #Predicate { $0.snapshotKey.starts(with: prefix) }) + try modelContext.delete(model: TrackedRankingCrawlLink.self, where: #Predicate { $0.snapshotKey.starts(with: prefix) }) + try modelContext.delete(model: TrackedKeywordDailyRanking.self, where: #Predicate { $0.trackIdentityKey == identity }) + try modelContext.delete(model: TrackedKeywordRefreshStatus.self, where: #Predicate { $0.trackIdentityKey == identity }) + try modelContext.delete(model: TrackedAppKeywordRefreshAttempt.self, where: #Predicate { $0.trackIdentityKey == identity }) + app.keywordTracks.removeAll { $0.identityKey == identity } + modelContext.delete(track) + } + } + try Task.checkCancellation() + var summary = OpenASOMCPMutationSummary.empty + summary.removed = removed.count + summary.skipped = skipped.count + return .init(summary: summary, removed: removed, skipped: skipped) + } + } + func updateKeywordNotes( appStoreID: Int64, keyword: String, @@ -2339,12 +2647,24 @@ final class OpenASOMCPService: Sendable { func refreshKeywordMetrics( appStoreID: Int64, storefronts: [String]? = nil, - platform: String? = nil + platform: String? = nil, + keywords: [String]? = nil ) async throws -> OpenASOMCPKeywordRefreshResult { let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) let storefronts = Set(try OpenASOMCPValidation.storefronts(storefronts)) let platform = try platform.map(OpenASOMCPValidation.platform) + let selectedKeywords: Set? + if let keywords { + let normalized = try OpenASOMCPValidation.keywords(keywords) + guard !normalized.isEmpty, normalized.count <= 50 else { + throw OpenASOError.providerUnavailable("Select between 1 and 50 keywords for a filtered metric refresh.") + } + selectedKeywords = Set(normalized.map(AppleAdsSearchTermPopularity.normalized)) + } else { + selectedKeywords = nil + } + let trackIdentityKeys = try await backgroundModelStore.read { modelContext in let descriptor = FetchDescriptor( predicate: #Predicate { track in @@ -2354,6 +2674,9 @@ final class OpenASOMCPService: Sendable { return try modelContext.fetch(descriptor).filter { track in if !storefronts.isEmpty && !storefronts.contains(track.storefront) { return false } if let platform, track.platform != platform { return false } + if let selectedKeywords, !selectedKeywords.contains(AppleAdsSearchTermPopularity.normalized(track.term)) { + return false + } return true }.map(\.identityKey) } @@ -2468,12 +2791,78 @@ final class OpenASOMCPService: Sendable { } } + /// Queries popularity for discovery terms without requiring tracked keywords. + func getKeywordPopularity( + keywords: [String], + storefronts: [String]? = nil, + platform: String? = nil + ) async throws -> OpenASOMCPKeywordPopularityResult { + let keywords = try OpenASOMCPValidation.keywords(keywords) + guard !keywords.isEmpty, keywords.count <= 50 else { + throw OpenASOError.providerUnavailable("Look up between 1 and 50 distinct keywords per call.") + } + for keyword in keywords { + _ = try OpenASOMCPValidation.nonEmpty(keyword, fieldName: "keyword", maximumLength: 200) + } + let requested = try OpenASOMCPValidation.storefronts(storefronts) + let storefronts = requested.isEmpty ? ["us"] : requested + guard storefronts.count <= 3 else { + throw OpenASOError.providerUnavailable("Look up popularity in at most three storefronts per call.") + } + let platform = try OpenASOMCPValidation.platform(platform) + var items: [OpenASOMCPKeywordPopularity] = [] + var errors: [OpenASOMCPKeywordSuggestionProviderError] = [] + for storefront in storefronts { + try Task.checkCancellation() + var rows: [KeywordPopularityLookup] = [] + var failure: String? + do { + guard let keywordMetricsService else { + throw OpenASOError.providerUnavailable("Apple Ads popularity lookup is unavailable in this runtime.") + } + rows = try await Self.withRankingSearchTimeout(operationName: "Keyword popularity") { + try await keywordMetricsService.lookupPopularity( + for: keywords, storefront: storefront, now: self.now + ) + } + } catch { + try Task.checkCancellation() + if let error = error as? URLError, error.code == .cancelled { throw CancellationError() } + let mapped = OpenASOError.map(error) + failure = mapped.localizedDescription + errors.append(.init(storefront: storefront, error: OpenASOMCPErrorDTO(mapped))) + } + let byKeyword = Dictionary(uniqueKeysWithValues: rows.map { + (AppleAdsSearchTermPopularity.normalized($0.keyword), $0) + }) + items.append(contentsOf: keywords.map { keyword in + let row = byKeyword[AppleAdsSearchTermPopularity.normalized(keyword)] + return OpenASOMCPKeywordPopularity( + keyword: keyword, + storefront: storefront, + platform: platform.rawValue, + popularityScore: row?.popularityScore, + source: row?.source, + observedAt: row?.observedAt ?? now(), + unavailableReason: row?.unavailableReason ?? failure + ) + }) + } + return .init(items: items, providerErrors: errors, notes: [ + "Fresh market popularity uses the official Apple Ads API, then the saved web session for missing terms. This lookup creates no tracks and stores no metrics.", + "A missing score is unknown, not zero. A score at or below 5 is weak demand evidence; prioritize relevant terms above that floor. Popularity is an index, not exact search volume." + ]) + } + func suggestKeywords( appStoreID: Int64, storefronts: [String]? = nil, platform: String? = nil, limit: Int? = nil, - websiteMarkdown: String? = nil + websiteMarkdown: String? = nil, + seedKeyword: String? = nil, + offset: Int = 0, + rankingLimit: Int? = nil ) async throws -> OpenASOMCPKeywordSuggestionResult { let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID) let storefronts = try await normalizedDiscoveryStorefronts(storefronts, appStoreID: appStoreID) @@ -2485,27 +2874,182 @@ final class OpenASOMCPService: Sendable { ) let app = try await appSummaryResolvingCatalog( appStoreID: appStoreID, storefront: storefronts.first ?? "us") - let seeds = try await seedKeywords( + let seedKeyword = try seedKeyword.map { + try OpenASOMCPValidation.nonEmpty($0, fieldName: "seed_keyword", maximumLength: 200) + } + guard (0...1_000).contains(offset) else { + throw OpenASOError.providerUnavailable("Suggestion offset must be between 0 and 1000.") + } + let rankingLimit = max(0, min(rankingLimit ?? ResponseLimits.keywordVerificationSearchBudget, + ResponseLimits.keywordVerificationSearchBudget)) + var seeds = try await seedKeywords( appStoreID: appStoreID, storefronts: storefronts, limit: limit * 2, websiteMarkdown: websiteMarkdown ) - let candidates = try await verifyKeywordSeeds( - seeds, + let metadataSeeds = seeds.map(\.keyword) + var appleAds = try await appleAdsSuggestions( appStoreID: appStoreID, storefronts: storefronts, - platform: platform, - limit: limit + seedKeyword: seedKeyword, + limit: limit, + offset: offset ) + let trackedKeys = try await trackedQueryKeys(appStoreID: appStoreID) + var freshMetrics: [String: KeywordMetricValue] = [:] + for index in appleAds.suggestions.indices { + let row = appleAds.suggestions[index] + appleAds.suggestions[index].isTracked = trackedKeys.contains( + TrackedAppKeyword.makeQueryKey(term: row.keyword, storefront: row.requestedStorefront, platform: platform) + ) + } + for storefront in storefronts.prefix(3) { + let keywords = appleAds.suggestions.filter { $0.requestedStorefront == storefront }.map(\.keyword) + guard !keywords.isEmpty, keywordMetricsService != nil else { continue } + let popularity = try await getKeywordPopularity( + keywords: keywords, storefronts: [storefront], platform: platform.rawValue + ) + appleAds.errors.append(contentsOf: popularity.providerErrors) + let byKeyword = Dictionary(uniqueKeysWithValues: popularity.items.map { + (AppleAdsSearchTermPopularity.normalized($0.keyword), $0) + }) + for index in appleAds.suggestions.indices where appleAds.suggestions[index].requestedStorefront == storefront { + let term = appleAds.suggestions[index].keyword + let key = TrackedAppKeyword.makeQueryKey(term: term, storefront: storefront, platform: platform) + let evidence = byKeyword[AppleAdsSearchTermPopularity.normalized(term)] + appleAds.suggestions[index].popularity = evidence + appleAds.suggestions[index].isTracked = trackedKeys.contains(key) + if let score = evidence?.popularityScore { + freshMetrics[key] = .init(popularityScore: score, difficultyScore: nil) + } + } + } + appleAds.suggestions.sort { + let left = $0.popularity?.popularityScore ?? -1 + let right = $1.popularity?.popularityScore ?? -1 + if left != right { return left > right } + if $0.suggestionScore != $1.suggestionScore { + return ($0.suggestionScore ?? -1) > ($1.suggestionScore ?? -1) + } + return $0.keyword < $1.keyword + } + appleAds.notes.append("Suggestions are ordered by fresh market popularity, then contextual suggestion score. Use relevant above-floor terms as new seed_keyword values; use suggestionPages.nextOffset to explore another page. ranking_limit: 0 skips ranking searches during expansion.") + // Put Apple's candidates inside the existing verification budget. Keep the + // complete bounded suggestion page in the response even when ranking is capped. + var seedsByKeyword = Dictionary(uniqueKeysWithValues: seeds.map { ($0.keyword, $0) }) + var appleKeywords: [String] = [] + for suggestion in appleAds.suggestions { + let keyword = AppleAdsSearchTermPopularity.normalized(suggestion.keyword) + if !appleKeywords.contains(keyword) { appleKeywords.append(keyword) } + if var existing = seedsByKeyword[keyword] { + existing.sources.insert("apple_ads_keyword_suggestions") + seedsByKeyword[keyword] = existing + } else { + seedsByKeyword[keyword] = KeywordSeed( + keyword: keyword, + sources: ["apple_ads_keyword_suggestions"], + storefronts: [] + ) + } + if seedsByKeyword[keyword]?.storefronts != nil { + seedsByKeyword[keyword]?.storefronts?.insert(suggestion.requestedStorefront) + } + seedsByKeyword[keyword]?.appleAdsStorefronts.insert(suggestion.requestedStorefront) + } + seeds = appleKeywords.compactMap { seedsByKeyword.removeValue(forKey: $0) } + + seeds.filter { seedsByKeyword[$0.keyword] != nil } + let candidates: KeywordVerificationResult + if rankingLimit == 0 { + candidates = KeywordVerificationResult(candidates: [], errors: []) + } else { + candidates = try await verifyKeywordSeeds( + seeds, appStoreID: appStoreID, storefronts: storefronts, platform: platform, + limit: limit, searchBudget: rankingLimit, metricsOverride: freshMetrics + ) + } return OpenASOMCPKeywordSuggestionResult( app: app, generatedAt: now(), candidates: candidates.candidates, - errors: candidates.errors + errors: candidates.errors, + appleAdsSuggestions: appleAds.suggestions, + providerErrors: appleAds.errors, + notes: appleAds.notes, + suggestionPages: appleAds.pages, + metadataSeeds: metadataSeeds ) } + private func appleAdsSuggestions( + appStoreID: Int64, + storefronts: [String], + seedKeyword: String?, + limit: Int, + offset: Int + ) async throws -> ( + suggestions: [OpenASOMCPAppleAdsKeywordSuggestion], + errors: [OpenASOMCPKeywordSuggestionProviderError], + notes: [String], + pages: [OpenASOMCPKeywordSuggestionPage] + ) { + guard let api = appleAdsPlatformAPI, + let credentials = await appleAdsCredentialsProvider(), credentials.canVerify else { + return ([], [], ["Apple Ads API credentials are unavailable; candidates use app metadata and optional website text."], []) + } + var suggestions: [OpenASOMCPAppleAdsKeywordSuggestion] = [] + var errors: [OpenASOMCPKeywordSuggestionProviderError] = [] + var pages: [OpenASOMCPKeywordSuggestionPage] = [] + var notes = [ + "Apple Ads suggestionScore is contextual to the app and seed, not country-wide market popularity. requestedStorefront records the request; Apple does not return a country with each suggestion.", + "Apple Ads suggestions may lack ranking verification when the live-search budget is exhausted. This tool does not add keywords or change stored popularity." + ] + for storefront in storefronts.prefix(3) { + try Task.checkCancellation() + do { + let rows = try await Self.withRankingSearchTimeout(operationName: "Apple Ads suggestions") { + try await api.keywordSuggestions( + appStoreID: appStoreID, + countryOrRegion: storefront, + seedKeyword: seedKeyword, + limit: limit, + offset: offset, + using: credentials + ) + } + try Task.checkCancellation() + pages.append(.init( + requestedStorefront: storefront, seedKeyword: seedKeyword, offset: offset, + limit: limit, returnedCount: rows.count, + nextOffset: rows.count == limit && offset + limit <= 1_000 ? offset + limit : nil + )) + suggestions.append(contentsOf: rows.prefix(limit).map { + OpenASOMCPAppleAdsKeywordSuggestion( + keyword: $0.keyword, + suggestionScore: $0.suggestionScore, + requestedStorefront: storefront, + seedKeyword: seedKeyword, + observedAt: now() + ) + }) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + try Task.checkCancellation() + errors.append(.init(storefront: storefront, error: OpenASOMCPErrorDTO(OpenASOError.map(error)))) + } + } + if storefronts.count > 3 { + notes.append("Apple Ads discovery is capped at the first three storefronts per call. Request remaining storefronts in another call.") + } + if suggestions.isEmpty { + notes.append("Apple Ads returned no candidates; metadata and optional website candidates remain available.") + } + return (suggestions, errors, notes, pages) + } + func refreshReviews( appStoreID: Int64, storefronts: [String]? = nil, @@ -3134,6 +3678,7 @@ final class OpenASOMCPService: Sendable { } static func withRankingSearchTimeout( + operationName: String = "Ranking search", operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in @@ -3143,12 +3688,12 @@ final class OpenASOMCPService: Sendable { group.addTask { try await Task.sleep(nanoseconds: ResponseLimits.rankingSearchTimeoutNanoseconds) throw OpenASOError.providerUnavailable( - "Ranking search timed out after \(ResponseLimits.rankingSearchTimeoutSeconds) seconds. Reduce the keyword/storefront batch size and retry." + "\(operationName) timed out after \(ResponseLimits.rankingSearchTimeoutSeconds) seconds. Reduce the keyword/storefront batch size and retry." ) } guard let result = try await group.next() else { - throw OpenASOError.providerUnavailable("Ranking search did not return a result.") + throw OpenASOError.providerUnavailable("\(operationName) did not return a result.") } group.cancelAll() return result @@ -3160,6 +3705,9 @@ extension OpenASOMCPService { fileprivate struct KeywordSeed: Hashable, Sendable { let keyword: String var sources: Set + /// Nil means metadata/website evidence that can be checked in every requested market. + var storefronts: Set? = nil + var appleAdsStorefronts: Set = [] } fileprivate struct KeywordVerificationResult: Sendable { @@ -3322,11 +3870,13 @@ extension OpenASOMCPService { storefronts: [String], platform: AppPlatform, limit: Int, - persistResults: Bool = false + persistResults: Bool = false, + searchBudget: Int = ResponseLimits.keywordVerificationSearchBudget, + metricsOverride: [String: KeywordMetricValue] = [:] ) async throws -> KeywordVerificationResult { let provider = try requireRankingProvider() let trackedKeys = try await trackedQueryKeys(appStoreID: appStoreID) - let metrics: [String: KeywordMetricValue] = try await backgroundModelStore.read { + var metrics: [String: KeywordMetricValue] = try await backgroundModelStore.read { modelContext in try Self.metricValuesByQueryKey( queryKeys: seeds.flatMap { seed in @@ -3337,13 +3887,15 @@ extension OpenASOMCPService { in: modelContext ) } + metrics.merge(metricsOverride) { _, fresh in fresh } var candidates: [OpenASOMCPKeywordCandidate] = [] var errors: [OpenASOMCPKeywordVerificationError] = [] var searchedCount = 0 seedLoop: for seed in seeds { for storefront in storefronts { + if let allowed = seed.storefronts, !allowed.contains(storefront) { continue } try Task.checkCancellation() - guard searchedCount < ResponseLimits.keywordVerificationSearchBudget else { + guard searchedCount < searchBudget else { errors.append( OpenASOMCPKeywordVerificationError( keyword: seed.keyword, @@ -3352,7 +3904,7 @@ extension OpenASOMCPService { error: OpenASOMCPErrorDTO( code: "verification_budget_exceeded", message: - "Keyword verification stopped after \(ResponseLimits.keywordVerificationSearchBudget) ranking searches to keep the MCP response inside the tool timeout. Run add_keywords or refresh_keyword_rankings in smaller batches for remaining seeds." + "Keyword verification stopped after \(searchBudget) ranking searches to keep the MCP response inside the tool timeout. Use get_ranked_apps_for_keyword for selected terms or refresh_keyword_rankings after adding tracks." ) )) break seedLoop @@ -3459,7 +4011,9 @@ extension OpenASOMCPService { keyword: seed.keyword, storefront: storefront, platform: platform.rawValue, - sources: seed.sources.sorted(), + sources: seed.sources.filter { + $0 != "apple_ads_keyword_suggestions" || seed.appleAdsStorefronts.contains(storefront) + }.sorted(), reason: reason, confidence: confidence, isTracked: isTracked, @@ -4409,95 +4963,36 @@ extension OpenASOMCPService { ) } - fileprivate static func keywordScore(_ keyword: OpenASOMCPKeywordSummary) - -> OpenASOMCPKeywordScore - { - let terms = keyword.keyword - .lowercased() - .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) - .map(String.init) - let genericTerms: Set = [ - "a", "after", "an", "and", "app", "apps", "before", "certain", "for", "it", "need", - "number", "of", "open", "the", "to", "wait", "waiting", "with", - ] - let strongIntentTerms: Set = [ - "block", "blocker", "control", "detox", "focus", "limit", "productivity", - "screen", "time", "website", "websites", - ] - let brandSignals = ["screenzen", "screen zen"] - let hasBrandSignal = brandSignals.contains { keyword.keyword.lowercased().contains($0) } - let strongIntentCount = terms.filter { strongIntentTerms.contains($0) }.count - let genericRatio = - terms.isEmpty - ? 1.0 : Double(terms.filter { genericTerms.contains($0) }.count) / Double(terms.count) - let hasRank = keyword.latestRank != nil - let topRank = keyword.latestRank.map { $0 <= 3 } ?? false - let visibleRank = keyword.latestRank.map { $0 <= 10 } ?? false - let hasPopularity = (keyword.popularityScore ?? 0) >= 10 - - var noiseScore = 0.0 - if terms.count <= 1 { noiseScore += 0.25 } - if genericRatio >= 0.6 { noiseScore += 0.45 } - if strongIntentCount == 0 && !hasBrandSignal { noiseScore += 0.3 } - if !hasRank && !hasPopularity { noiseScore += 0.2 } - noiseScore = min(noiseScore, 1.0) - - var relevanceScore = 0.0 - if hasBrandSignal { relevanceScore += 0.35 } - if strongIntentCount > 0 { relevanceScore += min(0.35, Double(strongIntentCount) * 0.12) } - if topRank { relevanceScore += 0.3 } else if visibleRank { relevanceScore += 0.2 } - if hasPopularity { relevanceScore += 0.15 } - if terms.count >= 3 { relevanceScore += 0.1 } - relevanceScore = min(max(relevanceScore - (noiseScore * 0.25), 0.0), 1.0) - + fileprivate static func keywordScore( + _ keyword: OpenASOMCPKeywordSummary, context: KeywordAppContext + ) -> OpenASOMCPKeywordScore { + let words = KeywordTargetingText.tokens(keyword.keyword) + let evidence = context.relevance(keyword.keyword) + let filler = KeywordTargetingText.filler(context.locale) + let genericRatio = words.isEmpty ? 1 : Double(words.filter { filler.contains($0) }.count) / Double(words.count) + let nativeRank = keyword.latestRankingSource == RankingSource.appStoreWeb.rawValue ? keyword.latestRank : nil + let hasDemand = (keyword.popularityScore ?? 0) > 5 + let noiseScore = words.isEmpty || genericRatio >= 0.6 ? 0.9 : (evidence.score == 0 ? 0.4 : 0.0) let priority: String - if noiseScore >= 0.65 && relevanceScore < 0.5 { - priority = "noisy" - } else if hasBrandSignal { - priority = "brand" - } else if topRank { - priority = "defend" - } else if visibleRank || hasPopularity { - priority = "attack" - } else if terms.count >= 3 && relevanceScore >= 0.35 { - priority = "long_tail" - } else { - priority = "experimental" - } - - let intent: String - if hasBrandSignal { - intent = "brand" - } else if terms.contains(where: { ["block", "blocker", "limit", "control"].contains($0) }) { - intent = "app_blocking" - } else if terms.contains(where: { ["website", "websites"].contains($0) }) { - intent = "website_blocking" - } else if terms.contains(where: { ["focus", "productivity", "detox"].contains($0) }) { - intent = "productivity" - } else { - intent = "unknown" - } - - var rationale: [String] = [] - if let rank = keyword.latestRank { rationale.append("latest rank \(rank)") } - if let popularity = keyword.popularityScore { rationale.append("popularity \(popularity)") } - if strongIntentCount > 0 { rationale.append("\(strongIntentCount) strong intent term(s)") } - if hasBrandSignal { rationale.append("contains brand signal") } - if noiseScore >= 0.65 { rationale.append("mostly generic or ambiguous phrase") } - if rationale.isEmpty { rationale.append("limited ranking and relevance evidence") } - + if evidence.isOwnBrand { priority = "brand" } + else if noiseScore >= 0.65 { priority = "noisy" } + else if let rank = nativeRank, rank <= 3 { priority = "defend" } + else if evidence.score >= 0.35 && (hasDemand || (nativeRank.map { $0 <= 10 } ?? false)) { priority = "attack" } + else if words.count >= 3 && evidence.score >= 0.35 { priority = "long_tail" } + else { priority = "experimental" } + var rationale = ["Relevance is derived from this app's metadata; semantic intent still needs review."] + if evidence.isOwnBrand { rationale.append("Matches this app's own brand.") } + if !evidence.matchedTerms.isEmpty { rationale.append("Metadata matches: " + evidence.matchedTerms.joined(separator: ", ")) } + if let popularity = keyword.popularityScore { rationale.append("Market popularity \(popularity)") } + if keyword.latestRank != nil && nativeRank == nil { + rationale.append("Observed rank is not verified native App Store evidence.") + } + if let nativeRank { rationale.append("Native rank \(nativeRank)") } return OpenASOMCPKeywordScore( - keyword: keyword.keyword, - storefront: keyword.storefront, - platform: keyword.platform, - latestRank: keyword.latestRank, - popularityScore: keyword.popularityScore, - resultCount: keyword.resultCount, - priority: priority, - intent: intent, - noiseScore: noiseScore, - relevanceScore: relevanceScore, - rationale: rationale + keyword: keyword.keyword, storefront: keyword.storefront, platform: keyword.platform, + latestRank: keyword.latestRank, popularityScore: keyword.popularityScore, resultCount: keyword.resultCount, + priority: priority, intent: evidence.isOwnBrand ? "brand" : (evidence.score > 0 ? "metadata_match" : "needs_relevance_review"), + noiseScore: noiseScore, relevanceScore: evidence.score, rationale: rationale ) } diff --git a/OpenASO/Services/Persistence/MetadataBaselineStore.swift b/OpenASO/Services/Persistence/MetadataBaselineStore.swift new file mode 100644 index 0000000..8244aad --- /dev/null +++ b/OpenASO/Services/Persistence/MetadataBaselineStore.swift @@ -0,0 +1,119 @@ +import Foundation + +struct MetadataAcquisitionEvidence: Codable, Equatable, Sendable { + let periodStart: Date + let periodEnd: Date + let searchImpressions: Int? + let searchDownloads: Int? + let source: String + + func validate() throws { + guard periodStart < periodEnd, + searchImpressions.map({ $0 >= 0 }) ?? true, + searchDownloads.map({ $0 >= 0 }) ?? true, + searchImpressions != nil || searchDownloads != nil, + source == "caller_provided" else { + throw OpenASOError.providerUnavailable("Acquisition evidence needs a valid reporting period and nonnegative counts; its source is caller_provided.") + } + } +} + +struct MetadataBaseline: Codable, Sendable { + let id: UUID + let label: String + let capturedAt: Date + let audit: KeywordTargetingAudit + let acquisition: MetadataAcquisitionEvidence? +} + +struct MetadataBaselineSummary: Codable, Sendable { + let id: UUID + let appStoreID: Int64 + let locale: String + let platform: String + let storefront: String + let version: String + let label: String + let capturedAt: Date +} + +/// UUID-named immutable records avoid cross-process read/modify/write races. +/// Records contain private metadata, so both the directory and files are private. +actor MetadataBaselineStore { + let directory: URL + + init(directory: URL) { self.directory = directory } + + func save(_ baseline: MetadataBaseline) throws { + try baseline.acquisition?.validate() + let manager = FileManager.default + try manager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + try manager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + let url = directory.appendingPathComponent(baseline.id.uuidString + ".json") + guard !manager.fileExists(atPath: url.path) else { + throw OpenASOError.providerUnavailable("Metadata baseline already exists; baselines are immutable.") + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(baseline).write(to: url, options: .atomic) + try manager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + func load(_ id: UUID) throws -> MetadataBaseline { + let url = directory.appendingPathComponent(id.uuidString + ".json") + let data = try Data(contentsOf: url) + guard data.count <= 4_000_000 else { + throw OpenASOError.providerUnavailable("Metadata baseline exceeds the supported record size.") + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let record = try decoder.decode(MetadataBaseline.self, from: data) + guard record.id == id, record.audit.metadataHash == record.audit.metadata.contentHash else { + throw OpenASOError.unexpectedResponse + } + return record + } + + func list(appStoreID: Int64, limit: Int = 20) throws -> [MetadataBaselineSummary] { + guard FileManager.default.fileExists(atPath: directory.path) else { return [] } + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + guard files.count <= 1_000 else { + throw OpenASOError.providerUnavailable("Metadata baseline directory exceeds the supported scan size.") + } + var records: [MetadataBaseline] = [] + for file in files { + guard let id = UUID(uuidString: file.deletingPathExtension().lastPathComponent) else { continue } + let record = try load(id) + if record.audit.metadata.appStoreID == appStoreID { records.append(record) } + } + return records.sorted { $0.capturedAt > $1.capturedAt }.prefix(max(1, min(limit, 50))).map { + .init(id: $0.id, appStoreID: $0.audit.metadata.appStoreID, locale: $0.audit.metadata.locale, + platform: $0.audit.searchPlatform, storefront: $0.audit.storefront, + version: $0.audit.metadata.version, label: $0.label, capturedAt: $0.capturedAt) + } + } +} + +struct MetadataRankChange: Codable, Sendable { + let keyword: String + let before: KeywordTargetingRanking? + let after: KeywordTargetingRanking? + let nativeRankImprovement: Int? +} + +struct MetadataBaselineComparison: Codable, Sendable { + let baselineID: UUID + let label: String + let before: KeywordTargetingAudit + let after: KeywordTargetingAudit + /// nil means private metadata visibility is insufficient to establish a change. + let metadataChanged: Bool? + let versionChanged: Bool + let gainedCoverage: [String] + let lostCoverage: [String] + let rankChanges: [MetadataRankChange] + let acquisitionBefore: MetadataAcquisitionEvidence? + let acquisitionAfter: MetadataAcquisitionEvidence? + let notes: [String] +} diff --git a/OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift b/OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift index e480fb8..4e863c8 100644 --- a/OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift +++ b/OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift @@ -67,7 +67,7 @@ struct KeywordDifficultyUnavailable: Equatable, Sendable { let notes: [String] } -struct KeywordDifficultyResultEvidence: Equatable, Sendable { +struct KeywordDifficultyResultEvidence: Codable, Equatable, Sendable { let position: Int let appStoreID: Int64 let title: String @@ -81,7 +81,7 @@ struct KeywordDifficultyResultEvidence: Equatable, Sendable { let exactSubtitlePhraseMatch: Bool } -struct KeywordDifficultyEvidence: Equatable, Sendable { +struct KeywordDifficultyEvidence: Codable, Equatable, Sendable { let consideredResultCount: Int let ratedResultCount: Int let weightedRatingCoveragePercentage: Int diff --git a/OpenASOTests/AppleAdsPlatformServiceTests.swift b/OpenASOTests/AppleAdsPlatformServiceTests.swift index 25e1e6e..4d5a3b7 100644 --- a/OpenASOTests/AppleAdsPlatformServiceTests.swift +++ b/OpenASOTests/AppleAdsPlatformServiceTests.swift @@ -3,6 +3,43 @@ import Testing @testable import OpenASO struct AppleAdsPlatformServiceTests { + @Test + func keywordSuggestionRequestKeepsAppMarketAndSeedWithoutRejectedSorting() throws { + let request = OfficialAppleAdsPlatformAPI.keywordSuggestionsRequest( + appStoreID: 123, + countryCode: "GB", + seedKeyword: " speech to text ", + limit: 500, + offset: 50 + ) + let object = try #require(JSONSerialization.jsonObject( + with: JSONEncoder().encode(request) + ) as? [String: Any]) + let filters = try #require(object["filters"] as? [[String: Any]]) + let byField = Dictionary(uniqueKeysWithValues: filters.compactMap { filter -> (String, [String])? in + guard let field = filter["field"] as? String, let value = filter["value"] as? [String] else { return nil } + return (field, value) + }) + + #expect(byField["promotedObjectId"] == ["123"]) + #expect(byField["promotedObjectType"] == ["APPSTORE_APP"]) + #expect(byField["countriesOrRegions"] == ["GB"]) + #expect(byField["terms"] == ["speech to text"]) + #expect(object["sorting"] == nil) + #expect((object["pagination"] as? [String: Int])?["pageSize"] == 50) + #expect((object["pagination"] as? [String: Int])?["offset"] == 50) + } + + @Test + func appBasedKeywordSuggestionsDoNotInventASeed() throws { + let request = OfficialAppleAdsPlatformAPI.keywordSuggestionsRequest( + appStoreID: 123, + countryCode: "US", + seedKeyword: nil, + limit: 10 + ) + #expect(request.filters?.contains { $0.field == "terms" } == false) + } @Test func coverageAccountsForEveryGeneratedOperation() { let coverage = AppleAdsPlatformCoverage.current diff --git a/OpenASOTests/KeywordMetricsServiceTests.swift b/OpenASOTests/KeywordMetricsServiceTests.swift index 7c9b6b6..6d40b87 100644 --- a/OpenASOTests/KeywordMetricsServiceTests.swift +++ b/OpenASOTests/KeywordMetricsServiceTests.swift @@ -6,6 +6,37 @@ import Testing @MainActor struct KeywordMetricsServiceTests { + @Test + func discoveryLookupKeepsOfficialAndWebSourcesWithoutPersisting() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let services = AppServices.mocked( + httpClient: MockHTTPClient { request in + let body = try JSONDecoder().decode( + KeywordPopularityRequestBody.self, from: try #require(request.httpBody) + ) + #expect(Set(body.terms) == ["voice to text", "missing"]) + return ( + Data(#"{"status":"success","data":[{"name":"voice to text","popularity":67}]}"#.utf8), + makeHTTPURLResponse(url: try #require(request.url), statusCode: 200) + ) + }, + modelContainer: container, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: [officialFlightTrackerPopularity]) + ) + try configureCampaignManagementFallback(in: services) + let rows = try await services.keywordMetricsService.lookupPopularity( + for: ["flight tracker", "voice to text", "missing"], storefront: "us" + ) + #expect(rows.first { $0.keyword == "flight tracker" }?.source == "apple_ads_platform") + #expect(rows.first { $0.keyword == "voice to text" }?.source == "apple_ads_web") + #expect(rows.first { $0.keyword == "voice to text" }?.popularityScore == 67) + #expect(rows.first { $0.keyword == "missing" }?.popularityScore == nil) + #expect(rows.first { $0.keyword == "missing" }?.unavailableReason != nil) + let modelContext = ModelContext(container) + #expect(try modelContext.fetchCount(FetchDescriptor()) == 0) + #expect(try modelContext.fetchCount(FetchDescriptor()) == 0) + } + @Test func expiredRefreshPreservesExistingPopularityAndRequiresReconnect() async throws { let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) @@ -2827,6 +2858,20 @@ struct StaticAppleAdsPlatformAPI: AppleAdsPlatformAPI { let coverage = AppleAdsPlatformCoverage.current let apps: [AppleAdsPromotedApp] var popularityRows: [AppleAdsSearchTermPopularity] = [] + var keywordSuggestionRows: [String: [AppleAdsKeywordSuggestion]] = [:] + var keywordSuggestionErrors: [String: OpenASOError] = [:] + + func keywordSuggestions( + appStoreID: Int64, + countryOrRegion: String, + seedKeyword: String?, + limit: Int, + offset: Int, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsKeywordSuggestion] { + if let error = keywordSuggestionErrors[countryOrRegion.lowercased()] { throw error } + return Array((keywordSuggestionRows[countryOrRegion.lowercased()] ?? []).dropFirst(offset).prefix(limit)) + } func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { AppleAdsPlatformConnection( @@ -2877,6 +2922,17 @@ struct FailingSearchPopularityAPI: AppleAdsPlatformAPI { let coverage = AppleAdsPlatformCoverage.current let error: OpenASOError + func keywordSuggestions( + appStoreID: Int64, + countryOrRegion: String, + seedKeyword: String?, + limit: Int, + offset: Int, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsKeywordSuggestion] { + throw error + } + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { throw error } @@ -2914,6 +2970,17 @@ struct HTTPBackedSearchPopularityAPI: AppleAdsPlatformAPI { let coverage = AppleAdsPlatformCoverage.current let httpClient: any HTTPClient + func keywordSuggestions( + appStoreID: Int64, + countryOrRegion: String, + seedKeyword: String?, + limit: Int, + offset: Int, + using credentials: AppleAdsCredentials + ) async throws -> [AppleAdsKeywordSuggestion] { + [] + } + func verify(credentials: AppleAdsCredentials) async throws -> AppleAdsPlatformConnection { AppleAdsPlatformConnection( userID: 1, diff --git a/OpenASOTests/KeywordTargetingTests.swift b/OpenASOTests/KeywordTargetingTests.swift new file mode 100644 index 0000000..056ad01 --- /dev/null +++ b/OpenASOTests/KeywordTargetingTests.swift @@ -0,0 +1,434 @@ +import CryptoKit +import Foundation +import MCP +import SwiftData +import Synchronization +import Testing +@testable import OpenASO + +@MainActor +struct KeywordTargetingTests { + @Test + func auditCompetitionRequiresNativeRatedEvidenceAndDoesNotPersistIt() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + fixture.state.value.withLock { $0.rank = nil; $0.competitorCount = 5 } + let native = try await fixture.service.auditKeywordTargeting(appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1) + let competition = try #require(native.items.first?.competition) + #expect(competition.score != nil) + #expect(competition.confidence != nil) + #expect(competition.evidence?.consideredResultCount == 5) + #expect(competition.evidence?.ratedResultCount == 5) + #expect(competition.algorithmIdentifier == "top10-authority-saturation") + #expect(try fixture.context.fetchCount(FetchDescriptor()) == 0) + #expect(try fixture.context.fetchCount(FetchDescriptor()) == 0) + fixture.state.value.withLock { $0.rankingSource = .iTunesFallback } + let fallback = try await fixture.service.auditKeywordTargeting(appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1) + #expect(fallback.items.first?.competition?.score == nil) + #expect(fallback.items.first?.competition?.unavailableReason == "native_ranking_required") + fixture.state.value.withLock { $0.rankingSource = .appStoreWeb; $0.competitorCount = 1 } + let sparse = try await fixture.service.auditKeywordTargeting(appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1) + #expect(sparse.items.first?.competition?.score == nil) + #expect(sparse.items.first?.competition?.unavailableReason == "insufficientResults") + fixture.state.value.withLock { $0.competitorCount = 5; $0.omitsRatings = true } + let unrated = try await fixture.service.auditKeywordTargeting(appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1) + #expect(unrated.items.first?.competition?.score == nil) + #expect(unrated.items.first?.competition?.unavailableReason == "insufficientRatingEvidence") + let skipped = try await fixture.service.auditKeywordTargeting(appStoreID: 123, keywords: ["voice notes"], rankingLimit: 0) + #expect(skipped.items.first?.competition == nil) + } + + @Test + func ownedMetadataSelectsLiveAndEditableWithoutMixingTheirFields() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let live = try await fixture.service.getAppStoreMetadata(appStoreID: 123) + #expect(live.version == "1.0") + #expect(live.fields.title == "Utter - Voice Notes") + #expect(live.fields.keywords == "audio,meeting,recorder") + let editable = try await fixture.service.getAppStoreMetadata(appStoreID: 123, scope: "editable") + #expect(editable.version == "2.0") + #expect(editable.fields.title == "Utter Draft") + #expect(editable.fields.keywords == "draft") + #expect(editable.contentHash != live.contentHash) + let again = try await fixture.service.getAppStoreMetadata(appStoreID: 123) + #expect(again.contentHash == live.contentHash) + #expect(fixture.state.value.withLock { $0.methods.allSatisfy { $0 == "GET" } }) + } + + @Test + func metadataRejectsMissingLocaleAmbiguousStateAndCredentialExfiltration() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + await #expect(throws: OpenASOError.self) { + _ = try await fixture.service.getAppStoreMetadata(appStoreID: 123, locale: "de-DE") + } + fixture.state.value.withLock { $0.ambiguousInfo = true } + await #expect(throws: OpenASOError.self) { + _ = try await fixture.service.getAppStoreMetadata(appStoreID: 123) + } + fixture.state.value.withLock { $0.ambiguousInfo = false; $0.unsafePagination = true } + await #expect(throws: OpenASOError.self) { + _ = try await fixture.service.getAppStoreMetadata(appStoreID: 123) + } + #expect(fixture.state.value.withLock { $0.hosts.allSatisfy { $0 == "api.appstoreconnect.apple.com" } }) + } + + @Test + func coverageSeparatesPhrasesComponentsVariantsAndEnglishFiller() { + let fields = AppStoreMetadataFields(title: "Utter - Voice Notes", subtitle: "AI Transcribe", + keywords: "speech,audio,meeting,taker,text") + let combined = KeywordTargetingAnalysis.coverage("AI meeting note taker", fields: fields, locale: "en-US") + #expect(combined.status == "variant_combination") + #expect(combined.missingTerms.isEmpty) + #expect(combined.literalPhraseFields.isEmpty) + #expect(KeywordTargetingAnalysis.coverage("voice notes", fields: fields, locale: "en-US").status == "literal_phrase") + #expect(KeywordTargetingAnalysis.coverage("speech to text", fields: fields, locale: "en-US").missingTerms.isEmpty) + #expect(KeywordTargetingAnalysis.coverage("note", fields: fields, locale: "fr-FR").missingTerms == ["note"]) + #expect(KeywordTargetingAnalysis.coverage("notebook", fields: fields, locale: "en-US").missingTerms == ["notebook"]) + } + + @Test + func fieldValidationFindsOverflowDuplicatesPluralWasteAndMissingPrivateData() { + let result = KeywordTargetingAnalysis.validate(.init( + title: "Voice Notes", subtitle: "AI", keywords: "voice,note,free,to," + String(repeating: "x", count: 100) + ), locale: "en-US") + #expect(result.characterCounts["keywords"]! > 100) + #expect(!result.errors.isEmpty) + #expect(result.warnings.count >= 4) + let unknown = KeywordTargetingAnalysis.validate(.init(title: "Utter", subtitle: "", keywords: nil), locale: "en-US") + #expect(!unknown.warnings.isEmpty) + } + + @Test + func relevanceUsesTheActualAppAndItsOwnBrand() { + let utter = KeywordAppContext(name: "Utter - Voice Notes", featureText: "Dictation and audio transcription") + let atten = KeywordAppContext(name: "Atten - App Blocker", featureText: "Screen time and website limits") + #expect(utter.relevance("dictation").score == 1) + #expect(atten.relevance("dictation").score == 0) + #expect(utter.relevance("Utter dictation").isOwnBrand) + #expect(!utter.relevance("ScreenZen").isOwnBrand) + #expect(atten.relevance("screen time limits").score == 1) + } + + @Test + func auditUsesFreshDemandButKeepsIrrelevantPopularityOutOfOpportunityBucket() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let audit = try await fixture.service.auditKeywordTargeting( + appStoreID: 123, keywords: ["transcribe audio to text", "voice notes", "screen blocker"], rankingLimit: 0 + ) + let gap = try #require(audit.items.first { $0.keyword == "transcribe audio to text" }) + #expect(gap.coverage.missingTerms == ["text"]) + #expect(gap.popularity.popularityScore == 50) + #expect(gap.opportunity == "metadata_gap") + #expect(audit.items.first { $0.keyword == "screen blocker" }?.opportunity == "relevance_review") + #expect(audit.items.first { $0.keyword == "voice notes" }?.coverage.status == "literal_phrase") + #expect(try fixture.context.fetchCount(FetchDescriptor()) == 0) + #expect(try fixture.context.fetchCount(FetchDescriptor()) == 0) + } + + @Test + func unavailablePrivateKeywordsRemainUnknownInsteadOfBecomingVerifiedGaps() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + fixture.state.value.withLock { $0.omitsPrivateKeywords = true } + let audit = try await fixture.service.auditKeywordTargeting( + appStoreID: 123, keywords: ["transcribe audio to text"], rankingLimit: 0 + ) + #expect(audit.metadata.fields.keywords == nil) + #expect(audit.items.first?.opportunity == "private_metadata_unknown") + let draft = KeywordTargetingAnalysis.compare( + audit, proposed: .init(title: audit.metadata.fields.title, subtitle: "Transcribe Audio to Text", keywords: "meeting") + ) + #expect(draft.gainedCoverage.isEmpty) + let baseline = try await fixture.service.recordMetadataBaseline( + appStoreID: 123, keywords: ["transcribe audio to text"], rankingLimit: 0, label: "Incomplete private fields" + ) + fixture.state.value.withLock { $0.omitsPrivateKeywords = false } + let comparison = try await fixture.service.compareMetadataBaseline(baselineID: baseline.id) + #expect(comparison.metadataChanged == nil) + } + + @Test + func draftComparisonShowsGainsLossesOverflowAndRejectsStaleBaseline() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let metadata = try await fixture.service.getAppStoreMetadata(appStoreID: 123) + let draft = try await fixture.service.compareMetadataDraft( + appStoreID: 123, keywords: ["transcribe audio to text", "voice notes"], + proposedTitle: "Utter", proposedSubtitle: "Transcribe Audio to Text", + expectedHash: metadata.contentHash + ) + #expect(draft.gainedCoverage == ["transcribe audio to text"]) + #expect(draft.lostCoverage == ["voice notes"]) + #expect(draft.validation.errors.isEmpty) + #expect(draft.validation.warnings.contains { $0.contains("voice notes") }) + fixture.state.value.withLock { $0.keywords = "changed" } + await #expect(throws: OpenASOError.self) { + _ = try await fixture.service.compareMetadataDraft( + appStoreID: 123, proposedSubtitle: "New subtitle", expectedHash: metadata.contentHash + ) + } + let invalid = try await fixture.service.compareMetadataDraft( + appStoreID: 123, keywords: ["voice notes"], proposedTitle: String(repeating: "x", count: 31) + ) + #expect(!invalid.validation.errors.isEmpty) + } + + @Test + func draftFlagsRemovalOfOwnBrandAndNativeWinningCoverage() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let audit = try await fixture.service.auditKeywordTargeting( + appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1 + ) + let draft = KeywordTargetingAnalysis.compare( + audit, proposed: .init(title: "Different Brand", subtitle: "", keywords: "") + ) + #expect(draft.lostCoverage == ["voice notes"]) + #expect(draft.visibilityChanges.first?.beforeFields == ["title"]) + #expect(draft.visibilityChanges.first?.afterFields == []) + #expect(draft.validation.warnings.contains { $0.contains("brand") }) + #expect(draft.validation.warnings.contains { $0.contains("voice notes") }) + #expect(draft.baselineMetadata.fields == audit.metadata.fields) + } + + @Test + func savedBaselinesSurviveNewStoreAndCompareOnlyTheSameQueries() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let baseline = try await fixture.service.recordMetadataBaseline( + appStoreID: 123, keywords: ["voice notes", "transcribe audio to text"], rankingLimit: 1, label: "Before 1.1" + ) + let reopened = MetadataBaselineStore(directory: fixture.directory) + let restored = try await reopened.load(baseline.id) + #expect(restored.audit.metadataHash == baseline.audit.metadataHash) + #expect(try await reopened.list(appStoreID: 123).map(\.id) == [baseline.id]) + await #expect(throws: OpenASOError.self) { try await reopened.save(baseline) } + let attributes = try FileManager.default.attributesOfItem( + atPath: fixture.directory.appendingPathComponent(baseline.id.uuidString + ".json").path + ) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + fixture.state.value.withLock { $0.rank = 1; $0.keywords = "audio,meeting,recorder,text"; $0.version = "1.1" } + let comparison = try await fixture.service.compareMetadataBaseline(baselineID: baseline.id) + #expect(comparison.metadataChanged == true) + #expect(comparison.versionChanged) + #expect(comparison.gainedCoverage == ["transcribe audio to text"]) + #expect(Set(comparison.before.items.map(\.keyword)) == Set(comparison.after.items.map(\.keyword))) + #expect(comparison.rankChanges.contains { $0.nativeRankImprovement == 2 }) + } + + @Test + func fallbackAndAbsentRanksNeverProduceNativeImprovement() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + fixture.state.value.withLock { $0.rankingSource = .iTunesFallback } + let baseline = try await fixture.service.recordMetadataBaseline( + appStoreID: 123, keywords: ["voice notes"], rankingLimit: 1, label: "Fallback" + ) + fixture.state.value.withLock { $0.rank = 1 } + let fallback = try await fixture.service.compareMetadataBaseline(baselineID: baseline.id) + #expect(fallback.rankChanges.allSatisfy { $0.nativeRankImprovement == nil }) + #expect(fallback.after.items.first?.opportunity == "ranking_needs_verification") + fixture.state.value.withLock { $0.rankingSource = .appStoreWeb; $0.rank = nil } + let absent = try await fixture.service.compareMetadataBaseline(baselineID: baseline.id) + #expect(absent.rankChanges.allSatisfy { $0.nativeRankImprovement == nil }) + } + + @Test + func acquisitionEvidenceRequiresPeriodsAndRetainsCallerProvenance() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let start = Date(timeIntervalSince1970: 1_700_000_000) + let invalid = MetadataAcquisitionEvidence(periodStart: start, periodEnd: start, + searchImpressions: -1, searchDownloads: nil, source: "caller_provided") + #expect(throws: OpenASOError.self) { try invalid.validate() } + let old = MetadataAcquisitionEvidence(periodStart: start, periodEnd: start.addingTimeInterval(604_800), + searchImpressions: 100, searchDownloads: 10, source: "caller_provided") + let baseline = try await fixture.service.recordMetadataBaseline( + appStoreID: 123, keywords: ["voice notes"], rankingLimit: 0, label: "Analytics", acquisition: old + ) + let current = MetadataAcquisitionEvidence(periodStart: start.addingTimeInterval(604_800), + periodEnd: start.addingTimeInterval(1_814_400), + searchImpressions: 200, searchDownloads: 18, source: "caller_provided") + let compared = try await fixture.service.compareMetadataBaseline(baselineID: baseline.id, acquisition: current) + #expect(compared.acquisitionBefore == old) + #expect(compared.acquisitionAfter == current) + #expect(compared.notes.contains { $0.contains("different lengths") }) + } + + @Test + func metadataWorkflowRoundTripsOverMCPAndPublishesNothing() async throws { + let fixture = try TargetingFixture() + defer { fixture.cleanup() } + let server = await OpenASOMCPServerFactory(service: fixture.service).makeServer() + let client = Client(name: "Metadata workflow test", version: "1") + let pair = await InMemoryTransport.createConnectedPair() + try await server.start(transport: pair.server) + do { + _ = try await client.connect(transport: pair.client) + let tools = try await client.listTools().tools + #expect(tools.first { $0.name == "compare_metadata_draft" }?.annotations.readOnlyHint == true) + #expect(tools.first { $0.name == "record_metadata_baseline" }?.annotations.readOnlyHint == false) + let snapshotResponse = try await client.callTool(name: "get_app_store_metadata", arguments: [ + "appStoreID": 123, "locale": "en-US" + ]) + let snapshotText = try #require(snapshotResponse.content.first?.metadataText) + let snapshotJSON = try #require(JSONSerialization.jsonObject(with: Data(snapshotText.utf8)) as? [String: Any]) + #expect((snapshotJSON["contentHash"] as? String)?.count == 64) + let response = try await client.callTool(name: "record_metadata_baseline", arguments: [ + "appStoreID": 123, "locale": "en-US", "storefront": "us", "keywords": ["voice notes"], + "ranking_limit": 0, "label": "MCP baseline", + ]) + let text = try #require(response.content.first?.metadataText) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let baseline = try decoder.decode(MetadataBaseline.self, from: Data(text.utf8)) + let compared = try await client.callTool(name: "compare_metadata_baseline", arguments: [ + "baseline_id": .string(baseline.id.uuidString) + ]) + let comparedText = try #require(compared.content.first?.metadataText) + let result = try decoder.decode(MetadataBaselineComparison.self, from: Data(comparedText.utf8)) + #expect(result.metadataChanged == false) + #expect(result.after.items.map(\.keyword) == ["voice notes"]) + #expect(fixture.state.value.withLock { $0.methods.allSatisfy { $0 == "GET" } }) + await client.disconnect() + await server.stop() + } catch { + await client.disconnect() + await server.stop() + throw error + } + } +} + +private struct TargetingState: Sendable { + var keywords = "audio,meeting,recorder" + var version = "1.0" + var competitorCount = 0 + var omitsRatings = false + var rank: Int? = 3 + var rankingSource: RankingSource = .appStoreWeb + var ambiguousInfo = false + var unsafePagination = false + var omitsPrivateKeywords = false + var methods: [String] = [] + var hosts: [String] = [] +} + +@MainActor +private struct TargetingFixture { + let directory: URL + let state: TargetingSharedState + let service: OpenASOMCPService + let context: ModelContext + + init() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent("metadata-tests-" + UUID().uuidString) + let state = TargetingSharedState() + self.state = state + let client = MockHTTPClient { request in + let url = try #require(request.url) + let current = state.value.withLock { + $0.methods.append(request.httpMethod ?? "GET"); $0.hosts.append(url.host ?? "") + return $0 + } + #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) + var records: [[String: Any]] + switch url.path { + case "/v1/apps/123/appStoreVersions": + records = [ + ["id": "live-" + current.version, "attributes": ["versionString": current.version, "appVersionState": "READY_FOR_DISTRIBUTION", "createdDate": "2026-10-01T00:00:00Z"]], + ["id": "draft", "attributes": ["versionString": "2.0", "appVersionState": "PREPARE_FOR_SUBMISSION", "createdDate": "2026-10-20T00:00:00Z"]], + ["id": "old-replaced", "attributes": ["versionString": "0.9", "appVersionState": "REPLACED_WITH_NEW_VERSION", "appStoreState": "READY_FOR_SALE", "createdDate": "2026-10-24T00:00:00Z"]], + ] + case "/v1/apps/123/appInfos": + records = [ + ["id": "info-live", "attributes": ["state": "READY_FOR_DISTRIBUTION"]], + ["id": "info-edit", "attributes": ["state": "PREPARE_FOR_SUBMISSION"]], + ] + if current.ambiguousInfo { records.append(["id": "other", "attributes": ["state": "READY_FOR_DISTRIBUTION"]]) } + case "/v1/appInfos/info-live/appInfoLocalizations": + records = [["id": "name", "attributes": ["locale": "en-US", "name": "Utter - Voice Notes", "subtitle": "AI Transcribe"]]] + case "/v1/appInfos/info-edit/appInfoLocalizations": + records = [["id": "name-draft", "attributes": ["locale": "en-US", "name": "Utter Draft", "subtitle": "New subtitle"]]] + case "/v1/appStoreVersions/draft/appStoreVersionLocalizations": + records = [["id": "text-draft", "attributes": ["locale": "en-US", "keywords": "draft"]]] + case "/v1/appStoreVersions/live-" + current.version + "/appStoreVersionLocalizations": + var attributes: [String: Any] = [ + "locale": "en-US", "keywords": current.keywords, + "description": "Voice notes and audio transcription. Dictation keyboard and meeting notes." + ] + if current.omitsPrivateKeywords { attributes.removeValue(forKey: "keywords") } + records = [["id": "text", "attributes": attributes]] + default: + throw OpenASOError.unexpectedResponse + } + var body: [String: Any] = ["data": records] + if current.unsafePagination && url.path.hasSuffix("appStoreVersions") { + body["links"] = ["next": "https://untrusted.example/steal"] + } + return (try JSONSerialization.data(withJSONObject: body), makeHTTPURLResponse(url: url, statusCode: 200)) + } + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + context = ModelContext(container) + let defaults = try #require(UserDefaults(suiteName: "metadata-tests-" + UUID().uuidString)) + let keychain = InMemoryKeychainService() + let credentials = AppleAdsCredentialStore(defaults: defaults, keychain: keychain, loadsEnvironmentCredentials: false) + try credentials.saveAPICredentials(.init(clientID: "c", teamID: "t", keyID: "k", privateKey: "fixture")) + let ads = StaticAppleAdsPlatformAPI(apps: [], popularityRows: [ + ("voice notes", 49), ("transcribe audio to text", 50), ("screen blocker", 99) + ].map { + .init(searchTerm: $0.0, countryOrRegion: "US", genre: "Productivity", week: "2026-10-17", + month: nil, rankInGenre: nil, popularityInGenre: nil, popularity1to100: $0.1, popularity1to5: nil) + }) + let metrics = KeywordMetricsService(httpClient: client, credentialStore: credentials, + settingsStore: AppSettingsStore(defaults: defaults), + webSessionStore: AppleAdsWebSessionStore(defaults: defaults, keychain: keychain), + apiClient: ads) + let resolver = DefaultAppResolver(httpClient: client) + let asc = AppStoreConnectCredentials(issuerID: "issuer", keyID: "key", privateKey: P256.Signing.PrivateKey().pemRepresentation) + service = OpenASOMCPService( + backgroundModelStore: BackgroundModelStore(modelContainer: container), appResolver: resolver, + appCatalogService: AppCatalogService(appResolver: resolver), httpClient: client, + rankingProvider: TargetingRankingProvider(state: state), keywordMetricsService: metrics, + appStoreConnectCredentialsProvider: { asc }, metadataBaselineStore: MetadataBaselineStore(directory: directory), + now: { Date(timeIntervalSince1970: 1_800_000_000) } + ) + } + + func cleanup() { try? FileManager.default.removeItem(at: directory) } +} + +private struct TargetingRankingProvider: SearchRankingProvider { + let state: TargetingSharedState + + func search(keyword: String, storefrontCode: String, platform: AppPlatform, limit: Int) async throws -> SearchRankingPage { + let current = state.value.withLock { $0 } + var items: [SearchRankingItem] = current.rank.map { position in + [.init(position: position, appStoreID: 123, bundleID: "com.test.utter", name: "Utter", + sellerName: "Test", iconURLString: nil, primaryGenreName: "Productivity", + screenshotURLs: [], ratingCount: 20, averageRating: 4.5, platform: platform)] + } ?? [] + for position in 0...self, from: Data(toolJSON.utf8)) #expect(appPage.items.map(\.appStoreID) == ["123"]) + let removalTool = try #require(tools.first { $0.name == "remove_keywords" }) + #expect(removalTool.annotations.readOnlyHint == false) + #expect(removalTool.annotations.destructiveHint == true) + #expect(removalTool.annotations.idempotentHint == true) + _ = try await client.callTool(name: "add_keywords", arguments: [ + "appStoreID": 123, "keywords": ["voice notes"], "storefronts": ["us"] + ]) + let removal = try await client.callTool(name: "remove_keywords", arguments: [ + "appStoreID": 123, "keywords": ["VOICE NOTES"], "storefronts": ["us"] + ]) + let removalJSON = try #require(removal.content.first?.textValue) + let removalResult = try JSONDecoder().decode(OpenASOMCPRemoveKeywordsResult.self, from: Data(removalJSON.utf8)) + #expect(removalResult.summary.removed == 1) + #expect(removalResult.removed.first?.keyword == "voice notes") + let resources = try await client.listResources().resources #expect(resources.map(\.uri).contains("openaso://workspace/summary")) let resourceContent = try await client.readResource(uri: "openaso://apps/123") @@ -580,6 +595,88 @@ struct OpenASOMCPServerTests { #expect(result.outcomes.first?.error == nil) } + @Test + func suggestKeywordsUsesSavedCredentialsOverMCPAndPreservesUnverifiedAppleSuggestions() async throws { + let context = try AppServicesMCPTestContext( + httpClient: MockHTTPClient { _ in throw OpenASOError.networkUnavailable }, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: [ + .init(searchTerm: "focus session", countryOrRegion: "US", genre: "Productivity", + week: "2026-05-02", month: nil, rankInGenre: nil, popularityInGenre: nil, + popularity1to100: 73, popularity1to5: nil) + ], keywordSuggestionRows: [ + "us": [.init(keyword: "focus session", suggestionScore: 0)] + ]) + ) + try context.insertTrackedKeyword(appStoreID: 123, keyword: "focus timer") + try context.configureAppleAds(contextAppStoreID: 987) + let server = try await context.services.mcpServerProvider.makeServer() + let client = Client(name: "Suggestion integration test", version: "1.0") + let transports = await InMemoryTransport.createConnectedPair() + try await server.start(transport: transports.server) + do { + _ = try await client.connect(transport: transports.client) + let response = try await client.callTool(name: "suggest_keywords", arguments: [ + "appStoreID": 123, + "storefronts": ["us"], + "seed_keyword": "focus", + "limit": 5 + ]) + let text = try #require(response.content.first?.textValue) + let result = try JSONDecoder.openASOMCP.decode( + OpenASOMCPKeywordSuggestionResult.self, from: Data(text.utf8) + ) + #expect(result.appleAdsSuggestions.first?.keyword == "focus session") + #expect(result.appleAdsSuggestions.first?.suggestionScore == 0) + #expect(result.appleAdsSuggestions.first?.popularity?.popularityScore == 73) + #expect(result.appleAdsSuggestions.first?.seedKeyword == "focus") + #expect(result.providerErrors.isEmpty) + #expect(!result.errors.isEmpty) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 1) + let lookupResponse = try await client.callTool(name: "get_keyword_popularity", arguments: [ + "keywords": ["focus session", "unavailable phrase"], "storefronts": ["us"] + ]) + let lookupText = try #require(lookupResponse.content.first?.textValue) + let lookup = try JSONDecoder.openASOMCP.decode( + OpenASOMCPKeywordPopularityResult.self, from: Data(lookupText.utf8) + ) + #expect(lookup.items.first?.popularityScore == 73) + #expect(lookup.items.first?.source == "apple_ads_platform") + #expect(lookup.items.last?.popularityScore == nil) + let compactResponse = try await client.callTool(name: "suggest_keywords", arguments: [ + "appStoreID": 123, "storefronts": ["us"], "ranking_limit": 0, "offset": 0 + ]) + let compactText = try #require(compactResponse.content.first?.textValue) + let compact = try JSONDecoder.openASOMCP.decode( + OpenASOMCPKeywordSuggestionResult.self, from: Data(compactText.utf8) + ) + #expect(compact.candidates.isEmpty) + #expect(compact.errors.isEmpty) + #expect(compact.appleAdsSuggestions.first?.popularity?.popularityScore == 73) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 0) + _ = try await client.callTool(name: "add_keywords", arguments: [ + "appStoreID": 123, "keywords": ["focus session"], "storefronts": ["us"] + ]) + let refreshResponse = try await client.callTool(name: "refresh_keyword_metrics", arguments: [ + "appStoreID": 123, "keywords": ["FOCUS SESSION"], "storefronts": ["us"], "platform": "iphone" + ]) + let refreshText = try #require(refreshResponse.content.first?.textValue) + let refresh = try JSONDecoder.openASOMCP.decode( + OpenASOMCPKeywordRefreshResult.self, from: Data(refreshText.utf8) + ) + #expect(refresh.summary.refreshed == 1) + #expect(refresh.summary.failed == 0) + #expect(refresh.outcomes.map(\.track.keyword) == ["focus session"]) + #expect(refresh.outcomes.first?.track.popularityScore == 73) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 1) + await client.disconnect() + await server.stop() + } catch { + await client.disconnect() + await server.stop() + throw error + } + } + @Test func appServicesMCPProviderSkipsHTTPWhenAppleAdsConfigurationIsMissing() async throws { var requestCount = 0 diff --git a/OpenASOTests/OpenASOMCPServiceTests.swift b/OpenASOTests/OpenASOMCPServiceTests.swift index 4a0dfbc..a0d7b9c 100644 --- a/OpenASOTests/OpenASOMCPServiceTests.swift +++ b/OpenASOTests/OpenASOMCPServiceTests.swift @@ -6,6 +6,64 @@ import Testing @MainActor struct OpenASOMCPServiceTests { + @Test + func removeKeywordsIsScopedIdempotentAndPreservesSharedResearch() async throws { + let context = try MCPTestContext() + try context.insertTrackedApp(appStoreID: 123, name: "Target") + try context.insertTrackedApp(appStoreID: 999, name: "Other") + _ = try await context.service.addKeywords(appStoreID: 123, keywords: ["Voice Notes"], storefronts: ["us", "gb"]) + _ = try await context.service.addKeywords(appStoreID: 123, keywords: ["Voice Notes"], storefronts: ["us"], platform: "ipad") + _ = try await context.service.addKeywords(appStoreID: 999, keywords: ["Voice Notes"], storefronts: ["us"]) + let removedIdentity = TrackedAppKeyword.makeIdentityKey(appStoreID: 123, term: "Voice Notes", storefront: "us", platform: .iphone) + try await context.backgroundModelStore.write { db in + let track = try #require(db.fetch(FetchDescriptor(predicate: #Predicate { $0.identityKey == removedIdentity })).first) + let snapshot = TrackedKeywordDailyRanking(rank: 3, searchedAt: isoDate("2026-05-07T12:00:00Z"), source: .appStoreWeb, resultCount: 10, keywordTrack: track) + db.insert(snapshot) + track.snapshots.append(snapshot) + db.insert(TrackedKeywordRankedResult(position: 1, appStoreID: 999, bundleID: nil, name: "Other", sellerName: nil, snapshot: snapshot)) + let crawl = RankingCrawlRecord(keyword: track.term, storefront: "us", platform: .iphone, observedAt: snapshot.searchedAt, source: .appStoreWeb, resultCount: 10, query: track.query) + db.insert(crawl) + db.insert(TrackedRankingCrawlLink(snapshotKey: snapshot.snapshotKey, crawl: crawl)) + db.insert(TrackedAppKeywordRefreshAttempt(trackIdentityKey: removedIdentity, appStoreID: 123, lastRankingRefreshAttemptAt: snapshot.searchedAt)) + db.insert(TrackedKeywordRefreshStatus(trackIdentityKey: removedIdentity, trackCreatedAt: track.createdAt, appStoreID: 123, domain: .ranking, message: "old error", updatedAt: snapshot.searchedAt)) + } + let result = try await context.service.removeKeywords(appStoreID: 123, keywords: ["voice notes", "VOICE NOTES", "unknown"], storefronts: ["US"], platform: "iphone") + #expect(result.summary.removed == 1) + #expect(result.summary.skipped == 1) + #expect(result.removed == [.init(keyword: "Voice Notes", storefront: "us", platform: "iphone")]) + let db = ModelContext(context.container) + #expect(try db.fetchCount(FetchDescriptor()) == 3) + #expect(try db.fetchCount(FetchDescriptor()) == 1) + #expect(try db.fetchCount(FetchDescriptor()) == 3) + #expect(try db.fetchCount(FetchDescriptor()) == 0) + #expect(try db.fetchCount(FetchDescriptor()) == 0) + #expect(try db.fetchCount(FetchDescriptor()) == 0) + #expect(try db.fetchCount(FetchDescriptor()) == 0) + #expect(try db.fetchCount(FetchDescriptor()) == 0) + let repeated = try await context.service.removeKeywords(appStoreID: 123, keywords: ["voice notes"], storefronts: ["us"]) + #expect(repeated.summary.removed == 0) + #expect(repeated.summary.skipped == 1) + _ = try await context.service.addKeywords(appStoreID: 123, keywords: ["Voice Notes"], storefronts: ["us"]) + try await context.backgroundModelStore.write { db in + let track = try #require(db.fetch(FetchDescriptor(predicate: #Predicate { $0.identityKey == removedIdentity })).first) + db.insert(TrackedKeywordDailyRanking(rank: 1, searchedAt: isoDate("2026-05-07T13:00:00Z"), source: .appStoreWeb, resultCount: 10, keywordTrack: track)) + } + let readded = ModelContext(context.container) + #expect(try readded.fetch(FetchDescriptor()).first?.rank == 1) + await #expect(throws: OpenASOError.self) { + _ = try await context.service.removeKeywords(appStoreID: 123, keywords: [], storefronts: ["us"]) + } + #expect(try readded.fetchCount(FetchDescriptor()) == 4) + } + + @Test + func legacyMutationSummariesDecodeWithoutRemovedCount() throws { + let data = Data(#"{"inserted":1,"updated":0,"skipped":0,"refreshed":0,"failed":0}"#.utf8) + let summary = try JSONDecoder().decode(OpenASOMCPMutationSummary.self, from: data) + #expect(summary.removed == 0) + #expect(try JSONDecoder().decode(OpenASOMCPMutationSummary.self, from: JSONEncoder().encode(summary)) == summary) + } + @Test func listAppsHandlesEmptyWorkspace() async throws { let service = try makeService() @@ -305,20 +363,23 @@ struct OpenASOMCPServiceTests { func scoreKeywordsClassifiesTrackedKeywordsForTriage() async throws { let context = try MCPTestContext() let storeApp = try context.insertTrackedApp(appStoreID: 123, name: "ScreenZen").storeApp + try context.insertMetadata(for: storeApp, storefront: "us", description: "Screen time control and focus tools") + storeApp.storefrontMetadata.first?.servedLanguageCode = "en-US" let screenTime = try context.insertKeyword("screen time control", trackedApp: storeApp, storefront: "us") - _ = try context.insertKeyword("after certain", trackedApp: storeApp, storefront: "us") + _ = try context.insertKeyword("the to", trackedApp: storeApp, storefront: "us") let brand = try context.insertKeyword("screenzen screen time", trackedApp: storeApp, storefront: "us") try context.insertRankingCrawl( keyword: screenTime.term, query: screenTime.query, storefront: "us", observedAt: isoDate("2026-05-01T10:00:00Z"), + source: .appStoreWeb, rows: [ RankingRow(position: 1, appStoreID: 123, name: "ScreenZen"), RankingRow(position: 2, appStoreID: 456, name: "Competitor") ] ) - try context.insertDailyRanking(track: screenTime, rank: 1, resultCount: 2) + try context.insertDailyRanking(track: screenTime, rank: 1, resultCount: 2, source: .appStoreWeb) try context.insertRankingCrawl( keyword: brand.term, query: brand.query, @@ -339,7 +400,7 @@ struct OpenASOMCPServiceTests { #expect(result.summary.totalCount == 3) #expect(result.items.first { $0.keyword == "screen time control" }?.priority == "defend") #expect(result.items.first { $0.keyword == "screenzen screen time" }?.priority == "brand") - #expect(result.items.first { $0.keyword == "after certain" }?.priority == "noisy") + #expect(result.items.first { $0.keyword == "the to" }?.priority == "noisy") } @Test @@ -693,6 +754,193 @@ struct OpenASOMCPServiceTests { #expect(result.candidates.first?.topRatedAppCount ?? 0 > 0) } + @Test + func discoveryPopularityLookupSupportsUntrackedTermsAndKeepsMarketsSeparate() async throws { + let rows = [("voice notes", "US", 42), ("voice notes", "GB", 73)].map { term, country, score in + AppleAdsSearchTermPopularity( + searchTerm: term, countryOrRegion: country, genre: "Productivity", week: "2026-05-02", + month: nil, rankInGenre: nil, popularityInGenre: nil, popularity1to100: score, popularity1to5: nil + ) + } + let context = try MCPTestContext( + includeKeywordMetricsService: true, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: rows), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + let result = try await context.service.getKeywordPopularity( + keywords: ["voice notes", "unknown", "VOICE NOTES"], storefronts: ["us", "gb"] + ) + #expect(result.items.count == 4) + #expect(result.items.first { $0.keyword == "voice notes" && $0.storefront == "us" }?.popularityScore == 42) + #expect(result.items.first { $0.keyword == "voice notes" && $0.storefront == "gb" }?.popularityScore == 73) + #expect(result.items.filter { $0.keyword == "unknown" }.allSatisfy { + $0.popularityScore == nil && $0.source == nil && $0.unavailableReason != nil + }) + #expect(result.items.filter { $0.keyword == "voice notes" }.allSatisfy { $0.source == "apple_ads_platform" }) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 0) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 0) + } + + @Test + func suggestionsPrioritizeFreshMarketPopularityBeforeContextScoreAndRankBudget() async throws { + let rows = [("useful phrase", 65), ("floor phrase", 5)].map { term, score in + AppleAdsSearchTermPopularity( + searchTerm: term, countryOrRegion: "US", genre: "Productivity", week: "2026-05-02", + month: nil, rankInGenre: nil, popularityInGenre: nil, popularity1to100: score, popularity1to5: nil + ) + } + let context = try MCPTestContext( + rankingProvider: StubMCPRankingProvider(pages: [:]), + includeKeywordMetricsService: true, + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], popularityRows: rows, keywordSuggestionRows: [ + "us": [.init(keyword: "floor phrase", suggestionScore: 99), + .init(keyword: "unknown phrase", suggestionScore: 80), + .init(keyword: "useful phrase", suggestionScore: 6)] + ]), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + try context.insertTrackedApp(appStoreID: 123, name: "Utter") + _ = try await context.service.addKeywords(appStoreID: 123, keywords: ["floor phrase"], storefronts: ["us"]) + let result = try await context.service.suggestKeywords( + appStoreID: 123, storefronts: ["us"], limit: 3, rankingLimit: 1 + ) + #expect(result.appleAdsSuggestions.map(\.keyword) == ["useful phrase", "floor phrase", "unknown phrase"]) + #expect(result.appleAdsSuggestions.first?.suggestionScore == 6) + #expect(result.appleAdsSuggestions.first?.popularity?.popularityScore == 65) + #expect(result.appleAdsSuggestions.first { $0.keyword == "floor phrase" }?.isTracked == true) + #expect(result.candidates.map(\.keyword) == ["useful phrase"]) + #expect(result.candidates.first?.popularityScore == 65) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 1) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 0) + } + + @Test + func discoveryPagesKeepOffsetsAndCanSkipRankingEntirely() async throws { + let context = try MCPTestContext( + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], keywordSuggestionRows: [ + "us": (0..<5).map { .init(keyword: "phrase \($0)", suggestionScore: 10) } + ]), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + try context.insertStoreApp(appStoreID: 123, name: "Utter") + let page = try await context.service.suggestKeywords( + appStoreID: 123, storefronts: ["us"], limit: 2, seedKeyword: "voice", offset: 2, rankingLimit: 0 + ) + #expect(page.appleAdsSuggestions.map(\.keyword) == ["phrase 2", "phrase 3"]) + #expect(page.suggestionPages.first?.offset == 2) + #expect(page.suggestionPages.first?.nextOffset == 4) + #expect(page.candidates.isEmpty) + #expect(page.errors.isEmpty) + let lastPage = try await context.service.suggestKeywords( + appStoreID: 123, storefronts: ["us"], limit: 2, offset: 4, rankingLimit: 0 + ) + #expect(lastPage.appleAdsSuggestions.map(\.keyword) == ["phrase 4"]) + #expect(lastPage.suggestionPages.first?.nextOffset == nil) + } + + @Test + func compactDiscoveryPreservesMetadataFallbackWithoutCredentials() async throws { + let context = try MCPTestContext() + try context.insertStoreApp(appStoreID: 123, name: "Focus Timer") + let result = try await context.service.suggestKeywords(appStoreID: 123, rankingLimit: 0) + #expect(result.appleAdsSuggestions.isEmpty) + #expect(result.metadataSeeds.contains("focus timer")) + #expect(result.candidates.isEmpty) + #expect(result.notes.contains { $0.contains("credentials are unavailable") }) + } + + @Test + func discoveryPopularityProviderFailurePreservesUnknownCandidates() async throws { + let context = try MCPTestContext( + includeKeywordMetricsService: true, + appleAdsPlatformAPI: FailingSearchPopularityAPI(error: .rateLimited), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + let result = try await context.service.getKeywordPopularity(keywords: ["voice notes"]) + #expect(result.items.first?.popularityScore == nil) + #expect(result.items.first?.unavailableReason != nil) + #expect(result.providerErrors.first?.error.code == "rate_limited") + } + + @Test + func suggestKeywordsAutomaticallyUsesSavedAppleAdsCredentialsAndKeepsScoresSeparate() async throws { + let api = StaticAppleAdsPlatformAPI(apps: [], keywordSuggestionRows: [ + "us": [.init(keyword: "voice notes", suggestionScore: 0), .init(keyword: "dictation", suggestionScore: nil)] + ]) + let context = try MCPTestContext( + rankingProvider: StubMCPRankingProvider(pages: [:]), + appleAdsPlatformAPI: api, + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private", adAccountID: "123") + ) + try context.insertStoreApp(appStoreID: 123, name: "Utter") + + let result = try await context.service.suggestKeywords( + appStoreID: 123, storefronts: ["us"], limit: 10, seedKeyword: "voice" + ) + + #expect(result.appleAdsSuggestions.map(\.keyword) == ["voice notes", "dictation"]) + #expect(result.appleAdsSuggestions.first?.suggestionScore == 0) + #expect(result.appleAdsSuggestions.last?.suggestionScore == nil) + #expect(result.appleAdsSuggestions.allSatisfy { $0.seedKeyword == "voice" && $0.requestedStorefront == "us" }) + #expect(result.candidates.contains { $0.keyword == "voice notes" && $0.sources.contains("apple_ads_keyword_suggestions") }) + #expect(result.candidates.allSatisfy { $0.popularityScore == nil }) + #expect(result.providerErrors.isEmpty) + #expect(try context.modelContext.fetchCount(FetchDescriptor()) == 0) + } + + @Test + func suggestKeywordsKeepsMetadataWhenAppleAdsFailsAndReportsProviderError() async throws { + let context = try MCPTestContext( + rankingProvider: StubMCPRankingProvider(pages: [:]), + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], keywordSuggestionErrors: ["us": .rateLimited]), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + try context.insertStoreApp(appStoreID: 123, name: "Focus Timer") + + let result = try await context.service.suggestKeywords(appStoreID: 123, storefronts: ["us"]) + + #expect(!result.candidates.isEmpty) + #expect(result.appleAdsSuggestions.isEmpty) + #expect(result.providerErrors.first?.storefront == "us") + #expect(result.providerErrors.first?.error.code == "rate_limited") + } + + @Test + func suggestKeywordsDoesNotUseAPIWithoutCredentials() async throws { + let context = try MCPTestContext( + rankingProvider: StubMCPRankingProvider(pages: [:]), + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], keywordSuggestionErrors: ["us": .rateLimited]) + ) + try context.insertStoreApp(appStoreID: 123, name: "Focus Timer") + + let result = try await context.service.suggestKeywords(appStoreID: 123, storefronts: ["us"]) + + #expect(!result.candidates.isEmpty) + #expect(result.providerErrors.isEmpty) + #expect(result.appleAdsSuggestions.isEmpty) + #expect(result.notes.contains { $0.contains("credentials are unavailable") }) + } + + @Test + func appleAdsOnlySeedsAreVerifiedInTheirRequestedStorefront() async throws { + let context = try MCPTestContext( + rankingProvider: StubMCPRankingProvider(pages: [:]), + appleAdsPlatformAPI: StaticAppleAdsPlatformAPI(apps: [], keywordSuggestionRows: [ + "us": [.init(keyword: "voice notes", suggestionScore: 10)], + "gb": [.init(keyword: "dictation", suggestionScore: 20)] + ]), + appleAdsCredentials: .init(clientID: "client", teamID: "team", keyID: "key", privateKey: "private") + ) + try context.insertStoreApp(appStoreID: 123, name: "Utter") + + let result = try await context.service.suggestKeywords(appStoreID: 123, storefronts: ["us", "gb"]) + + #expect(result.candidates.contains { $0.keyword == "voice notes" && $0.storefront == "us" }) + #expect(!result.candidates.contains { $0.keyword == "voice notes" && $0.storefront == "gb" }) + #expect(result.candidates.contains { $0.keyword == "dictation" && $0.storefront == "gb" }) + #expect(!result.candidates.contains { $0.keyword == "dictation" && $0.storefront == "us" }) + } + @Test func suggestKeywordsKeepsPartialResultsWhenOneSeedFails() async throws { let rankingProvider = StubMCPRankingProvider( diff --git a/OpenASOTests/RankingRefreshCoordinatorTests.swift b/OpenASOTests/RankingRefreshCoordinatorTests.swift index 8b81d42..f0ebfc3 100644 --- a/OpenASOTests/RankingRefreshCoordinatorTests.swift +++ b/OpenASOTests/RankingRefreshCoordinatorTests.swift @@ -1808,6 +1808,25 @@ struct AppDetailRefreshServiceQueueTests { #expect(await recorder.activeRunCount() == 0) } + @Test + func manualStopReturnsCancellationWithoutARefreshFailure() async throws { + let progress = AppRefreshProgressStore() + let recorder = RefreshMetricsRecorder(clock: .appDetailTestConstant) + let http = ControlledRatingsHTTPClient() + let fixture = try makeAppDetailRefreshQueueFixture(appStoreIDs: [1], httpClient: http, + progressStore: progress, recorder: recorder) + let task = Task { await fixture.service.refresh(Self.request(appStoreID: 1)) } + defer { task.cancel() } + try await http.waitForRequestCount(1) + task.cancel() + let result = await task.value + #expect(result.wasCancelled) + #expect(result.firstError == nil) + #expect(progress.activeRefresh == nil) + #expect(progress.pendingAppRefreshCount == 0) + #expect(await http.cancellationCount() == 1) + } + @Test func activeCancellationCancelsTransportAndCleansProgressAndObservation() async throws { let progressStore = AppRefreshProgressStore() diff --git a/README.md b/README.md index d91c912..bdc8e64 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,45 @@ Anonymous analytics are enabled by default and can be disabled at any time in Se OpenASO stores app, keyword, rating, review, and metric data locally with SwiftData. CSV export is the primary path for using that data outside the app, especially for AI-assisted review analysis, keyword research, reporting, or spreadsheets. +### MCP Keyword Suggestions + +The `suggest_keywords` tool automatically queries Apple's official keyword suggestion API when saved Apple Ads API credentials are available. It combines those candidates with app metadata and optional website text, then checks a bounded sample against live App Store rankings. Without API access, or when an Apple request fails, metadata and website suggestions remain available; `providerErrors` reports failed Apple requests separately from ranking errors. + +Pass `seed_keyword` to explore a specific topic, for example: + +```json +{ + "appStoreID": 123456789, + "storefronts": ["us"], + "seed_keyword": "speech to text", + "limit": 20 +} +``` + +The `appleAdsSuggestions` array includes the returned keyword, contextual `suggestionScore`, requested storefront, seed, observation time, and `isTracked`. Each suggestion also carries a fresh `popularity` lookup when available, with its market score, source, time, and unavailability reason. Suggestions are ordered by market popularity before contextual score. Contextual scores can change with the seed and remain separate from market `popularityScore`. Apple does not return a country on each suggestion, so the requested storefront is not a guarantee of the contextual score's geographic scope. + +For broader discovery, use `limit: 50` and `ranking_limit: 0`, try distinct feature seeds, then branch using relevant terms with market popularity above 5. Follow `suggestionPages.nextOffset` with the same seed/limit to explore more results. `get_keyword_popularity` checks up to 50 untracked or tracked terms in three storefronts without adding tracks or persisting metrics; it uses the official API and saved web-session fallback. Missing popularity is unknown, not zero, and popularity is an index rather than exact search volume. Stop at a request budget or when successive branches stop producing useful new terms, and report remaining gaps. + +Discovery queries cover at most three storefronts per call, with at most 50 suggestions per page and offsets through 1000. Ranking verification defaults to 16 checks and can return partial results; use `get_ranked_apps_for_keyword` to verify a shortlist after expansion. `metadataSeeds` remains available when ranking checks are skipped or API credentials are unavailable. Discovery does not add tracks. Select relevant terms and call `add_keywords` to track them, which skips duplicates. Then use `refresh_keyword_metrics` with an optional `keywords` filter (at most 50 terms) to save their market popularity without refreshing every tracked term. The [ASO skill](.agents/skills/openaso-aso/SKILL.md) documents the full research workflow. + +### MCP Metadata Targeting + +Saved App Store Connect credentials support `get_app_store_metadata`, which reads an exact locale from live or editable metadata, including private keywords and version/state. Apple Ads credentials supply separate popularity evidence. Metadata reads fail on missing locales or ambiguous app-info state instead of mixing languages or versions. + +Pass a discovery shortlist to `audit_keyword_targeting` to compare fresh popularity, app-specific relevance, field coverage and bounded ranking evidence. At most 50 keywords and eight ranking checks are supported; ranking checks default to five and can be skipped with `ranking_limit: 0`. Literal phrases, word combinations, plural variants and missing components remain distinct. Relevance uses the app's metadata rather than hard-coded app-blocker terms; overlap and popularity are evidence for review, not guaranteed opportunities. + +Audits also calculate estimated competition from the same bounded native ranking sample. Results include the heuristic score, confidence, source/time, top-result rating and metadata evidence, and an explicit reason when native evidence or ratings are insufficient. Fallback and missing data never become a low-competition score. These read-only calculations do not persist difficulty or alter popularity. + +Use `compare_metadata_draft` for agent-written title/subtitle/keyword alternatives. It validates 30/30/100 character limits, highlights duplicate/filler terms, reports gained/lost coverage and accepts `expected_hash` to reject a stale baseline. All metadata reads, audits and draft comparisons leave App Store fields unchanged. + +`record_metadata_baseline` stores a private local snapshot before a metadata release. `list_metadata_baselines` locates it and `compare_metadata_baseline` compares the same keyword/market/platform set after the release. Rank deltas require known native positions in both samples; fallback and absent ranks remain unknown. Optional search impressions/downloads have caller-provided provenance and reporting periods, with no per-keyword attribution or causal lift claim. No tool publishes metadata or schedules a follow-up. + +See the [metadata targeting workflow](.agents/skills/openaso-aso/references/metadata-targeting.md) for sequencing and evidence requirements. + +`remove_keywords` retires explicitly selected tracks for one app/platform and up to three storefronts. It removes track-specific history and refresh status in one transaction while retaining shared ranking observations and query popularity. Missing tracks are skipped, and the response returns persisted terms for re-adding. Removal requires user authorization; a low popularity score alone is not a pruning instruction. + +Manual app refreshes now offer **Stop**. Stopping cancels in-flight work and skips later apps in an all-app run, keeps already saved results, and preserves queued keyword additions without immediately starting another refresh. + ### Recovering A Local Database If OpenASO cannot open its local database, it does not delete or replace the existing files and shows the database location when available. Before attempting recovery: diff --git a/research/asc-cli-opportunities-2026-10-26.md b/research/asc-cli-opportunities-2026-10-26.md new file mode 100644 index 0000000..cc05a7a --- /dev/null +++ b/research/asc-cli-opportunities-2026-10-26.md @@ -0,0 +1,60 @@ +# ASC CLI opportunities for OpenASO + +Checked 26 October 2026 against primary upstream source and Apple documentation. This is a research note; no upstream implementation is copied and no application credentials or store metadata are changed. + +## Version and scope + +The installed `asc version` reports **5.14.0**. GitHub's latest-release API reports **5.17.0**, published **25 October 2026 at 22:49:09 UTC**, with source commit `ecad9f122524248749b6c44bb01e951734131889`. The intervening releases mainly improve authentication recovery, diagnostics, security, and resource reads. The directly relevant new 5.17 feature is custom product page keyword validation. Broader keyword discovery and optimization features below also exist in the current CLI, but should not all be described as newly introduced since 5.14. [5.17.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.17.0), [5.16.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.16.0), [5.15.0 release](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.15.0). + +OpenASO's current branch already implements saved-credential keyword suggestions, seeded/paged expansion, independent market popularity, authenticated metadata snapshots, relevance and coverage audits, draft comparison, and immutable local release baselines. Acquisition counts in those baselines are caller-provided. The retained competition estimator and persistence types are a foundation that still needs production wiring; their existence does not mean current MCP audits produce fresh competition evidence. See `README.md`, `OpenASO/Services/MCP/KeywordTargetingAnalysis.swift`, and `OpenASO/Services/SearchRanking/KeywordDifficultyEstimator.swift`. + +## Recommended additions before release + +### 1. Expand discovery with official phrase suggestions + +ASC combines app keyword suggestions with **phrase suggestions**, whereas OpenASO currently calls only the keyword endpoint. Apple offers a second read-only discovery surface at `POST /v1/suggestions/phrases/query`, including app discovery and partial-text searches. This is a direct fit for finding more relevant, longer queries. [ASC discovery design](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/optimize-keywords.md), [Apple suggestion endpoints](https://developer.apple.com/documentation/apple-ads-platform-api/suggestions-endpoints). + +Implementation can reuse the pinned **Apple Ads Swift SDK v1.109.0** and saved Apple Ads account credentials. That version already exposes `queryPhraseSuggestions(headers:body:)`; a dependency upgrade is unnecessary. Discovery needs filters `queryType=SUGGESTION`, `promotedObjectId=`, and `promotedObjectType=APPSTORE_APP`. Branching can use `queryType=SEARCH` with `phrase LIKE`; exact lookup uses `phrase IN`. The search route does not need an app ID. No campaign is required. [Pinned SDK generated client](https://github.com/apple/apple-ads-platform-api-swift/blob/v1.109.0/Sources/AppleAdsClient/Generated/Client.swift), [Apple phrase query contract](https://developer.apple.com/documentation/apple-ads-platform-api/query-phrase-suggestions). + +The result has optional `phrase` and `popularity`. Neither the phrase result nor its documented filters includes a country dimension. Keep that contextual score separate from the existing country-specific popularity lookup, and pass newly discovered phrases through that lookup before recommending additions. Do not describe phrase scores as local search volume. The SDK exposes request `offset`/`pageSize` and response `offset`/`pageSize`/`totalCount`; retain independent source pagination and deduplicate across both endpoints. [SDK response and request types](https://github.com/apple/apple-ads-platform-api-swift/blob/v1.109.0/Sources/AppleAdsClient/Generated/Types.swift), [ASC source scope and bounded discovery](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/optimize/keywords_discover.go). + +Release-sized scope: one provider method, source-aware DTOs/pagination, MCP expansion and skill guidance. Bound phrase discovery independently, fetch app-wide phrases once per app/page rather than repeating them for each storefront, preserve successful keyword results if phrases fail, and omit popularity sorting because live APIs have rejected that sort despite examples suggesting it. No persistent schema change is required. [ASC observed request handling](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/ads/search_optimization.go). + +Live qualification: read-only probes for both monitored apps returned working keyword suggestions, while each app-associated phrase source returned HTTP 400 indicating that the promoted app was missing or inaccessible to the ad account. The documented app-independent `SEARCH` route then accepted four `phrase LIKE` queries (`dictation`, `voice`, `focus`, and the documented example `productivity`) but returned zero phrases in every case. The unadorned LIKE syntax matches Apple's documented example. Existing Ads credentials therefore do not guarantee useful phrase results. Preserve partial source failures and zero-result evidence; treat phrase support as experimental until it demonstrates incremental candidates for this account. It should not delay this release on an unsupported claim of better coverage. Raw probes remain outside the repository. Installed 5.14 supports `asc ads suggestions phrases find --file --ads-profile ` for these checks. + +### 2. Connect competition evidence to the shortlist and targeting audit + +The CLI evaluates candidate reachability from competitor evidence as well as demand. OpenASO already retains its own top-ten rating-authority and metadata-saturation estimator with confidence and evidence types. Wire that foundation into bounded native ranking samples, then show difficulty, confidence, sample count, source, time, and relevant competitor examples alongside popularity and metadata gaps. This helps distinguish a popular term from a practical target. This is an application analysis heuristic, not an Apple-provided difficulty metric. [ASC evaluation methodology and limitations](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/optimize-keywords.md). + +Reuse OpenASO's estimator instead of importing ASC's alternative formula. Treat absent ratings, too few results, stale samples, and iTunes fallback as explicit limitations; they must not become an easy-keyword score. Read-only audits should calculate ephemeral evidence without silently storing refresh data. Persist evidence only through an explicit refresh path. Release scope is moderate because production integration is missing even though model types exist. Avoid a database migration merely to expose a read-only audit result. + +### 3. Protect custom product page keyword assignments + +ASC 5.17 adds an advisory check for assigned custom page keywords absent from the selected app version's same-locale keyword field. Apple allows custom pages to appear for assigned keywords from the latest approved version, so a draft that removes an assigned term needs a visible warning. This extends OpenASO's current draft protection beyond title/subtitle demotion and existing native winners. [5.17 feature and correction](https://github.com/rorkai/App-Store-Connect-CLI/releases/tag/5.17.0), [Apple custom page configuration](https://developer.apple.com/help/app-store-connect/create-custom-product-pages/configure-multiple-product-page-versions). + +Use the existing App Store Connect JWT provider for a read-only resource chain: + +1. `GET /v1/apps/{app}/appCustomProductPages`. +2. `GET /v1/appCustomProductPages/{page}/appCustomProductPageVersions`. +3. `GET /v1/appCustomProductPageVersions/{version}/appCustomProductPageLocalizations`. +4. Read included search keyword relationship IDs, checking completeness; alternatively list `GET /v1/appCustomProductPageLocalizations/{localization}/searchKeywords` with pagination. + +Apple documents the [app/page resources](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-pages), [page versions](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-page-versions), [localizations](https://developer.apple.com/documentation/appstoreconnectapi/app-custom-product-page-localizations), and [keyword read](https://developer.apple.com/documentation/appstoreconnectapi/get-v1-appcustomproductpagelocalizations-_id_-searchkeywords). + +The verified ASC behavior is **iOS/iPadOS only**, excluding replaced page versions. It compares each keyword as a normalized whole phrase against comma-separated keyword entries in the same locale; title/subtitle token combinations are not a substitute. Read failures or incomplete relationships produce **unverified**, rather than a clean pass. ASC uses `limit[searchKeywords]=50` on included relationships and validates reported totals. Implement from Apple's contract rather than copying ASC code. Bound page/version/localization reads and preserve partial evidence. No page assignment, publishing, or metadata write is needed. [ASC read integration](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/validate/custom_product_pages.go), [iOS and failure guards](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/validate/readiness.go), [phrase membership check](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/validation/custom_product_pages.go). + +## Other worthwhile improvements + +| Addition | Value and scope | Authentication and limits | +| --- | --- | --- | +| Localization keyword hygiene | Add malformed separators, empty segments, configurable blocked terms, and unused-budget findings to current draft validation; add a bounded multi-locale inventory later. Repeated terms across locales are review evidence, not automatic proof of wasted indexing. | Same ASC credentials for owned fields; pure validation itself needs none. Most duplicate/filler/length checks already exist in OpenASO, so extend rather than duplicate. [ASC audit contract](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/metadata/keywords_audit.go). | +| App tag audit | Read Apple's assigned tags and visibility, explain whether tags match supported app features, and use them as relevance context. Developers can deselect assigned tags; this is not an API for inventing tags or buying rankings. | Owned-app ASC credentials. Start with read-only tag names and visibility. Territory relations are deprecated in current API coverage; do not build new targeting behavior around them. [Apple tags API](https://developer.apple.com/documentation/appstoreconnectapi/app-tags), [ASC tag support/deprecation](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/apps/app_tags_deprecation.go). | +| Automatic acquisition measurements | Replace optional manually supplied baseline counts with parsed engagement and download report data, report completeness, equal comparison windows, and acquisition-source breakdowns. Valuable, but a larger follow-up than the three bounded features above. | ASC report access and an existing/generated report request; creation is a separate remote operation. Requires downloading and parsing report segments, handling revised instances, suppression, and compatible dimensions. App Store Search includes Apple Ads, so source totals are not purely organic or per-keyword attribution. [Apple engagement report](https://developer.apple.com/documentation/analytics-reports/app-store-discovery-and-engagement), [Apple download report](https://developer.apple.com/documentation/analytics-reports/app-download). | + +## Measurement boundaries that affect recommendations + +`asc analytics compare` currently accepts **Sales** as its comparison source. `asc insights weekly --source analytics` summarizes report/instance availability and explicitly leaves business conversion unavailable; it does not parse acquisition metrics from report payloads. Copying those commands' surface alone would not deliver automatic ASO impact measurement. [Analytics comparison source](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/analytics/analytics_compare.go), [insights implementation](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/internal/cli/insights/insights.go). + +Paid search-term reports can identify queries with actual taps and installs, which is stronger paid-conversion evidence than popularity alone. ASC's optimization plan joins these reports with suggestions, targeting, and impression share. This is useful only when the account has relevant campaign history; it neither establishes organic conversion nor guarantees that a metadata edit improves ranking. Keep it a separate later tool with paid provenance. [ASC cross-API plan](https://github.com/rorkai/App-Store-Connect-CLI/blob/5.17.0/docs/design/official-search-optimization-plan.md), [Apple search-term reporting guidance](https://developer.apple.com/documentation/apple-ads-platform-api/journey-app-store-ads). + +No verified recent ASC release introduces a new country-specific popularity source that eliminates low/floor values. Phrase discovery broadens candidates; relevance, independent market-popularity verification, native ranking evidence, and competition assessment remain necessary. diff --git a/research/fork-opportunities-2026-10-26.md b/research/fork-opportunities-2026-10-26.md new file mode 100644 index 0000000..7a7e7a5 --- /dev/null +++ b/research/fork-opportunities-2026-10-26.md @@ -0,0 +1,36 @@ +# OpenASO fork review before 0.4.6 + +Reviewed 26 October 2026 against baseline `d21d334`. This records feature research, not an import of fork code. The gaps below describe that baseline. The approved follow-up implements competition evidence in audits, scoped MCP untracking, and a Stop control; tags, market comparisons, and other proposals remain future work. + +## Coverage and method + +GitHub lists **29 forks**. Their published branch lists contain **91 branches**; all were compared with upstream `main` after merging PRs #87 and #88. Eight forks contain commits outside that upstream history. Every fork reports zero child forks. The remaining 21 forks have no unique commits in the inspected branches. These counts describe published branches, not private or deleted work. [Fork inventory API](https://api.github.com/repos/hubab1/OpenASO/forks?per_page=100), [upstream fork network](https://github.com/hubab1/OpenASO/forks). + +Ahead/behind counts alone overstate useful additions: several forks retain old versions or differently integrated commits for capabilities already present in OpenASO. Review current behavior and individual changes rather than merging a fork wholesale. Raw branch/commit/file inventories are saved locally under `/tmp/openaso-fork-research-20261026`; they are not part of this note. + +## Features worth considering + +| Feature | Verified fork evidence | Current gap and recommendation | +| --- | --- | --- | +| Keyword tags and filtering | Heondo adds a tag editor, table chips, filters, CSV round trips, and MCP tag updates. [Feature commit](https://github.com/heondo/OpenASO/commit/c10829c8b34124f4d06d6cbb89afb8c737e1195a). | Useful for separating brand, core intent, experiments, rejected terms, and metadata targets. Current exports leave the tag column empty and MCP has no tag tool. Requires persistent model/UI integration and migration validation; a larger follow-up than a tool-only improvement. | +| Safe MCP untracking | Heondo adds `remove_keywords`, scoped by app/storefront/platform, with atomic batch handling and idempotent missing-track behavior. [Removal commit](https://github.com/heondo/OpenASO/commit/bf1e9875db7c7da1f45a667295b18dc3997c8cf4). | Agents can currently add but cannot retire tracked terms. A useful bounded addition: preserve shared query observations and popularity used by other apps, validate exact scope, and report removed/skipped terms. Adapt deletion to current normalized V6 ranking and V7 rating storage rather than copying older relationship cleanup. | +| Stop a manual refresh | Heondo retains the refresh task, offers Stop/Stopping states, and prevents cancelled work from creating false failures or immediately launching another refresh. [Stop commit](https://github.com/heondo/OpenASO/commit/acd69fd25b43257cd8341d7ce9ecd2d8203537df). | Current services have cancellation checks, but the manual refresh UI does not retain a user-cancellable task. Valuable before release if tested across single-app and multi-app runs. Stop must affect the operation the view owns and preserve committed results. | +| Competition evidence and country comparison | Nicremo has dormant branches for bounded country comparisons, a market detail UI, and `get_estimated_keyword_difficulty`. [MCP addition](https://github.com/nicremo/OpenASO/commit/fcc85a79b51c7ecc6fdfe2fddfc375ee514a8ef8), [market UI branch](https://github.com/nicremo/OpenASO/tree/23b-market-insights-ui). Heondo also wires a top-ten heuristic into refreshes. | OpenASO retains a provenance-aware estimator/store but has no production callers or MCP result exposure. First connect ephemeral native competition evidence to the targeting audit. Then build a bounded country matrix with missing/stale/fallback states. Missing results must not imply low competition, and popularity indices across countries are not additive market sizes. | +| Shared keyword sets | Akshaynexus adds a sidebar manager, applying shared terms to apps, and automatic all-market synchronization. [Global manager](https://github.com/akshaynexus/OpenASO/commit/9da616b88cc0fe2d48d846965289f31f521f5c51), [sync service source](https://github.com/akshaynexus/OpenASO/blob/main/OpenASO/Services/AppDetail/GlobalKeywordSync.swift). | Reusable sets are useful, but OpenASO already has research projects and explicit copying to tracked apps. Extend that workflow with named reusable sets and deliberate market selection. Automatic propagation to every app/market can pollute relevance and expand refresh work; do not adopt that behavior by default. | +| Headless and scheduled-run reliability | Heondo adds schedule read/write tools, persisted diagnostics, a longer run budget, and a process entry point that routes MCP/background work before SwiftUI/AppKit initializes. [Refresh hardening](https://github.com/heondo/OpenASO/commit/3009d84f7cc330a0102f480b943b054990e63b0c), [schedule tools](https://github.com/heondo/OpenASO/commit/4fe80266e99d175feb26cf62589cf3eea9a044fb), [headless entry fix](https://github.com/heondo/OpenASO/commit/6538c0be963a08f705dfa9c9531f96cd2e4894c0). | Current startup still enters SwiftUI and touches `NSApplication.shared` for headless modes. The fork reports that this can cause normal app launches to activate a windowless process. Reproduce in an isolated namespace before claiming the fix is necessary. Audit noninteractive Keychain behavior and background deadlines separately; do not import its broad startup/background rewrite without testing current storage preparation. | +| Apple TV keyword rankings | Wojciecholszewski's `tvos-support` branch adds a platform, storefront routing, and ranking tests. [tvOS commit](https://github.com/wojciecholszewski/OpenASO/commit/b7374a8361bc6c671547a133020bf8ee371c3c66). | Concrete new platform, but lower value for the currently researched iPhone apps. Requires checking persisted platform values, native/fallback routing, metadata platform mapping, and discovery support. Defer from a focused ASO release. | + +## Useful work already represented upstream + +- FelixLisczyk's stdio Apple Ads dependencies, Keychain failures, stale popularity status cleanup, MCP path validation, and refresh selection fairness have corresponding upstream implementations. Current HTTP routing already rejects paths other than `/mcp`. [Fork commits](https://github.com/FelixLisczyk/OpenASO/commits/main), [current runtime](../OpenASO/Services/MCP/OpenASOMCPRuntime.swift), [current controller](../OpenASO/Services/MCP/OpenASOMCPServerController.swift). +- Roeland's main branch mainly adjusts minimum sizes and screen fitting. Current root window placement and sheets already include related work. Treat individual remaining sizing differences as targeted UI checks. [Fork commits](https://github.com/roeland/OpenASO/commits/main), [current app startup](../OpenASO/App/OpenASOApp.swift). +- Many Nicremo branches reproduce storage migration, provider gating, ranking provenance, research projects, pricing, and headless foundations already represented in upstream. The market-insights and difficulty exposure branches are the useful remaining leads, rather than the complete older stack. [Fork branches](https://github.com/nicremo/OpenASO/branches), [current source](../OpenASO). +- Spencershwetz's unique commit consists of ranking-refresh artifacts; Sbenodiz's unique `pr-1-assets` branch adds a screenshot. Neither supplies a new application feature. [Artifact commit](https://github.com/spencershwetz/OpenASO/commit/470f22480e59679fb70fd21cc3d166c30e1bbcb3), [screenshot branch](https://github.com/sbenodiz/OpenASO/tree/pr-1-assets). + +## Suggested scope + +For a focused pre-release increment, prioritize **competition evidence in keyword audits**, **safe MCP untracking**, and **a Stop control for manual refreshes**. Tags and the cross-country matrix are valuable next steps but involve more persistent/UI integration. Investigate the headless launch issue as a separate release-quality check. + +For ASC-specific opportunities and live API qualifications, see [ASC CLI research](asc-cli-opportunities-2026-10-26.md). Phrase discovery is an additional candidate source, not a replacement popularity API or evidence that floor scores disappear. + +No fork code has been copied or merged during this review. Any future port should retain the relevant license notices and credit the original contributor, and must be adapted to current storage and concurrency boundaries.