Apple's top charts are one of the few genuinely useful public rankings left.
They are free, they need no key, and they update daily.
They also have one property that makes them frustrating: there is no history.
The feed shows you today. Yesterday is gone. If you want to know whether an app
climbed or slid, or which category a publisher quietly took over last month, that
data does not exist anywhere unless somebody was writing it down.
Nobody is writing it down, which means the archive is worth more than the feed.
This post covers both halves: reading the charts today, and the small amount of
work it takes to have a year of history in a year's time.
The feed
Apple's RSS feeds sit at a predictable URL per country, chart type and category.
No key, no login, and no bot wall. That is unusual enough in 2026 to be worth
saying out loud.
What is awkward is the shape. Each entry buries the app id under
id.attributes["im:id"], the price under im:price.attributes, the developer
under im:artist.label, and the icon in an array where you want the last
element. Parsing it correctly is 30 lines of nested .get() calls that you write
once and never enjoy.
I put that in an Apify Actor,
App Store Top Charts Scraper,
so I could stop rewriting it. One HTTP call runs it and returns flat rows.
import json
import os
import urllib.request
ACTOR = "glitchbound~app-charts-scraper"
API = "https://api.apify.com/v2"
def top_charts(countries, charts=("top-free",), limit=50):
payload = {
"countries": list(countries),
"charts": list(charts),
"categories": ["all"],
"limit": limit,
}
url = (f"{API}/acts/{ACTOR}/run-sync-get-dataset-items"
f"?token={os.environ['APIFY_TOKEN']}&maxTotalChargeUsd=0.50")
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
rows = json.load(r)
# A country or chart that fails comes back as a row with an `error` field
# rather than failing the run, and those are not charged. Split them out or a
# run that fetched nothing still looks successful.
return [r for r in rows if not r.get("error")]
rows = top_charts(["us", "gb", "de"], charts=["top-free"], limit=20)
for r in sorted(rows, key=lambda r: (r["country"], r["rank"])):
print(f'{r["country"]} #{r["rank"]:<3} {r["title"][:42]:42} {r["developer"][:24]}')
Real output, from an actual run, top of the German chart:
DE #1 Claude by Anthropic Anthropic PBC
DE #2 ChatGPT OpenAI OpCo, LLC
DE #3 Nect Wallet Nect
DE #4 Google Gemini Google
DE #5 EasyPark parken - Park App Easy Park AS
DE #6 AusweisApp Bund Governikus GmbH & Co. KG
DE #7 Mein dm dm-drogerie markt GmbH +
DE #8 Post & DHL Deutsche Post AG
That German top ten is itself the argument for archiving. Four of those eight are
government or logistics apps that nobody would predict from the US chart, and the
top two are AI assistants that were not in this chart at all two years ago. You
cannot see either of those facts in a single pull.
Every row also carries appId, bundleId, price, currency, isFree,
genre, releaseDate, icon and url, so a chart pull doubles as an app
metadata pull.
The part that is actually valuable
One pull tells you the ranking today, which you could have read off your phone.
The interesting queries all need two pulls separated by time:
- Which apps entered the top 20 this week
- Which publisher gained the most chart slots this month
- How long a given app has held its position
- Whether a competitor's launch actually moved anything
- Which categories are churning and which are frozen
None of that is answerable from the feed. All of it is answerable from a table
you append to daily.
So the whole trick is: run it on a schedule and never delete anything.
import datetime as dt
rows = top_charts(["us"], charts=["top-free", "top-paid"], limit=100)
stamp = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
for r in rows:
r["capturedAt"] = stamp # the feed has no timestamp; add your own
# append to wherever you keep things: Postgres, SQLite, a CSV, a dataset
On Apify you get this without writing the storage layer: put the Actor on a
Schedule and every run appends to the same dataset, each with its own timestamp.
Three months later you have something nobody sells.
One detail that matters if you build this yourself: the feed carries no
timestamp of its own. If you do not stamp each capture at write time, a year
later you have a pile of rankings and no way to order them. Stamp it on the way
in, not on the way out.
What it costs
Charged per chart entry returned. A 100-app chart is 100 results, so at $3.00 per
1,000 on the free tier a daily 100-app capture for one country is about 30 cents
a month, plus a $0.02 Actor-start event per run. Countries and chart types
multiply that, so pick the markets you actually care about rather than sweeping
all of them.
Rows that come back as errors are not charged.
Caveats worth stating plainly
- Apple only. Google Play publishes no equivalent public chart feed. Anything claiming a Play "top chart" API is scraping a rendered page, which breaks on redesign.
-
Charts are per country, per chart type, per category. An app can be absent
from
top-freewhile sitting high intop-grossing. "Not in the chart" is always relative to which chart you asked for. - Apple caps a feed at 200 entries. Beyond that the answer does not exist in the feed.
- Rankings are Apple's, computed however Apple computes them. This reads them; it does not model them.
Code
Runnable examples, including this one, are here:
github.com/danielhagever/apify-data-actors
Every example input in that repo is generated from the Actor's own input schema,
so they are valid by construction rather than by my memory of the field names.
Top comments (0)