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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/mobbin-cli.js +249 -29
  3. 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
- cookieDbPath: path.join(home, 'Library/Application Support/Google/Chrome', profile, 'Cookies'),
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
- cookieDbPath: path.join(
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
- cookieDbPath: path.join(
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
- cookieDbPath: path.join(home, 'Library/Application Support/Chromium', profile, 'Cookies'),
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
- if (!fs.existsSync(candidate.cookieDbPath)) continue;
355
+ const dbPaths = candidateCookieDbPathsMac({ baseDir: candidate.baseDir, profile });
356
+ if (!dbPaths.length) continue;
328
357
 
329
- const rows = queryCookiesSqlite(candidate.cookieDbPath);
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
- const slug = siteSlug || (siteId ? `x-${siteId}` : null);
987
- if (!slug) throw new Error('Unable to resolve site target: missing siteSlug/siteId');
1076
+ if (!siteSlug && !siteId) {
1077
+ throw new Error('Unable to resolve site target: missing siteSlug/siteId');
1078
+ }
988
1079
 
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');
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 resolved = extractMobbinSiteVersionFromPathname(
997
- new URL(res.location, 'https://mobbin.com').pathname,
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
- if (!resolved?.siteVersionId) {
1000
- throw new Error('Unable to resolve site version: unexpected redirect location');
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
- return resolved;
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
- throw new Error(`No fullpage screens found at ${sectionsUrl}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobbin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI + lightweight SDK for Mobbin's (mostly undocumented) web JSON endpoints and asset downloads.",
5
5
  "author": "Tomas Roda <dev@tomasroda.com>",
6
6
  "license": "MIT",