PageSourceSearch

https://theanalyticalscientist.com/scripts/register-article-authors.js

js theanalyticalscientist.com collected 2026-10-02 03:32:45 UTC 13,240 bytes, 273 lines download raw bytes

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.