USPS Address Validation - Common

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

目前為 2025-11-11 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/555040/1693621/USPS%20Address%20Validation%20-%20Common.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         USPS Address Validation - Common
// @namespace    https://github.com/nate-kean/
// @version      2025.11.11.2
// @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="jrc-address-validator-css">
		.address-panel > .panel-heading {
			width: 50% !important;
			background-color: unset !important;

			& > .panel-title {
				padding-right: 50px;
				display: flex;
				justify-content: space-between;
			}
		}

		button.jrc-address-validation-indicator {
			float: right;
			width:  32px;
			height: 32px;
			text-align: center;
			padding: 0;
			border: none;
			margin-top: -4px;

			& + .tooltip > .tooltip-inner {
				max-width: 250px !important;
			}

			& > i {
				margin-top: 3px;
				font-size: 16px;
				font-weight: 600;
			}

			& > i.fa-check {
				color: #00c853;
			}
			& > i.fa-exclamation {
				color: #ff8f00;
				background-color: hsla(0, 0%, 100%, .1);
				border-radius: 6px;
				transition: background-color 100ms;
			}
			& > i.fa-times {
				color: #c84040;
				/* This icon is just smaller than the others for some reason */
				font-size: 20px;
			}
		}

		.address-2-header > .jrc-address-validation-indicator {
			margin-right: 20px;
		}
	</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} message
 * @property {number} correctionCount
 * @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(),
	);
}


/**
 * @param {Element | Document} el
 * @param {string} selector
 * @returns {Element}
 */
function tryQuerySelector(el, selector) {
	const maybeEl = el.querySelector(selector);
	if (maybeEl === null) {
		console.error(el);
		throw new Error(`Failed to find '${selector}' in given element`);
	}
	return maybeEl;
}


/**
 * This really turned into quite the behemoth
 */
class Validator {
	static Code = Object.freeze({
		__proto__: null,
		MATCH: 0,
		CORRECTION: 1,
		NOT_FOUND: 2,
		NOPE: 3,
		ERROR: 4,
		LOADING: 5,
		EMPTY: 6,
		NOT_IMPL: 7,
	});

