|
// Scriptable script. Input: one Google Maps URL as the Shortcuts "Parameter". |
|
// Output: a waze:// URL, or an ERROR: message. No place-name fallback. |
|
|
|
function fail(message) { throw new Error(message); } |
|
|
|
function firstURL(value) { |
|
const match = String(value || "").match(/https?:\/\/[^\s<>"']+/i); |
|
if (!match) fail("Provide a Google Maps URL."); |
|
return match[0].replace(/[),.;]+$/, ""); |
|
} |
|
|
|
function urlParts(url) { |
|
const match = /^https:\/\/([^/?#:]+)(\/[^?#]*)?(?:\?([^#]*))?/i.exec(url); |
|
if (!match) fail("Only HTTPS Google Maps links are supported."); |
|
const host = match[1].toLowerCase(); |
|
const google = /^(?:(?:www|maps|consent)\.)?google\.(?:com|[a-z]{2}|co\.[a-z]{2})$/.test(host); |
|
if (!google && host !== "maps.app.goo.gl" && host !== "goo.gl") { |
|
fail("This is not a supported Google Maps link."); |
|
} |
|
return { host, path: match[2] || "/", query: match[3] || "" }; |
|
} |
|
|
|
function parameter(url, key) { |
|
const query = urlParts(url).query; |
|
for (const field of query.split("&")) { |
|
const equal = field.indexOf("="); |
|
if (equal < 0 || field.slice(0, equal) !== key) continue; |
|
try { return decodeURIComponent(field.slice(equal + 1).replace(/\+/g, "%20")); } |
|
catch (_) { fail("The Google Maps URL has invalid encoding."); } |
|
} |
|
return null; |
|
} |
|
|
|
function unwrapConsent(url) { |
|
for (let i = 0; i < 3 && urlParts(url).host.startsWith("consent.google."); i++) { |
|
const next = parameter(url, "continue"); |
|
if (!next) fail("Google's consent page did not contain a destination URL."); |
|
urlParts(next); // Check the nested URL before requesting it. |
|
url = next; |
|
} |
|
if (urlParts(url).host.startsWith("consent.google.")) fail("Google consent redirects nested too deeply."); |
|
return url; |
|
} |
|
|
|
function addConsentBypass(url) { |
|
if (parameter(url, "ucbcb") !== null) return url; |
|
const hash = url.indexOf("#"); |
|
const head = hash < 0 ? url : url.slice(0, hash); |
|
const tail = hash < 0 ? "" : url.slice(hash); |
|
return head + (head.includes("?") ? "&" : "?") + "ucbcb=1" + tail; |
|
} |
|
|
|
function coordinatePair(latText, lonText) { |
|
const lat = Number(latText), lon = Number(lonText); |
|
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { |
|
fail("The destination coordinates are invalid."); |
|
} |
|
return [lat, lon]; |
|
} |
|
|
|
function literalPair(text) { |
|
const match = /^\s*(-?\d{1,2}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/.exec(text || ""); |
|
return match ? coordinatePair(match[1], match[2]) : null; |
|
} |
|
|
|
function featureID(url) { |
|
const ftid = parameter(url, "ftid"); |
|
if (ftid && /^0x[0-9a-f]+:0x[0-9a-f]+$/i.test(ftid)) return ftid.toLowerCase(); |
|
const match = /!1s(0x[0-9a-f]+:0x[0-9a-f]+)/i.exec(url); |
|
return match ? match[1].toLowerCase() : null; |
|
} |
|
|
|
function explicitDestination(url) { |
|
const parts = urlParts(url); |
|
if (parts.host === "maps.app.goo.gl" || parts.host === "goo.gl") return null; |
|
|
|
// In Maps URLs, a place ID takes precedence over a query coordinate. |
|
if (!parameter(url, "destination_place_id")) { |
|
const destination = literalPair(parameter(url, "destination")); |
|
if (destination) return destination; |
|
} |
|
|
|
if (/^\/maps\/place\//.test(parts.path)) { |
|
const pairs = []; |
|
const pattern = /!3d(-?\d+(?:\.\d+)?)!4d(-?\d+(?:\.\d+)?)/g; |
|
let match; |
|
while ((match = pattern.exec(url)) !== null) pairs.push(coordinatePair(match[1], match[2])); |
|
if (pairs.length && pairs.every(p => p[0] === pairs[0][0] && p[1] === pairs[0][1])) return pairs[0]; |
|
if (pairs.length) fail("The place URL contains conflicting destination coordinates."); |
|
} |
|
|
|
if (!featureID(url) && !parameter(url, "query_place_id")) { |
|
const query = literalPair(parameter(url, "query")) || literalPair(parameter(url, "q")); |
|
if (query) return query; |
|
} |
|
return null; // Deliberately ignore map viewport @lat,lng and ll=. |
|
} |
|
|
|
const desktopHeaders = { |
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36", |
|
"Accept-Language": "en-US,en;q=0.9" |
|
}; |
|
|
|
async function headFinalURL(url) { |
|
urlParts(url); |
|
const request = new Request(url); |
|
request.method = "HEAD"; |
|
request.headers = desktopHeaders; |
|
request.timeoutInterval = 10; |
|
await request.load(); |
|
if (!request.response || request.response.statusCode !== 200) fail("Google did not resolve the short link."); |
|
const finalURL = request.response.url || url; |
|
urlParts(finalURL); |
|
return finalURL; |
|
} |
|
|
|
function smallPreviewURL(id) { |
|
const pb = "!1m5!1s" + id + "!2s!3m2!1i1024!2i768"; |
|
return "https://www.google.com/maps/preview/place?pb=" + encodeURIComponent(pb); |
|
} |
|
|
|
async function getPage(url) { |
|
urlParts(url); |
|
const request = new Request(url); |
|
request.headers = desktopHeaders; |
|
request.timeoutInterval = 20; |
|
const body = await request.loadString(); |
|
const finalURL = request.response && request.response.url || url; |
|
urlParts(finalURL); |
|
if (!request.response || request.response.statusCode !== 200) { |
|
fail("Google returned HTTP " + (request.response && request.response.statusCode || "error") + "."); |
|
} |
|
return { body, url: finalURL }; |
|
} |
|
|
|
function previewURL(html, id) { |
|
const match = /<link\b[^>]*href="(\/maps\/preview\/place\?[^"<>]+)"/i.exec(html); |
|
if (!match) fail("Google did not expose place details for this link."); |
|
const path = match[1].replace(/&/g, "&"); |
|
let decoded; |
|
try { decoded = decodeURIComponent(path).toLowerCase(); } |
|
catch (_) { fail("Google's place details link was unreadable."); } |
|
if (!decoded.includes(id)) { |
|
fail("Google's place details did not match the shared place."); |
|
} |
|
return "https://www.google.com" + path; |
|
} |
|
|
|
function coordinatesForID(text, id) { |
|
let data; |
|
try { data = JSON.parse(text.replace(/^\)\]\}'\s*/, "")); } |
|
catch (_) { fail("Google's place data was unreadable."); } |
|
const found = []; |
|
function visit(value) { |
|
if (!Array.isArray(value)) return; |
|
for (let i = 0; i < value.length - 1; i++) { |
|
const pair = value[i]; |
|
if (typeof value[i + 1] === "string" && value[i + 1].toLowerCase() === id && |
|
Array.isArray(pair) && pair[0] === null && pair[1] === null && |
|
typeof pair[2] === "number" && typeof pair[3] === "number") { |
|
found.push(coordinatePair(pair[2], pair[3])); |
|
} |
|
} |
|
for (const child of value) visit(child); |
|
} |
|
visit(data); |
|
if (!found.length) fail("Google did not provide coordinates tied to this place ID."); |
|
if (!found.every(p => p[0] === found[0][0] && p[1] === found[0][1])) { |
|
fail("Google returned conflicting coordinates for this place."); |
|
} |
|
return found[0]; |
|
} |
|
|
|
async function resolve(input) { |
|
let url = unwrapConsent(firstURL(input)); |
|
let coordinates = explicitDestination(url); |
|
if (coordinates) return coordinates; |
|
|
|
// Fast path: resolve only the redirect, then ask for details by the same ID. |
|
// The ID-only place endpoint is undocumented, so retain the page-based fallback. |
|
try { |
|
const finalURL = featureID(url) ? url : await headFinalURL(url); |
|
if (!urlParts(finalURL).host.startsWith("consent.google.")) { |
|
coordinates = explicitDestination(finalURL); |
|
if (coordinates) return coordinates; |
|
const id = featureID(finalURL); |
|
if (id) { |
|
const details = await getPage(smallPreviewURL(id)); |
|
return coordinatesForID(details.body, id); |
|
} |
|
} |
|
} catch (_) { |
|
// A failed or changed fast endpoint must not become a guessed destination. |
|
} |
|
|
|
for (let attempt = 0; attempt < 3; attempt++) { |
|
const page = await getPage(url); |
|
const finalURL = unwrapConsent(page.url); |
|
if (finalURL !== page.url) { |
|
url = addConsentBypass(finalURL); |
|
continue; |
|
} |
|
coordinates = explicitDestination(finalURL); |
|
if (coordinates) return coordinates; |
|
const id = featureID(finalURL); |
|
if (!id) fail("This Maps link has no explicit coordinates or identifiable place ID."); |
|
const details = await getPage(previewURL(page.body, id)); |
|
if (urlParts(details.url).host.startsWith("consent.google.")) { |
|
fail("Google requires consent before exposing this place. Try again later or provide a place URL containing !3d and !4d coordinates."); |
|
} |
|
return coordinatesForID(details.body, id); |
|
} |
|
fail("Google kept redirecting to consent. Try again later or provide a place URL containing !3d and !4d coordinates."); |
|
} |
|
|
|
try { |
|
const [lat, lon] = await resolve(args.shortcutParameter); |
|
Script.setShortcutOutput("waze://?ll=" + lat + "," + lon + "&navigate=yes"); |
|
} catch (error) { |
|
Script.setShortcutOutput("ERROR: " + (error && error.message || String(error))); |
|
} |
|
Script.complete(); |