/** * Adds a custom menu to the spreadsheet to run the script. */functiononOpen(){SpreadsheetApp.getUi().createMenu('Image Metadata').addItem('Get Metadata','getMetadataWithPrompts').addToUi();}/** * Prompts the user for the URL and output ranges and then runs the main function. */functiongetMetadataWithPrompts(){constui=SpreadsheetApp.getUi();// Prompt for the URL rangeconsturlRangeResponse=ui.prompt('Range of image URLs ','(e.g., A2:A)',ui.ButtonSet.OK_CANCEL);// Exit if the user clicks "Cancel"if(urlRangeResponse.getSelectedButton()!==ui.Button.OK){return;}constsourceRange=urlRangeResponse.getResponseText();// Prompt for header inclusionconstheaderResponse=ui.prompt('Header row included?','Leave empty to include. Type "n" to omit',ui.ButtonSet.OK_CANCEL);letincludeHeader=true;// Set a default value of trueif(headerResponse.getSelectedButton()===ui.Button.OK){// If the user types "no" (or "NO", "No", etc.), set includeHeader to false.if(headerResponse.getResponseText().toLowerCase().trim()==='no'){includeHeader=false;}}else{// If the user clicks "Cancel," we should exit the script.return;}// Prompt for the output start cellconstoutputCellResponse=ui.prompt('Starting output cell','(e.g., B2)',ui.ButtonSet.OK_CANCEL);// Exit if the user clicks "Cancel"if(outputCellResponse.getSelectedButton()!==ui.Button.OK){return;}constoutputStartCell=outputCellResponse.getResponseText();// Run the main function with the collected parameters.getImageMetadata(sourceRange,outputStartCell,includeHeader);}/** * Main function to get the image metadata. * @param {string} sourceRange A string representing the range of URLs. * @param {string} outputStartCell A string representing the starting cell for the output. * @param {boolean} includeHeader If true, a header row will be included. */functiongetImageMetadata(sourceRange,outputStartCell,includeHeader){constspreadsheet=SpreadsheetApp.getActiveSpreadsheet();constsheet=spreadsheet.getActiveSheet();try{consturls=sheet.getRange(sourceRange).getValues().flat();constoutputCell=sheet.getRange(outputStartCell);constallResults=[];// Add header row based on user inputif(includeHeader){allResults.push(["File Type","Size (kB)","Width (px)","Height (px)","Aspect Ratio"]);}constchunkSize=100;for(leti=0;i<urls.length;i+=chunkSize){constchunk=urls.slice(i,i+chunkSize);constrequests=[];for(letj=0;j<chunk.length;j++){consturl=chunk[j];if(!url||url.toString().trim()===''||!is_valid_url(url)){allResults.push(["Error: Invalid/Empty URL",null,null,null,null]);continue;}constencodedUrl=encodeURI(url);requests.push({url:encodedUrl,muteHttpExceptions:true,originalUrl:url});}letresponses=[];if(requests.length>0){responses=UrlFetchApp.fetchAll(requests);}letresponseIndex=0;for(letj=0;j<chunk.length;j++){consturl=chunk[j];if(!url||url.toString().trim()===''||!is_valid_url(url)){// Already handled abovecontinue;}letmetadata=[];constresponse=responses[responseIndex];try{if(!response||response.getResponseCode()>=400){thrownewError(HTTPError:${response?response.getResponseCode():'No Response'});}constblob=response.getBlob();constheaders=response.getHeaders();constsizeBytes=blob.getBytes().length;constsizeKB=(sizeBytes/1024).toFixed(2);constcontentType=headers['Content-Type'];letfileType=null,width=null,height=null,aspectRatio=null;constmatch=/^(image\/(\w+));\s*width=(\d+);\s*height=(\d+)$/.exec(contentType);if(match){fileType=match[2];width=parseInt(match[3],10);height=parseInt(match[4],10);constcommonDivisor=gcd(width,height);aspectRatio=(width/commonDivisor)+":"+(height/commonDivisor);}else{fileType=contentType?contentType.split('/')[1]:null;}metadata=[fileType,sizeKB,width,height,aspectRatio];}catch(e){metadata=[Error:${e.message},null,null,null,null];}allResults.push(metadata);responseIndex++;}if(i+chunkSize<urls.length){Utilities.sleep(1*1000);// Sleep for 1 second}}if(allResults.length>0){constoutputRange=sheet.getRange(outputCell.getRow(),outputCell.getColumn(),allResults.length,allResults[0].length);outputRange.setValues(allResults);}SpreadsheetApp.getUi().alert('✅ Completed!');}catch(e){SpreadsheetApp.getUi().alert('❌ Error: '+e.message);}}// Helper function to find the greatest common divisor (GCD) using the Euclidean algorithmfunctiongcd(a,b){returnb===0?a:gcd(b,a%b);}// Helper function to validate if a URL is in the correct formatfunctionis_valid_url(url){returnurl.toString().startsWith('http://')||url.toString().startsWith('https://');}
/** * Gets the file type, file size (kB), width (px), and height (px) of images from a list of URLs. * This function is designed to process an entire range at once, avoiding the need for ARRAYFORMULA. * * @param {A2:A} urls A one-dimensional range of image URLs. * @param {TRUE} chunkSize Optional (default = 100). # of asynchronous requests in a single batch * @param {TRUE} includeHeader Optional (default = true). If true, a header row with column names will be included in the output. If false, header row will be omitted. * @returns {string[][]} A two-dimensional array containing the metadata for each URL. * @customfunction */functionIMAGE_METADATA(urls,chunkSize=100,includeHeader=true){constallResults=[];consturlsToProcess=Array.isArray(urls)?urls.flat():[urls];// Add header row only for a range input with more than one row and if the includeHeader parameter is true. if(Array.isArray(urls)&&urls.length>1&&includeHeader===true){allResults.push(["File Type","Size (kB)","Width (px)","Height (px)","Aspect Ratio"]);}for(leti=0;i<urlsToProcess.length;i+=chunkSize){constchunk=urlsToProcess.slice(i,i+chunkSize);constrequests=[];constchunkMap=newMap();// 1. Build the array of requests for the current chunk for(letj=0;j<chunk.length;j++){consturl=chunk[j];if(!url||url.toString().trim()===''||!is_valid_url(url)){chunkMap.set(j,null);continue;}constencodedUrl=encodeURI(url);requests.push({url:encodedUrl,muteHttpExceptions:true});chunkMap.set(j,encodedUrl);}// 2. Make all valid requests in the chunk concurrently letresponses=[];if(requests.length>0){responses=UrlFetchApp.fetchAll(requests);}constencodedUrlToResponseMap=newMap(requests.map((req,index)=>[req.url,responses[index]]));// 3. Process the results for the current chunk for(letj=0;j<chunk.length;j++){consturl=chunk[j];letmetadata=[];// Handle empty and invalid URLs without fetching if(!url||url.toString().trim()===''){metadata=[null,null,null,null,null];}elseif(!is_valid_url(url)){metadata=["Error: Invalid URL",null,null,null,null];}else{constencodedUrl=encodeURI(url);constresponse=encodedUrlToResponseMap.get(encodedUrl);try{if(!response||response.getResponseCode()>=400){thrownewError(HTTPError:${response?response.getResponseCode():'No Response'});}constblob=response.getBlob();constheaders=response.getHeaders();constsizeBytes=blob.getBytes().length;constsizeKB=(sizeBytes/1024).toFixed(2);constcontentType=headers['Content-Type'];constmatch=/^(image\/(\w+));\s*width=(\d+);\s*height=(\d+)$/.exec(contentType);letfileType,width,height,aspectRatio;if(match){fileType=match[2];width=parseInt(match[3],10);height=parseInt(match[4],10);constcommonDivisor=gcd(width,height);aspectRatio=(width/commonDivisor)+":"+(height/commonDivisor);}else{fileType=contentType?contentType.split('/')[1]:null;width=null;height=null;aspectRatio=null;}metadata=[fileType,sizeKB,width,height,aspectRatio];}catch(e){metadata=[Error:${e.message},null,null,null,null];}}allResults.push(metadata);}// Add a small delay between chunks to avoid rate limits if(i+chunkSize<urlsToProcess.length){Utilities.sleep(1*1000);// Sleep for x seconds }}returnallResults;}// Helper function to find the greatest common divisor (GCD) using the Euclidean algorithm functiongcd(a,b){returnb===0?a:gcd(b,a%b);}// Helper function to validate if a URL is in the correct format functionis_valid_url(url){returnurl.toString().startsWith('http://')||url.toString().startsWith('https://');}