1/** 2 * register-article-authors.js 3 * -------------------------------------------------------------------------- 4 * Browser-side helper for registering an article's authors with the KOL 5 * Portal at https://api.kolportal.conexiant.app. Designed to be embedded in 6 * a publisher CMS template (ASCO Post, Conexiant OB/GYN, etc.) so that 7 * loading an article page automatically POSTs the authors to 8 * POST /api/articles/authors 9 * 10 * The function is plain ES2018+ JavaScript with no dependencies â drop it 11 * into a <script> tag or an existing module bundle. 12 * 13 * -------------------------------------------------------------------------- 14 * SECURITY NOTE 15 * -------------------------------------------------------------------------- 16 * The KOL Portal ingestion endpoint expects a fixed API key in the 17 * X-Api-Key header. Anything sent from a browser is visible in DevTools and 18 * source view, so: 19 * 20 * 1. Treat the publisher-side key as a "low-trust" key. Rotate it on the 21 * same cadence you'd rotate any front-end token. 22 * 2. CORS on the API restricts which Origin headers are accepted, so a 23 * key leaked from one publisher's site can only be replayed from 24 * origins you explicitly allowlisted in the AllowedOrigins 25 * configuration entry on the API. Configure the API server to include 26 * every publisher domain that calls this script. 27 * 3. If you need a higher-trust contract, mint short-lived per-page 28 * tokens server-side and inject them into the page render. 29 * 30 * -------------------------------------------------------------------------- 31 * USAGE â minimal 32 * -------------------------------------------------------------------------- 33 * 34 * <script src="/static/register-article-authors.js"></script> 35 * <script> 36 * // Author byline on the article page might look like: 37 * // <span data-kol-authors>Jame Abraham, MD; Halle Moore</span> 38 * const names = document.querySelector('[data-kol-authors]').textContent; 39 * 40 * registerArticleAuthors({ 41 * apiBaseUrl: 'https://api.kolportal.conexiant.app', 42 * apiKey: 'YOUR_PUBLISHER_KEY', 43 * authorNames: names, 44 * }).then((result) => { 45 * console.log('KOL Portal:', result); 46 * }).catch((err) => { 47 * // Don't let a failed ingestion block the article render. 48 * console.warn('KOL Portal registration failed:', err); 49 * }); 50 * </script> 51 * 52 * -------------------------------------------------------------------------- 53 * USAGE â force a refresh of the bio + snapshot 54 * -------------------------------------------------------------------------- 55 * 56 * await registerArticleAuthors({ 57 * apiBaseUrl: 'https://api.kolportal.conexiant.app', 58 * apiKey: 'YOUR_PUBLISHER_KEY', 59 * authorNames: 'Jame Abraham, MD; Halle Moore', 60 * refresh: true, // re-roll the AuthorBios JSON snapshot 61 * }); 62 */ 63 64/** 65 * Register an article's authors with the KOL Portal. 66 * 67 * @param {object} opts 68 * @param {string} opts.apiBaseUrl Base URL of the KOL Portal API (no trailing slash). 69 * @param {string} opts.apiKey Value sent as the X-Api-Key header. 70 * @param {string} opts.authorNames Free-form author byline string. Names may be separated 71 * by commas, semicolons, "and", or "&". Embedded credentials 72 * after a name (e.g. "Jame Abraham, MD") are kept on the 73 * name and stripped server-side by the import pipeline. 74 * @param {string} [opts.articleUrl] Defaults to window.location.href. Override only when calling 75 * from a non-browser context or when the canonical URL of the 76 * article differs from the URL the user is currently viewing 77 * (e.g. a query-stringed/share-token URL). 78 * @param {boolean} [opts.refresh=false] When true, the API regenerates each author's bio even if 79 * they already exist and are already linked to the article. 80 * Default false makes the call a fast no-op for repeat hits. 81 * @param {AbortSignal} [opts.signal] Optional AbortSignal â useful to cancel on page unload. 82 * @returns {Promise<object>} The parsed JSON response from POST /api/articles/authors. 83 * @throws {Error} If the network request fails or the API returns a non-2xx status. 84 */ 85function registerArticleAuthors(opts) { 86 var options = opts || {}; 87 var apiBaseUrl = options.apiBaseUrl; 88 var apiKey = options.apiKey; 89 var authorNames = options.authorNames; 90 var articleUrl = options.articleUrl 91 || (typeof window !== 'undefined' ? window.location.href : ''); 92 var refresh = !!options.refresh; 93 var signal = options.signal; 94 95 if (!apiBaseUrl) throw new Error('registerArticleAuthors: apiBaseUrl is required'); 96 if (!apiKey) throw new Error('registerArticleAuthors: apiKey is required'); 97 if (!authorNames) throw new Error('registerArticleAuthors: authorNames is required'); 98 if (!articleUrl) throw new Error('registerArticleAuthors: articleUrl is required (window.location.href was empty)'); 99 100 var names = parseAuthorNames(authorNames); 101 if (names.length === 0) { 102 return Promise.reject(new Error('registerArticleAuthors: no usable author names parsed from input')); 103 } 104
105 // Trim trailing slash on the base URL so we don't end up with a double slash on the path. 106 var trimmedBase = apiBaseUrl.replace(/\/+$/, ''); 107 var url = trimmedBase + '/api/articles/authors' + (refresh ? '?refresh=true' : ''); 108 109 return fetch(url, { 110 method: 'POST', 111 headers: { 112 'Content-Type': 'application/json', 113 'X-Api-Key': apiKey, 114 // Keep the header set minimal so the CORS preflight doesn't surface unnecessary 115 // headers in Access-Control-Request-Headers. 116 }, 117 body: JSON.stringify({ 118 articleUrl: articleUrl, 119 authorNames: names, 120 }), 121 // Don't send cookies â this is a cross-origin call authenticated via X-Api-Key. 122 credentials: 'omit', 123 signal: signal, 124 }).then(function (response) { 125 var contentType = response.headers.get('content-type') || ''; 126 var bodyPromise = contentType.indexOf('application/json') !== -1 127 ? response.json().catch(function () { return {}; }) 128 : response.text().catch(function () { return ''; }); 129 130 return bodyPromise.then(function (body) { 131 if (!response.ok) { 132 var msg = (body && typeof body === 'object' && body.errors && body.errors[0] && body.errors[0].message) 133 ? body.errors[0].message 134 : 'HTTP ' + response.status; 135 var err = new Error('registerArticleAuthors: ' + msg); 136 err.status = response.status; 137 err.body = body; 138 throw err; 139 } 140 return body; 141 }); 142 }); 143} 144 145/** 146 * Parse a free-form byline string into an array of author names. Handles four 147 * common publisher patterns and keeps embedded credentials with the name they 148 * belong to. 149 * 150 * "Alice, Bob, Carol" -> ["Alice", "Bob", "Carol"] 151 * "Alice; Bob; Carol" -> ["Alice", "Bob", "Carol"] 152 * "Alice and Bob" -> ["Alice", "Bob"] 153 * "Alice & Bob" -> ["Alice", "Bob"] 154 * "Jame Abraham, MD" -> ["Jame Abraham, MD"] 155 * "Jame Abraham, MD, Halle Moore" -> ["Jame Abraham, MD", "Halle Moore"] 156 * "Jame Abraham, MD; Halle Moore, MD" -> ["Jame Abraham, MD", "Halle Moore, MD"] 157 * 158 * The credential-vs-separator distinction is heuristic: a chunk after a comma is 159 * treated as embedded credentials when it's short (<=30 chars) AND >=50% uppercase 160 * letters. The server-side import pipeline applies the same heuristic and will 161 * strip any embedded credentials into the canonical credentials field. 162 */ 163function parseAuthorNames(input) { 164 if (!input || typeof input !== 'string') return []; 165 166 // Use an unambiguous internal separator string (a sequence that won't appear in 167 // real byline text) to bridge the unambiguous publisher separators 168 // (semicolon, "&", " and ") into the comma-aware second pass. 169 var SEP = '|||AUTHORSEP|||'; 170 var normalized = input 171 .replace(/;/g, SEP) 172 .replace(/\s+&\s+/g, SEP) 173 .replace(/\s+and\s+/gi, SEP); 174 175 var tokens = []; 176 177 // For each unambiguous chunk, do a credential-aware comma split. 178 var chunks = normalized.split(SEP); 179 for (var i = 0; i < chunks.length; i++) { 180 var chunk = chunks[i]; 181 if (!chunk || !chunk.trim()) continue; 182 183 var buffer = ''; 184 var parts = chunk.split(','); 185 for (var j = 0; j < parts.length; j++) { 186 var trimmed = parts[j].trim(); 187 if (!trimmed) continue; 188 189 if (looksLikeCredentialChunk(trimmed) && buffer) { 190 // "Name" + ", " + "MD" â keep them together as "Name, MD" 191 buffer += ', ' + trimmed; 192 } else { 193 if (buffer) tokens.push(buffer); 194 buffer = trimmed; 195 } 196 } 197 if (buffer) tokens.push(buffer); 198 } 199 200 return tokens.map(function (t) { return t.trim(); }).filter(Boolean); 201} 202 203/** 204 * Return true if the text looks like a credential abbreviation block ("MD", "PhD", 205 * "MD, FACP", "FNP-BC"). The hard problem here is publishers (e.g. ASCO Post) 206 * who render bylines in ALL CAPS â a naive "â¥50% uppercase letters" rule 207 * misclassifies "HAGOP KANTARJIAN" or "EMIL J. FREIREICH" as credentials and 208 * merges multiple authors into one string. 209 * 210 * The fix: a chunk is a credential only when ONE of the following holds: 211 * 1. Every space/dot/hyphen-separated token matches a known credential 212 * abbreviation (case-insensitive). Catches the common cases tightly. 213 * 2. Every alphanumeric token is short (â¤4 letters) AND â¥80% of letters in 214 * the chunk are uppercase. Catches uncommon credentials like "BCOP" 215 * while still rejecting all-caps names whose tokens run 5+ letters 216 * (HAGOP=5, EMIL J. FREIREICH has FREIREICH=9, KANTARJIAN=10). 217 * 218 * The 4-letter cutoff is chosen because virtually every degree, board cert, 219 * and licensure abbreviation in active use fits in 4 letters per token; the 220 * few that exceed it (PHARMD, FAAFP, FACOG, FAAOMS, etc.) are in the 221 * whitelist below. Real surnames are almost always longer than 4 letters 222 * even after dropping the title â so the structural rule cleanly separates 223 * the two classes. 224 */ 225function looksLikeCredentialChunk(text) { 226 if (!text || text.length > 30) return false; 227 228 var trimmed = text.trim(); 229 if (!trimmed) return false; 230 231 // Tokens are split by whitespace, dot, or hyphen. (Comma-separated 232 // sub-chunks have already been split by the caller.) Empty tokens are 233 // dropped â e.g. "MD." becomes ["MD"], "FNP-BC" becomes ["FNP", "BC"]. 234 var tokens = trimmed.split(/[\s.\-]+/).filter(function (t) { return t.length > 0; }); 235 if (tokens.length === 0) return false; 236 237 // Whitelist of well-known degree, licensure, and board-certification 238 // abbreviations seen in medical bylines. Tested case-insensitively. Keep 239 // intentionally broad â any extension is safe (only widens the credential 240 // bucket; doesn't risk swallowing names). 241 var KNOWN_CREDENTIAL = /^(MD|DO|DDS|DMD|DPM|DVM|OD|PHD|EDD|JD|MBA|MBBS|MBCHB|PHARMD|PSYD|DNP|DPT|AUD|MS|MSC|MSN|MA|MPH|MHS|MED|MSW|MHA|BS|BA|BSC|BSN|BPHARM|RN|NP|FNP|ANP|PNP|GNP|WHNP|CNS|CNM|CRNA|CRNP|AGNP|AGACNP|AGPCNP|PA|PAC|FACP|FAAP|FACS|FCCP|FACE|FNAP|FRCP|FACOG|FACR|FACAAI|FAAD|FAAOS|FAAFP|FACG|FACEP|FACPM|FAANP|FAAN|FAAOMS|FACOEM|FACPHM|FAACVPR|BCPS|BCOP|BCPP|BCNP|FRCS|MRCP|MRCS|FACC|BC|CME|CCRN|CCRP|CCRC|CRC|CCM|CMD|JR|SR|II|III|IV|ESQ)$/i; 242 var allKnown = tokens.every(function (t) { return KNOWN_CREDENTIAL.test(t); }); 243 if (allKnown) return true; 244 245 // Structural fallback for credentials we haven't whitelisted. Every token's 246 // letter count must be â¤4 (so "HAGOP" = 5 falls out, "MD" = 2 stays in) 247 // AND the chunk's letters must be â¥80% uppercase. 248 var allShort = tokens.every(function (t) { 249 var letters = t.replace(/[^A-Za-z]/g, ''); 250 return letters.length <= 4; 251 }); 252 if (!allShort) return false; 253 254 var allLetters = trimmed.match(/[A-Za-z]/g) || []; 255 if (allLetters.length === 0) return false; 256 var upperCount = 0; 257 for (var i = 0; i < allLetters.length; i++) { 258 if (allLetters[i] === allLetters[i].toUpperCase()) upperCount++; 259 } 260 // â¥80% â using integer math to avoid float comparison subtleties. 261 return upperCount * 5 >= allLetters.length * 4; 262} 263 264// Expose under both module and global names so the file works as a script tag 265// (registerArticleAuthors becomes window.registerArticleAuthors) AND as a module 266// (`import { registerArticleAuthors } from '...'` after a build step that picks up
267// CommonJS exports). 268if (typeof window !== 'undefined') { 269 window.registerArticleAuthors = registerArticleAuthors; 270} 271if (typeof module !== 'undefined' && module.exports) { 272 module.exports = { registerArticleAuthors: registerArticleAuthors, parseAuthorNames: parseAuthorNames }; 273}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.