Zero-dependency Google Drive, Docs, Sheets, and Slides direct download URL generator with format conversion (PDF, DOCX, XLSX) and link status verification.
| 1 | // gdrive.js — bundle standalone. npm deps: node:fs, node:fs/promises, node:path, node:stream, node:stream/promises, node:url |
| 2 | |
| 3 | /** |
| 4 | * Scraper: gdrive.js |
| 5 | * Source : mithra/gdrive.js |
| 6 | * |
| 7 | * Input : Function parameters / CLI args (e.g. query: string, url: string, options: object) |
| 8 | * Output : Promise<Object | Array | Buffer> (JSON formatted scraping response / stream URL / media metadata) |
| 9 | * |
| 10 | * CLI Run: |
| 11 | * node mithra/gdrive.js [functionName] [args...] |
| 12 | * node cli.js mithra/gdrive.js [functionName] [args...] |
| 13 | */ |
| 14 | |
| 15 | import * as __ns_585 from 'node:fs' |
| 16 | const statSync = __ns_585.statSync |
| 17 | const createWriteStream = __ns_585.createWriteStream |
| 18 | import * as __ns_586 from 'node:fs/promises' |
| 19 | const fs = __ns_586.default |
| 20 | import * as __ns_587 from 'node:path' |
| 21 | const path = __ns_587.default |
| 22 | import * as __ns_588 from 'node:stream' |
| 23 | const Readable = __ns_588.Readable |
| 24 | import * as __ns_589 from 'node:stream/promises' |
| 25 | const pipeline = __ns_589.pipeline |
| 26 | import * as __ns_590 from 'node:url' |
| 27 | const pathToFileURL = __ns_590.pathToFileURL |
| 28 | |
| 29 | /** |
| 30 | * Google Drive / Google Docs direct download link generator. |
| 31 | * |
| 32 | * Logic diambil dari vinish.dev/google-drive-file-downloader (client-side, |
| 33 | * tanpa server API). Zero npm dependency, Node >= 20 (built-in fetch). |
| 34 | * |
| 35 | * TEST LANGSUNG: |
| 36 | * node gdrive.js https://drive.google.com/file/d/<id>/view |
| 37 | * node gdrive.js <fileId> |
| 38 | * node gdrive.js https://docs.google.com/document/d/<id>/edit --format pdf |
| 39 | * node gdrive.js <url> --check |
| 40 | * node gdrive.js <url> --download [outputPath] |
| 41 | */ |
| 42 | |
| 43 | const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' |
| 44 | |
| 45 | const MIME_EXT = { |
| 46 | 'application/pdf': '.pdf', |
| 47 | 'text/plain': '.txt', |
| 48 | 'text/csv': '.csv', |
| 49 | 'text/html': '.html', |
| 50 | 'application/json': '.json', |
| 51 | 'image/png': '.png', |
| 52 | 'image/jpeg': '.jpg', |
| 53 | 'image/gif': '.gif', |
| 54 | 'image/webp': '.webp', |
| 55 | 'image/svg+xml': '.svg', |
| 56 | 'application/zip': '.zip', |
| 57 | 'application/x-zip-compressed': '.zip', |
| 58 | 'application/gzip': '.gz', |
| 59 | 'application/x-tar': '.tar', |
| 60 | 'application/vnd.rar': '.rar', |
| 61 | 'application/x-7z-compressed': '.7z', |
| 62 | 'application/msword': '.doc', |
| 63 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', |
| 64 | 'application/vnd.ms-excel': '.xls', |
| 65 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', |
| 66 | 'application/vnd.ms-powerpoint': '.ppt', |
| 67 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx', |
| 68 | 'application/vnd.oasis.opendocument.text': '.odt', |
| 69 | 'application/vnd.oasis.opendocument.spreadsheet': '.ods', |
| 70 | 'application/vnd.oasis.opendocument.presentation': '.odp', |
| 71 | 'audio/mpeg': '.mp3', |
| 72 | 'audio/mp4': '.m4a', |
| 73 | 'audio/wav': '.wav', |
| 74 | 'audio/ogg': '.ogg', |
| 75 | 'video/mp4': '.mp4', |
| 76 | 'video/webm': '.webm', |
| 77 | 'video/x-matroska': '.mkv', |
| 78 | 'video/quicktime': '.mov', |
| 79 | } |
| 80 | |
| 81 | const TYPES = { |
| 82 | file: { |
| 83 | name: 'Google Drive File', |
| 84 | formats: [], |
| 85 | defaultFormat: null, |
| 86 | url: (id) => `https://drive.google.com/uc?export=download&confirm=t&id=${id}`, |
| 87 | }, |
| 88 | document: { |
| 89 | name: 'Google Document', |
| 90 | formats: ['pdf', 'docx', 'odt', 'rtf', 'txt', 'html'], |
| 91 | defaultFormat: 'pdf', |
| 92 | url: (id, fmt) => `https://docs.google.com/document/d/${id}/export?format=${fmt}`, |
| 93 | }, |
| 94 | spreadsheets: { |
| 95 | name: 'Google Spreadsheet', |
| 96 | formats: ['xlsx', 'pdf', 'ods', 'csv', 'tsv'], |
| 97 | defaultFormat: 'xlsx', |
| 98 | url: (id, fmt) => `https://docs.google.com/spreadsheets/d/${id}/export?format=${fmt}`, |
| 99 | }, |
| 100 | presentation: { |
| 101 | name: 'Google Presentation', |
| 102 | formats: ['pptx', 'pdf', 'odp', 'txt'], |
| 103 | defaultFormat: 'pptx', |
| 104 | url: (id, fmt) => `https://docs.google.com/presentation/d/${id}/export/${fmt}`, |
| 105 | }, |
| 106 | drawing: { |
| 107 | name: 'Google Drawing', |
| 108 | formats: ['png', 'jpeg', 'svg', 'pdf'], |
| 109 | defaultFormat: 'png', |
| 110 | url: (id, fmt) => `https://docs.google.com/drawings/d/${id}/export/${fmt}`, |
| 111 | }, |
| 112 | folder: { |
| 113 | name: 'Google Drive Folder', |
| 114 | formats: [], |
| 115 | defaultFormat: null, |
| 116 | url: null, |
| 117 | }, |
| 118 | } |
| 119 | |
| 120 | const URL_PATTERNS = [ |
| 121 | { regex: /drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/, type: 'file' }, |
| 122 | { regex: /drive\.google\.com\/open\?id=([a-zA-Z0-9_-]+)/, type: 'file' }, |
| 123 | { regex: /drive\.google\.com\/uc\?(?:.*&)?id=([a-zA-Z0-9_-]+)/, type: 'file' }, |
| 124 | { regex: /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/, type: 'document' }, |
| 125 | { regex: /docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/, type: 'spreadsheets' }, |
| 126 | { regex: /docs\.google\.com\/presentation\/d\/([a-zA-Z0-9_-]+)/, type: 'presentation' }, |
| 127 | { regex: /docs\.google\.com\/drawings\/d\/([a-zA-Z0-9_-]+)/, type: 'drawing' }, |
| 128 | { regex: /drive\.google\.com\/drive\/folders\/([a-zA-Z0-9_-]+)/, type: 'folder' }, |
| 129 | { regex: /[?&]id=([a-zA-Z0-9_-]+)/, type: 'file' }, |
| 130 | ] |
| 131 | |
| 132 | /** |
| 133 | * Parse input (URL atau file ID polos) menjadi { fileId, type }. |
| 134 | * @returns {{fileId:string, type:string}|null} |
| 135 | */ |
| 136 | function parseGoogleUrl(input) { |
| 137 | const value = String(input || '').trim() |
| 138 | if (!value) return null |
| 139 | |
| 140 | // File ID polos (hanya alfanumerik, strip, underscore) dianggap file Drive. |
| 141 | if (/^[a-zA-Z0-9_-]{6,}$/.test(value) && !value.includes('.')) { |
| 142 | return { fileId: value, type: 'file' } |
| 143 | } |
| 144 | |
| 145 | for (const pattern of URL_PATTERNS) { |
| 146 | const match = value.match(pattern.regex) |
| 147 | if (match && match[1]) { |
| 148 | return { fileId: match[1], type: pattern.type } |
| 149 | } |
| 150 | } |
| 151 | return null |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Generate link download langsung. |
| 156 | * @returns {{fileId:string, type:string, typeName:string, formats:string[], defaultFormat:string|null, format:string|null, downloadUrl:string|null}} |
| 157 | */ |
| 158 | function generateDownloadLink(input, { format } = {}) { |
| 159 | const parsed = parseGoogleUrl(input) |
| 160 | if (!parsed) throw new Error('Invalid Google Drive/Docs URL. Example: https://drive.google.com/file/d/<id>/view') |
| 161 | |
| 162 | const config = TYPES[parsed.type] |
| 163 | if (parsed.type === 'folder') { |
| 164 | throw new Error('Folders cannot be downloaded directly. Share individual files instead.') |
| 165 | } |
| 166 | |
| 167 | let fmt = format || config.defaultFormat || null |
| 168 | if (fmt && config.formats.length && !config.formats.includes(fmt)) { |
| 169 | throw new Error(`Invalid format "${fmt}". Available: ${config.formats.join(', ')}`) |
| 170 | } |
| 171 | |
| 172 | return { |
| 173 | fileId: parsed.fileId, |
| 174 | type: parsed.type, |
| 175 | typeName: config.name, |
| 176 | formats: config.formats, |
| 177 | defaultFormat: config.defaultFormat, |
| 178 | format: fmt, |
| 179 | downloadUrl: config.url(parsed.fileId, fmt), |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Verifikasi link (status + content-type + ukuran). Body dibatalkan setelah header. |
| 185 | */ |
| 186 | async function checkLink(url) { |
| 187 | const res = await fetch(url, { |
| 188 | method: 'GET', |
| 189 | redirect: 'follow', |
| 190 | headers: { 'User-Agent': UA }, |
| 191 | signal: AbortSignal.timeout(25000), |
| 192 | }) |
| 193 | const len = res.headers.get('content-length') |
| 194 | if (res.body?.cancel) await res.body.cancel().catch(() => {}) |
| 195 | return { |
| 196 | status: res.status, |
| 197 | ok: res.ok, |
| 198 | contentType: res.headers.get('content-type') || '', |
| 199 | size: len ? Number(len) : null, |
| 200 | finalUrl: res.url || url, |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Ambil link download asli dari halaman konfirmasi virus-scan Drive (file besar). |
| 205 | function extractConfirmLink(html) { |
| 206 | const form = /<form[^>]*action="([^"]+)"[^>]*>/.exec(html) |
| 207 | if (form && /drive\.(usercontent\.)?google\.com/.test(form[1])) { |
| 208 | return form[1].replace(/&/g, '&') |
| 209 | } |
| 210 | const anchor = /href="(https:\/\/drive\.usercontent\.google\.com\/download[^"]+)"/.exec(html) |
| 211 | if (anchor) return anchor[1].replace(/&/g, '&') |
| 212 | return null |
| 213 | } |
| 214 | |
| 215 | // Nama file dari content-disposition, fallback ke ekstensi MIME / format. |
| 216 | function resolveFileName(res, result) { |
| 217 | const cd = res.headers.get('content-disposition') || '' |
| 218 | const star = /filename\*=UTF-8''([^;]+)/i.exec(cd) |
| 219 | if (star) { |
| 220 | const name = decodeURIComponent(star[1].trim()) |
| 221 | if (name) return name |
| 222 | } |
| 223 | const plain = /filename="?([^";]+)"?/i.exec(cd) |
| 224 | if (plain && plain[1]) return plain[1] |
| 225 | const mime = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() |
| 226 | const ext = MIME_EXT[mime] || (result.format ? `.${result.format}` : '') |
| 227 | return `drive_${result.fileId}${ext}` |
| 228 | } |
| 229 | |
| 230 | function resolveOutputPath(outputPath, fileName) { |
| 231 | if (!outputPath) return path.join(process.cwd(), fileName) |
| 232 | let isDir = false |
| 233 | try { isDir = statSync(outputPath).isDirectory() } catch { /* bukan direktori / belum ada */ } |
| 234 | if (isDir || outputPath.endsWith('/') || outputPath.endsWith(path.sep)) { |
| 235 | return path.join(outputPath, fileName) |
| 236 | } |
| 237 | return outputPath |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Download file ke disk. Streaming (tanpa buffer penuh di memori). |
| 242 | * @returns {Promise<{filePath:string, fileName:string, size:number, contentType:string, url:string, type:string}>} |
| 243 | */ |
| 244 | async function downloadFile(input, { format, outputPath, timeout = 120000 } = {}) { |
| 245 | const result = generateDownloadLink(input, { format }) |
| 246 | |
| 247 | let res = await fetch(result.downloadUrl, { |
| 248 | redirect: 'follow', |
| 249 | headers: { 'User-Agent': UA }, |
| 250 | signal: AbortSignal.timeout(timeout), |
| 251 | }) |
| 252 | if (!res.ok) { |
| 253 | await res.body?.cancel?.().catch(() => {}) |
| 254 | throw new Error(`HTTP ${res.status}`) |
| 255 | } |
| 256 | let finalUrl = res.url || result.downloadUrl |
| 257 | |
| 258 | // File Drive besar: uc mengembalikan halaman konfirmasi virus (text/html). |
| 259 | // File non-publik: uc mengembalikan halaman Sign-in. |
| 260 | const ctype = (res.headers.get('content-type') || '').toLowerCase() |
| 261 | if (ctype.includes('text/html')) { |
| 262 | const html = await res.text() |
| 263 | if (/sign-in|servicelogin|\/v3\/signin\//i.test(html)) { |
| 264 | throw new Error('File tidak publik (butuh login Google). Pastikan file di-share sebagai Anyone with the link.') |
| 265 | } |
| 266 | const direct = extractConfirmLink(html) |
| 267 | if (!direct) { |
| 268 | throw new Error('Drive meminta konfirmasi tetapi link download tidak ditemukan.') |
| 269 | } |
| 270 | res = await fetch(direct, { |
| 271 | redirect: 'follow', |
| 272 | headers: { 'User-Agent': UA }, |
| 273 | signal: AbortSignal.timeout(timeout), |
| 274 | }) |
| 275 | if (!res.ok) { |
| 276 | await res.body?.cancel?.().catch(() => {}) |
| 277 | throw new Error(`HTTP ${res.status}`) |
| 278 | } |
| 279 | finalUrl = res.url || direct |
| 280 | } |
| 281 | |
| 282 | const fileName = resolveFileName(res, result) |
| 283 | const filePath = resolveOutputPath(outputPath, fileName) |
| 284 | |
| 285 | await fs.mkdir(path.dirname(filePath), { recursive: true }) |
| 286 | const nodeStream = Readable.fromWeb(res.body) |
| 287 | await pipeline(nodeStream, createWriteStream(filePath)) |
| 288 | |
| 289 | const stat = await fs.stat(filePath) |
| 290 | return { |
| 291 | filePath, |
| 292 | fileName, |
| 293 | size: stat.size, |
| 294 | contentType: res.headers.get('content-type') || '', |
| 295 | url: finalUrl, |
| 296 | type: result.type, |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | // ===== SELF-TEST: node gdrive.js <url|id> [--format <fmt>] [--check] [--download <path>] ===== |
| 301 | const isDirectRun = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href |
| 302 | |
| 303 | if (isDirectRun) { |
| 304 | ;(async () => { |
| 305 | const args = process.argv.slice(2) |
| 306 | const input = args.find((a) => !a.startsWith('--')) |
| 307 | const fmtArg = args.indexOf('--format') >= 0 ? args[args.indexOf('--format') + 1] : undefined |
| 308 | const doCheck = args.includes('--check') |
| 309 | const dlIdx = args.indexOf('--download') |
| 310 | const dlPath = dlIdx >= 0 ? (args[dlIdx + 1] && !args[dlIdx + 1].startsWith('--') ? args[dlIdx + 1] : undefined) : undefined |
| 311 | const doDownload = dlIdx >= 0 |
| 312 | |
| 313 | if (!input) { |
| 314 | console.log('Usage: node gdrive.js <url|fileId> [--format <fmt>] [--check] [--download <path>]') |
| 315 | console.log('') |
| 316 | console.log('Examples:') |
| 317 | console.log(' node gdrive.js https://drive.google.com/file/d/<id>/view') |
| 318 | console.log(' node gdrive.js <fileId> --check') |
| 319 | console.log(' node gdrive.js https://docs.google.com/document/d/<id>/edit --format pdf') |
| 320 | console.log(' node gdrive.js <url> --download ./downloads') |
| 321 | console.log(' node gdrive.js <url> --format mp4') |
| 322 | process.exit(0) |
| 323 | } |
| 324 | |
| 325 | try { |
| 326 | const result = generateDownloadLink(input, { format: fmtArg }) |
| 327 | console.log('== Google Drive downloader ==') |
| 328 | console.log(`input : ${input}`) |
| 329 | console.log(`type : ${result.type} (${result.typeName})`) |
| 330 | console.log(`id : ${result.fileId}`) |
| 331 | if (result.formats.length) { |
| 332 | console.log(`formats: ${result.formats.join(', ')}`) |
| 333 | console.log(`format: ${result.format} (default ${result.defaultFormat})`) |
| 334 | } |
| 335 | console.log(`url : ${result.downloadUrl}`) |
| 336 | |
| 337 | if (doCheck) { |
| 338 | const info = await checkLink(result.downloadUrl) |
| 339 | console.log(`check : HTTP ${info.status}${info.ok ? '' : ' (may require confirmation)'} | ${info.contentType || 'unknown'} | ${info.size != null ? `${(info.size / 1048576).toFixed(2)} MB` : 'size unknown'}`) |
| 340 | } |
| 341 | |
| 342 | if (doDownload) { |
| 343 | const dl = await downloadFile(input, { format: fmtArg, outputPath: dlPath }) |
| 344 | console.log(`saved : ${dl.filePath}`) |
| 345 | console.log(`name : ${dl.fileName}`) |
| 346 | console.log(`size : ${(dl.size / 1048576).toFixed(2)} MB (${dl.size} bytes)`) |
| 347 | console.log(`type : ${dl.contentType || 'unknown'}`) |
| 348 | } |
| 349 | console.log('done') |
| 350 | } catch (e) { |
| 351 | console.error(`ERROR: ${e.message}`) |
| 352 | process.exit(1) |
| 353 | } |
| 354 | })() |
| 355 | } |
| 356 | |
| 357 | export { generateDownloadLink, parseGoogleUrl, checkLink, downloadFile }; |
| 358 | export default generateDownloadLink; |
| 359 | |
| 360 | /* Legacy generic self-test intentionally removed; the CLI above is the single entry point. */ |
| 361 | |
No comments yet — be the first to leave feedback or start a code discussion.
Sign in with your GitHub account to write comments and join the discussion.