USPS Address Validation - Common

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

当前为 2025-11-06 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/555040/1690714/USPS%20Address%20Validation%20-%20Common.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
	}
}