	static #CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
	static #CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";
	static #API_ROOT = "https://corsproxy.io/?url=https://apis.usps.com";

	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) {
		// Really a wrapper around #validate that uses cache
		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(address) {
		let { streetAddress, city, state, zip } = address;
		const trivialChecksResult = Validator.#trivialChecks(address);
		if (trivialChecksResult !== null) return trivialChecksResult;

		await Validator.#pickUpTimeout();

		const accessToken = await Validator.#getAccessToken();

		// Normalize and format the address to prepare to send to USPS
		streetAddress = toTitleCase(streetAddress);
		// #'s confuse the API. Official USPS addresses will never contain #.
		// USPS will correct "apartment" to "Apt", or "Unit", or whatever else
		// it's supposed to be. We could replace #'s with "Apt" to keep from
		// confusing the API, but we also want USPS to correct it in case it
		// really is supposed to be "Apt", so we normalize them to "apartment"
		// instead.
		streetAddress = streetAddress.replaceAll("#", "apartment");
		city = toTitleCase(city);
		/**
		 * @type {Record<string, string>}
		 */
		const params = { streetAddress, city, state };
		const zipParts = zip?.split("-") ?? [];
		// Only include the ZIP code if it's properly formatted (as a 5 or 5+4).
		// Otherwise the API throws an HTTP 400
		if (zipParts[0]?.length === 5) {
			params.ZIPCode = zipParts[0];
			if (zipParts[1]?.length === 4) {
				params.ZIPPlus4 = zipParts[1];
			}
		}

		const response = await fetch(
			`${Validator.#API_ROOT}/addresses/v3/address?${new URLSearchParams(params)}`,
			{ headers: { "Authorization": `Bearer ${accessToken}` } },
		);

		const earlyResult = await Validator.#parseStatus(response);
		if (earlyResult !== null) return earlyResult;

		const json = await response.json();
		return Validator.#makeCorrectionResult(json, address, zipParts);
	}

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

	/**
	 * @param {Response} response
	 * @returns {Promise<ValResult?>}
	 */
	static async #parseStatus(response) {
		switch (response.status) {
			case 200:
				return null;
			case 400: {
				const json = await response.json();
				switch (json.error.message) {
					case "Address Not Found":
						return {
							code: Validator.Code.NOT_FOUND,
							message: "",
							correctionCount: 0,
							address: null,
						};
					default:
						console.error(json);
						return {
							code: Validator.Code.ERROR,
							message: "USPS returned error True 400",
							correctionCount: 0,
							address: null,
						};
				}
			}
			case 401:
				await Validator.#regenerateToken();
				// @ts-ignore -- tuple type error nonsense
				return await Validator.#validate(...arguments);
			case 404:
				return {
					code: Validator.Code.NOT_FOUND,
					message: "",
					correctionCount: 0,
					address: null,
				};
			case 429:
			case 503:
				return await Validator.#retry(
					// @ts-ignore -- you can totally do this and i don't know
					// what it's talking about
					() => Validator.#validate(...arguments)
				);
			default:
				console.error(await response.json());
				return {
					code: Validator.Code.ERROR,
					message: `USPS returned status code ${response.status}`,
					correctionCount: 0,
					address: null,
				};
		}
	}

	/**
	 * @param {any} json
	 * @param {QueriedAddress} query
	 * @param {string[]} zipParts
	 * @returns {ValResult}
	 */
	static #makeCorrectionResult(json, query, zipParts) {
		let note = "";
		let correctionCount = 0;
		const code = json.corrections[0]?.code || json.matches[0]?.code;
		switch (code) {
			case "31":
				break;
			case "32":
				note = (
					"Missing or incorrectly-formatted apartment, suite, or "
					+ "box number."
				);
				correctionCount++;
				break;
			case "22":
				note = json.corrections[0].text;
				correctionCount++;
				break;
			default:
				return {
					code: Validator.Code.NOT_IMPL,
					message: `Status code ${code} not implemented`,
					correctionCount: 0,
					address: null,
				};
		}
		/**
		 * @type {CanonicalAddress}
		 */
		const canon = {
			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 (canon.streetAddress === query.streetAddress) {
			new_addr += query.streetAddress;
		} else {
			new_addr += `<strong>${canon.streetAddress}</strong>`;
			correctionCount++;
		}
		new_addr += "<br>";
		if (canon.city === query.city) {
			new_addr += query.city;
		} else {
			new_addr += `<strong>${canon.city}</strong>`;
			correctionCount++;
		}
		new_addr += ", ";
		if (canon.state === query.state) {
			new_addr += query.state;
		} else {
			new_addr += `<strong>${canon.state}</strong>`;
			correctionCount++;
		}
		new_addr += " ";
		if (canon.zip5 === zipParts[0] && canon.zip4 === zipParts[1]) {
			new_addr += `${canon.zip5}-${canon.zip4}`;
		} else {
			new_addr += `<strong>${canon.zip5}-${canon.zip4}</strong>`;
			correctionCount++;
		}
		if (correctionCount > 0) {
			return {
				code: Validator.Code.CORRECTION,
				message: `<span>${new_addr}${note ? `<br><i>${note}</i>` : ""}</span>`,
				correctionCount: correctionCount,
				address: canon,
			};
		} else {
			return {
				code: Validator.Code.MATCH,
				message: "",
				correctionCount: 0,
				address: canon,
			};
		}
	}

	/**
	 * Do some trivial checks that we don't need the API for ourselves.
	 * @param {QueriedAddress} address
	 * @returns {ValResult?}
	 */
	static #trivialChecks({ streetAddress, city, state, zip, country }) {

		// Missing required field
		if (!streetAddress || !city || !state) {
			return {
				code: Validator.Code.EMPTY,
				message: "",
				correctionCount: 0,
				address: null,
			};
		}

		// Improper state abbreviation case
		if (state !== state.toUpperCase()) {
			const [zip4, zip5] = zip.split("-");
			return {
				code: Validator.Code.CORRECTION,
				message: (
					`${streetAddress.replace("\n", "<br>")}<br>${city}, `
					+ `<strong>${state.toUpperCase()}</strong> ${zip}`
				),
				correctionCount: 1,
				address: { streetAddress, city, state, zip4, zip5 },
			};
		}

		// Non-US country code (except blank is fine)
		if (country.length > 0 && country !== "US") {
			return {
				code: Validator.Code.NOPE,
				message: "",
				correctionCount: 0,
				address: null,
			};
		}

		return null;
	}

	/**
	 * @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.#CLIENT_ID,
				client_secret: Validator.#CLIENT_SECRET,
			}),
		});
		switch (response.status) {
			case 200:
				break;
			case 429:
			case 503:
				return await Validator.#retry(Validator.#regenerateToken);
			default:
				throw Error(`${response.status} ${response.statusText}`);
		}
		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);
	}

	/**
	 * Exponential backoff retry
	 * @template T
	 * @param {() => Promise<T>} callback 
	 * @returns {Promise<T>}
	 */
	static async #retry(callback) {
		const timeoutDate = new Date();
		timeoutDate.setMilliseconds(
			timeoutDate.getMilliseconds() + Validator.#backoff
		);
		window.sessionStorage.setItem("ndk retry", timeoutDate.toISOString());
		await delay(Validator.#backoff);
		Validator.#backoff **= 2;
		const promise = callback();
		Validator.#backoff = Validator.#DEFAULT_BACKOFF;
		window.sessionStorage.removeItem("ndk retry");
		return await promise;
	}
}


