USPS Address Validation - Common

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

2025-11-10 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/555040/1693055/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.10.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="jrc-address-validator-css">
		.address-panel > .panel-heading {
			width: 50;
			padding-right: 50px;
			display: flex;
			justify-content: space-between;
			background-color: unset;
		}

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

			&.fa-check {
				color: #00c853;
			}

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

			&.fa-times {
				color: #c84040
			}

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

		.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;
}


/**
 * @param {string[]} streetAddrLines
 * @returns {string}
 */
function normalizeStreetAddressQuery(streetAddrLines) {
	// If the individual has an Address Validation flag, ignore the first
	// line of the street address, because it's probably a message about the
	// address.
	const addDetailsKeys = document.querySelectorAll(
		".other-panel > .panel-body > .info-left-column > .other-lbl"
	);
	let iStartStreetAddr = 0;
	for (const key of addDetailsKeys) {
		const hasAddrValEntry = key.textContent.trim() === "Address Validation";
		const streetAddrOneLine = streetAddrLines.length <= 1;
		if (hasAddrValEntry && !streetAddrOneLine) {
			// Skip first two nodes within the street address element:
			// The address validation message, and the <br /> underneath it.
			iStartStreetAddr = 2;
			break;
		}
	}
	// Construct the street address, ignoring beginning lines if the above
	// block says to, and using spaces instead of <br />s or newlines.
	let streetAddress = "";
	for (let i = iStartStreetAddr; i < streetAddrLines.length; i++) {
		const text = streetAddrLines[i];
		streetAddress += text.trim();
		if (i + 1 !== streetAddrLines.length) {
			streetAddress += " ";
		}
	}
	return streetAddress;
}


/**
 * 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 optional entries that are populated;
		// empty strings cause HTTP 400
		if (zipParts[0]) params.ZIPCode = zipParts[0];
		if (zipParts[1]) 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.NOT_FOUND,
				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 {HTMLButtonElement}
		 */
		this.button = document.createElement("button");
		/**
		 * @type {HTMLElement}
		 */
		this.#icon = document.createElement("i");
		/**
		 * @type {ValResult}
		 */
		this.status = {
			address: null,
			code: Validator.Code.LOADING,
			correctionCount: 0,
			message: "",
		};
		this.#typing = false;

		this.button.classList.add("jrc-address-validation-indicator");
		this.#icon.setAttribute("data-toggle", "tooltip");
		this.#icon.setAttribute("data-placement", "top");
		this.#icon.setAttribute("data-html", "true");
		this.#setIcon("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) {
		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}`;
				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.#icon.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.#icon.removeAttribute("data-original-title");
	}

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