Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Tests

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: ${{ matrix.node }}
- run: node --test test/Code.test.cjs
148 changes: 75 additions & 73 deletions Code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function onOpen() {
}

/**
* Run this once to save the user's IPGeolocation.io API key.
* Saves the IPGeolocation.io API key used by this spreadsheet.
*/
function setIpGeolocationApiKey() {
const ui = SpreadsheetApp.getUi();
Expand Down Expand Up @@ -149,10 +149,11 @@ function IPSECURITY(ip, field) {
*/
function IPGEO_BULK(ipRange, field, include) {
try {
const ips = normalizeRangeToList_(ipRange);
const inputs = normalizeRangeToList_(ipRange);
const ips = inputs.filter(function (ip) { return ip !== ''; });

if (!ips.length) {
return [['']];
return inputs.length ? inputs.map(function () { return ['']; }) : [['']];
}

if (ips.length > IPGEO_CONFIG.MAX_BULK_SIZE) {
Expand All @@ -161,8 +162,13 @@ function IPGEO_BULK(ipRange, field, include) {

const results = fetchIpGeoBulk_(ips, include);

return results.map(function (item) {
return [getNestedValue_(item, field)];
if (!Array.isArray(results) || results.length !== ips.length) {
throw new Error('Invalid bulk response: expected one result per IP address.');
}

let resultIndex = 0;
return inputs.map(function (ip) {
return [ip === '' ? '' : getNestedValue_(results[resultIndex++], field)];
});
} catch (error) {
return [[formatError_(error)]];
Expand Down Expand Up @@ -223,76 +229,68 @@ function fetchIpSecurity_(ip) {
}

function fetchIpGeoBulk_(ips, include) {
const apiKey = getApiKey_();

const params = { apiKey: apiKey, output: 'json' };
const params = {};

if (include && String(include).trim()) {
params.include = String(include).trim();
}

const url = `${IPGEO_CONFIG.BASE_URL}/ipgeo-bulk?${buildQueryString_(params)}`;
const cacheKey = makeCacheKey_('POST:' + url + ':' + JSON.stringify(ips));
const cached = CacheService.getScriptCache().get(cacheKey);

if (cached) {
return JSON.parse(cached);
}

const response = UrlFetchApp.fetch(url, {
return fetchJsonWithCache_(buildUrl_('/ipgeo-bulk', params), {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ ips: ips }),
muteHttpExceptions: true
});
}

const statusCode = response.getResponseCode();
const body = response.getContentText();
function fetchJsonWithCache_(url, options) {
options = options || { method: 'get', muteHttpExceptions: true };
const cacheKey = makeCacheKey_(options.method.toUpperCase() + ':' + url + ':' + (options.payload || ''));
const cached = getCachedJson_(cacheKey);

if (statusCode < 200 || statusCode >= 300) {
throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`);
if (cached !== null) {
return cached;
}

const data = JSON.parse(body);
const requestKey = decodeURIComponent((url.match(/[?&]apiKey=([^&]*)/i) || [])[1] || '');
let data;
try {
const response = UrlFetchApp.fetch(url, options);
const statusCode = response.getResponseCode();
const body = response.getContentText();

CacheService.getScriptCache().put(
cacheKey,
JSON.stringify(data),
IPGEO_CONFIG.CACHE_TTL_SECONDS
);
if (statusCode < 200 || statusCode >= 300) {
throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`);
}

data = JSON.parse(body);
} catch (error) {
throw new Error(redactErrorMessage_(error, requestKey));
}

cacheJson_(cacheKey, data);
return data;
}

function fetchJsonWithCache_(url) {
const cacheKey = makeCacheKey_('GET:' + url);
const cached = CacheService.getScriptCache().get(cacheKey);

if (cached) {
return JSON.parse(cached);
function getCachedJson_(cacheKey) {
try {
const cached = CacheService.getScriptCache().get(cacheKey);
return cached ? JSON.parse(cached) : null;
} catch (error) {
return null;
}
}

const response = UrlFetchApp.fetch(url, {
method: 'get',
muteHttpExceptions: true
});

const statusCode = response.getResponseCode();
const body = response.getContentText();

if (statusCode < 200 || statusCode >= 300) {
throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`);
function cacheJson_(cacheKey, data) {
try {
const json = JSON.stringify(data);
if (json.length > 100000 || Utilities.newBlob(json).getBytes().length > 100000) {
return;
}
CacheService.getScriptCache().put(cacheKey, json, IPGEO_CONFIG.CACHE_TTL_SECONDS);
} catch (error) {
return;
}

const data = JSON.parse(body);

CacheService.getScriptCache().put(
cacheKey,
JSON.stringify(data),
IPGEO_CONFIG.CACHE_TTL_SECONDS
);

return data;
}

function buildUrl_(path, params) {
Expand Down Expand Up @@ -364,26 +362,13 @@ function getNestedValue_(object, path) {
}

function normalizeRangeToList_(range) {
if (!Array.isArray(range)) {
return String(range || '').trim() ? [String(range).trim()] : [];
}

const list = [];

range.forEach(function (row) {
if (Array.isArray(row)) {
row.forEach(function (cell) {
const value = String(cell || '').trim();
if (value) {
list.push(value);
}
});
} else {
const value = String(row || '').trim();
if (value) {
list.push(value);
}
}
const rows = Array.isArray(range) ? range : [range];
rows.forEach(function (row) {
const cells = Array.isArray(row) ? row : [row];
cells.forEach(function (cell) {
list.push(String(cell || '').trim());
});
});

return list;
Expand All @@ -404,5 +389,22 @@ function makeCacheKey_(value) {
}

function formatError_(error) {
return 'Error: ' + error.message;
try {
const apiKey = PropertiesService.getScriptProperties().getProperty(IPGEO_CONFIG.API_KEY_PROPERTY);
return 'Error: ' + redactErrorMessage_(error, apiKey);
} catch (storageError) {
return 'Error: IPGeolocation.io request failed.';
}
}

function redactErrorMessage_(error, apiKey) {
let message = error && error.message !== undefined ? String(error.message) : String(error || 'Request failed.');
if (apiKey) {
const variants = [apiKey, encodeURIComponent(apiKey), JSON.stringify(apiKey).slice(1, -1)];
variants.sort(function (a, b) { return b.length - a.length; }).forEach(function (value) {
const pattern = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
message = message.replace(new RegExp(pattern, 'gi'), '[REDACTED]');
});
}
return message.replace(/([?&]apiKey=)[^&#\s"'<>]*/gi, '$1[REDACTED]');
}
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ The first time you run a function, Google will ask you to review and approve per

After permission is granted, a small popup will appear asking for your API key. Paste your IPGeolocation.io API key and click **OK**.

Your key is now saved securely inside the script settings. You never need to put it inside a formula or a cell. To learn more about why this matters, see [Important Notes About API Keys](#important-notes-about-api-keys).
The sheet’s API key is now saved in its Apps Script project. All formulas in this sheet use this key. See [Important Notes About API Keys](#important-notes-about-api-keys) for details about sharing and editor access.

### Refresh Your Google Sheet

Expand Down Expand Up @@ -347,7 +347,7 @@ These shortcut formulas save typing for the most common lookups. They are equiva

### Bulk Lookup Formula

When you have a large list of IP addresses, use `IPGEO_BULK` instead of individual formulas. It processes up to 50,000 IP addresses in a single call and fills a column of results automatically. This is much faster and puts less strain on your sheet than dragging individual formulas down hundreds of rows.
`IPGEO_BULK` sends the nonblank IP addresses in a range in one request and returns results in the same order. Blank cells stay blank, so results remain aligned with the input. The request limit is 50,000 IP addresses; Google Apps Script execution limits also apply.

```excel
=IPGEO_BULK(A2:A100, "location.country_name")
Expand Down Expand Up @@ -393,11 +393,11 @@ For the best onboarding experience, consider using the tab structure described i

## Important Notes About API Keys

Every user should use their own IPGeolocation.io API key.
This spreadsheet uses one IPGeolocation.io API key for all of its formulas.

Never paste a real API key directly inside a formula or a cell. The script stores the key using Google Apps Script's built-in properties storage, which keeps it out of your spreadsheet entirely and makes the sheet safe to share.
Save it through the IPGeolocation.io menu rather than entering it in a formula or cell. Anyone with edit access can access the attached Apps Script project and read or change its saved key.

If a team member needs to use the same sheet, they should follow the [Save Your API Key](#save-your-api-key) step using their own key. If you need to remove a saved key at any time, use the Clear API Key option in the IPGeolocation.io menu.
For a separate API key, use a separate copy of the sheet and set the key in that copy. The Clear API Key menu option removes the key used by this sheet.

---

Expand Down Expand Up @@ -491,8 +491,8 @@ This integration targets the IPGeolocation.io v3 API. The three endpoints used a

`/v3/security` powers the [Dedicated Security Formula](#dedicated-security-formula).

Results are cached for six hours using Google Apps Script's built-in cache service. This reduces API credit usage when the same IP address is looked up multiple times across formulas in the same sheet.
Responses up to 100 KB may be cached for up to six hours using Google Apps Script's built-in cache service. Missing or invalid cached data triggers a fresh request. If caching fails, successful lookups still return their results.

The API key is stored using `PropertiesService`, which is Google's secure key-value store for script settings. External HTTP requests are made using `UrlFetchApp`.
The sheet’s API key is stored in script properties shared by the script’s users. External HTTP requests are made using `UrlFetchApp`.

For the latest API field references, visit the [IPGeolocation.io API documentation](https://ipgeolocation.io/documentation.html).
Loading
Loading