mobbin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/mobbin-cli.js +1297 -0
- package/mobbin-sdk.js +203 -0
- package/package.json +29 -0
package/mobbin-cli.js
ADDED
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { MobbinClient, buildContentAppsPayload } from './mobbin-sdk.js';
|
|
11
|
+
|
|
12
|
+
const AUTH_LIKELY_COMMANDS = new Set([
|
|
13
|
+
'search',
|
|
14
|
+
'popular-apps',
|
|
15
|
+
'app-versions',
|
|
16
|
+
'screen-info',
|
|
17
|
+
'recent-searches',
|
|
18
|
+
'content-apps',
|
|
19
|
+
'call',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
23
|
+
|
|
24
|
+
function toCamelCaseFlag(kebab) {
|
|
25
|
+
return kebab.replace(/-([a-z0-9])/gi, (_, ch) => ch.toUpperCase());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tryReadTextFile(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeCliArgs(argv) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let afterDoubleDash = false;
|
|
39
|
+
|
|
40
|
+
for (const arg of argv) {
|
|
41
|
+
if (afterDoubleDash) {
|
|
42
|
+
out.push(arg);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (arg === '--') {
|
|
46
|
+
out.push(arg);
|
|
47
|
+
afterDoubleDash = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!arg.startsWith('--') || !arg.includes('-')) {
|
|
51
|
+
out.push(arg);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const [flag, value] = arg.split('=', 2);
|
|
56
|
+
const normalizedFlag = `--${toCamelCaseFlag(flag.slice(2))}`;
|
|
57
|
+
out.push(value === undefined ? normalizedFlag : `${normalizedFlag}=${value}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readCookieFile(filePath) {
|
|
64
|
+
const raw = (tryReadTextFile(filePath) || '').trim();
|
|
65
|
+
if (!raw) return '';
|
|
66
|
+
|
|
67
|
+
if (raw.startsWith('{') || raw.startsWith('[')) {
|
|
68
|
+
try {
|
|
69
|
+
const json = JSON.parse(raw);
|
|
70
|
+
const cookies = Array.isArray(json) ? json : json.cookies;
|
|
71
|
+
if (Array.isArray(cookies)) {
|
|
72
|
+
const pairs = cookies
|
|
73
|
+
.filter((c) => {
|
|
74
|
+
const domain = c.domain || c.host || c.hostKey || c.host_key || '';
|
|
75
|
+
return String(domain).includes('mobbin.com');
|
|
76
|
+
})
|
|
77
|
+
.map((c) => {
|
|
78
|
+
const name = c.name || c.Name || '';
|
|
79
|
+
const value = c.value || c.Value || '';
|
|
80
|
+
return `${name}=${value}`;
|
|
81
|
+
})
|
|
82
|
+
.filter((pair) => pair !== '=');
|
|
83
|
+
return pairs.join('; ');
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (raw.startsWith('# Netscape')) {
|
|
91
|
+
const pairs = [];
|
|
92
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
93
|
+
if (!line || line.startsWith('#')) continue;
|
|
94
|
+
const parts = line.split('\t');
|
|
95
|
+
if (parts.length < 7) continue;
|
|
96
|
+
const [domain, , , , , name, value] = parts;
|
|
97
|
+
if (domain && domain.includes('mobbin.com')) {
|
|
98
|
+
pairs.push(`${name}=${value}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return pairs.join('; ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return raw.split(/\r?\n/)[0].trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function execText(cmd, args) {
|
|
108
|
+
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decryptChromeCookieValueMac(encryptedBytes, safeStoragePassword) {
|
|
112
|
+
const prefix = encryptedBytes.subarray(0, 3).toString('utf8');
|
|
113
|
+
if (prefix !== 'v10' && prefix !== 'v11') return null;
|
|
114
|
+
|
|
115
|
+
const ciphertext = encryptedBytes.subarray(3);
|
|
116
|
+
const key = crypto.pbkdf2Sync(safeStoragePassword, 'saltysalt', 1003, 16, 'sha1');
|
|
117
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
118
|
+
|
|
119
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
|
120
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
121
|
+
return plaintext.toString('utf8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readNullTerminatedString(buf, offset) {
|
|
125
|
+
if (!Number.isFinite(offset) || offset < 0 || offset >= buf.length) return '';
|
|
126
|
+
const end = buf.indexOf(0x00, offset);
|
|
127
|
+
const sliceEnd = end === -1 ? buf.length : end;
|
|
128
|
+
return buf.toString('utf8', offset, sliceEnd);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseSafariBinaryCookies(buf) {
|
|
132
|
+
if (!buf || buf.length < 16) return [];
|
|
133
|
+
if (buf.toString('ascii', 0, 4) !== 'cook') return [];
|
|
134
|
+
|
|
135
|
+
const pages = buf.readUInt32BE(4);
|
|
136
|
+
const sizesOffset = 8;
|
|
137
|
+
const sizesBytes = pages * 4;
|
|
138
|
+
if (sizesOffset + sizesBytes > buf.length) return [];
|
|
139
|
+
|
|
140
|
+
const pageSizes = [];
|
|
141
|
+
for (let i = 0; i < pages; i++) {
|
|
142
|
+
pageSizes.push(buf.readUInt32BE(sizesOffset + i * 4));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let pageOffset = sizesOffset + sizesBytes;
|
|
146
|
+
const cookies = [];
|
|
147
|
+
|
|
148
|
+
for (const pageSize of pageSizes) {
|
|
149
|
+
if (pageOffset + pageSize > buf.length) break;
|
|
150
|
+
const page = buf.subarray(pageOffset, pageOffset + pageSize);
|
|
151
|
+
pageOffset += pageSize;
|
|
152
|
+
|
|
153
|
+
if (page.length < 12) continue;
|
|
154
|
+
// Page header is big-endian 0x00000100, but fields within the page are little-endian.
|
|
155
|
+
const numCookies = page.readUInt32LE(4);
|
|
156
|
+
const offsetsStart = 8;
|
|
157
|
+
const offsetsEnd = offsetsStart + numCookies * 4;
|
|
158
|
+
if (offsetsEnd > page.length) continue;
|
|
159
|
+
|
|
160
|
+
const offsets = [];
|
|
161
|
+
for (let i = 0; i < numCookies; i++) {
|
|
162
|
+
offsets.push(page.readUInt32LE(offsetsStart + i * 4));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const cookieOffset of offsets) {
|
|
166
|
+
if (cookieOffset + 4 > page.length) continue;
|
|
167
|
+
const cookieSize = page.readUInt32LE(cookieOffset);
|
|
168
|
+
if (cookieSize <= 0 || cookieOffset + cookieSize > page.length) continue;
|
|
169
|
+
|
|
170
|
+
const cookie = page.subarray(cookieOffset, cookieOffset + cookieSize);
|
|
171
|
+
if (cookie.length < 56) continue;
|
|
172
|
+
|
|
173
|
+
const domainOffset = cookie.readUInt32LE(16);
|
|
174
|
+
const nameOffset = cookie.readUInt32LE(20);
|
|
175
|
+
const pathOffset = cookie.readUInt32LE(24);
|
|
176
|
+
const valueOffset = cookie.readUInt32LE(28);
|
|
177
|
+
const expires = cookie.readDoubleLE(40);
|
|
178
|
+
const created = cookie.readDoubleLE(48);
|
|
179
|
+
|
|
180
|
+
const domain = readNullTerminatedString(cookie, domainOffset);
|
|
181
|
+
const name = readNullTerminatedString(cookie, nameOffset);
|
|
182
|
+
const cookiePath = readNullTerminatedString(cookie, pathOffset);
|
|
183
|
+
const value = readNullTerminatedString(cookie, valueOffset);
|
|
184
|
+
|
|
185
|
+
if (!name) continue;
|
|
186
|
+
cookies.push({ domain, name, path: cookiePath, value, expires, created });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return cookies;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function queryCookiesSqlite(cookieDbPath) {
|
|
194
|
+
const tmp = path.join(
|
|
195
|
+
os.tmpdir(),
|
|
196
|
+
`mobbin-cookies-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`,
|
|
197
|
+
);
|
|
198
|
+
try {
|
|
199
|
+
fs.copyFileSync(cookieDbPath, tmp);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const out = execText('sqlite3', [
|
|
206
|
+
'-readonly',
|
|
207
|
+
'-separator',
|
|
208
|
+
'\t',
|
|
209
|
+
tmp,
|
|
210
|
+
"SELECT host_key, name, value, hex(encrypted_value), expires_utc FROM cookies WHERE host_key LIKE '%mobbin.com%' AND name != '' ORDER BY expires_utc DESC;",
|
|
211
|
+
]);
|
|
212
|
+
if (!out) return [];
|
|
213
|
+
|
|
214
|
+
const rows = [];
|
|
215
|
+
for (const line of out.split(/\r?\n/)) {
|
|
216
|
+
if (!line) continue;
|
|
217
|
+
const [hostKey, name, value, encryptedHex] = line.split('\t');
|
|
218
|
+
rows.push({ hostKey, name, value, encryptedHex });
|
|
219
|
+
}
|
|
220
|
+
return rows;
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
} finally {
|
|
224
|
+
try {
|
|
225
|
+
fs.unlinkSync(tmp);
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getMacSafeStoragePassword({ service, account }) {
|
|
231
|
+
try {
|
|
232
|
+
return execText('security', ['find-generic-password', '-w', '-s', service, '-a', account]);
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readMobbinCookieFromSafariMac() {
|
|
239
|
+
if (process.platform !== 'darwin') return '';
|
|
240
|
+
|
|
241
|
+
const home = os.homedir();
|
|
242
|
+
const candidates = [
|
|
243
|
+
path.join(home, 'Library/Cookies/Cookies.binarycookies'),
|
|
244
|
+
path.join(home, 'Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies'),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const nowUnix = Date.now() / 1000;
|
|
248
|
+
const cocoaToUnix = (cocoaSeconds) => (Number.isFinite(cocoaSeconds) ? cocoaSeconds + 978307200 : 0);
|
|
249
|
+
|
|
250
|
+
const bestByName = new Map();
|
|
251
|
+
|
|
252
|
+
for (const filePath of candidates) {
|
|
253
|
+
let buf;
|
|
254
|
+
try {
|
|
255
|
+
buf = fs.readFileSync(filePath);
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const cookies = parseSafariBinaryCookies(buf);
|
|
261
|
+
for (const c of cookies) {
|
|
262
|
+
if (!c.domain || !String(c.domain).includes('mobbin.com')) continue;
|
|
263
|
+
if (!c.name || !c.value) continue;
|
|
264
|
+
|
|
265
|
+
const expiresUnix = cocoaToUnix(c.expires);
|
|
266
|
+
const isExpired = expiresUnix > 0 && expiresUnix < nowUnix;
|
|
267
|
+
if (isExpired) continue;
|
|
268
|
+
|
|
269
|
+
const prev = bestByName.get(c.name);
|
|
270
|
+
if (!prev || (expiresUnix || 0) > (prev.expiresUnix || 0)) {
|
|
271
|
+
bestByName.set(c.name, { value: c.value, expiresUnix });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (bestByName.size) break;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const pairs = [];
|
|
279
|
+
for (const [name, meta] of bestByName.entries()) {
|
|
280
|
+
pairs.push(`${name}=${meta.value}`);
|
|
281
|
+
}
|
|
282
|
+
return pairs.join('; ');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readMobbinCookieFromMacBrowsers({ browser = 'auto', profile = 'Default' } = {}) {
|
|
286
|
+
if (process.platform !== 'darwin') return '';
|
|
287
|
+
|
|
288
|
+
if (browser === 'safari') {
|
|
289
|
+
return readMobbinCookieFromSafariMac();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const home = os.homedir();
|
|
293
|
+
const candidates = [
|
|
294
|
+
{
|
|
295
|
+
id: 'chrome',
|
|
296
|
+
cookieDbPath: path.join(home, 'Library/Application Support/Google/Chrome', profile, 'Cookies'),
|
|
297
|
+
keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'edge',
|
|
301
|
+
cookieDbPath: path.join(
|
|
302
|
+
home,
|
|
303
|
+
'Library/Application Support/Microsoft Edge',
|
|
304
|
+
profile,
|
|
305
|
+
'Cookies',
|
|
306
|
+
),
|
|
307
|
+
keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'brave',
|
|
311
|
+
cookieDbPath: path.join(
|
|
312
|
+
home,
|
|
313
|
+
'Library/Application Support/BraveSoftware/Brave-Browser',
|
|
314
|
+
profile,
|
|
315
|
+
'Cookies',
|
|
316
|
+
),
|
|
317
|
+
keychain: { service: 'Brave Safe Storage', account: 'Brave' },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: 'chromium',
|
|
321
|
+
cookieDbPath: path.join(home, 'Library/Application Support/Chromium', profile, 'Cookies'),
|
|
322
|
+
keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
|
|
323
|
+
},
|
|
324
|
+
].filter((c) => browser === 'auto' || c.id === browser);
|
|
325
|
+
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
if (!fs.existsSync(candidate.cookieDbPath)) continue;
|
|
328
|
+
|
|
329
|
+
const rows = queryCookiesSqlite(candidate.cookieDbPath);
|
|
330
|
+
if (!rows.length) continue;
|
|
331
|
+
|
|
332
|
+
const byName = new Map();
|
|
333
|
+
let needsDecrypt = false;
|
|
334
|
+
|
|
335
|
+
for (const row of rows) {
|
|
336
|
+
if (byName.has(row.name)) continue;
|
|
337
|
+
if (row.value) {
|
|
338
|
+
byName.set(row.name, row.value);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (row.encryptedHex) needsDecrypt = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let safeStoragePassword = null;
|
|
345
|
+
if (needsDecrypt) {
|
|
346
|
+
safeStoragePassword = getMacSafeStoragePassword(candidate.keychain);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
if (byName.has(row.name)) continue;
|
|
351
|
+
if (!row.encryptedHex) continue;
|
|
352
|
+
if (!safeStoragePassword) continue;
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const encryptedBytes = Buffer.from(row.encryptedHex, 'hex');
|
|
356
|
+
const decrypted = decryptChromeCookieValueMac(encryptedBytes, safeStoragePassword);
|
|
357
|
+
if (decrypted) byName.set(row.name, decrypted);
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const pairs = [];
|
|
362
|
+
for (const [name, value] of byName.entries()) {
|
|
363
|
+
if (!name || !value) continue;
|
|
364
|
+
pairs.push(`${name}=${value}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (pairs.length) return pairs.join('; ');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return '';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function defaultCookieFileCandidates() {
|
|
374
|
+
const home = os.homedir();
|
|
375
|
+
return [
|
|
376
|
+
process.env.MOBBIN_COOKIE_FILE,
|
|
377
|
+
path.join(home, '.config/mobbin/cookie.txt'),
|
|
378
|
+
path.join(home, '.config/mobbin/storage-state.json'),
|
|
379
|
+
path.join(home, '.mobbin-cookie'),
|
|
380
|
+
path.join(process.cwd(), 'cookie.txt'),
|
|
381
|
+
path.join(process.cwd(), 'cookies.txt'),
|
|
382
|
+
path.join(process.cwd(), 'storage-state.json'),
|
|
383
|
+
].filter(Boolean);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveCookie(values, { command } = {}) {
|
|
387
|
+
if (values.cookie) return values.cookie;
|
|
388
|
+
if (values.cookieFile) {
|
|
389
|
+
const fromFile = readCookieFile(values.cookieFile);
|
|
390
|
+
if (fromFile) return fromFile;
|
|
391
|
+
}
|
|
392
|
+
if (process.env.MOBBIN_COOKIE) return process.env.MOBBIN_COOKIE;
|
|
393
|
+
|
|
394
|
+
for (const candidate of defaultCookieFileCandidates()) {
|
|
395
|
+
const fromFile = readCookieFile(candidate);
|
|
396
|
+
if (fromFile) return fromFile;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const authLikely =
|
|
400
|
+
values.cookieFrom || AUTH_LIKELY_COMMANDS.has(command);
|
|
401
|
+
|
|
402
|
+
if (authLikely) {
|
|
403
|
+
const cookieFrom = values.cookieFrom || 'auto';
|
|
404
|
+
let fromBrowser = readMobbinCookieFromMacBrowsers({
|
|
405
|
+
browser: cookieFrom,
|
|
406
|
+
profile: values.cookieProfile || 'Default',
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
if (!fromBrowser && cookieFrom === 'auto') {
|
|
410
|
+
fromBrowser = readMobbinCookieFromSafariMac();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (fromBrowser) return fromBrowser;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function maybeInteractiveAuthCookie(values) {
|
|
420
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
|
|
421
|
+
if (process.platform !== 'darwin') return undefined;
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
execFileSync('open', ['https://mobbin.com'], { stdio: 'ignore' });
|
|
425
|
+
} catch {}
|
|
426
|
+
|
|
427
|
+
process.stdout.write(
|
|
428
|
+
'Mobbin auth required. Log into Mobbin in your browser, then press Enter to continue...\n',
|
|
429
|
+
);
|
|
430
|
+
await new Promise((resolve) => process.stdin.once('data', resolve));
|
|
431
|
+
|
|
432
|
+
return resolveCookie(
|
|
433
|
+
{ ...values, cookieFrom: values.cookieFrom || 'auto' },
|
|
434
|
+
{ command: 'recent-searches' },
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const DEFAULT_BYTESCALE_ACCOUNT_ID = 'FW25bBB';
|
|
439
|
+
const DEFAULT_SUPABASE_BYTESCALE_PREFIX_MAP = {
|
|
440
|
+
'https://eqcn': 'mobbin.com/development/',
|
|
441
|
+
'https://qvzj': 'mobbin.com/staging/',
|
|
442
|
+
'https://ujas': 'mobbin.com/prod/',
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const SUPABASE_PUBLIC_OBJECT_PREFIX = '/storage/v1/object/public/';
|
|
446
|
+
|
|
447
|
+
function readJsonFile(filePath) {
|
|
448
|
+
try {
|
|
449
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function writeJsonFile(filePath, data) {
|
|
456
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
457
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function fetchText(url, { timeoutMs = 15000, headers } = {}) {
|
|
461
|
+
const controller = new AbortController();
|
|
462
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
463
|
+
try {
|
|
464
|
+
const res = await fetch(url, { headers, signal: controller.signal });
|
|
465
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
466
|
+
return await res.text();
|
|
467
|
+
} finally {
|
|
468
|
+
clearTimeout(timeout);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function fetchRedirectLocation(url, { timeoutMs = 15000, headers } = {}) {
|
|
473
|
+
const controller = new AbortController();
|
|
474
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(url, { headers, redirect: 'manual', signal: controller.signal });
|
|
477
|
+
return { status: res.status, location: res.headers.get('location') || null };
|
|
478
|
+
} finally {
|
|
479
|
+
clearTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function findMobbinMainAppChunkUrl(html) {
|
|
484
|
+
const match = html.match(/\/_next\/static\/chunks\/main-app-[^"']+\.js[^"']*/);
|
|
485
|
+
if (!match) return null;
|
|
486
|
+
return `https://mobbin.com${match[0].replace(/&/g, '&')}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function extractBytescaleConfigFromMobbinJs(js) {
|
|
490
|
+
const accountIdMatch = js.match(/NEXT_PUBLIC_BYTESCALE_ACCOUNT_ID:"([^"]+)"/);
|
|
491
|
+
const accountId = accountIdMatch ? accountIdMatch[1] : null;
|
|
492
|
+
|
|
493
|
+
const prefixMapMatch = js.match(/NEXT_PUBLIC_SUPABASE_BYTESCALE_IMAGE_HOST_PREFIX:'([^']+)'/);
|
|
494
|
+
let prefixMap = null;
|
|
495
|
+
if (prefixMapMatch) {
|
|
496
|
+
try {
|
|
497
|
+
prefixMap = JSON.parse(prefixMapMatch[1]);
|
|
498
|
+
} catch {}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { accountId, prefixMap };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function fetchBytescaleConfigFromMobbin() {
|
|
505
|
+
const html = await fetchText('https://mobbin.com');
|
|
506
|
+
const mainChunkUrl = findMobbinMainAppChunkUrl(html);
|
|
507
|
+
if (!mainChunkUrl) throw new Error('Unable to locate Mobbin main-app chunk URL');
|
|
508
|
+
const js = await fetchText(mainChunkUrl);
|
|
509
|
+
|
|
510
|
+
const extracted = extractBytescaleConfigFromMobbinJs(js);
|
|
511
|
+
return {
|
|
512
|
+
accountId: extracted.accountId || DEFAULT_BYTESCALE_ACCOUNT_ID,
|
|
513
|
+
prefixMap: extracted.prefixMap || DEFAULT_SUPABASE_BYTESCALE_PREFIX_MAP,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function getBytescaleConfig({ refresh = false } = {}) {
|
|
518
|
+
const configPath = path.join(os.homedir(), '.config', 'mobbin', 'bytescale.json');
|
|
519
|
+
|
|
520
|
+
const envAccountId = process.env.MOBBIN_BYTESCALE_ACCOUNT_ID;
|
|
521
|
+
const envPrefixMapRaw =
|
|
522
|
+
process.env.MOBBIN_SUPABASE_BYTESCALE_PREFIX_MAP || process.env.MOBBIN_BYTESCALE_PREFIX_MAP;
|
|
523
|
+
|
|
524
|
+
let envPrefixMap = null;
|
|
525
|
+
if (envPrefixMapRaw) {
|
|
526
|
+
try {
|
|
527
|
+
envPrefixMap = JSON.parse(envPrefixMapRaw);
|
|
528
|
+
} catch {
|
|
529
|
+
throw new Error(
|
|
530
|
+
'MOBBIN_SUPABASE_BYTESCALE_PREFIX_MAP must be a JSON object like {"https://ujas":"mobbin.com/prod/"}',
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const cached = refresh ? null : readJsonFile(configPath);
|
|
536
|
+
let accountId = envAccountId || cached?.accountId || DEFAULT_BYTESCALE_ACCOUNT_ID;
|
|
537
|
+
let prefixMap = envPrefixMap || cached?.prefixMap || DEFAULT_SUPABASE_BYTESCALE_PREFIX_MAP;
|
|
538
|
+
|
|
539
|
+
// If we have no cache/env, try to fetch the latest config from mobbin.com (public).
|
|
540
|
+
if (refresh || (!cached && !envAccountId && !envPrefixMapRaw)) {
|
|
541
|
+
try {
|
|
542
|
+
const fetched = await fetchBytescaleConfigFromMobbin();
|
|
543
|
+
accountId = envAccountId || fetched.accountId || accountId;
|
|
544
|
+
prefixMap = envPrefixMap || fetched.prefixMap || prefixMap;
|
|
545
|
+
writeJsonFile(configPath, { accountId, prefixMap, fetchedAt: new Date().toISOString() });
|
|
546
|
+
} catch {
|
|
547
|
+
// Keep defaults if discovery fails.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { accountId, prefixMap };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function parseSupabasePublicObjectUrl(url) {
|
|
555
|
+
try {
|
|
556
|
+
const u = new URL(url);
|
|
557
|
+
if (!u.host.endsWith('.supabase.co')) return null;
|
|
558
|
+
if (!u.pathname.startsWith(SUPABASE_PUBLIC_OBJECT_PREFIX)) return null;
|
|
559
|
+
return { origin: u.origin, path: u.pathname.slice(SUPABASE_PUBLIC_OBJECT_PREFIX.length) };
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function encodePathSegments(p) {
|
|
566
|
+
return p.split('/').map((seg) => encodeURIComponent(seg)).join('/');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function toBytescaleRawUrlFromSupabase(url, bytescaleConfig) {
|
|
570
|
+
const parsed = parseSupabasePublicObjectUrl(url);
|
|
571
|
+
if (!parsed) return null;
|
|
572
|
+
|
|
573
|
+
const originPrefix = parsed.origin.slice(0, 12);
|
|
574
|
+
const hostPrefix = bytescaleConfig.prefixMap?.[originPrefix];
|
|
575
|
+
if (!hostPrefix) return null;
|
|
576
|
+
|
|
577
|
+
const filePath = `${hostPrefix}${parsed.path}`;
|
|
578
|
+
return `https://bytescale.mobbin.com/${bytescaleConfig.accountId}/raw/${encodePathSegments(filePath)}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function toBytescaleImageUrlFromSupabase(
|
|
582
|
+
url,
|
|
583
|
+
bytescaleConfig,
|
|
584
|
+
{ width = 1920, quality = 85, format = 'png' } = {},
|
|
585
|
+
) {
|
|
586
|
+
const parsed = parseSupabasePublicObjectUrl(url);
|
|
587
|
+
if (!parsed) return null;
|
|
588
|
+
|
|
589
|
+
const originPrefix = parsed.origin.slice(0, 12);
|
|
590
|
+
const hostPrefix = bytescaleConfig.prefixMap?.[originPrefix];
|
|
591
|
+
if (!hostPrefix) return null;
|
|
592
|
+
|
|
593
|
+
const filePath = `${hostPrefix}${parsed.path}`;
|
|
594
|
+
const out = new URL(
|
|
595
|
+
`https://bytescale.mobbin.com/${bytescaleConfig.accountId}/image/${encodePathSegments(filePath)}`,
|
|
596
|
+
);
|
|
597
|
+
out.searchParams.set('f', format);
|
|
598
|
+
out.searchParams.set('w', String(width));
|
|
599
|
+
out.searchParams.set('q', String(quality));
|
|
600
|
+
out.searchParams.set('fit', 'shrink-cover');
|
|
601
|
+
return out.toString();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function toBytescaleRawUrlFromBytescaleImageUrl(url) {
|
|
605
|
+
try {
|
|
606
|
+
const u = new URL(url);
|
|
607
|
+
if (u.host !== 'bytescale.mobbin.com') return null;
|
|
608
|
+
|
|
609
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
610
|
+
if (parts.length < 3) return null;
|
|
611
|
+
const [accountId, kind, ...rest] = parts;
|
|
612
|
+
if (kind !== 'image') return null;
|
|
613
|
+
|
|
614
|
+
u.pathname = `/${[accountId, 'raw', ...rest].join('/')}`;
|
|
615
|
+
u.search = '';
|
|
616
|
+
return u.toString();
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function sanitizePathSegment(s) {
|
|
623
|
+
return String(s)
|
|
624
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
625
|
+
.replace(/^-+/, '')
|
|
626
|
+
.replace(/-+$/, '')
|
|
627
|
+
.slice(0, 120);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function extFromUrl(url) {
|
|
631
|
+
try {
|
|
632
|
+
const u = new URL(url);
|
|
633
|
+
const base = path.basename(u.pathname);
|
|
634
|
+
const ext = path.extname(base);
|
|
635
|
+
return ext || '';
|
|
636
|
+
} catch {
|
|
637
|
+
return '';
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function normalizeUrlInput(s) {
|
|
642
|
+
let out = String(s).trim();
|
|
643
|
+
while (out.endsWith('\\')) out = out.slice(0, -1);
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function extractMobbinScreenIdFromUrl(url) {
|
|
648
|
+
try {
|
|
649
|
+
const u = new URL(url);
|
|
650
|
+
if (u.host !== 'mobbin.com') return null;
|
|
651
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
652
|
+
if (parts.length !== 2) return null;
|
|
653
|
+
const [kind, id] = parts;
|
|
654
|
+
if (kind !== 'screens' && kind !== '@modalscreens') return null;
|
|
655
|
+
return id || null;
|
|
656
|
+
} catch {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function extractUuidFromString(s) {
|
|
662
|
+
const m = String(s || '').match(UUID_RE);
|
|
663
|
+
return m ? m[0].toLowerCase() : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function extractMobbinSiteInfoFromUrl(url) {
|
|
667
|
+
try {
|
|
668
|
+
const u = new URL(url);
|
|
669
|
+
if (u.host !== 'mobbin.com') return null;
|
|
670
|
+
|
|
671
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
672
|
+
if (parts[0] !== 'sites') return null;
|
|
673
|
+
if (!parts[1]) return null;
|
|
674
|
+
|
|
675
|
+
const siteSlug = parts[1];
|
|
676
|
+
const siteId = extractUuidFromString(siteSlug);
|
|
677
|
+
const siteVersionId = parts[2] && UUID_RE.test(parts[2]) ? parts[2].toLowerCase() : null;
|
|
678
|
+
const tab = parts[3] || null;
|
|
679
|
+
|
|
680
|
+
return { siteSlug, siteId, siteVersionId, tab };
|
|
681
|
+
} catch {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function extractMobbinSiteVersionFromPathname(pathname) {
|
|
687
|
+
const parts = String(pathname || '')
|
|
688
|
+
.split('/')
|
|
689
|
+
.filter(Boolean);
|
|
690
|
+
if (parts[0] !== 'sites') return null;
|
|
691
|
+
if (parts.length < 3) return null;
|
|
692
|
+
return { siteSlug: parts[1], siteVersionId: parts[2], tab: parts[3] || null };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function decodeBasicHtmlEntities(s) {
|
|
696
|
+
return String(s || '').replace(/&/g, '&');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function extractMobbinTitle(html) {
|
|
700
|
+
const m = String(html || '').match(/<title>([^<]+)<\/title>/i);
|
|
701
|
+
if (!m) return null;
|
|
702
|
+
return decodeBasicHtmlEntities(m[1]).trim();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function extractFullpageScreenUrlsFromMobbinSiteHtml(html) {
|
|
706
|
+
const out = [];
|
|
707
|
+
const seen = new Set();
|
|
708
|
+
|
|
709
|
+
const add = (url) => {
|
|
710
|
+
const normalized = normalizeUrlInput(decodeBasicHtmlEntities(url));
|
|
711
|
+
if (!normalized) return;
|
|
712
|
+
if (seen.has(normalized)) return;
|
|
713
|
+
seen.add(normalized);
|
|
714
|
+
out.push(normalized);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const supabaseRe =
|
|
718
|
+
/https:\/\/[a-z0-9]+\.supabase\.co\/storage\/v1\/object\/public\/content\/app_fullpage_screens\/[^"<> ]+/gi;
|
|
719
|
+
const bytescaleRe = /https:\/\/bytescale\.mobbin\.com\/[^"<> ]+/gi;
|
|
720
|
+
|
|
721
|
+
for (const m of String(html || '').matchAll(supabaseRe)) add(m[0]);
|
|
722
|
+
for (const m of String(html || '').matchAll(bytescaleRe)) {
|
|
723
|
+
if (m[0].includes('content/app_fullpage_screens/')) add(m[0]);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function downloadFile(url, destPath, { overwrite = false, headers } = {}) {
|
|
730
|
+
if (!overwrite && fs.existsSync(destPath)) {
|
|
731
|
+
return { status: 'skipped', destPath, url };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
735
|
+
const tmpPath = `${destPath}.tmp`;
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
const res = await fetch(url, { headers });
|
|
739
|
+
if (!res.ok) {
|
|
740
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
741
|
+
}
|
|
742
|
+
if (!res.body) throw new Error('No response body');
|
|
743
|
+
|
|
744
|
+
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath));
|
|
745
|
+
fs.renameSync(tmpPath, destPath);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
status: 'downloaded',
|
|
749
|
+
destPath,
|
|
750
|
+
url,
|
|
751
|
+
contentType: res.headers.get('content-type') || null,
|
|
752
|
+
bytes: Number(res.headers.get('content-length') || 0) || null,
|
|
753
|
+
};
|
|
754
|
+
} catch (err) {
|
|
755
|
+
try {
|
|
756
|
+
fs.unlinkSync(tmpPath);
|
|
757
|
+
} catch {}
|
|
758
|
+
throw err;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
763
|
+
const results = new Array(items.length);
|
|
764
|
+
let index = 0;
|
|
765
|
+
|
|
766
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
767
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
768
|
+
while (true) {
|
|
769
|
+
const i = index++;
|
|
770
|
+
if (i >= items.length) break;
|
|
771
|
+
results[i] = await fn(items[i], i);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
await Promise.all(workers);
|
|
776
|
+
return results;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function printUsage() {
|
|
780
|
+
const bin = path.basename(process.argv[1] || 'mobbin-cli.js');
|
|
781
|
+
console.log(
|
|
782
|
+
[
|
|
783
|
+
'',
|
|
784
|
+
'Mobbin CLI',
|
|
785
|
+
'',
|
|
786
|
+
'Commands:',
|
|
787
|
+
' trending-apps --platform ios',
|
|
788
|
+
' trending-filter-tags --platform ios --experience apps',
|
|
789
|
+
' trending-keywords --platform ios',
|
|
790
|
+
' trending-sites',
|
|
791
|
+
' searchable-sites',
|
|
792
|
+
' searchable-apps --platform ios',
|
|
793
|
+
' dictionary',
|
|
794
|
+
' search --query "login" --platform ios --experience apps',
|
|
795
|
+
' popular-apps --platform ios --limit 10',
|
|
796
|
+
' app-versions --app-id <uuid>',
|
|
797
|
+
' screen-info --screen-id <uuid>',
|
|
798
|
+
' download [--app-id <uuid> | --site-id <uuid> | --screen-id <uuid> | <url> ...] --out-dir ./out',
|
|
799
|
+
' extract [--app-id <uuid> | --site-id <uuid> | --screen-id <uuid> | <url> ...] --out-dir ./out',
|
|
800
|
+
' recent-searches',
|
|
801
|
+
' content-apps --platform ios --tab latest --page-size 24',
|
|
802
|
+
' auth print-cookie',
|
|
803
|
+
' auth save-cookie --out ~/.config/mobbin/cookie.txt',
|
|
804
|
+
` call /api/endpoint --method POST --data '{"foo":"bar"}'`,
|
|
805
|
+
'',
|
|
806
|
+
'Options:',
|
|
807
|
+
' --cookie <string> Raw Cookie header value',
|
|
808
|
+
' --cookie-file <path> Netscape cookie file or Playwright storage state JSON',
|
|
809
|
+
' --cookie-from <auto|safari|chrome|edge|brave|chromium> Read cookies from local browser store (macOS)',
|
|
810
|
+
' --cookie-profile <name> Browser profile dir (default: Default)',
|
|
811
|
+
' --base-url <url> Override API base URL',
|
|
812
|
+
' --out-dir <path> Download output directory (default: ./out)',
|
|
813
|
+
' --concurrency <n> Download concurrency (default: 6)',
|
|
814
|
+
' --overwrite Overwrite existing files when downloading',
|
|
815
|
+
' --bytescale-refresh Refresh cached Bytescale config (download)',
|
|
816
|
+
' --raw Print raw (non-JSON) output',
|
|
817
|
+
' --compact Print compact JSON (1 line)',
|
|
818
|
+
'',
|
|
819
|
+
'Examples:',
|
|
820
|
+
` ${bin} trending-apps --platform ios`,
|
|
821
|
+
` ${bin} search --query "login" --platform ios --experience apps`,
|
|
822
|
+
` ${bin} auth save-cookie --out ~/.config/mobbin/cookie.txt`,
|
|
823
|
+
` ${bin} download --screen-id <uuid> --out-dir ./out`,
|
|
824
|
+
` ${bin} download --app-id <uuid> --out-dir ./out`,
|
|
825
|
+
` ${bin} download --site-id <uuid> --out-dir ./out`,
|
|
826
|
+
` ${bin} call /api/search-bar/fetch-trending-sites --method POST --data "{}"`,
|
|
827
|
+
'',
|
|
828
|
+
].join('\n'),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function main() {
|
|
833
|
+
const args = process.argv.slice(2);
|
|
834
|
+
if (!args.length || args[0] === 'help' || args[0] === '--help') {
|
|
835
|
+
printUsage();
|
|
836
|
+
process.exit(0);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const command = args[0];
|
|
840
|
+
const isDownloadCommand = command === 'download' || command === 'extract';
|
|
841
|
+
|
|
842
|
+
const { values, positionals } = parseArgs({
|
|
843
|
+
args: normalizeCliArgs(args.slice(1)),
|
|
844
|
+
options: {
|
|
845
|
+
cookie: { type: 'string' },
|
|
846
|
+
cookieFile: { type: 'string' },
|
|
847
|
+
cookieFrom: { type: 'string' },
|
|
848
|
+
cookieProfile: { type: 'string' },
|
|
849
|
+
baseUrl: { type: 'string' },
|
|
850
|
+
platform: { type: 'string' },
|
|
851
|
+
experience: { type: 'string' },
|
|
852
|
+
query: { type: 'string' },
|
|
853
|
+
limit: { type: 'string' },
|
|
854
|
+
pageSize: { type: 'string' },
|
|
855
|
+
tab: { type: 'string' },
|
|
856
|
+
appId: { type: 'string' },
|
|
857
|
+
siteId: { type: 'string' },
|
|
858
|
+
screenId: { type: 'string' },
|
|
859
|
+
method: { type: 'string' },
|
|
860
|
+
data: { type: 'string' },
|
|
861
|
+
out: { type: 'string' },
|
|
862
|
+
outDir: { type: 'string' },
|
|
863
|
+
concurrency: { type: 'string' },
|
|
864
|
+
overwrite: { type: 'boolean', default: false },
|
|
865
|
+
bytescaleRefresh: { type: 'boolean', default: false },
|
|
866
|
+
raw: { type: 'boolean', default: false },
|
|
867
|
+
compact: { type: 'boolean', default: false },
|
|
868
|
+
},
|
|
869
|
+
allowPositionals: true,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
const downloadNeedsAuth =
|
|
873
|
+
isDownloadCommand &&
|
|
874
|
+
(Boolean(values.appId) ||
|
|
875
|
+
Boolean(values.siteId) ||
|
|
876
|
+
Boolean(values.screenId) ||
|
|
877
|
+
positionals.some((p) => extractMobbinScreenIdFromUrl(normalizeUrlInput(p))) ||
|
|
878
|
+
positionals.some((p) => extractMobbinSiteInfoFromUrl(normalizeUrlInput(p))));
|
|
879
|
+
|
|
880
|
+
// `download` only needs cookies when we have to call auth-gated JSON endpoints
|
|
881
|
+
// (e.g. resolving screen IDs or fetching app versions).
|
|
882
|
+
const cookieDiscoveryCommand = downloadNeedsAuth ? 'recent-searches' : command;
|
|
883
|
+
|
|
884
|
+
let cookie = resolveCookie(values, { command: cookieDiscoveryCommand });
|
|
885
|
+
if (!cookie && (AUTH_LIKELY_COMMANDS.has(command) || downloadNeedsAuth)) {
|
|
886
|
+
cookie = await maybeInteractiveAuthCookie(values);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const client = new MobbinClient({
|
|
890
|
+
baseUrl: values.baseUrl || 'https://mobbin.com',
|
|
891
|
+
cookie,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
let result;
|
|
895
|
+
switch (command) {
|
|
896
|
+
case 'trending-apps':
|
|
897
|
+
result = await client.fetchTrendingApps({ platform: values.platform || 'ios' });
|
|
898
|
+
break;
|
|
899
|
+
case 'trending-filter-tags':
|
|
900
|
+
result = await client.fetchTrendingFilterTags({
|
|
901
|
+
platform: values.platform || 'ios',
|
|
902
|
+
experience: values.experience || 'apps',
|
|
903
|
+
});
|
|
904
|
+
break;
|
|
905
|
+
case 'trending-keywords':
|
|
906
|
+
result = await client.fetchTrendingKeywords({ platform: values.platform || 'ios' });
|
|
907
|
+
break;
|
|
908
|
+
case 'trending-sites':
|
|
909
|
+
result = await client.fetchTrendingSites();
|
|
910
|
+
break;
|
|
911
|
+
case 'searchable-sites':
|
|
912
|
+
result = await client.fetchSearchableSites();
|
|
913
|
+
break;
|
|
914
|
+
case 'searchable-apps':
|
|
915
|
+
result = await client.fetchSearchableApps({ platform: values.platform || 'ios' });
|
|
916
|
+
break;
|
|
917
|
+
case 'dictionary':
|
|
918
|
+
result = await client.fetchDictionaryDefinitions();
|
|
919
|
+
break;
|
|
920
|
+
case 'search':
|
|
921
|
+
result = await client.search({
|
|
922
|
+
query: values.query,
|
|
923
|
+
experience: values.experience || 'apps',
|
|
924
|
+
platform: values.platform || 'ios',
|
|
925
|
+
});
|
|
926
|
+
break;
|
|
927
|
+
case 'popular-apps':
|
|
928
|
+
result = await client.fetchPopularApps({
|
|
929
|
+
platform: values.platform || 'ios',
|
|
930
|
+
limitPerCategory: Number(values.limit || 10),
|
|
931
|
+
});
|
|
932
|
+
break;
|
|
933
|
+
case 'app-versions':
|
|
934
|
+
result = await client.fetchAppVersionsScreens({ appId: values.appId });
|
|
935
|
+
break;
|
|
936
|
+
case 'screen-info':
|
|
937
|
+
result = await client.fetchScreenInfo({ screenId: values.screenId });
|
|
938
|
+
break;
|
|
939
|
+
case 'extract':
|
|
940
|
+
case 'download': {
|
|
941
|
+
const outDir = values.outDir || path.join(process.cwd(), 'out');
|
|
942
|
+
const concurrency = Math.max(1, Number(values.concurrency || 6) || 6);
|
|
943
|
+
const overwrite = values.overwrite === true;
|
|
944
|
+
const userAgent =
|
|
945
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36';
|
|
946
|
+
|
|
947
|
+
let bytescaleConfigPromise = null;
|
|
948
|
+
const getBytescale = async () => {
|
|
949
|
+
if (!bytescaleConfigPromise) {
|
|
950
|
+
bytescaleConfigPromise = getBytescaleConfig({ refresh: values.bytescaleRefresh === true });
|
|
951
|
+
}
|
|
952
|
+
return bytescaleConfigPromise;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const jobs = [];
|
|
956
|
+
const usedDestinations = new Set();
|
|
957
|
+
|
|
958
|
+
const uniqueDestPath = (candidatePath) => {
|
|
959
|
+
if (!usedDestinations.has(candidatePath)) {
|
|
960
|
+
usedDestinations.add(candidatePath);
|
|
961
|
+
return candidatePath;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const ext = path.extname(candidatePath);
|
|
965
|
+
const base = candidatePath.slice(0, candidatePath.length - ext.length);
|
|
966
|
+
let n = 2;
|
|
967
|
+
while (usedDestinations.has(`${base}__${n}${ext}`)) {
|
|
968
|
+
n += 1;
|
|
969
|
+
}
|
|
970
|
+
const out = `${base}__${n}${ext}`;
|
|
971
|
+
usedDestinations.add(out);
|
|
972
|
+
return out;
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const addUrlJob = (url, filenameHint) => {
|
|
976
|
+
const ext = extFromUrl(url);
|
|
977
|
+
const fallbackName = filenameHint || `download${ext || ''}`;
|
|
978
|
+
const fileName = sanitizePathSegment(fallbackName) || `download${ext || ''}`;
|
|
979
|
+
const destPath = uniqueDestPath(path.join(outDir, fileName));
|
|
980
|
+
jobs.push({ url, destPath });
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const cookieHeader = cookie ? { Cookie: cookie } : {};
|
|
984
|
+
|
|
985
|
+
const resolveSiteTarget = async ({ siteSlug, siteId }) => {
|
|
986
|
+
const slug = siteSlug || (siteId ? `x-${siteId}` : null);
|
|
987
|
+
if (!slug) throw new Error('Unable to resolve site target: missing siteSlug/siteId');
|
|
988
|
+
|
|
989
|
+
const res = await fetchRedirectLocation(`https://mobbin.com/sites/${slug}`, {
|
|
990
|
+
headers: { 'User-Agent': userAgent, ...cookieHeader },
|
|
991
|
+
});
|
|
992
|
+
if (!res.location) {
|
|
993
|
+
throw new Error('Unable to resolve site version: missing redirect location');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const resolved = extractMobbinSiteVersionFromPathname(
|
|
997
|
+
new URL(res.location, 'https://mobbin.com').pathname,
|
|
998
|
+
);
|
|
999
|
+
if (!resolved?.siteVersionId) {
|
|
1000
|
+
throw new Error('Unable to resolve site version: unexpected redirect location');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return resolved;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const addSiteJobsFromSectionsPage = async ({ siteSlug, siteVersionId, siteId }) => {
|
|
1007
|
+
if (!cookie) {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
'Downloading from Mobbin site pages requires auth cookies. Run: mobbin auth save-cookie',
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const sectionsUrl = `https://mobbin.com/sites/${siteSlug}/${siteVersionId}/sections`;
|
|
1014
|
+
const html = await fetchText(sectionsUrl, {
|
|
1015
|
+
headers: { 'User-Agent': userAgent, ...cookieHeader },
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
const title = extractMobbinTitle(html);
|
|
1019
|
+
const siteNameFromTitle = title ? title.replace(/\s*\|\s*Mobbin.*$/i, '').trim() : '';
|
|
1020
|
+
const siteName = sanitizePathSegment(siteNameFromTitle || siteSlug || 'site') || 'site';
|
|
1021
|
+
const siteDir = path.join(outDir, `${siteName}__${siteId || 'site'}`);
|
|
1022
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
1023
|
+
|
|
1024
|
+
const urls = extractFullpageScreenUrlsFromMobbinSiteHtml(html);
|
|
1025
|
+
if (!urls.length) {
|
|
1026
|
+
throw new Error(`No fullpage screens found at ${sectionsUrl}`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
let index = 1;
|
|
1030
|
+
for (const url of urls) {
|
|
1031
|
+
const base = path.basename(new URL(url).pathname) || `screen${extFromUrl(url) || '.png'}`;
|
|
1032
|
+
const fileName = `${String(index).padStart(4, '0')}__${base}`;
|
|
1033
|
+
const destPath = uniqueDestPath(path.join(siteDir, fileName));
|
|
1034
|
+
jobs.push({ url, destPath });
|
|
1035
|
+
index += 1;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
if (values.appId) {
|
|
1040
|
+
const appIds = String(values.appId)
|
|
1041
|
+
.split(',')
|
|
1042
|
+
.map((s) => s.trim())
|
|
1043
|
+
.filter(Boolean);
|
|
1044
|
+
|
|
1045
|
+
for (const appId of appIds) {
|
|
1046
|
+
const data = await client.fetchAppVersionsScreens({ appId });
|
|
1047
|
+
const app = data?.value;
|
|
1048
|
+
if (!app) throw new Error('Unexpected response from app-versions: missing value');
|
|
1049
|
+
|
|
1050
|
+
const appName = sanitizePathSegment(app.appName || 'app') || 'app';
|
|
1051
|
+
const appDir = path.join(outDir, `${appName}__${appId}`);
|
|
1052
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
1053
|
+
|
|
1054
|
+
for (const version of app.appVersions || []) {
|
|
1055
|
+
for (const screen of version.appScreens || []) {
|
|
1056
|
+
const ext = extFromUrl(screen.screenUrl) || '.png';
|
|
1057
|
+
const screenNumber = Number.isFinite(screen.screenNumber)
|
|
1058
|
+
? String(screen.screenNumber).padStart(3, '0')
|
|
1059
|
+
: '000';
|
|
1060
|
+
const fileName = `${version.id}__${screenNumber}__${screen.id}${ext}`;
|
|
1061
|
+
const destPath = uniqueDestPath(path.join(appDir, fileName));
|
|
1062
|
+
jobs.push({ url: screen.screenUrl, destPath });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
} else if (values.siteId) {
|
|
1067
|
+
const siteIds = String(values.siteId)
|
|
1068
|
+
.split(',')
|
|
1069
|
+
.map((s) => s.trim())
|
|
1070
|
+
.filter(Boolean);
|
|
1071
|
+
|
|
1072
|
+
for (const siteId of siteIds) {
|
|
1073
|
+
const resolved = await resolveSiteTarget({ siteId });
|
|
1074
|
+
await addSiteJobsFromSectionsPage({
|
|
1075
|
+
siteSlug: resolved.siteSlug,
|
|
1076
|
+
siteVersionId: resolved.siteVersionId,
|
|
1077
|
+
siteId,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
} else if (values.screenId) {
|
|
1081
|
+
const screenIds = String(values.screenId)
|
|
1082
|
+
.split(',')
|
|
1083
|
+
.map((s) => s.trim())
|
|
1084
|
+
.filter(Boolean);
|
|
1085
|
+
|
|
1086
|
+
for (const screenId of screenIds) {
|
|
1087
|
+
const data = await client.fetchScreenInfo({ screenId });
|
|
1088
|
+
const screenUrl = data?.value?.screenUrl;
|
|
1089
|
+
if (!screenUrl) throw new Error('Unexpected response from screen-info: missing screenUrl');
|
|
1090
|
+
const ext = extFromUrl(screenUrl) || '.png';
|
|
1091
|
+
const fileName = `screen__${screenId}${ext}`;
|
|
1092
|
+
const destPath = uniqueDestPath(path.join(outDir, fileName));
|
|
1093
|
+
jobs.push({ url: screenUrl, destPath });
|
|
1094
|
+
}
|
|
1095
|
+
} else if (positionals.length) {
|
|
1096
|
+
let i = 0;
|
|
1097
|
+
for (const rawInput of positionals) {
|
|
1098
|
+
const input = normalizeUrlInput(rawInput);
|
|
1099
|
+
if (!input) continue;
|
|
1100
|
+
|
|
1101
|
+
const screenIdFromUrl = extractMobbinScreenIdFromUrl(input);
|
|
1102
|
+
if (screenIdFromUrl) {
|
|
1103
|
+
const data = await client.fetchScreenInfo({ screenId: screenIdFromUrl });
|
|
1104
|
+
const screenUrl = data?.value?.screenUrl;
|
|
1105
|
+
if (!screenUrl) throw new Error(`Unexpected response from screen-info for ${input}`);
|
|
1106
|
+
const ext = extFromUrl(screenUrl) || '.png';
|
|
1107
|
+
const fileName = `screen__${screenIdFromUrl}${ext}`;
|
|
1108
|
+
const destPath = uniqueDestPath(path.join(outDir, fileName));
|
|
1109
|
+
jobs.push({ url: screenUrl, destPath });
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const siteInfo = extractMobbinSiteInfoFromUrl(input);
|
|
1114
|
+
if (siteInfo) {
|
|
1115
|
+
const resolved =
|
|
1116
|
+
siteInfo.siteVersionId
|
|
1117
|
+
? { siteSlug: siteInfo.siteSlug, siteVersionId: siteInfo.siteVersionId }
|
|
1118
|
+
: await resolveSiteTarget({ siteSlug: siteInfo.siteSlug, siteId: siteInfo.siteId });
|
|
1119
|
+
|
|
1120
|
+
await addSiteJobsFromSectionsPage({
|
|
1121
|
+
siteSlug: resolved.siteSlug,
|
|
1122
|
+
siteVersionId: resolved.siteVersionId,
|
|
1123
|
+
siteId: siteInfo.siteId || extractUuidFromString(resolved.siteSlug) || 'site',
|
|
1124
|
+
});
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
addUrlJob(input, path.basename(new URL(input).pathname) || `download__${i}`);
|
|
1129
|
+
i += 1;
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
throw new Error('download requires --app-id, --site-id, --screen-id, or one or more URLs');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (!jobs.length) {
|
|
1136
|
+
throw new Error('Nothing to download (no URLs resolved)');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const results = await mapWithConcurrency(jobs, concurrency, async (job) => {
|
|
1140
|
+
const normalized = normalizeUrlInput(job.url);
|
|
1141
|
+
const candidates = [];
|
|
1142
|
+
|
|
1143
|
+
const bytescaleRawFromBytescaleImage = toBytescaleRawUrlFromBytescaleImageUrl(normalized);
|
|
1144
|
+
if (bytescaleRawFromBytescaleImage) {
|
|
1145
|
+
candidates.push(bytescaleRawFromBytescaleImage, normalized);
|
|
1146
|
+
} else {
|
|
1147
|
+
const supabaseParsed = parseSupabasePublicObjectUrl(normalized);
|
|
1148
|
+
if (supabaseParsed) {
|
|
1149
|
+
const cfg = await getBytescale();
|
|
1150
|
+
const rawUrl = await toBytescaleRawUrlFromSupabase(normalized, cfg);
|
|
1151
|
+
if (rawUrl) candidates.push(rawUrl);
|
|
1152
|
+
|
|
1153
|
+
const ext = extFromUrl(normalized).replace(/^\./, '');
|
|
1154
|
+
const format = ext || 'png';
|
|
1155
|
+
const imgUrl = await toBytescaleImageUrlFromSupabase(normalized, cfg, {
|
|
1156
|
+
width: 3840,
|
|
1157
|
+
quality: 90,
|
|
1158
|
+
format,
|
|
1159
|
+
});
|
|
1160
|
+
if (imgUrl) candidates.push(imgUrl);
|
|
1161
|
+
|
|
1162
|
+
candidates.push(normalized);
|
|
1163
|
+
} else {
|
|
1164
|
+
candidates.push(normalized);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
let lastErr = null;
|
|
1169
|
+
for (const candidate of candidates) {
|
|
1170
|
+
try {
|
|
1171
|
+
const downloaded = await downloadFile(candidate, job.destPath, {
|
|
1172
|
+
overwrite,
|
|
1173
|
+
headers: { 'User-Agent': userAgent },
|
|
1174
|
+
});
|
|
1175
|
+
return { ...downloaded, sourceUrl: normalized, finalUrl: candidate };
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
lastErr = err;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return {
|
|
1182
|
+
status: 'error',
|
|
1183
|
+
destPath: job.destPath,
|
|
1184
|
+
url: normalized,
|
|
1185
|
+
error: lastErr ? String(lastErr.message || lastErr) : 'Unknown error',
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
const summary = {
|
|
1190
|
+
outDir,
|
|
1191
|
+
downloaded: results.filter((r) => r.status === 'downloaded'),
|
|
1192
|
+
skipped: results.filter((r) => r.status === 'skipped'),
|
|
1193
|
+
errors: results.filter((r) => r.status === 'error'),
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
if (values.raw) {
|
|
1197
|
+
for (const r of summary.downloaded) console.log(r.destPath);
|
|
1198
|
+
for (const r of summary.skipped) console.log(r.destPath);
|
|
1199
|
+
} else {
|
|
1200
|
+
console.log(JSON.stringify(summary, null, values.compact ? 0 : 2));
|
|
1201
|
+
}
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
case 'recent-searches':
|
|
1205
|
+
result = await client.fetchRecentSearches();
|
|
1206
|
+
break;
|
|
1207
|
+
case 'content-apps': {
|
|
1208
|
+
const payload = buildContentAppsPayload({
|
|
1209
|
+
platform: values.platform || 'ios',
|
|
1210
|
+
tab: values.tab || 'latest',
|
|
1211
|
+
pageSize: Number(values.pageSize || 24),
|
|
1212
|
+
});
|
|
1213
|
+
result = await client.fetchContentApps(payload);
|
|
1214
|
+
break;
|
|
1215
|
+
}
|
|
1216
|
+
case 'auth': {
|
|
1217
|
+
const sub = positionals[0] || 'help';
|
|
1218
|
+
if (sub === 'print-cookie') {
|
|
1219
|
+
const cookie = resolveCookie(
|
|
1220
|
+
{ ...values, cookieFrom: values.cookieFrom || 'auto' },
|
|
1221
|
+
{ command: 'recent-searches' },
|
|
1222
|
+
);
|
|
1223
|
+
if (!cookie) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
'No mobbin.com cookies found. Log into Mobbin in your browser, then run: mobbin auth save-cookie (you may need --cookie-profile "Profile 1")',
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
console.log(cookie);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (sub === 'save-cookie') {
|
|
1233
|
+
const outPath = values.out || path.join(os.homedir(), '.config/mobbin/cookie.txt');
|
|
1234
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1235
|
+
|
|
1236
|
+
let cookie = resolveCookie(
|
|
1237
|
+
{ ...values, cookieFrom: values.cookieFrom || 'auto' },
|
|
1238
|
+
{ command: 'recent-searches' },
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
if (!cookie && process.platform === 'darwin') {
|
|
1242
|
+
try {
|
|
1243
|
+
execFileSync('open', ['https://mobbin.com'], { stdio: 'ignore' });
|
|
1244
|
+
} catch {}
|
|
1245
|
+
|
|
1246
|
+
if (process.stdin.isTTY) {
|
|
1247
|
+
process.stdout.write(
|
|
1248
|
+
'No mobbin.com cookies found. Log into Mobbin in your browser, then press Enter to retry...\n',
|
|
1249
|
+
);
|
|
1250
|
+
await new Promise((resolve) => process.stdin.once('data', resolve));
|
|
1251
|
+
cookie = resolveCookie(
|
|
1252
|
+
{ ...values, cookieFrom: values.cookieFrom || 'auto' },
|
|
1253
|
+
{ command: 'recent-searches' },
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (!cookie) {
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
'No mobbin.com cookies found. Ensure you are logged in, then re-run `mobbin auth save-cookie`.',
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
fs.writeFileSync(outPath, `${cookie}\n`, { mode: 0o600 });
|
|
1265
|
+
console.log(
|
|
1266
|
+
JSON.stringify({ savedTo: outPath, cookieCount: cookie.split(';').length }, null, 2),
|
|
1267
|
+
);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
printUsage();
|
|
1272
|
+
process.exit(1);
|
|
1273
|
+
}
|
|
1274
|
+
case 'call': {
|
|
1275
|
+
const path = positionals[0];
|
|
1276
|
+
if (!path) throw new Error('call requires a path, e.g. /api/search-bar/search');
|
|
1277
|
+
const method = (values.method || 'GET').toUpperCase();
|
|
1278
|
+
const data = values.data ? JSON.parse(values.data) : undefined;
|
|
1279
|
+
result = await client.request(path, { method, json: data });
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
default:
|
|
1283
|
+
printUsage();
|
|
1284
|
+
process.exit(1);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (values.raw) {
|
|
1288
|
+
console.log(result);
|
|
1289
|
+
} else {
|
|
1290
|
+
console.log(JSON.stringify(result, null, values.compact ? 0 : 2));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
main().catch((err) => {
|
|
1295
|
+
console.error(err?.data ? JSON.stringify(err.data, null, 2) : err.message);
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
});
|