class Indicator {
	#icon;
	#typing;

	/**
	 * @param {Node} parent
	 */
	constructor(parent) {
		/**
		 * @type {ValResult}
		 */
		this.status = {
			address: null,
			code: Validator.Code.LOADING,
			correctionCount: 0,
			message: "",
		};

		this.#typing = false;

		/**
		 * @type {HTMLButtonElement}
		 */
		this.button = document.createElement("button");
		this.button.classList.add("jrc-address-validation-indicator");
		this.button.setAttribute("data-toggle", "tooltip");
		this.button.setAttribute("data-placement", "top");
		this.button.setAttribute("data-html", "true");
		this.button.type = "button";
		this.button.disabled = true;
		// @ts-ignore -- environment will have jquery
		$(this.button).tooltip();
		this.#setIcon("fal", "fa-spinner-third", "fa-spin");

		this.#icon = document.createElement("i");
		this.button.appendChild(this.#icon);

		parent.appendChild(this.button);
	}

	/**
	 * @param {ValResult} result
	 * @returns {void}
	 */
	onValidationResult(result) {
		if (this.#typing) return;

		this.status = result;
		const { code, message: msg, correctionCount: correctionCount } = this.status;
		let tooltipContent = "";

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

	/**
	 * Set an icon class while removing all the other icon classes.
	 * @param  {...string} classNames
	 */
	#setIcon(...classNames) {
		this.#icon.classList.remove(
			"fa-spinner-third", "fa-spin",
			"fa-check",
			"fa-exclamation",
			"fa-times",
			"fa-circle",
		);
		this.#icon.classList.add(...classNames);
	}

	onTyping() {
		this.#typing = true;
		this.#setIcon("fa-spinner-third", "fa-spin");
		this.button.removeAttribute("data-original-title");
		this.button.disabled = true;
	}

	onEmptyField() {
		this.#typing = false;
		this.#setIcon("fa-circle");
	}
}