
Self-hosted ASO engine fetching Apple Search Ads' real keyword popularity and rankings, tracking review velocity, scoring keyword opportunities, offering discovery, web UI, REST API and MCP agent interface.
App Store Optimization radar — a self-hosted, agent-friendly ASO engine. It fetches the real keyword popularity (Apple's 0–100 search index) and ranking data from the App Store, tracks it over time, scores which keywords are actually worth targeting, and exposes all of it over a web UI, a REST API and an MCP server so an AI agent can run the keyword-optimization loop for you.
It reads the internal Apple Search Ads popularity endpoint through browser automation, snapshots popularity / rank / top-of-results per keyword into a local database, and turns that into recommendations — the useful parts of a tool like AppFigures, running locally and open source.
Currently App Store only; the architecture is multi-store (Play Store is a matter of adding the sources + a Play scorer).
⏳ Give it a few days. The scoring leans on 30-day review velocity (the best proxy for the download velocity that drives rank). Until a keyword's competitors have a couple of days of history, its verdict is
UNKNOWNand asonar keeps re-fetching it. Real verdicts emerge as the data accrues.
Bring up MySQL + RabbitMQ (the fetch queue), then run the app:
docker compose up -d # MySQL 8.4 (:3306) + RabbitMQ (:5672, mgmt :15672)
./gradlew :app:run # boots on :8080
curl localhost:8080/health # -> OKOpen http://localhost:8080 — that's the web UI (keywords, apps, and the MCP guide).
Config is namespaced ASONAR_* (so it never clashes with generic DB_* in your shell), see
app/src/main/resources/application.conf:
ASONAR_PORT (default 8080)ASONAR_DB_PROTOCOL — mysql (default, uses the compose stack) or h2 for a zero-server local file
DB under ASONAR_DB_DIR (RabbitMQ is still required for fetching)ASONAR_ASA_PROFILE_DIR — where the Apple Search Ads browser profile is kept (see below)Popularity comes from the Apple Search Ads booking endpoint, which needs a logged-in Apple session.
The first popularity fetch opens a real Chrome window (via kdriver) —
log in once; the session persists in ASONAR_ASA_PROFILE_DIR, so later fetches are headless-ish
and reuse it. Stop the app gracefully (don't pkill the Chrome) to keep the session.
http://localhost:8080
/keywords — every tracked keyword + its latest popularity; add keywords here./apps → /apps/{id} — a tracked app's ranking coverage: the opportunity recommendations,
an interactive rank-over-time chart (filter by country / Top-N / period), and the full
keyword table./apps/{id}/candidates — keyword discovery: run a pass, then tick the terms worth tracking
and hit Ajouter. Dismissed terms never come back./mcp-guide — how to connect the MCP server to Claude Code / Desktop.GET/POST /api/keywords, GET/DELETE /api/keywords/{id}, POST /api/keywords/{id}/refresh,
GET /api/keywords/{id}/popularity · /top-apps · /ranks/{appId}
GET/POST /api/apps, GET/DELETE /api/apps/{id}
GET /api/app-coverage?appId= — a tracked app's ranking coverageGET /api/keyword-opportunities?appId= — the scored recommendationsGET /api/app-ratings?... — an app's ratings history + 30-day velocityGET/POST /api/keyword-candidates?appId= — list discovered candidates / run a discovery pass,
POST /api/keyword-candidates/review — accept (start tracking) or dismiss themPOST http://localhost:8080/mcp (Streamable HTTP). Tools:
register_app, list_apps, get_app, delete_app
track_keyword, untrack_keyword, list_keywords, get_keyword, refresh_keyword
discover_keywords, list_keyword_candidates, review_keyword_candidates
get_keyword_popularity_history, get_keyword_top_apps, get_keyword_ranks, get_app_ratings
get_app_coverage, get_keyword_opportunities
Guessing keywords does not work. Measured on a real app: 208 hand-picked terms that described the product perfectly — 100 % of them at popularity 5, the floor of Apple's index. App Store search volume sits on a few broad category heads, not on the vocabulary that describes an app best.
So asonar asks Apple instead. Discovery replays Apple Search Ads' own keyword recommendations through the same authenticated session as the popularity fetch, and every suggestion comes back with its popularity — a candidate that floors is dropped before it ever costs a fetch.
instagram, snapchat…) rather than anything about your app, so a pass without seeds
returns nothing. By default asonar seeds from your best-measured tracked keywords in each market./apps/{id}/candidates), over the API, or hand the whole loop to an
agent: discover_keywords → list_keyword_candidates → review_keyword_candidates.Apple's suggestions are also the fastest way to find the foreign terms that carry volume in a non-native storefront — e.g. German terms with real French volume — which is where the walls are lowest (see Scoring).
An app is registered with a role: OWNED (one you optimize) or COMPETITOR (one you watch).
Tracking is identical either way — a fetch already records the rank of every registered app on the
store and snapshots the ratings of everything it sees — so a competitor gets its ranking and review
history for free. The role says what asonar does with it: opportunities are scored for your apps,
while competitors answer "where do they beat us" and (soon) "what terms do they index".
Each keyword is scored per app into a verdict — YES / YES_BUT / NO / RESERVE / UNKNOWN — plus a 0–100 score and a plain-language comment. The model:
5 is the floor (barely searched); below that a keyword
is parked (RESERVE) no matter how winnable.title-usage × review-strength, position-weighted (the top counts far more, and a lone but total
defender still walls it). Review-strength is the 30-day review velocity — a stale giant is
beatable, a fast climber isn't; a big old review total is deliberately not used (ratings have no
direct rank correlation). Without that velocity yet → UNKNOWN.The scorer is a pure, unit-tested function selected per store, so weights/thresholds can be re-tuned without re-fetching.
Clean architecture, Kotlin + Ktor, four Gradle modules — dependencies point inward only:
| Module | Holds | Depends on |
|---|---|---|
domain |
Models, repository interfaces, use cases (the business rules + scoring) | — |
infrastructure |
Exposed tables + repositories, DB factories, kdriver / iTunes scrapers, RabbitMQ | domain |
presentation |
Ktor HTTP layer: web routes, REST, MCP tools, serialization | domain |
app |
Ktor entrypoint, Koin wiring, configuration | all three |
Stack: Kotlin 2.3 · Ktor 3.4 · Koin 4.1 (DI) · Exposed 1.5 (ORM) · Flyway (migrations) · HikariCP · MySQL 8.4 / H2 · RabbitMQ (kourier) · OpenTelemetry · kdriver (Chrome/CDP) · kotlinx serialization/datetime/coroutines · Kover.
Snapshots are append-only; reads assemble the current picture from history. Writes are batched (one transaction per fetch) and the read paths batch-load per keyword, so it stays fast as the history grows.
The schema lives in versioned SQL scripts under infrastructure/src/commonMain/resources/db/migration,
applied by Flyway when the app boots — a failed migration stops
the boot. The Exposed tables only describe that schema to the code; nothing creates or alters a table
from Kotlin any more.
To change the schema:
./gradlew :infrastructure:generateMigrations (needs Docker). It starts a throwaway MySQL 8.4,
replays the existing scripts, diffs the result against the tables and writes the missing DDL as a
new V<timestamp>__….sql.withDefinition().Flyway runs with three options worth knowing (see Migrations.kt):
outOfOrder(true) — migrations are named by timestamp, so a branch merged after a newer one still
gets its migration applied instead of Flyway refusing to start;ignoreMigrationPatterns("*:future", "*:missing") — the previous image still boots after a rollback
or during a rolling update, when the database holds a migration it does not ship;failOnMissingLocations(true) — if db/migration is missing from the jar, the app stops instead of
booting on an empty schema.Required: merge the service files in the fat jar. flyway-core and flyway-mysql each ship
META-INF/services/org.flywaydb.core.extensibility.Plugin. Shadow (applied by the Ktor plugin) keeps
only one of them by default; Flyway then loses its core extensions and the app crashes at boot with an
NPE in PluginRegister. No test catches it, since tests do not run on the fat jar. app/build.gradle.kts
must keep:
tasks.shadowJar {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
mergeServiceFiles()
filesNotMatching("META-INF/services/**") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
}To check a fat jar: unzip -p app/build/libs/app-all.jar META-INF/services/org.flywaydb.core.extensibility.Plugin
must list the org.flywaydb.core… entries as well as the org.flywaydb.database.mysql… ones.
MigrationsTest replays every script on a fresh MySQL (Testcontainers, skipped without Docker) and
fails if a table expects something no migration creates.
A database created before Flyway (by the old SchemaUtils.create) is recorded at version 1 on its
first boot instead of being recreated. If its Apps table has no role column yet, add it first —
the statement is at the top of V1__baseline.sql.
asonar exports traces and metrics over OTLP with OpenTelemetry, configured by the standard OTEL_*
environment variables. By default it sends to localhost:4317; the quickest way to look at it locally
is grafana/otel-lgtm (Grafana on :3000):
docker run -d -p 3000:3000 -p 4317:4317 -p 4318:4318 grafana/otel-lgtmWithout a collector, set OTEL_SDK_DISABLED=true, or the exporter warns at every export interval.
asonar.keyword.fetches / asonar.keyword.fetch.duration, by store and by what was refreshed
(asonar.refreshed.ranking, asonar.refreshed.popularity; both false = the data was still fresh);asonar.popularity.reads, by asonar.result (found / missing). missing climbing is the
alert to set: on the App Store it almost always means the Apple Search Ads session expired.Everything scraped — the browser profile and fetched payloads — lives under ./data/ (or your
ASONAR_DB_DIR / ASONAR_ASA_PROFILE_DIR), which is gitignored. Nothing scraped is ever committed.
App Store Optimization radar — a self-hosted, agent-friendly ASO engine. It fetches the real keyword popularity (Apple's 0–100 search index) and ranking data from the App Store, tracks it over time, scores which keywords are actually worth targeting, and exposes all of it over a web UI, a REST API and an MCP server so an AI agent can run the keyword-optimization loop for you.
It reads the internal Apple Search Ads popularity endpoint through browser automation, snapshots popularity / rank / top-of-results per keyword into a local database, and turns that into recommendations — the useful parts of a tool like AppFigures, running locally and open source.
Currently App Store only; the architecture is multi-store (Play Store is a matter of adding the sources + a Play scorer).
⏳ Give it a few days. The scoring leans on 30-day review velocity (the best proxy for the download velocity that drives rank). Until a keyword's competitors have a couple of days of history, its verdict is
UNKNOWNand asonar keeps re-fetching it. Real verdicts emerge as the data accrues.
Bring up MySQL + RabbitMQ (the fetch queue), then run the app:
docker compose up -d # MySQL 8.4 (:3306) + RabbitMQ (:5672, mgmt :15672)
./gradlew :app:run # boots on :8080
curl localhost:8080/health # -> OKOpen http://localhost:8080 — that's the web UI (keywords, apps, and the MCP guide).
Config is namespaced ASONAR_* (so it never clashes with generic DB_* in your shell), see
app/src/main/resources/application.conf:
ASONAR_PORT (default 8080)ASONAR_DB_PROTOCOL — mysql (default, uses the compose stack) or h2 for a zero-server local file
DB under ASONAR_DB_DIR (RabbitMQ is still required for fetching)ASONAR_ASA_PROFILE_DIR — where the Apple Search Ads browser profile is kept (see below)Popularity comes from the Apple Search Ads booking endpoint, which needs a logged-in Apple session.
The first popularity fetch opens a real Chrome window (via kdriver) —
log in once; the session persists in ASONAR_ASA_PROFILE_DIR, so later fetches are headless-ish
and reuse it. Stop the app gracefully (don't pkill the Chrome) to keep the session.
http://localhost:8080
/keywords — every tracked keyword + its latest popularity; add keywords here./apps → /apps/{id} — a tracked app's ranking coverage: the opportunity recommendations,
an interactive rank-over-time chart (filter by country / Top-N / period), and the full
keyword table./apps/{id}/candidates — keyword discovery: run a pass, then tick the terms worth tracking
and hit Ajouter. Dismissed terms never come back./mcp-guide — how to connect the MCP server to Claude Code / Desktop.GET/POST /api/keywords, GET/DELETE /api/keywords/{id}, POST /api/keywords/{id}/refresh,
GET /api/keywords/{id}/popularity · /top-apps · /ranks/{appId}
GET/POST /api/apps, GET/DELETE /api/apps/{id}
GET /api/app-coverage?appId= — a tracked app's ranking coverageGET /api/keyword-opportunities?appId= — the scored recommendationsGET /api/app-ratings?... — an app's ratings history + 30-day velocityGET/POST /api/keyword-candidates?appId= — list discovered candidates / run a discovery pass,
POST /api/keyword-candidates/review — accept (start tracking) or dismiss themPOST http://localhost:8080/mcp (Streamable HTTP). Tools:
register_app, list_apps, get_app, delete_app
track_keyword, untrack_keyword, list_keywords, get_keyword, refresh_keyword
discover_keywords, list_keyword_candidates, review_keyword_candidates
get_keyword_popularity_history, get_keyword_top_apps, get_keyword_ranks, get_app_ratings
get_app_coverage, get_keyword_opportunities
Guessing keywords does not work. Measured on a real app: 208 hand-picked terms that described the product perfectly — 100 % of them at popularity 5, the floor of Apple's index. App Store search volume sits on a few broad category heads, not on the vocabulary that describes an app best.
So asonar asks Apple instead. Discovery replays Apple Search Ads' own keyword recommendations through the same authenticated session as the popularity fetch, and every suggestion comes back with its popularity — a candidate that floors is dropped before it ever costs a fetch.
instagram, snapchat…) rather than anything about your app, so a pass without seeds
returns nothing. By default asonar seeds from your best-measured tracked keywords in each market./apps/{id}/candidates), over the API, or hand the whole loop to an
agent: discover_keywords → list_keyword_candidates → review_keyword_candidates.Apple's suggestions are also the fastest way to find the foreign terms that carry volume in a non-native storefront — e.g. German terms with real French volume — which is where the walls are lowest (see Scoring).
An app is registered with a role: OWNED (one you optimize) or COMPETITOR (one you watch).
Tracking is identical either way — a fetch already records the rank of every registered app on the
store and snapshots the ratings of everything it sees — so a competitor gets its ranking and review
history for free. The role says what asonar does with it: opportunities are scored for your apps,
while competitors answer "where do they beat us" and (soon) "what terms do they index".
Each keyword is scored per app into a verdict — YES / YES_BUT / NO / RESERVE / UNKNOWN — plus a 0–100 score and a plain-language comment. The model:
5 is the floor (barely searched); below that a keyword
is parked (RESERVE) no matter how winnable.title-usage × review-strength, position-weighted (the top counts far more, and a lone but total
defender still walls it). Review-strength is the 30-day review velocity — a stale giant is
beatable, a fast climber isn't; a big old review total is deliberately not used (ratings have no
direct rank correlation). Without that velocity yet → UNKNOWN.The scorer is a pure, unit-tested function selected per store, so weights/thresholds can be re-tuned without re-fetching.
Clean architecture, Kotlin + Ktor, four Gradle modules — dependencies point inward only:
| Module | Holds | Depends on |
|---|---|---|
domain |
Models, repository interfaces, use cases (the business rules + scoring) | — |
infrastructure |
Exposed tables + repositories, DB factories, kdriver / iTunes scrapers, RabbitMQ | domain |
presentation |
Ktor HTTP layer: web routes, REST, MCP tools, serialization | domain |
app |
Ktor entrypoint, Koin wiring, configuration | all three |
Stack: Kotlin 2.3 · Ktor 3.4 · Koin 4.1 (DI) · Exposed 1.5 (ORM) · Flyway (migrations) · HikariCP · MySQL 8.4 / H2 · RabbitMQ (kourier) · OpenTelemetry · kdriver (Chrome/CDP) · kotlinx serialization/datetime/coroutines · Kover.
Snapshots are append-only; reads assemble the current picture from history. Writes are batched (one transaction per fetch) and the read paths batch-load per keyword, so it stays fast as the history grows.
The schema lives in versioned SQL scripts under infrastructure/src/commonMain/resources/db/migration,
applied by Flyway when the app boots — a failed migration stops
the boot. The Exposed tables only describe that schema to the code; nothing creates or alters a table
from Kotlin any more.
To change the schema:
./gradlew :infrastructure:generateMigrations (needs Docker). It starts a throwaway MySQL 8.4,
replays the existing scripts, diffs the result against the tables and writes the missing DDL as a
new V<timestamp>__….sql.withDefinition().Flyway runs with three options worth knowing (see Migrations.kt):
outOfOrder(true) — migrations are named by timestamp, so a branch merged after a newer one still
gets its migration applied instead of Flyway refusing to start;ignoreMigrationPatterns("*:future", "*:missing") — the previous image still boots after a rollback
or during a rolling update, when the database holds a migration it does not ship;failOnMissingLocations(true) — if db/migration is missing from the jar, the app stops instead of
booting on an empty schema.Required: merge the service files in the fat jar. flyway-core and flyway-mysql each ship
META-INF/services/org.flywaydb.core.extensibility.Plugin. Shadow (applied by the Ktor plugin) keeps
only one of them by default; Flyway then loses its core extensions and the app crashes at boot with an
NPE in PluginRegister. No test catches it, since tests do not run on the fat jar. app/build.gradle.kts
must keep:
tasks.shadowJar {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
mergeServiceFiles()
filesNotMatching("META-INF/services/**") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
}To check a fat jar: unzip -p app/build/libs/app-all.jar META-INF/services/org.flywaydb.core.extensibility.Plugin
must list the org.flywaydb.core… entries as well as the org.flywaydb.database.mysql… ones.
MigrationsTest replays every script on a fresh MySQL (Testcontainers, skipped without Docker) and
fails if a table expects something no migration creates.
A database created before Flyway (by the old SchemaUtils.create) is recorded at version 1 on its
first boot instead of being recreated. If its Apps table has no role column yet, add it first —
the statement is at the top of V1__baseline.sql.
asonar exports traces and metrics over OTLP with OpenTelemetry, configured by the standard OTEL_*
environment variables. By default it sends to localhost:4317; the quickest way to look at it locally
is grafana/otel-lgtm (Grafana on :3000):
docker run -d -p 3000:3000 -p 4317:4317 -p 4318:4318 grafana/otel-lgtmWithout a collector, set OTEL_SDK_DISABLED=true, or the exporter warns at every export interval.
asonar.keyword.fetches / asonar.keyword.fetch.duration, by store and by what was refreshed
(asonar.refreshed.ranking, asonar.refreshed.popularity; both false = the data was still fresh);asonar.popularity.reads, by asonar.result (found / missing). missing climbing is the
alert to set: on the App Store it almost always means the Apple Search Ads session expired.Everything scraped — the browser profile and fetched payloads — lives under ./data/ (or your
ASONAR_DB_DIR / ASONAR_ASA_PROFILE_DIR), which is gitignored. Nothing scraped is ever committed.