mcp-lyricscom 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
+ // src/config.ts
2
+ import process from "process";
3
+
1
4
  // src/version.ts
2
- var PKG_VERSION = "1.4.1";
5
+ var PKG_VERSION = "2.0.0";
3
6
  var REPO_URL = "https://github.com/smeet666/mcp-lyricscom";
4
7
 
5
8
  // src/config.ts
@@ -16,7 +19,9 @@ var MIN_ALLOWED_INTERVAL_MS = 500;
16
19
  var LOG_LEVELS = ["silent", "error", "info", "debug"];
17
20
  function readNumber(name, env, range) {
18
21
  const raw = env[name];
19
- if (raw === void 0 || raw.trim() === "") return range.fallback;
22
+ if (raw === void 0 || raw.trim() === "") {
23
+ return range.fallback;
24
+ }
20
25
  const parsed = Number(raw);
21
26
  if (!Number.isFinite(parsed)) {
22
27
  warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
@@ -34,7 +39,9 @@ function warn(message) {
34
39
  }
35
40
  function readInterval(env) {
36
41
  const raw = env.LYRICSCOM_MIN_INTERVAL_MS;
37
- if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
42
+ if (raw === void 0 || raw.trim() === "") {
43
+ return DEFAULTS.minIntervalMs;
44
+ }
38
45
  const parsed = Number(raw);
39
46
  if (!Number.isFinite(parsed)) {
40
47
  warn(`LYRICSCOM_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
@@ -112,7 +119,9 @@ var TtlLruCache = class {
112
119
  store = /* @__PURE__ */ new Map();
113
120
  get(key) {
114
121
  const entry = this.store.get(key);
115
- if (!entry) return void 0;
122
+ if (!entry) {
123
+ return void 0;
124
+ }
116
125
  if (Date.now() >= entry.expiresAt) {
117
126
  this.store.delete(key);
118
127
  return void 0;
@@ -122,12 +131,16 @@ var TtlLruCache = class {
122
131
  return entry.value;
123
132
  }
124
133
  set(key, value) {
125
- if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
134
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) {
135
+ return;
136
+ }
126
137
  this.store.delete(key);
127
138
  this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
128
139
  while (this.store.size > this.maxEntries) {
129
140
  const oldest = this.store.keys().next();
130
- if (oldest.done) break;
141
+ if (oldest.done) {
142
+ break;
143
+ }
131
144
  this.store.delete(oldest.value);
132
145
  }
133
146
  }
@@ -192,17 +205,32 @@ function invalidInput(message, hint) {
192
205
  }
193
206
 
194
207
  // src/lyricscom/parsers/detectThrottle.ts
208
+ var CLOSING_HTML_TAG = /<\/html>/i;
195
209
  var MIN_PLAUSIBLE_HTML = 2e3;
196
210
  function classifyResponse(status, body) {
197
- if (status === 202) return { kind: "throttled", reason: "status-202" };
198
- if (status === 429) return { kind: "throttled", reason: "status-429" };
199
- if (status === 503) return { kind: "throttled", reason: "status-503" };
200
- if (status === 403) return { kind: "blocked", status };
201
- if (status === 404) return { kind: "not-found" };
202
- if (status >= 500) return { kind: "server-error", status };
211
+ if (status === 202) {
212
+ return { kind: "throttled", reason: "status-202" };
213
+ }
214
+ if (status === 429) {
215
+ return { kind: "throttled", reason: "status-429" };
216
+ }
217
+ if (status === 503) {
218
+ return { kind: "throttled", reason: "status-503" };
219
+ }
220
+ if (status === 403) {
221
+ return { kind: "blocked", status };
222
+ }
223
+ if (status === 404) {
224
+ return { kind: "not-found" };
225
+ }
226
+ if (status >= 500) {
227
+ return { kind: "server-error", status };
228
+ }
203
229
  const trimmed = body.trim();
204
- if (trimmed.length === 0) return { kind: "throttled", reason: "empty-body" };
205
- if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\/html>/i.test(trimmed)) {
230
+ if (trimmed.length === 0) {
231
+ return { kind: "throttled", reason: "empty-body" };
232
+ }
233
+ if (trimmed.length < MIN_PLAUSIBLE_HTML && !CLOSING_HTML_TAG.test(trimmed)) {
206
234
  return { kind: "throttled", reason: "short-body" };
207
235
  }
208
236
  return { kind: "ok", html: body };
@@ -258,10 +286,14 @@ var RateLimiter = class {
258
286
  this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
259
287
  }
260
288
  async waitForSlot() {
261
- if (this.intervalMs === 0 || this.lastStart === 0) return;
289
+ if (this.intervalMs === 0 || this.lastStart === 0) {
290
+ return;
291
+ }
262
292
  const elapsed = Date.now() - this.lastStart;
263
293
  const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
264
- if (remaining > 0) await sleep(remaining);
294
+ if (remaining > 0) {
295
+ await sleep(remaining);
296
+ }
265
297
  }
266
298
  };
267
299
  function sleep(ms) {
@@ -280,7 +312,7 @@ function backoffDelay(attempt, random = Math.random) {
280
312
  async function fetchHtml(url, deps) {
281
313
  const { config, limiter, logger } = deps;
282
314
  const doFetch = deps.fetchImpl ?? fetch;
283
- return limiter.schedule(async () => {
315
+ return await limiter.schedule(async () => {
284
316
  let lastError;
285
317
  let askedWaitMs = null;
286
318
  for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
@@ -340,21 +372,38 @@ async function fetchHtml(url, deps) {
340
372
  throw blockedUserAgent(url);
341
373
  case "not-found":
342
374
  throw notFound(url);
375
+ // Every kind the classifier declares is handled above, and the linter
376
+ // holds that. This clause is the runtime backstop: a response nothing
377
+ // above matched leaves as an error rather than as a silent retry.
378
+ default:
379
+ throw new LyricsComError(
380
+ "network_error",
381
+ `lyrics.com returned a response this client cannot classify for ${url}.`,
382
+ { url }
383
+ );
343
384
  }
344
385
  }
345
386
  throw lastError ?? new LyricsComError("network_error", `Could not fetch ${url}.`, { url });
346
387
  });
347
388
  }
348
389
  function parseRetryAfter(raw) {
349
- if (!raw) return null;
390
+ if (!raw) {
391
+ return null;
392
+ }
350
393
  const seconds = Number(raw.trim());
351
- if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
394
+ if (Number.isFinite(seconds) && seconds >= 0) {
395
+ return Math.round(seconds * 1e3);
396
+ }
352
397
  const when = Date.parse(raw);
353
- if (Number.isNaN(when)) return null;
398
+ if (Number.isNaN(when)) {
399
+ return null;
400
+ }
354
401
  return Math.max(0, when - Date.now());
355
402
  }
356
403
  function asTransportError(error, url) {
357
- if (error instanceof LyricsComError) return error;
404
+ if (error instanceof LyricsComError) {
405
+ return error;
406
+ }
358
407
  const name = error instanceof Error ? error.name : "";
359
408
  if (name === "TimeoutError" || name === "AbortError") {
360
409
  return new LyricsComError("timeout", "lyrics.com did not answer in time.", {
@@ -367,9 +416,11 @@ function asTransportError(error, url) {
367
416
  }
368
417
 
369
418
  // src/lyricscom/parsers/searchResults.ts
370
- import * as cheerio from "cheerio";
419
+ import process2 from "process";
420
+ import { load } from "cheerio";
371
421
 
372
422
  // src/text/normalize.ts
423
+ var MOJIBAKE_MARKS = /[ÃÂ]/;
373
424
  var HTML_ENTITIES = {
374
425
  amp: "&",
375
426
  lt: "<",
@@ -386,12 +437,16 @@ var HTML_ENTITIES = {
386
437
  rdquo: "\u201D"
387
438
  };
388
439
  function unescapeHtml(text) {
389
- if (!text) return text;
440
+ if (!text) {
441
+ return text;
442
+ }
390
443
  return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity) => {
391
444
  if (entity.startsWith("#")) {
392
445
  const isHex = entity[1] === "x" || entity[1] === "X";
393
446
  const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
394
- if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 1114111) return match;
447
+ if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 1114111) {
448
+ return match;
449
+ }
395
450
  try {
396
451
  return String.fromCodePoint(codePoint);
397
452
  } catch {
@@ -402,10 +457,14 @@ function unescapeHtml(text) {
402
457
  });
403
458
  }
404
459
  function fixEncodingIssues(text) {
405
- if (!text || !/[ÃÂ]/.test(text)) return text;
460
+ if (!(text && MOJIBAKE_MARKS.test(text))) {
461
+ return text;
462
+ }
406
463
  try {
407
464
  const repaired = Buffer.from(text, "latin1").toString("utf8");
408
- if (repaired.includes("\uFFFD")) return text;
465
+ if (repaired.includes("\uFFFD")) {
466
+ return text;
467
+ }
409
468
  return repaired;
410
469
  } catch {
411
470
  return text;
@@ -415,23 +474,33 @@ function collapseWhitespace(text) {
415
474
  return text.replace(/\s+/g, " ").trim();
416
475
  }
417
476
  function cleanInlineText(text) {
418
- if (!text) return "";
477
+ if (!text) {
478
+ return "";
479
+ }
419
480
  return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));
420
481
  }
421
482
  function cleanLyricsText(text) {
422
- if (!text) return "";
483
+ if (!text) {
484
+ return "";
485
+ }
423
486
  const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\r\n?/g, "\n");
424
487
  const lines = normalized.split("\n").map((line) => line.trim());
425
488
  const out = [];
426
489
  for (const line of lines) {
427
- if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
490
+ if (line === "" && (out.length === 0 || out.at(-1) === "")) {
491
+ continue;
492
+ }
428
493
  out.push(line);
429
494
  }
430
- while (out.length > 0 && out[out.length - 1] === "") out.pop();
495
+ while (out.length > 0 && out.at(-1) === "") {
496
+ out.pop();
497
+ }
431
498
  return out.join("\n");
432
499
  }
433
500
 
434
501
  // src/lyricscom/urls.ts
502
+ var ABSOLUTE_URL = /^https?:\/\//i;
503
+ var DIGITS_ONLY = /^\d+$/;
435
504
  var BASE_URL = "https://www.lyrics.com";
436
505
  var RESULTS_PER_PAGE = 24;
437
506
  var SONG_HREF_RE = /^\/lyric(?:-lf)?\/(\d+)\//;
@@ -441,7 +510,9 @@ function encodeTerm(term) {
441
510
  }
442
511
  function buildSearchUrl(term, page) {
443
512
  const encoded = encodeTerm(term);
444
- if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;
513
+ if (page <= 1) {
514
+ return `${BASE_URL}/lyrics/${encoded}`;
515
+ }
445
516
  return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;
446
517
  }
447
518
  function buildSongUrl(id) {
@@ -454,22 +525,28 @@ function isLyricsComHost(rawUrl) {
454
525
  } catch {
455
526
  return false;
456
527
  }
457
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
528
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
529
+ return false;
530
+ }
458
531
  return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
459
532
  }
460
533
  function extractSongId(hrefOrUrl) {
461
534
  let path = hrefOrUrl;
462
- if (/^https?:\/\//i.test(hrefOrUrl)) {
463
- if (!isLyricsComHost(hrefOrUrl)) return null;
535
+ if (ABSOLUTE_URL.test(hrefOrUrl)) {
536
+ if (!isLyricsComHost(hrefOrUrl)) {
537
+ return null;
538
+ }
464
539
  path = new URL(hrefOrUrl).pathname;
465
540
  }
466
- if (!path.startsWith("/")) path = `/${path}`;
541
+ if (!path.startsWith("/")) {
542
+ path = `/${path}`;
543
+ }
467
544
  const match = SONG_HREF_RE.exec(path);
468
545
  return match?.[1] ?? null;
469
546
  }
470
547
  function resolveSongRef(input) {
471
548
  if (input.id) {
472
- if (!/^\d+$/.test(input.id)) {
549
+ if (!DIGITS_ONLY.test(input.id)) {
473
550
  throw invalidInput(
474
551
  `"${input.id}" is not a lyrics.com song id.`,
475
552
  "Ids are digits only, as returned by the search tools."
@@ -527,8 +604,9 @@ var SEL = {
527
604
  };
528
605
 
529
606
  // src/lyricscom/parsers/searchResults.ts
607
+ var YEAR = /\b(1[89]\d{2}|20\d{2})\b/;
530
608
  function parseSearchResults(html, options) {
531
- const $ = cheerio.load(html);
609
+ const $ = load(html);
532
610
  const rows = $(SEL.serpItem);
533
611
  if (rows.length === 0) {
534
612
  if ($(SEL.serpShell).length === 0) {
@@ -545,7 +623,7 @@ function parseSearchResults(html, options) {
545
623
  const title = cleanInlineText(titleLink.text());
546
624
  const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());
547
625
  const id = href ? extractSongId(href) : null;
548
- if (!id || !title || !artist) {
626
+ if (!(id && title && artist)) {
549
627
  skipped += 1;
550
628
  return;
551
629
  }
@@ -567,7 +645,7 @@ function parseSearchResults(html, options) {
567
645
  throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);
568
646
  }
569
647
  if (skipped > 0) {
570
- process.stderr.write(
648
+ process2.stderr.write(
571
649
  `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}
572
650
  `
573
651
  );
@@ -580,20 +658,23 @@ function parseSearchResults(html, options) {
580
658
  };
581
659
  }
582
660
  function parseYear(raw) {
583
- const match = /\b(1[89]\d{2}|20\d{2})\b/.exec(raw);
584
- if (!match?.[1]) return null;
661
+ const match = YEAR.exec(raw);
662
+ if (!match?.[1]) {
663
+ return null;
664
+ }
585
665
  return Number.parseInt(match[1], 10);
586
666
  }
587
667
 
588
668
  // src/lyricscom/parsers/songPage.ts
589
- import * as cheerio2 from "cheerio";
669
+ import { load as load2 } from "cheerio";
670
+ var NO_LYRICS_FOUND = /no lyrics found/i;
590
671
  function parseSongPage(html, options) {
591
- const $ = cheerio2.load(html);
672
+ const $ = load2(html);
592
673
  const title = cleanInlineText($(SEL.songTitle).first().text()) || null;
593
674
  const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;
594
675
  const body = $(SEL.songBody).first();
595
676
  if (body.length === 0) {
596
- const declaresNoLyrics = $(SEL.songNoData).length > 0 || /no lyrics found/i.test($("title").text());
677
+ const declaresNoLyrics = $(SEL.songNoData).length > 0 || NO_LYRICS_FOUND.test($("title").text());
597
678
  if (!declaresNoLyrics) {
598
679
  throw parseFailure(options.url, "no lyrics container and no 'no lyrics' marker");
599
680
  }
@@ -609,12 +690,13 @@ function extractBodyText($, body) {
609
690
  const rawHtml = $.html(body);
610
691
  const withoutAnchors = rawHtml.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, "$1");
611
692
  const withNewlines = withoutAnchors.replace(/<br\s*\/?>/gi, "\n");
612
- return cheerio2.load(withNewlines).root().text();
693
+ return load2(withNewlines).root().text();
613
694
  }
614
695
 
615
696
  // src/lyricscom/client.ts
697
+ var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
616
698
  function withGuarantees(config) {
617
- const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
699
+ const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
618
700
  return {
619
701
  ...config,
620
702
  userAgent,
@@ -636,11 +718,11 @@ var LyricsComClient = class {
636
718
  }
637
719
  async search(term, page) {
638
720
  const url = buildSearchUrl(term, page);
639
- return this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));
721
+ return await this.fetchParsed(url, (html) => parseSearchResults(html, { page, url }));
640
722
  }
641
723
  async getSong(ref) {
642
724
  const { id, url } = resolveSongRef(ref);
643
- return this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));
725
+ return await this.fetchParsed(url, (html) => parseSongPage(html, { id, url }));
644
726
  }
645
727
  /**
646
728
  * Fetch, parse, then cache. In that order: a page that could not be read is