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.
- package/CHANGELOG.md +130 -0
- package/LICENSE +1 -1
- package/README.md +37 -0
- package/dist/index.js +326 -120
- package/dist/index.js.map +1 -1
- package/dist/lyricscom/client.js +130 -48
- package/dist/lyricscom/client.js.map +1 -1
- package/package.json +15 -11
- package/server.json +4 -4
package/dist/lyricscom/client.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import process from "process";
|
|
3
|
+
|
|
1
4
|
// src/version.ts
|
|
2
|
-
var PKG_VERSION = "
|
|
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() === "")
|
|
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() === "")
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (status ===
|
|
201
|
-
|
|
202
|
-
|
|
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)
|
|
205
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
390
|
+
if (!raw) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
350
393
|
const seconds = Number(raw.trim());
|
|
351
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
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))
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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"))
|
|
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)
|
|
477
|
+
if (!text) {
|
|
478
|
+
return "";
|
|
479
|
+
}
|
|
419
480
|
return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));
|
|
420
481
|
}
|
|
421
482
|
function cleanLyricsText(text) {
|
|
422
|
-
if (!text)
|
|
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
|
|
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
|
|
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)
|
|
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:")
|
|
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 (
|
|
463
|
-
if (!isLyricsComHost(hrefOrUrl))
|
|
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("/"))
|
|
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 (
|
|
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 $ =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
584
|
-
if (!match?.[1])
|
|
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
|
|
669
|
+
import { load as load2 } from "cheerio";
|
|
670
|
+
var NO_LYRICS_FOUND = /no lyrics found/i;
|
|
590
671
|
function parseSongPage(html, options) {
|
|
591
|
-
const $ =
|
|
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 ||
|
|
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
|
|
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 =
|
|
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
|