1import gplay from 'google-play-scraper';
2import { request, sleep } from './http.js';
3
4
5
6
7export function parseApp(raw) {
8 const s = String(raw).trim();
9 let m;
10 if ((m = s.match(/play\.google\.com\/store\/apps\/details\?(?:.*&)?id=([\w.]+)/))) return { store: 'google', id: m[1] };
11 if ((m = s.match(/apps\.apple\.com\/.*\/id(\d+)/)) || (m = s.match(/itunes\.apple\.com\/.*\/id(\d+)/))) return { store: 'apple', id: m[1] };
12 if ((m = s.match(/^(?:id)?(\d{6,12})$/))) return { store: 'apple', id: m[1] };
13 if (/^[a-zA-Z][\w]*(\.[\w]+){1,}$/.test(s)) return { store: 'google', id: s };
14 return { name: s };
15}
16
17
18
19export async function googleApp(id, { country, lang }) {
20 const a = await gplay.app({ appId: id, country, lang });
21 return {
22 store: 'google',
23 appId: a.appId,
24 title: a.title,
25 developer: a.developer,
26 developerEmail: a.developerEmail ?? null,
27 developerWebsite: a.developerWebsite ?? null,
28 rating: a.score ?? null,
29 ratingsCount: a.ratings ?? null,
30 reviewsCount: a.reviews ?? null,
31 histogram: a.histogram ?? null,
32 installs: a.installs ?? null,
33 minInstalls: a.minInstalls ?? null,
34 price: a.price ?? 0,
35 free: a.free ?? null,
36 currency: a.currency ?? null,
37 genre: a.genre ?? null,
38 contentRating: a.contentRating ?? null,
39 version: a.version ?? null,
40 released: a.released ?? null,
41 updatedAt: a.updated ? new Date(a.updated).toISOString() : null,
42 summary: a.summary ?? null,
43 description: a.description ?? null,
44 icon: a.icon ?? null,
45 url: a.url ?? `https://play.google.com/store/apps/details?id=${id}`,
46 country,
47 };
48}
49
50export async function googleSearch(term, { country, lang }) {
51 const r = await gplay.search({ term, num: 5, country, lang });
52 return r[0]?.appId ?? null;
53}
54
55const G_SORT = { newest: gplay.sort.NEWEST, relevant: gplay.sort.HELPFULNESS, rating: gplay.sort.RATING };
56
57
58export async function* googleReviews(id, { country, lang, sort, max }) {
59 let token = null;
60 let got = 0;
61 for (let page = 0; page < 1000 && got < max; page++) {
62 let r;
63 for (let attempt = 0; attempt < 3; attempt++) {
64 try {
65 r = await gplay.reviews({ appId: id, country, lang, sort: G_SORT[sort] ?? gplay.sort.NEWEST, num: 150, paginate: true, nextPaginationToken: token });
66 break;
67 } catch (err) {
68 if (attempt === 2) throw err;
69 await sleep(2000 * (attempt + 1));
70 }
71 }
72 const data = r?.data ?? [];
73 for (const x of data) {
74 yield {
75 store: 'google',
76 reviewId: x.id,
77 rating: x.score,
78 title: x.title ?? null,
79 text: x.text ?? null,
80 date: x.date ? new Date(x.date).toISOString() : null,
81 appVersion: x.version ?? null,
82 thumbsUp: x.thumbsUp ?? 0,
83 replyText: x.replyText ?? null,
84 replyDate: x.replyDate ? new Date(x.replyDate).toISOString() : null,
85 userName: x.userName ?? null,
86 userImage: x.userImage ?? null,
87 url: x.url ?? null,
88 country,
89 language: lang,
90 };
91 if (++got >= max) return;
92 }
93 token = r?.nextPaginationToken;
94 if (!token || !data.length) return;
95 }
96}
97
98
99
100export async function appleApp(id, { country }) {
101 const d = await request(`https://itunes.apple.com/lookup?id=${id}&country=${country}&entity=software`);
102 const a = d.results?.[0];
103 if (!a) throw new Error(`App ${id} not found in the ${country.toUpperCase()} App Store`);
104 return {
105 store: 'apple',
106 appId: String(a.trackId),
107 bundleId: a.bundleId ?? null,
108 title: a.trackName,
109 developer: a.artistName ?? a.sellerName ?? null,
110 developerWebsite: a.sellerUrl ?? null,
111 rating: a.averageUserRating ?? null,
112 ratingsCount: a.userRatingCount ?? null,
113 ratingCurrentVersion: a.averageUserRatingForCurrentVersion ?? null,
114 price: a.price ?? 0,
115 free: a.price === 0,
116 currency: a.currency ?? null,
117 genre: a.primaryGenreName ?? null,
118 genres: a.genres ?? [],
119 contentRating: a.contentAdvisoryRating ?? null,
120 version: a.version ?? null,
121 released: a.releaseDate ?? null,
122 updatedAt: a.currentVersionReleaseDate ?? null,
123 releaseNotes: a.releaseNotes ?? null,
124 description: a.description ?? null,
125 sizeBytes: a.fileSizeBytes ? Number(a.fileSizeBytes) : null,
126 minimumOsVersion: a.minimumOsVersion ?? null,
127 languages: a.languageCodesISO2A ?? [],
128 icon: a.artworkUrl512 ?? a.artworkUrl100 ?? null,
129 url: a.trackViewUrl,
130 country,
131 };
132}
133
134export async function appleSearch(term, { country }) {
135 const d = await request(`https://itunes.apple.com/search?term=${encodeURIComponent(term)}&entity=software&country=${country}&limit=5`);
136 return d.results?.[0]?.trackId ? String(d.results[0].trackId) : null;
137}
138
139
140
141
142
143export async function* appleReviews(id, { country, sort, max }) {
144 const sortBy = sort === 'relevant' ? 'mostHelpful' : 'mostRecent';
145 const variants = (p) => [
146 `https://itunes.apple.com/${country}/rss/customerreviews/page=${p}/id=${id}/sortBy=${sortBy}/json`,
147 `https://itunes.apple.com/${country}/rss/customerreviews/page=${p}/id=${id}/sortby=${sortBy.toLowerCase()}/json`,
148 `https://itunes.apple.com/${country}/rss/customerreviews/id=${id}/page=${p}/sortby=${sortBy.toLowerCase()}/json`,
149 ];
150 let got = 0;
151 let emptyStreak = 0;
152 const seen = new Set();
153 for (let p = 1; p <= 10 && got < max; p++) {
154 let entries = [];
155 for (const url of variants(p)) {
156 try {
157 const d = await request(url, { retries: 1 });
158 const e = d?.feed?.entry;
159 entries = Array.isArray(e) ? e : e ? [e] : [];
160 if (entries.length) break;
161 } catch { }
162 }
163 entries = entries.filter((e) => e['im:rating']);
164 if (!entries.length) {
165 if (++emptyStreak >= 2) return;
166 continue;
167 }
168 emptyStreak = 0;
169 for (const e of entries) {
170 const rid = e.id?.label;
171 if (seen.has(rid)) continue;
172 seen.add(rid);
173 yield {
174 store: 'apple',
175 reviewId: rid,
176 rating: Number(e['im:rating']?.label),
177 title: e.title?.label ?? null,
178 text: e.content?.label ?? null,
179 date: e.updated?.label ? new Date(e.updated.label).toISOString() : null,
180 appVersion: e['im:version']?.label ?? null,
181 thumbsUp: Number(e['im:voteSum']?.label ?? 0),
182 replyText: null,
183 replyDate: null,
184 userName: e.author?.name?.label ?? null,
185 userImage: null,
186 url: `https://apps.apple.com/${country}/app/id${id}?see-all=reviews`,
187 country,
188 language: null,
189 };
190 if (++got >= max) return;
191 }
192 }
193}