Skip to content

Commit dc09b71

Browse files
authored
Blog post and January 2026 Top Advertisers and Ad Network Report
Blog post and January 2026 Top Advertisers and Ad Network Report
2 parents 8b5380a + 3fce140 commit dc09b71

File tree

11 files changed

+3876
-55
lines changed

11 files changed

+3876
-55
lines changed

backend/api_app/controllers/apps.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def get_search_results(state: State, search_term: str) -> AppGroupByStore:
9999

100100

101101
def create_app_country_plot_dict(app_hist: pd.DataFrame) -> pd.DataFrame:
102-
"""Create plot dicts for the app country history with linear interpolation for missing weeks.
102+
"""Create plot dicts for the app country history with linear interpolation.
103103
104104
Processes each country independently using groupby to maintain separate time series.
105105
"""
@@ -230,7 +230,7 @@ def process_country_group(group):
230230
return app_hist
231231

232232

233-
def create_app_plot_dict(app_hist: pd.DataFrame) -> pd.DataFrame:
233+
def create_app_plot_df(app_hist: pd.DataFrame) -> pd.DataFrame:
234234
"""Create plot dicts for the app history with linear interpolation for missing weeks."""
235235
star_cols = ["one_star", "two_star", "three_star", "four_star", "five_star"]
236236
cumulative_metrics = ["rating", *star_cols]
@@ -251,7 +251,7 @@ def create_app_plot_dict(app_hist: pd.DataFrame) -> pd.DataFrame:
251251
for metric in weekly_metrics:
252252
rate_of_change_metric = f"{metric}_rate_of_change"
253253
avg_per_day_metric = f"{metric}_avg_per_day"
254-
# Formula: ((new - old) / old) * 100
254+
# Formula is ((new - old) / old) * 100
255255
app_hist[rate_of_change_metric] = (
256256
app_hist[metric] / app_hist[metric].shift(1)
257257
) * 100
@@ -286,8 +286,6 @@ def create_app_plot_dict(app_hist: pd.DataFrame) -> pd.DataFrame:
286286
app_hist = app_hist.replace([np.inf, -np.inf], np.nan)
287287
# Drop columns that are all NaN
288288
app_hist = app_hist.dropna(axis="columns", how="all")
289-
if app_hist.empty:
290-
return app_hist.to_dict(orient="records")
291289
# Drop rating_avg_per_day as it's not useful (rating is an average, not cumulative)
292290
app_hist = app_hist.drop(["rating_avg_per_day"], axis=1, errors="ignore")
293291
return app_hist
@@ -488,10 +486,10 @@ async def app_global_metrics_history(
488486
logger.info(f"App global metrics history not found: {store_id}")
489487
return {}
490488

491-
hist_df = create_app_plot_dict(hist_df)
489+
hist_df = create_app_plot_df(hist_df)
492490
hist_dict = hist_df.to_dict(orient="records")
493491
duration = round((time.perf_counter() * 1000 - start), 2)
494-
logger.info(f"{self.path}/{store_id}/metrics-history took {duration}ms")
492+
logger.info(f"{self.path}/{store_id}/global-metrics-history took {duration}ms")
495493
return hist_dict
496494

497495
@get(path="/{store_id:str}/sdksoverview", cache=3600)
@@ -942,7 +940,8 @@ async def get_crossfilter_apps(self: Self, state: State, data: dict) -> dict:
942940
logger.info(
943941
f"Crossfilter query: include={len(include_domains)} domains, "
944942
f"exclude={len(exclude_domains)} domains, sdk_api={require_sdk_api}, "
945-
f"iap={require_iap}, ads={require_ads}, ranking_country={ranking_country}, date={mydate}, "
943+
f"iap={require_iap}, ads={require_ads}, "
944+
f"ranking_country={ranking_country}, date={mydate}, "
946945
f"category={category}, store={store}"
947946
)
948947

@@ -967,8 +966,8 @@ async def get_crossfilter_apps(self: Self, state: State, data: dict) -> dict:
967966
)
968967
apps_df = extend_app_icon_url(apps_df)
969968
apps_list = apps_df.to_dict(orient="records")
970-
except Exception as e:
971-
logger.exception(f"Crossfilter query failed: {e}")
969+
except Exception:
970+
logger.exception("Crossfilter query failed")
972971
apps_list = []
973972

974973
apps_dict = {"apps": apps_list}

backend/api_app/controllers/scry.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def log_umami_event(ip: str | None, url: str) -> None:
4646

4747

4848
def process_sdk_scan_request(
49-
state, store_ids: list[str], ip: str | None, user_id: int | None
49+
state: State, store_ids: list[str], ip: str | None, user_id: int | None
5050
) -> None:
5151
"""Process a sdk scan request."""
5252
url = "/api/public/sdks/apps/requestSDKScan"
@@ -84,7 +84,7 @@ async def lookup_apps(
8484
"""Lookup apps' SDKs by store_ids."""
8585
start = time.perf_counter() * 1000
8686
store_ids = data.get("store_ids", [])
87-
87+
log_info = f"Scry: lookup_apps store_ids={len(store_ids)}"
8888
df = get_apps_sdk_overview(state, store_ids=tuple(store_ids))
8989
df = df.merge(
9090
get_company_logos_df(state),
@@ -174,10 +174,10 @@ async def lookup_apps(
174174
ip = None
175175

176176
logger.info(
177-
f"Scry: store_ids:{len(success_store_ids)} found SDKs:{df.shape[0]}"
177+
f"{log_info} success_store_ids={len(success_store_ids)} SDKs={df.shape[0]}"
178178
)
179179
duration = round((time.perf_counter() * 1000 - start), 2)
180-
logger.info(f"Scry: lookup_apps response took {duration}ms")
180+
logger.info(f"{log_info} response took {duration}ms")
181181
return Response(
182182
my_dict,
183183
background=BackgroundTask(process_get_sdks, ip),
@@ -197,7 +197,8 @@ async def lookup_apps_request(
197197
else:
198198
ip = None
199199

200-
logger.info(f"Scry: store_ids:{len(store_ids)} request SDK Scan")
200+
log_info = f"Single app request store_ids={len(store_ids)}"
201+
logger.info(f"{log_info} request SDK Scan")
201202
return Response(
202203
{"status": "ok"},
203204
background=BackgroundTask(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
title: "AppGoblin added a few huge new features in past months"
3+
description: "AppGoblin is growing fast and has recently added automated SDK reporting, an app explorer dash, MAU and revenue estimates for all apps."
4+
pubDate: "March 11 2026"
5+
heroImage: "/blog-images/appgoblin_app_explorer.png"
6+
---
7+
8+
9+
## Tons of huge new features landed on AppGoblin
10+
11+
* New AppGoblin user accounts. AppGoblin added free accounts which give you access to the more detailed SDK pages, app-ads.txt, app trends and more. This is also exciting because in the future we will let you add keywords, apps etc to your account so you can follow your favorite pages easier.
12+
* MAU & Monthly Revenue estimates - This has long been the #1 feature users request and it was finally time to add it. Now on all app pages you can see a revenue estimate for each mobile app and a breakdown of their Ad Revenue and IAP. These numbers are estimates, so there is a lot of room for new ideas, let us know what you think and how they can be improved.
13+
* AppGoblin's [App Explorer](https://appgoblin.info/app-explorer) This premium tool let's you browse all 4m apps based on Monthly Installs, Company SDKs and App Store Rankings as well as a number of other metrics for filtering.
14+
* New [pricing and automated reports](https://appgoblin.info/pricing) - Premium B2B and App-Ads.txt tiers were added with automated reports generated daily for which apps use which company SDKs and app-ads.txt
15+
16+
17+
These features were all inspired by direct asks from researchers, clients and regular users. So if you have other ideas please don't hesitate to reach out on socials or email with suggestions or feedback.

frontend/src/lib/CompanyButton.svelte

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
companyDomain: string;
44
companyName?: string;
55
companyLogoUrl?: string;
6-
size?: 'sm' | 'md' | 'lg';
6+
size?: 'sm' | 'md' | 'lg' | 'logo-only';
77
}
88
99
let { companyName, companyDomain, companyLogoUrl, size = 'md' }: Props = $props();
@@ -46,4 +46,17 @@
4646
{companyName ?? companyDomain}
4747
</div>
4848
</a>
49+
{:else if size === 'logo-only'}
50+
<a href={`${baseUrl}/${companyDomain}`}>
51+
<div class="btn preset-tonal px-0 py-0 hover:preset-tonal-primary">
52+
{#if companyLogoUrl}
53+
<img
54+
src={`https://media.appgoblin.info/${companyLogoUrl}`}
55+
alt={companyLogoUrl}
56+
class="md:w-14 md:h-14 w-8 h-8 rounded-sm"
57+
loading="lazy"
58+
/>
59+
{/if}
60+
</div>
61+
</a>
4962
{/if}

frontend/src/routes/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
href="mailto:contact@appgoblin.info"
9797
class="btn preset-outlined-primary-100-900 inline-flex items-center gap-2 px-6 py-3 rounded-lg"
9898
>
99-
<span class="font-bold">Get in Touch</span>
99+
<span class="font-bold text-white">Get in Touch</span>
100100
</a>
101101
</div>
102102
<div class="text-white/80 text-sm pt-1">

frontend/src/routes/apps/[id]/ranks/+page.svelte

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,22 @@
1010
import { page } from '$app/state';
1111
let { data }: Props = $props();
1212
13+
type CountryValue = string | { langen?: string };
14+
type CountryLookup = Record<string, CountryValue>;
15+
16+
function getCountryName(countryCode: string) {
17+
const countries = data.countries as CountryLookup;
18+
const normalizedCode = countryCode.toUpperCase();
19+
const value = countries[normalizedCode] ?? countries[countryCode];
20+
21+
if (typeof value === 'string') return value;
22+
if (value && typeof value === 'object' && value.langen) return value.langen;
23+
24+
return normalizedCode;
25+
}
26+
1327
let country = $state(page.url.searchParams.get('country') || 'US');
14-
let countryTitle = $derived(data.countries[country as keyof typeof data.countries]);
28+
let countryTitle = $derived(getCountryName(country));
1529
let isLoadingRanks = $state(false);
1630
const dataMyranks = $derived(data.myranks);
1731
let overrideRanks = $state<Promise<any> | null>(null);
@@ -28,12 +42,12 @@
2842
2943
function setDefaultCountry(ranks: { countries: string[] }) {
3044
if (country == '' && ranks.countries && ranks.countries.length > 0) {
31-
country = ranks.countries[0];
45+
country = ranks.countries[0].toUpperCase();
3246
}
3347
}
3448
3549
function handleCountryChange(newCountry: string) {
36-
country = newCountry;
50+
country = newCountry.toUpperCase();
3751
// Trigger form submission
3852
if (formElement) {
3953
formElement.requestSubmit();
@@ -48,7 +62,7 @@
4862
overrideRanks = Promise.resolve(result.data);
4963
// Update URL without reload
5064
const url = new URL(window.location.href);
51-
url.searchParams.set('country', country);
65+
url.searchParams.set('country', country.toUpperCase());
5266
window.history.replaceState({}, '', url.toString());
5367
}
5468
};
@@ -62,8 +76,7 @@
6276
{#await data.myranksOverview}
6377
Loading app ranks...
6478
{:then ranks}
65-
{#if typeof ranks == 'string'}
66-
{ranks}
79+
{#if typeof ranks == 'string' || !ranks.best_ranks || ranks.best_ranks.length === 0}
6780
<p>
6881
No official ranks available for this app. This app is not ranked on the store's top 200
6982
apps for it's categories.
@@ -75,7 +88,7 @@
7588
in: {myrow.collection}
7689
{myrow.category}
7790
({countryCodeToEmoji(myrow.country)}
78-
{data.countries[myrow.country as keyof typeof data.countries]})
91+
{getCountryName(myrow.country)})
7992
</div>
8093
{/each}
8194
{#if ranks.best_ranks.length > 10}
@@ -110,16 +123,16 @@
110123
>
111124
{#if typeof ranks != 'string' && ranks.countries && ranks.countries.length > 0}
112125
{#each ranks.countries as countryCode}
113-
<option value={countryCode}
126+
<option value={countryCode.toUpperCase()}
114127
>{countryCodeToEmoji(countryCode)}
115-
{data.countries[countryCode as keyof typeof data.countries]}</option
128+
{getCountryName(countryCode)}</option
116129
>
117130
{/each}
118131
{:else}
119132
{#each Object.keys(data.countries) as countryCode}
120133
<option value={countryCode}
121134
>{countryCodeToEmoji(countryCode)}
122-
{data.countries[countryCode as keyof typeof data.countries]}</option
135+
{getCountryName(countryCode)}</option
123136
>
124137
{/each}
125138
{/if}

0 commit comments

Comments
 (0)