USPS Address Validation - Common

Library used between the Add/Edit page and the View page.

Verze ze dne 06. 11. 2025. Zobrazit nejnovější verzi.

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/555040/1690714/USPS%20Address%20Validation%20-%20Common.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         USPS Address Validation - Common
// @namespace    https://github.com/nate-kean/
// @version      2025.11.6.1
// @description  Library used between the Add/Edit page and the View page.
// @author       Nate Kean
// @match        https://jamesriver.fellowshiponego.com/members/add*
// @match        https://jamesriver.fellowshiponego.com/members/edit/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=fellowshiponego.com
// @grant        none
// @license      MIT
// ==/UserScript==

// @ts-check

document.head.insertAdjacentHTML("beforeend", `
	<style id="data-team-address-validator-css">
		.address-panel {
			position: relative;
		}

		.data-team-address-validation-indicator {
			float: right;
			font-size: 16px;
			font-weight: 600;
			width: 24px;
			height: 24px;
			text-align: center;
			padding-top: 4px;
		}

		.data-team-address-validation-indicator.fa-check {
			color: #00c853;
		}

		.data-team-address-validation-indicator.fa-exclamation {
			color: #ff8f00;
			cursor: pointer;
			background-color: hsla(0, 0%, 100%, .1);
			border-radius: 6px;
			transition: background-color 100ms;
		}

		.data-team-address-validation-indicator.fa-times {
			color: #c84040
		}

		.data-team-address-validation-indicator + .tooltip > .tooltip-inner {
			max-width: 250px !important;
		}
	</style>
`);


/**
 * @typedef {Object} QueriedAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip
 * @property {string} country
 */

/**
 * @typedef {Object} CanonicalAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip5
 * @property {string} zip4
 */

/**
 * Validation Result
 * @typedef {Object} ValResult
 * @property {Validator.Code[keyof Validator.Code]} code
 * @property {string} msg
 * @property {number} corrs - correction count
 * @property {CanonicalAddress?} address
 */


/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function delay(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}


/**
 * @param {string} str
 * @returns {string}
 */
function toTitleCase(str) {
	return str.replace(
		/\w\S*/g,
		text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
	);
}


class Validator {
	static Code = Object.freeze({
		__proto__: null,
		MATCH: 0,
		CORRECTION: 1,
		NOT_FOUND: 2,
		NOPE: 3,
		ERROR: 4,
		NOT_IMPL: 5,
	});

	static #USPS_API_CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
	static #USPS_API_CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";

	static #DEFAULT_BACKOFF = 4000;
	static #backoff = Validator.#DEFAULT_BACKOFF;

	/**
	 * Call when there is a new address to validate.
	 * @param {Indicator} indicator
	 * @param {QueriedAddress} address
	 * @returns {Promise<void>}
	 */
	static async onNewAddressQuery(indicator, address) {
		const cached = Validator.#getFromCache(address);
		if (cached !== null) {
			indicator.onValidationResult(cached);
			return;
		};
		const result = await Validator.#validate(address);
		Validator.#sendToCache(address, result);
		indicator.onValidationResult(result);
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {Promise<ValResult>}
	 */
	static async #validate({ streetAddress, city, state, zip, country }) {
		// We have to check this ourselves because USPS very curiously returns
		// HTTP 400 if it's not right. (Like. why not just return a correction?)
		if (state !== state.toUpperCase()) {
			const [zip4, zip5] = zip.split("-");
			return {
				code: Validator.Code.CORRECTION,
				msg: (
					`${streetAddress.replace("\n", "<br>")}<br>${city}, `
					+ `<strong>${state.toUpperCase()}</strong> ${zip}`
				),
				corrs: 1,
				address: { streetAddress, city, state, zip4, zip5 },
			};
		}

		if (country.length == 2 && country !== "US")
			return { code: Validator.Code.NOPE, msg: "", corrs: 0, address: null, };

		// Handle being timed out on a previous page
		const prevBackoff = window.sessionStorage.getItem("ndk usps 402");
		if (prevBackoff !== null) {
			const prevBackoffDate = new Date(prevBackoff);
			const prelimBackoff = (
				prevBackoffDate.getMilliseconds()
				- (new Date().getMilliseconds())
			);
			await delay(prelimBackoff);
			window.sessionStorage.removeItem("ndk usps 402");
		}

		const accessToken = await Validator.#getAccessToken();
		streetAddress = toTitleCase(streetAddress);
		city = toTitleCase(city);
		const zipParts = zip?.split("-") ?? [];
		const zip5 = zipParts[0] ?? "";
		const zip4 = zipParts[1] ?? "";
		/**
		 * @type {Record<string, string>}
		 */
		const params = {};
		// Only include entries that are populated; empty string causes HTTP 400
		if (streetAddress) params.streetAddress = streetAddress;
		if (city) params.city = city;
		if (state) params.state = state;
		if (zip5) params.ZIPCode = zip5;
		if (zip4) params.ZIPPlus4 = zip4;
		const payloadURL = (
			"https://corsproxy.io/?url="
			+ "https://apis.usps.com/addresses/v3/address?"
			+ new URLSearchParams(params).toString()
		);
		const response = await fetch(payloadURL, {
			headers: new Headers({
				"Authorization": "Bearer " + accessToken,
			}),
		}
		);
		switch (response.status) {
			case 200:
				break;
			case 401:
				await Validator.#regenerateToken();
				// @ts-ignore -- tuple type error nonsense
				return await Validator.#validate(...arguments);
			case 404:
				return { code: Validator.Code.NOT_FOUND, msg: "", corrs: 0, address: null };
			case 429:
			case 503: {
				// Exponential backoff retry
				const timeoutDate = new Date();
				timeoutDate.setMilliseconds(
					timeoutDate.getMilliseconds() + Validator.#backoff
				);
				window.sessionStorage.setItem(
					"ndk usps 402",
					timeoutDate.toISOString()
				);
				await delay(Validator.#backoff);
				Validator.#backoff **= 2;
				// @ts-ignore
				const validatePromise = Validator.#validate(...arguments);
				Validator.#backoff = Validator.#DEFAULT_BACKOFF;
				window.sessionStorage.removeItem("ndk usps 402");
				return await validatePromise;
			}
			default:
				return {
					code: Validator.Code.ERROR,
					msg: `USPS returned status code ${response.status}`,
					corrs: 0,
					address: null,
				};
		}
		const json = await response.json();

		let note = "";
		let correctionCount = 0;
		const code = json.corrections[0]?.code || json.matches[0]?.code;
		switch (code) {
			case "31":
				break;
			case "32":
				note = "Missing apartment, suite, or box number.";
				correctionCount++;
				break;
			case "22":
				note = json.corrections[0].text;
				correctionCount++;
				break;
			default:
				return {
					code: Validator.Code.NOT_IMPL,
					msg: `Status code ${code} not implemented`,
					corrs: 0,
					address: null,
				};
		}
		/**
		 * @type {CanonicalAddress}
		 */
		const canonicalAddr = {
			streetAddress: toTitleCase(
				`${json.address.streetAddress} ${json.address.secondaryAddress}`
			).trim(),
			city: toTitleCase(json.address.city),
			state: json.address.state,
			zip5: json.address.ZIPCode,
			zip4: json.address.ZIPPlus4,
		};
		let new_addr = "";
		if (canonicalAddr.streetAddress === streetAddress) {
			new_addr += streetAddress;
		} else {
			new_addr += `<strong>${canonicalAddr.streetAddress}</strong>`;
			correctionCount++;
		}
		new_addr += "<br>";
		if (canonicalAddr.city === city) {
			new_addr += city;
		} else {
			new_addr += `<strong>${canonicalAddr.city}</strong>`;
			correctionCount++;
		}
		new_addr += ", ";
		if (canonicalAddr.state === state) {
			new_addr += state;
		} else {
			new_addr += `<strong>${canonicalAddr.state}</strong>`;
			correctionCount++;
		}
		new_addr += " ";
		if (canonicalAddr.zip5 === zip5 && canonicalAddr.zip4 === zip4) {
			new_addr += `${zip5}-${zip4}`;
		} else {
			new_addr += `<strong>${canonicalAddr.zip5}-${canonicalAddr.zip4}</strong>`;
			correctionCount++;
		}
		if (correctionCount > 0) {
			return {
				code: Validator.Code.CORRECTION,
				msg: `<span>${new_addr}${note ? `<br><i>${note}</i>` : ""}</span>`,
				corrs: correctionCount,
				address: canonicalAddr,
			};
		} else {
			return { code: Validator.Code.MATCH, msg: "", corrs: 0, address: canonicalAddr };
		}
	}

	/**
	 * @returns {Promise<string | null>}
	 */
	static async #getAccessToken() {
		let accessToken = window.localStorage.getItem("natesUSPSAccessToken");
		if (accessToken === "null" || accessToken === null) {
			await Validator.#regenerateToken();
			accessToken = window.localStorage.getItem("natesUSPSAccessToken");
		}
		return accessToken;
	}

	/**
	 * @returns {Promise<void>}
	 */
	static async #regenerateToken() {
		const response = await fetch(
			"https://corsproxy.io/?url=https://apis.usps.com/oauth2/v3/token", {
			method: "POST",
			headers: new Headers({
				"Content-Type": "application/json",
			}),
			body: JSON.stringify({
				grant_type: "client_credentials",
				scope: "addresses",
				client_id: Validator.#USPS_API_CLIENT_ID,
				client_secret: Validator.#USPS_API_CLIENT_SECRET,
			}),
		});
		switch (response.status) {
			case 200:
				break;
			case 429:
			case 503: {
				// Exponential backoff retry
				const timeoutDate = new Date();
				timeoutDate.setMilliseconds(
					timeoutDate.getMilliseconds() + Validator.#backoff
				);
				window.sessionStorage.setItem(
					"ndk usps 402",
					timeoutDate.toISOString()
				);
				await delay(Validator.#backoff);
				Validator.#backoff **= 2;
				const validatePromise = Validator.#regenerateToken();
				Validator.#backoff = Validator.#DEFAULT_BACKOFF;
				window.sessionStorage.removeItem("ndk usps 402");
				return await validatePromise;
			}
			default:
				// @ts-ignore -- it wants string but i will give it objecte anyway
				throw Error(response);
		}
		const json = await response.json();
		window.localStorage.setItem("natesUSPSAccessToken", json.access_token);
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {string}
	 */
	static #serializeAddress({ streetAddress, city, state, zip, country }) {
		return `ndk ${streetAddress} ${city} ${state} ${zip} ${country}`;
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {ValResult?}
	 */
	static #getFromCache(address) {
		const key = Validator.#serializeAddress(address);
		const value = window.sessionStorage.getItem(key);
		if (value === null) return null;
		return JSON.parse(value);
	}

	/**
	 * @param {QueriedAddress} address
	 * @param {ValResult} result
	 * @returns {void}
	 */
	static #sendToCache(address, result) {
		if (
			result.code === Validator.Code.ERROR
			|| result.code === Validator.Code.NOT_IMPL
		) return;
		const key = Validator.#serializeAddress(address);
		const value = JSON.stringify(result);
		window.sessionStorage.setItem(key, value);
	}
}


class Indicator {
	#icon;

	/**
	 * @param {Node} parent
	 */
	constructor(parent) {
		/**
		 * @type {HTMLButtonElement}
		 */
		this.button = document.createElement("button");
		/**
		 * @type {HTMLElement}
		 */
		this.#icon = document.createElement("i");
		/**
		 * @type {ValResult?}
		 */
		this.status = null;

		this.#icon.classList.add("data-team-address-validation-indicator");
		this.#icon.setAttribute("data-toggle", "tooltip");
		this.#icon.setAttribute("data-placement", "top");
		this.#icon.setAttribute("data-html", "true");
		this.#icon.classList.add("fal", "fa-spinner-third", "fa-spin");
		// @ts-ignore  -- environment will have jquery
		$(this.#icon).tooltip();
		this.button.appendChild(this.#icon);
		parent.appendChild(this.button);
	}

	/**
	 * @param {ValResult} result
	 * @returns {void}
	 */
	onValidationResult(result) {
		this.status = result;
		const { code, msg, corrs: correctionCount } = this.status;
		let tooltipContent = "";

		this.#icon.classList.remove("fa-spinner-third", "fa-spin");
		switch (code) {
			case Validator.Code.MATCH:
				this.#icon.classList.add("fa-check");
				tooltipContent = "USPS — Verified valid";
				break;
			case Validator.Code.CORRECTION:
				this.#icon.classList.add("fa-exclamation");
				const s = correctionCount > 1 ? "s" : "";
				tooltipContent = `USPS — Correction${s} suggested:<br>${msg}`;
				break;
			case Validator.Code.NOT_FOUND:
				this.#icon.classList.add("fa-times");
				tooltipContent = "USPS — Address not found";
				break;
			case Validator.Code.NOPE:
				this.#icon.classList.add("fa-circle");
				tooltipContent = "USPS validation skipped: incompatible country";
				break;
			case Validator.Code.ERROR:
				this.#icon.classList.add("fa-times");
				tooltipContent = `ERROR: ${msg}. Contact Nate`;
				break;
			case Validator.Code.NOT_IMPL:
				this.#icon.classList.add("fa-times");
				tooltipContent = `ERROR: ${msg}. Contact Nate`;
				break;
			default:
				this.#icon.classList.add("fa-times");
				tooltipContent = "PLUGIN ERROR: contact Nate";
				break;
		}
		this.#icon.setAttribute("data-original-title", tooltipContent);
	}
}