The Hidden Cost of App Store Optimization
App Store Optimization (ASO) is not a set-it-and-forget-it channel. Keyword rankings shift daily as competitors update their metadata, run ad campaigns, and release new versions. For a game like CCFish, manually tracking keyword positions across the US, UK, Japan, and Korea app stores means hours of repetitive work every week. We decided to automate it.
This is the story of how we built a cron-driven keyword tracking pipeline that monitors CCFish rankings in real-time, alerts us to drops, and measures the impact of metadata changes — all with zero monthly subscription fees.
The Pipeline: Cron + Playwright + D1
Our ASO pipeline runs as a Cloudflare Cron Trigger every 6 hours:
```
[Cron Trigger] → Playwright Worker scrapes keyword rankings
↓
D1 stores position snapshots
↓
Another Worker renders ASO dashboard
↓
Telegram alert if rank drops 5+ spots
```
Step 1: Keyword Selection
We maintain a keyword list in D1 with priority scores:
```sql
CREATE TABLE aso_keywords (
id INTEGER PRIMARY KEY,
keyword TEXT NOT NULL,
store TEXT NOT NULL, -- 'app_store' or 'google_play'
country TEXT NOT NULL, -- 'US', 'JP', 'KR', 'GB'
priority INTEGER DEFAULT 1, -- 1-5 scale
our_app_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
INSERT INTO aso_keywords (keyword, store, country, priority) VALUES
('fish game', 'app_store', 'US', 5),
('fishing slots', 'app_store', 'US', 4),
('casino fish', 'app_store', 'JP', 3),
('slot fish', 'google_play', 'KR', 4);
```
We currently track ~50 keywords across 4 countries and 2 stores.
Step 2: Playwright Scraping
Cloudflare Workers do not support Playwright natively, so we run the scraper on a cheap VPS ($5/month) triggered by cron:
```python
from playwright.sync_api import sync_playwright
import json, sqlite3
def scrape_apple_keyword(device, keyword):
url = f"https://apps.apple.com/{device['country']}/search?term={keyword}"
page.goto(url)
page.wait_for_selector(".results")
results = page.query_selector_all(".result")
for i, result in enumerate(results[:20]):
app_id = result.get_attribute("data-metrics-click-action-item-id")
if app_id == device["our_app_id"]:
return i + 1 # 1-indexed position
return None # Not found in top 20
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
position = scrape_apple_keyword({
"country": "us",
"our_app_id": "ccfish-app-id"
}, "fish game")
print(json.dumps({"keyword": "fish game", "position": position}))
```
We upload results to D1 via the Worker API.
Step 3: D1 Storage and Trend Analysis
Each scrape produces a snapshot:
```sql
CREATE TABLE aso_rankings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword_id INTEGER REFERENCES aso_keywords(id),
position INTEGER,
scraped_at TEXT DEFAULT (datetime('now'))
);
-- Get trend over 7 days
SELECT k.keyword, r.position, r.scraped_at
FROM aso_rankings r
JOIN aso_keywords k ON r.keyword_id = k.id
WHERE k.keyword = 'fish game' AND k.country = 'US'
AND r.scraped_at > datetime('now', '-7 days')
ORDER BY r.scraped_at ASC;
```
Step 4: Telegram Alerts
When a keyword drops more than 5 positions from its 7-day average, a Telegram bot fires a warning:
```
⚠️ ASO Alert: CCFish dropped 8 positions
Keyword: "fish game" (US App Store)
Current: #12 → Yesterday: #4
Possible causes:
- Competitor "Fish Frenzy" updated metadata
- New keyword bid from ad campaign
- Seasonal drift (check Google Trends)
```
This lets us react within hours instead of discovering the drop weeks later during a manual check.
What We Learned
1. Metadata Changes Have Measurable Impact
In one test, we changed CCFish subtitle from "Catch Fish" to "Fish Slots Casino" and tracked the keyword "fish slots" move from #18 to #6 in 48 hours. Without automated tracking, we would have attributed the lift to the wrong cause.
2. Weekend Rankings Are Noise
App Store rankings fluctuate wildly on weekends. We now weight weekday scrapes at 3x weekend scrapes in our trend calculations. This prevents false-positive alerts.
3. Country-Specific Keywords Matter
Searches that work in the US fail in Japan. Our keyword list is split 50/50 between English and localized keywords. The cron-driven approach lets us expand keyword coverage without adding manual work.
Cost Breakdown
| Component | Monthly Cost |
|-----------|-------------|
| VPS (Playwright host) | $5 |
| D1 storage | $0 (free tier) |
| Telegram bot | $0 |
| Cloudflare Cron Triggers | $0 |
| **Total** | **$5/month** |
Compare to ASO tools charging $50-$500/month for the same functionality.
Dashboard: Server-Rendered in Workers
We built a simple server-rendered dashboard that runs on Cloudflare Workers:
```typescript
export const onRequest: PagesFunction = async (context) => {
const { env } = context;
const rankings = await env.DB.prepare(`
SELECT k.keyword, k.country, k.store, r.position, r.scraped_at
FROM aso_rankings r
JOIN (
SELECT keyword_id, MAX(scraped_at) as max_scraped
FROM aso_rankings
GROUP BY keyword_id
) latest ON r.keyword_id = latest.keyword_id AND r.scraped_at = latest.max_scraped
JOIN aso_keywords k ON r.keyword_id = k.id
ORDER BY r.position ASC
`).all();
const html = rankings.results.map(row => `
<tr>
<td>${row.keyword}</td>
<td>${row.country}</td>
<td class="rank-${row.position <= 10 ? 'green' : row.position <= 20 ? 'yellow' : 'red'}">
#${row.position}
</td>
<td>${row.scraped_at}</td>
</tr>
`).join("");
return new Response(`<!DOCTYPE html><html>...${html}...</html>`, {
headers: { "Content-Type": "text/html" }
});
};
```
The dashboard is nothing fancy — just color-coded table rows — but it tells us in 3 seconds what used to take 30 minutes of manual checking.
The Dev+Marketing Takeaway
ASO is a data game masquerading as a creative one. The studios that win are the ones that can measure the impact of every metadata change, respond to competitor moves in hours not weeks, and expand keyword coverage without scaling headcount. Our cron-driven pipeline cost $5 to build and runs itself. If you are managing a mobile game without automated ASO tracking, you are leaving installs on the table.