Spaces:
Running
Running
| /** | |
| * Ticker validation utilities β usable in browser (sets window.TickerValidation) | |
| * and in Node.js (module.exports) for unit testing. | |
| * | |
| * All rejection objects carry a ``code`` field for structured error handling. | |
| */ | |
| (function (root, factory) { | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = factory(); // Node.js / CommonJS | |
| } else { | |
| root.TickerValidation = factory(); // browser global | |
| } | |
| }(typeof self !== 'undefined' ? self : this, function () { | |
| 'use strict'; | |
| // ----------------------------------------------------------------------- | |
| // Error codes (mirror the Python ErrorCode class) | |
| // ----------------------------------------------------------------------- | |
| const ErrorCodes = { | |
| EMPTY_INPUT: 'EMPTY_INPUT', | |
| INVALID_FORMAT: 'INVALID_FORMAT', | |
| RESERVED_WORD: 'RESERVED_WORD', | |
| TICKER_NOT_FOUND: 'TICKER_NOT_FOUND', | |
| TICKER_DELISTED: 'TICKER_DELISTED', | |
| API_TIMEOUT: 'API_TIMEOUT', | |
| API_ERROR: 'API_ERROR', | |
| RATE_LIMITED: 'RATE_LIMITED', | |
| DATA_FETCH_FAILED: 'DATA_FETCH_FAILED', | |
| INTERNAL_ERROR: 'INTERNAL_ERROR', | |
| }; | |
| // ----------------------------------------------------------------------- | |
| // Known non-stock inputs (must be uppercase) | |
| // ----------------------------------------------------------------------- | |
| const _RESERVED = new Set(['TEST', 'NULL', 'NONE', 'HELP', 'NA']); | |
| const _CRYPTO = new Set([ | |
| 'BTC', 'ETH', 'XRP', 'LTC', 'BNB', 'SOL', 'ADA', 'DOT', | |
| 'AVAX', 'DOGE', 'MATIC', 'SHIB', 'TRX', 'LINK', 'ATOM', 'USDT', 'USDC', | |
| ]); | |
| const _CRYPTO_MESSAGE = | |
| 'IRIS-AI analyzes stocks and ETFs. ' + | |
| 'For cryptocurrency analysis, please use a crypto-specific platform.'; | |
| const _MAX_RAW_LENGTH = 20; // chars before any processing | |
| // ----------------------------------------------------------------------- | |
| // Layer 0 β input sanitisation | |
| // ----------------------------------------------------------------------- | |
| /** | |
| * Clean arbitrary user input into a normalised ticker string. | |
| * Mirrors the Python ``sanitize_ticker_input`` function. | |
| * | |
| * @param {string} raw | |
| * @returns {string} uppercase, cleaned ticker (may still be invalid format) | |
| */ | |
| function sanitizeTicker(raw) { | |
| let s = String(raw == null ? '' : raw).trim(); | |
| if (s.length > _MAX_RAW_LENGTH) s = s.slice(0, _MAX_RAW_LENGTH); | |
| s = s.replace(/^[\$#]+/, ''); // $ / # prefix | |
| s = s.replace(/^ticker:/i, ''); // "ticker:" prefix | |
| s = s.replace(/\s+(stock|etf|shares)$/i, ''); // trailing words | |
| s = s.replace(/\s+/g, ''); // internal spaces | |
| return s.toUpperCase(); | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Layer 1 β format check (client-side, instant) | |
| // ----------------------------------------------------------------------- | |
| // 1-5 letters optionally followed by ONE dot and 1-2 letters (e.g. BRK.B) | |
| const _TICKER_RE = /^[A-Z]{1,5}(\.[A-Z]{1,2})?$/; | |
| // Yahoo special symbols (mirrors Python ticker_validator.py) | |
| const _INDEX_RE = /^\^[A-Z0-9.\-]{1,14}$/; // ^GSPC, ^DJI, ^IXIC | |
| const _FUTURES_RE = /^[A-Z0-9]{1,8}=F$/; // CL=F, GC=F, SI=F | |
| const _COMPOSITE_RE = /^[A-Z0-9]{1,8}-[A-Z0-9]{1,8}\.[A-Z]{1,6}$/; // DX-Y.NYB | |
| function _isSpecialMarketSymbol(ticker) { | |
| return _INDEX_RE.test(ticker) || _FUTURES_RE.test(ticker) || _COMPOSITE_RE.test(ticker); | |
| } | |
| /** | |
| * Instant client-side format check β no network call. | |
| * Sanitises *input* before checking. | |
| * | |
| * @param {string} input | |
| * @returns {{ valid: boolean, code?: string, error?: string, cleaned?: string }} | |
| */ | |
| function validateTickerFormat(input) { | |
| const cleaned = sanitizeTicker(input); | |
| if (!cleaned) { | |
| return { valid: false, code: ErrorCodes.EMPTY_INPUT, | |
| error: 'Please enter a stock ticker symbol.' }; | |
| } | |
| if (_CRYPTO.has(cleaned)) { | |
| return { valid: false, code: ErrorCodes.RESERVED_WORD, | |
| error: _CRYPTO_MESSAGE }; | |
| } | |
| if (!_TICKER_RE.test(cleaned) && !_isSpecialMarketSymbol(cleaned)) { | |
| return { valid: false, code: ErrorCodes.INVALID_FORMAT, | |
| error: 'Invalid ticker format. Use stock format (e.g., AAPL, BRK.B) or special market symbols (e.g., ^GSPC, CL=F).' }; | |
| } | |
| if (_RESERVED.has(cleaned)) { | |
| return { valid: false, code: ErrorCodes.RESERVED_WORD, | |
| error: `"${cleaned}" is not a stock ticker.` }; | |
| } | |
| return { valid: true, cleaned }; | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Layer 2 β server-side verification | |
| // ----------------------------------------------------------------------- | |
| /** | |
| * Server-side ticker verification via POST /api/validate-ticker. | |
| * Accepts an optional AbortSignal for external cancellation. | |
| * A 5-second internal timeout is always applied. | |
| * | |
| * @param {string} ticker β sanitised uppercase ticker | |
| * @param {AbortSignal|null} [externalSignal] | |
| * @returns {Promise<object|null>} | |
| * Returns null when cancelled via externalSignal (caller should ignore). | |
| */ | |
| async function validateTickerRemote(ticker, externalSignal) { | |
| const timeoutController = new AbortController(); | |
| const timeoutId = setTimeout(() => timeoutController.abort(), 5000); | |
| let onExternalAbort = null; | |
| if (externalSignal) { | |
| if (externalSignal.aborted) { | |
| clearTimeout(timeoutId); | |
| return null; | |
| } | |
| onExternalAbort = () => { clearTimeout(timeoutId); timeoutController.abort(); }; | |
| externalSignal.addEventListener('abort', onExternalAbort, { once: true }); | |
| } | |
| try { | |
| const response = await fetch('/api/validate-ticker', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ ticker }), | |
| signal: timeoutController.signal, | |
| }); | |
| clearTimeout(timeoutId); | |
| if (externalSignal && onExternalAbort) { | |
| externalSignal.removeEventListener('abort', onExternalAbort); | |
| } | |
| return await response.json(); | |
| } catch (err) { | |
| clearTimeout(timeoutId); | |
| if (externalSignal && onExternalAbort) { | |
| externalSignal.removeEventListener('abort', onExternalAbort); | |
| } | |
| if (err.name === 'AbortError') { | |
| if (externalSignal && externalSignal.aborted) { | |
| return null; // cancelled externally β caller ignores | |
| } | |
| return { valid: false, code: ErrorCodes.API_TIMEOUT, | |
| error: 'Validation timed out. Please try again.' }; | |
| } | |
| return { valid: false, code: ErrorCodes.API_ERROR, | |
| error: 'Network error. Please check your connection.' }; | |
| } | |
| } | |
| return { validateTickerFormat, validateTickerRemote, sanitizeTicker, ErrorCodes }; | |
| })); | |