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.