mobbin 0.1.0 → 0.1.1
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/README.md +1 -1
- package/mobbin-cli.js +249 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ Notes:
|
|
|
160
160
|
|
|
161
161
|
- `download --app-id`, `--screen-id`, and `--site-id` require auth cookies
|
|
162
162
|
- `--app-id`, `--screen-id`, and `--site-id` accept comma-separated lists
|
|
163
|
-
- `download --site-id` fetches the site's `/sections` page HTML and extracts `content/app_fullpage_screens/*` URLs (if this breaks, pass the exact `/sites/.../<siteVersionId>/sections` URL)
|
|
163
|
+
- `download --site-id` resolves the latest `siteVersionId` (Mobbin no longer reliably redirects) then fetches the site's `/sections` page HTML and extracts `content/app_fullpage_screens/*` URLs (if this breaks, pass the exact `/sites/.../<siteVersionId>/sections` URL)
|
|
164
164
|
- `--platform` is typically one of `ios|android|web`
|
|
165
165
|
- `--experience` defaults to `apps` (Mobbin also uses other values in the UI)
|
|
166
166
|
- `content-apps --tab` maps to sorting behavior:
|
package/mobbin-cli.js
CHANGED
|
@@ -282,6 +282,44 @@ function readMobbinCookieFromSafariMac() {
|
|
|
282
282
|
return pairs.join('; ');
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
function candidateCookieDbPathsMac({ baseDir, profile }) {
|
|
286
|
+
const p = String(profile || 'Default');
|
|
287
|
+
const out = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
|
|
290
|
+
const add = (filePath) => {
|
|
291
|
+
if (!filePath) return;
|
|
292
|
+
if (seen.has(filePath)) return;
|
|
293
|
+
seen.add(filePath);
|
|
294
|
+
if (fs.existsSync(filePath)) out.push(filePath);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Typical Chromium layout (profile dir contains Cookies).
|
|
298
|
+
add(path.join(baseDir, p, 'Cookies'));
|
|
299
|
+
add(path.join(baseDir, p, 'Network', 'Cookies'));
|
|
300
|
+
|
|
301
|
+
// Some setups point `profile` at a user-data-dir that itself contains another profile dir.
|
|
302
|
+
// Example seen in the wild: .../Google/Chrome/Default/Default/Cookies.
|
|
303
|
+
add(path.join(baseDir, p, p, 'Cookies'));
|
|
304
|
+
add(path.join(baseDir, p, p, 'Network', 'Cookies'));
|
|
305
|
+
add(path.join(baseDir, p, 'Default', 'Cookies'));
|
|
306
|
+
add(path.join(baseDir, p, 'Default', 'Network', 'Cookies'));
|
|
307
|
+
|
|
308
|
+
// Last-resort: scan one directory level under the provided profile dir for a Cookies DB.
|
|
309
|
+
// This is bounded to keep it cheap.
|
|
310
|
+
try {
|
|
311
|
+
const dir = path.join(baseDir, p);
|
|
312
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).slice(0, 50);
|
|
313
|
+
for (const ent of entries) {
|
|
314
|
+
if (!ent.isDirectory()) continue;
|
|
315
|
+
add(path.join(dir, ent.name, 'Cookies'));
|
|
316
|
+
add(path.join(dir, ent.name, 'Network', 'Cookies'));
|
|
317
|
+
}
|
|
318
|
+
} catch {}
|
|
319
|
+
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
285
323
|
function readMobbinCookieFromMacBrowsers({ browser = 'auto', profile = 'Default' } = {}) {
|
|
286
324
|
if (process.platform !== 'darwin') return '';
|
|
287
325
|
|
|
@@ -293,40 +331,35 @@ function readMobbinCookieFromMacBrowsers({ browser = 'auto', profile = 'Default'
|
|
|
293
331
|
const candidates = [
|
|
294
332
|
{
|
|
295
333
|
id: 'chrome',
|
|
296
|
-
|
|
334
|
+
baseDir: path.join(home, 'Library/Application Support/Google/Chrome'),
|
|
297
335
|
keychain: { service: 'Chrome Safe Storage', account: 'Chrome' },
|
|
298
336
|
},
|
|
299
337
|
{
|
|
300
338
|
id: 'edge',
|
|
301
|
-
|
|
302
|
-
home,
|
|
303
|
-
'Library/Application Support/Microsoft Edge',
|
|
304
|
-
profile,
|
|
305
|
-
'Cookies',
|
|
306
|
-
),
|
|
339
|
+
baseDir: path.join(home, 'Library/Application Support/Microsoft Edge'),
|
|
307
340
|
keychain: { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
|
|
308
341
|
},
|
|
309
342
|
{
|
|
310
343
|
id: 'brave',
|
|
311
|
-
|
|
312
|
-
home,
|
|
313
|
-
'Library/Application Support/BraveSoftware/Brave-Browser',
|
|
314
|
-
profile,
|
|
315
|
-
'Cookies',
|
|
316
|
-
),
|
|
344
|
+
baseDir: path.join(home, 'Library/Application Support/BraveSoftware/Brave-Browser'),
|
|
317
345
|
keychain: { service: 'Brave Safe Storage', account: 'Brave' },
|
|
318
346
|
},
|
|
319
347
|
{
|
|
320
348
|
id: 'chromium',
|
|
321
|
-
|
|
349
|
+
baseDir: path.join(home, 'Library/Application Support/Chromium'),
|
|
322
350
|
keychain: { service: 'Chromium Safe Storage', account: 'Chromium' },
|
|
323
351
|
},
|
|
324
352
|
].filter((c) => browser === 'auto' || c.id === browser);
|
|
325
353
|
|
|
326
354
|
for (const candidate of candidates) {
|
|
327
|
-
|
|
355
|
+
const dbPaths = candidateCookieDbPathsMac({ baseDir: candidate.baseDir, profile });
|
|
356
|
+
if (!dbPaths.length) continue;
|
|
328
357
|
|
|
329
|
-
|
|
358
|
+
let rows = [];
|
|
359
|
+
for (const dbPath of dbPaths) {
|
|
360
|
+
rows = queryCookiesSqlite(dbPath);
|
|
361
|
+
if (rows.length) break;
|
|
362
|
+
}
|
|
330
363
|
if (!rows.length) continue;
|
|
331
364
|
|
|
332
365
|
const byName = new Map();
|
|
@@ -480,6 +513,57 @@ async function fetchRedirectLocation(url, { timeoutMs = 15000, headers } = {}) {
|
|
|
480
513
|
}
|
|
481
514
|
}
|
|
482
515
|
|
|
516
|
+
function extractUuidCandidates(s) {
|
|
517
|
+
const out = [];
|
|
518
|
+
const seen = new Set();
|
|
519
|
+
const re = new RegExp(UUID_RE.source, 'gi');
|
|
520
|
+
for (const m of String(s || '').matchAll(re)) {
|
|
521
|
+
const id = m[0].toLowerCase();
|
|
522
|
+
if (seen.has(id)) continue;
|
|
523
|
+
seen.add(id);
|
|
524
|
+
out.push(id);
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function extractSiteVersionCandidatesFromRsc(rscText, { siteId } = {}) {
|
|
530
|
+
const out = new Set();
|
|
531
|
+
const text = String(rscText || '');
|
|
532
|
+
|
|
533
|
+
const add = (id) => {
|
|
534
|
+
if (!id) return;
|
|
535
|
+
const normalized = String(id).toLowerCase();
|
|
536
|
+
if (!UUID_RE.test(normalized)) return;
|
|
537
|
+
out.add(normalized);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Common shapes we've seen in Mobbin's client bundles / RSC payloads.
|
|
541
|
+
const patterns = [
|
|
542
|
+
/latest_site_version[^0-9a-f]{0,240}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi,
|
|
543
|
+
/siteVersionId[^0-9a-f]{0,240}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi,
|
|
544
|
+
/site_version_id[^0-9a-f]{0,240}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi,
|
|
545
|
+
];
|
|
546
|
+
for (const re of patterns) {
|
|
547
|
+
for (const m of text.matchAll(re)) add(m[1]);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// If we know the siteId, look around occurrences of it and grab nearby UUIDs.
|
|
551
|
+
if (siteId && UUID_RE.test(String(siteId))) {
|
|
552
|
+
const needle = String(siteId).toLowerCase();
|
|
553
|
+
let idx = 0;
|
|
554
|
+
while (idx >= 0) {
|
|
555
|
+
idx = text.toLowerCase().indexOf(needle, idx);
|
|
556
|
+
if (idx < 0) break;
|
|
557
|
+
const windowStart = Math.max(0, idx - 800);
|
|
558
|
+
const windowEnd = Math.min(text.length, idx + 2400);
|
|
559
|
+
for (const id of extractUuidCandidates(text.slice(windowStart, windowEnd))) add(id);
|
|
560
|
+
idx = idx + needle.length;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return Array.from(out);
|
|
565
|
+
}
|
|
566
|
+
|
|
483
567
|
function findMobbinMainAppChunkUrl(html) {
|
|
484
568
|
const match = html.match(/\/_next\/static\/chunks\/main-app-[^"']+\.js[^"']*/);
|
|
485
569
|
if (!match) return null;
|
|
@@ -726,6 +810,12 @@ function extractFullpageScreenUrlsFromMobbinSiteHtml(html) {
|
|
|
726
810
|
return out;
|
|
727
811
|
}
|
|
728
812
|
|
|
813
|
+
function looksLikeMobbinNotFound(html) {
|
|
814
|
+
const s = String(html || '');
|
|
815
|
+
// Mobbin frequently serves a Next.js not-found page with 200 status.
|
|
816
|
+
return /Page not found/i.test(s) || /not-found\.tsx/i.test(s);
|
|
817
|
+
}
|
|
818
|
+
|
|
729
819
|
async function downloadFile(url, destPath, { overwrite = false, headers } = {}) {
|
|
730
820
|
if (!overwrite && fs.existsSync(destPath)) {
|
|
731
821
|
return { status: 'skipped', destPath, url };
|
|
@@ -983,24 +1073,115 @@ async function main() {
|
|
|
983
1073
|
const cookieHeader = cookie ? { Cookie: cookie } : {};
|
|
984
1074
|
|
|
985
1075
|
const resolveSiteTarget = async ({ siteSlug, siteId }) => {
|
|
986
|
-
|
|
987
|
-
|
|
1076
|
+
if (!siteSlug && !siteId) {
|
|
1077
|
+
throw new Error('Unable to resolve site target: missing siteSlug/siteId');
|
|
1078
|
+
}
|
|
988
1079
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1080
|
+
let siteName = null;
|
|
1081
|
+
if (!siteSlug && siteId) {
|
|
1082
|
+
try {
|
|
1083
|
+
const searchable = await client.fetchSearchableSites();
|
|
1084
|
+
const list = searchable?.value || searchable;
|
|
1085
|
+
const hit = Array.isArray(list) ? list.find((s) => s?.id === siteId) : null;
|
|
1086
|
+
siteName = hit?.name || null;
|
|
1087
|
+
} catch {
|
|
1088
|
+
// Best-effort only.
|
|
1089
|
+
}
|
|
994
1090
|
}
|
|
995
1091
|
|
|
996
|
-
const
|
|
997
|
-
|
|
1092
|
+
const slugCandidates = [];
|
|
1093
|
+
if (siteSlug) slugCandidates.push(siteSlug);
|
|
1094
|
+
if (siteId) {
|
|
1095
|
+
slugCandidates.push(`x-${siteId}`);
|
|
1096
|
+
if (siteName) {
|
|
1097
|
+
const nameSlug = (sanitizePathSegment(siteName) || 'site').toLowerCase();
|
|
1098
|
+
slugCandidates.push(`${nameSlug}-${siteId}`);
|
|
1099
|
+
}
|
|
1100
|
+
slugCandidates.push(`site-${siteId}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const uniqueCandidates = Array.from(
|
|
1104
|
+
new Set(slugCandidates.map((s) => String(s).trim()).filter(Boolean)),
|
|
998
1105
|
);
|
|
999
|
-
|
|
1000
|
-
|
|
1106
|
+
|
|
1107
|
+
let lastErr = null;
|
|
1108
|
+
const validateSections = async ({ siteSlug, siteVersionId }) => {
|
|
1109
|
+
const sectionsUrl = `https://mobbin.com/sites/${siteSlug}/${siteVersionId}/sections`;
|
|
1110
|
+
const html = await fetchText(sectionsUrl, {
|
|
1111
|
+
headers: { 'User-Agent': userAgent, ...cookieHeader },
|
|
1112
|
+
});
|
|
1113
|
+
const urls = extractFullpageScreenUrlsFromMobbinSiteHtml(html);
|
|
1114
|
+
if (urls.length) return { ok: true, siteSlug, siteVersionId };
|
|
1115
|
+
if (looksLikeMobbinNotFound(html)) {
|
|
1116
|
+
return { ok: false, error: new Error(`Mobbin returned a not-found page for ${sectionsUrl}`) };
|
|
1117
|
+
}
|
|
1118
|
+
return { ok: false, error: new Error(`No fullpage screens found at ${sectionsUrl}`) };
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
for (const slug of uniqueCandidates) {
|
|
1122
|
+
const siteUrl = `https://mobbin.com/sites/${slug}`;
|
|
1123
|
+
|
|
1124
|
+
// Older Mobbin behavior redirected /sites/<slug> -> /sites/<slug>/<siteVersionId>.
|
|
1125
|
+
// Newer Mobbin often returns a 200 with no Location header, so we also fall back
|
|
1126
|
+
// to parsing the RSC payload to locate the latest siteVersionId.
|
|
1127
|
+
try {
|
|
1128
|
+
const res = await fetchRedirectLocation(siteUrl, {
|
|
1129
|
+
headers: { 'User-Agent': userAgent, ...cookieHeader },
|
|
1130
|
+
});
|
|
1131
|
+
if (res.location) {
|
|
1132
|
+
const resolved = extractMobbinSiteVersionFromPathname(
|
|
1133
|
+
new URL(res.location, 'https://mobbin.com').pathname,
|
|
1134
|
+
);
|
|
1135
|
+
if (resolved?.siteVersionId) {
|
|
1136
|
+
const check = await validateSections({
|
|
1137
|
+
siteSlug: resolved.siteSlug,
|
|
1138
|
+
siteVersionId: resolved.siteVersionId,
|
|
1139
|
+
}).catch((err) => ({ ok: false, error: err }));
|
|
1140
|
+
if (check.ok) return resolved;
|
|
1141
|
+
lastErr = check.error || lastErr;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
lastErr = err;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
try {
|
|
1149
|
+
const rsc = await fetchText(siteUrl, {
|
|
1150
|
+
headers: {
|
|
1151
|
+
'User-Agent': userAgent,
|
|
1152
|
+
Accept: 'text/x-component',
|
|
1153
|
+
RSC: '1',
|
|
1154
|
+
...cookieHeader,
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
const candidates = extractSiteVersionCandidatesFromRsc(rsc, { siteId });
|
|
1159
|
+
for (const siteVersionId of candidates) {
|
|
1160
|
+
try {
|
|
1161
|
+
const check = await validateSections({ siteSlug: slug, siteVersionId });
|
|
1162
|
+
if (check.ok) return { siteSlug: slug, siteVersionId, tab: 'sections' };
|
|
1163
|
+
lastErr = check.error || lastErr;
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
// Keep trying other candidates.
|
|
1166
|
+
lastErr = err;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
lastErr = err;
|
|
1171
|
+
}
|
|
1001
1172
|
}
|
|
1002
1173
|
|
|
1003
|
-
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
[
|
|
1176
|
+
'Unable to resolve site version for download.',
|
|
1177
|
+
'Mobbin may require auth cookies, and it no longer reliably redirects /sites/<slug> to /sites/<slug>/<siteVersionId>.',
|
|
1178
|
+
'Try passing a logged-in cookie (mobbin auth save-cookie) or pass an exact sections URL:',
|
|
1179
|
+
' mobbin download "https://mobbin.com/sites/<slug>/<siteVersionId>/sections" --out-dir ./out',
|
|
1180
|
+
lastErr ? `Last error: ${String(lastErr.message || lastErr)}` : null,
|
|
1181
|
+
]
|
|
1182
|
+
.filter(Boolean)
|
|
1183
|
+
.join('\n'),
|
|
1184
|
+
);
|
|
1004
1185
|
};
|
|
1005
1186
|
|
|
1006
1187
|
const addSiteJobsFromSectionsPage = async ({ siteSlug, siteVersionId, siteId }) => {
|
|
@@ -1023,7 +1204,21 @@ async function main() {
|
|
|
1023
1204
|
|
|
1024
1205
|
const urls = extractFullpageScreenUrlsFromMobbinSiteHtml(html);
|
|
1025
1206
|
if (!urls.length) {
|
|
1026
|
-
|
|
1207
|
+
if (looksLikeMobbinNotFound(html)) {
|
|
1208
|
+
throw new Error(
|
|
1209
|
+
[
|
|
1210
|
+
`Mobbin returned a not-found page for ${sectionsUrl}.`,
|
|
1211
|
+
'This usually means your auth cookie is missing/invalid, expired, or you do not have access to this content.',
|
|
1212
|
+
'Try: mobbin auth save-cookie (then rerun download with that cookie), or pass a fresh Cookie header via --cookie.',
|
|
1213
|
+
].join('\n'),
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
[
|
|
1218
|
+
`No fullpage screens found at ${sectionsUrl}.`,
|
|
1219
|
+
'This site/version may not have full-page captures, or Mobbin changed the page format.',
|
|
1220
|
+
].join('\n'),
|
|
1221
|
+
);
|
|
1027
1222
|
}
|
|
1028
1223
|
|
|
1029
1224
|
let index = 1;
|
|
@@ -1064,6 +1259,31 @@ async function main() {
|
|
|
1064
1259
|
}
|
|
1065
1260
|
}
|
|
1066
1261
|
} else if (values.siteId) {
|
|
1262
|
+
if (!cookie) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
'Downloading from Mobbin site pages requires auth cookies. Run: mobbin auth save-cookie',
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Validate that we actually have an authenticated session cookie.
|
|
1269
|
+
// Some browser stores may include non-auth cookies (analytics) that look real but won't grant access.
|
|
1270
|
+
try {
|
|
1271
|
+
await client.fetchRecentSearches();
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
const status = err && typeof err === 'object' ? err.status : null;
|
|
1274
|
+
if (status === 401 || status === 403) {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
[
|
|
1277
|
+
'Your Mobbin cookie does not appear to be authenticated (recent-searches returned 401/403).',
|
|
1278
|
+
'Save a logged-in cookie and retry:',
|
|
1279
|
+
' mobbin auth save-cookie --out ~/.config/mobbin/cookie.txt',
|
|
1280
|
+
' mobbin download --site-id <uuid> --out-dir ./out',
|
|
1281
|
+
].join('\n'),
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
throw err;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1067
1287
|
const siteIds = String(values.siteId)
|
|
1068
1288
|
.split(',')
|
|
1069
1289
|
.map((s) => s.trim())
|
package/package.json
CHANGED