Created
December 30, 2025 05:38
-
-
Save b0o/8b935b7b165725108ba8ee4033d76ba0 to your computer and use it in GitHub Desktop.
Lovable Project Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Lovable Project Downloader | |
| // @license MIT | |
| // @namespace https://lovable.dev | |
| // @version 2.0.0 | |
| // @description Download entire Lovable project | |
| // @match https://lovable.dev/projects/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=lovable.dev | |
| // @connect api.lovable.dev | |
| // @grant GM_registerMenuCommand | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| const API_BASE_URL = "https://api.lovable.dev"; | |
| const JWT_REGEX = /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; | |
| const JWT_REGEX_STRICT = /^eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; | |
| // --- Logging --- | |
| const log = { | |
| prefix: '[Lovable DL]', | |
| info: (msg, ...args) => console.log(`${log.prefix} ${msg}`, ...args), | |
| error: (msg, ...args) => console.error(`${log.prefix} ${msg}`, ...args), | |
| }; | |
| // --- Utility Functions --- | |
| function getProjectIdFromUrl() { | |
| const match = window.location.pathname.match(/\/projects\/([a-f0-9-]+)/i); | |
| return match?.[1] || null; | |
| } | |
| function base64ToArrayBuffer(base64) { | |
| const binaryString = atob(base64); | |
| const bytes = new Uint8Array(binaryString.length); | |
| for (let i = 0; i < binaryString.length; i++) { | |
| bytes[i] = binaryString.charCodeAt(i); | |
| } | |
| return bytes.buffer; | |
| } | |
| function triggerDownload(blob, filename) { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.style.display = 'none'; | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| log.info(`Download triggered: ${filename}`); | |
| } | |
| // --- Token Extraction --- | |
| function validateJwt(jwt) { | |
| return jwt && JWT_REGEX_STRICT.test(jwt); | |
| } | |
| function extractJwtFromString(str) { | |
| if (!str) return null; | |
| const match = str.match(JWT_REGEX); | |
| return match?.[0] || null; | |
| } | |
| function getTokenFromNextData() { | |
| if (typeof __next_f === 'undefined') return null; | |
| for (let i = 0; i < __next_f.length; i++) { | |
| const content = __next_f[i]?.[1]; | |
| if (typeof content === 'string' && content.includes('eyJ')) { | |
| const jwt = extractJwtFromString(content); | |
| if (validateJwt(jwt)) { | |
| log.info(`Token found in __next_f[${i}]`); | |
| return jwt; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function getTokenFromLocalStorage() { | |
| const keys = ['rl_session', 'rl_trait']; | |
| for (const key of keys) { | |
| try { | |
| const value = localStorage.getItem(key); | |
| if (value) { | |
| try { | |
| const parsed = JSON.parse(value); | |
| const tokenStr = JSON.stringify(parsed); | |
| const jwt = extractJwtFromString(tokenStr); | |
| if (validateJwt(jwt)) { | |
| log.info(`Token found in localStorage[${key}]`); | |
| return jwt; | |
| } | |
| } catch { | |
| const jwt = extractJwtFromString(value); | |
| if (validateJwt(jwt)) { | |
| log.info(`Token found in localStorage[${key}]`); | |
| return jwt; | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // localStorage access error | |
| } | |
| } | |
| return null; | |
| } | |
| function getTokenFromScripts() { | |
| const scripts = document.querySelectorAll('script'); | |
| const regex = /\\"idToken\\":\\"([A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+)\\"/; | |
| for (const script of scripts) { | |
| const content = script.textContent; | |
| if (content) { | |
| const match = content.match(regex); | |
| if (match?.[1] && validateJwt(match[1])) { | |
| log.info("Token found in script tag"); | |
| return match[1]; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function getIdToken() { | |
| return getTokenFromNextData() || getTokenFromLocalStorage() || getTokenFromScripts(); | |
| } | |
| // --- TAR Library (inline, zero dependencies) --- | |
| // Minimal tar implementation based on USTAR format | |
| function createTar(files) { | |
| const encoder = new TextEncoder(); | |
| const chunks = []; | |
| for (const file of files) { | |
| const nameBytes = encoder.encode(file.name); | |
| if (nameBytes.length > 100) { | |
| log.error(`Filename too long, skipping: ${file.name}`); | |
| continue; | |
| } | |
| const content = file.binary | |
| ? new Uint8Array(base64ToArrayBuffer(file.contents)) | |
| : encoder.encode(file.contents); | |
| const size = content.length; | |
| // Create 512-byte header | |
| const header = new Uint8Array(512); | |
| // File name (0-99) | |
| header.set(nameBytes, 0); | |
| // File mode (100-107) - "0000644\0" | |
| header.set(encoder.encode('0000644\0'), 100); | |
| // UID (108-115) - "0000000\0" | |
| header.set(encoder.encode('0000000\0'), 108); | |
| // GID (116-123) - "0000000\0" | |
| header.set(encoder.encode('0000000\0'), 116); | |
| // File size in octal (124-135) | |
| const sizeOctal = size.toString(8).padStart(11, '0') + '\0'; | |
| header.set(encoder.encode(sizeOctal), 124); | |
| // Mtime (136-147) - current time in octal | |
| const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0'; | |
| header.set(encoder.encode(mtime), 136); | |
| // Checksum placeholder (148-155) - spaces for calculation | |
| header.set(encoder.encode(' '), 148); | |
| // Type flag (156) - '0' for regular file | |
| header[156] = 48; // '0' | |
| // USTAR indicator (257-262) | |
| header.set(encoder.encode('ustar\0'), 257); | |
| // USTAR version (263-264) | |
| header.set(encoder.encode('00'), 263); | |
| // Calculate checksum | |
| let checksum = 0; | |
| for (let i = 0; i < 512; i++) { | |
| checksum += header[i]; | |
| } | |
| const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '; | |
| header.set(encoder.encode(checksumOctal), 148); | |
| chunks.push(header); | |
| chunks.push(content); | |
| // Pad content to 512-byte boundary | |
| const padding = 512 - (size % 512); | |
| if (padding < 512) { | |
| chunks.push(new Uint8Array(padding)); | |
| } | |
| } | |
| // End of archive - two 512-byte blocks of zeros | |
| chunks.push(new Uint8Array(1024)); | |
| // Combine all chunks | |
| const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0); | |
| const result = new Uint8Array(totalSize); | |
| let offset = 0; | |
| for (const chunk of chunks) { | |
| result.set(chunk, offset); | |
| offset += chunk.length; | |
| } | |
| return new Blob([result], { type: 'application/x-tar' }); | |
| } | |
| // --- Main Download Function --- | |
| async function downloadProject() { | |
| log.info("Starting project download..."); | |
| const projectId = getProjectIdFromUrl(); | |
| if (!projectId) { | |
| alert("Could not find project ID in URL."); | |
| return; | |
| } | |
| log.info(`Project ID: ${projectId}`); | |
| const idToken = getIdToken(); | |
| if (!idToken) { | |
| alert("Authentication token not found. Make sure you're logged in."); | |
| return; | |
| } | |
| log.info("Auth token found."); | |
| // Fetch project details | |
| log.info("Fetching project details..."); | |
| let projectDetails; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/projects/${projectId}/details`, { | |
| method: 'GET', | |
| headers: { 'Authorization': `Bearer ${idToken}` }, | |
| credentials: 'include' | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| projectDetails = await response.json(); | |
| log.info(`Project: ${projectDetails.display_name}`); | |
| } catch (error) { | |
| log.error("Failed to fetch project details:", error); | |
| alert(`Failed to fetch project details: ${error.message}`); | |
| return; | |
| } | |
| const displayName = projectDetails.display_name || 'project'; | |
| const shaShort = (projectDetails.preview_build_commit_sha || '').substring(0, 7); | |
| log.info("Fetching project source code..."); | |
| let sourceData; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/projects/${projectId}/source-code`, { | |
| method: 'GET', | |
| headers: { 'Authorization': `Bearer ${idToken}` }, | |
| credentials: 'include' | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| sourceData = await response.json(); | |
| log.info(`Fetched ${sourceData.files?.length || 0} files.`); | |
| } catch (error) { | |
| log.error("Fetch failed:", error); | |
| alert(`Failed to fetch project data: ${error.message}`); | |
| return; | |
| } | |
| if (!sourceData.files || sourceData.files.length === 0) { | |
| alert("No files found in project."); | |
| return; | |
| } | |
| // Create TAR | |
| log.info("Creating TAR archive..."); | |
| try { | |
| const files = sourceData.files | |
| .filter(f => f.contents !== undefined) | |
| .map(f => ({ | |
| name: `${displayName}/${f.name}`, | |
| contents: f.contents, | |
| binary: !!f.binary | |
| })); | |
| log.info(`Packaging ${files.length} files...`); | |
| const tarBlob = createTar(files); | |
| log.info(`TAR created, size: ${tarBlob.size} bytes`); | |
| const filename = `Lovable - ${displayName} ${shaShort}.tar`; | |
| triggerDownload(tarBlob, filename); | |
| log.info("Download complete!"); | |
| } catch (error) { | |
| log.error("TAR creation failed:", error); | |
| alert(`Failed to create TAR: ${error.message}`); | |
| } | |
| } | |
| // --- Register Menu Command --- | |
| GM_registerMenuCommand("📥 Download Project", downloadProject); | |
| log.info("Script loaded. Use Tampermonkey menu to download project."); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment