USPS Address Validation - Common

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

От 06.11.2025. Виж последната версия.

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.greasyfork.org/scripts/555040/1690714/USPS%20Address%20Validation%20-%20Common.js

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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);
	}
}