mcp-lyricscom 0.1.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.
@@ -0,0 +1,624 @@
1
+ // src/version.ts
2
+ var PKG_VERSION = "0.1.0";
3
+ var REPO_URL = "https://github.com/smeet666/mcp-lyricscom";
4
+
5
+ // src/config.ts
6
+ var DEFAULT_USER_AGENT = `mcp-lyricscom/${PKG_VERSION} (+${REPO_URL})`;
7
+ var DEFAULTS = {
8
+ minIntervalMs: 1100,
9
+ timeoutMs: 15e3,
10
+ maxRetries: 3,
11
+ cacheTtlMs: 15 * 60 * 1e3,
12
+ cacheMaxEntries: 200,
13
+ logLevel: "error"
14
+ };
15
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
16
+ function readNumber(name, env, range) {
17
+ const raw = env[name];
18
+ if (raw === void 0 || raw.trim() === "") return range.fallback;
19
+ const parsed = Number(raw);
20
+ if (!Number.isFinite(parsed)) {
21
+ warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
22
+ return range.fallback;
23
+ }
24
+ const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));
25
+ if (clamped !== Math.round(parsed)) {
26
+ warn(`${name}=${raw} is out of range, clamped to ${clamped}`);
27
+ }
28
+ return clamped;
29
+ }
30
+ function warn(message) {
31
+ process.stderr.write(`[mcp-lyricscom] ${message}
32
+ `);
33
+ }
34
+ function loadConfig(env = process.env) {
35
+ const rawUserAgent = env.LYRICSCOM_USER_AGENT?.trim();
36
+ const rawLogLevel = env.LYRICSCOM_LOG_LEVEL?.trim().toLowerCase();
37
+ let logLevel = DEFAULTS.logLevel;
38
+ if (rawLogLevel) {
39
+ if (LOG_LEVELS.includes(rawLogLevel)) {
40
+ logLevel = rawLogLevel;
41
+ } else {
42
+ warn(`LYRICSCOM_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
43
+ }
44
+ }
45
+ return {
46
+ userAgent: rawUserAgent || DEFAULT_USER_AGENT,
47
+ // 0 is allowed so the throttling behaviour can be exercised deliberately.
48
+ minIntervalMs: readNumber("LYRICSCOM_MIN_INTERVAL_MS", env, {
49
+ min: 0,
50
+ max: 6e4,
51
+ fallback: DEFAULTS.minIntervalMs
52
+ }),
53
+ timeoutMs: readNumber("LYRICSCOM_TIMEOUT_MS", env, {
54
+ min: 1e3,
55
+ max: 12e4,
56
+ fallback: DEFAULTS.timeoutMs
57
+ }),
58
+ maxRetries: readNumber("LYRICSCOM_MAX_RETRIES", env, {
59
+ min: 0,
60
+ max: 10,
61
+ fallback: DEFAULTS.maxRetries
62
+ }),
63
+ cacheTtlMs: readNumber("LYRICSCOM_CACHE_TTL_MS", env, {
64
+ min: 0,
65
+ max: 24 * 60 * 60 * 1e3,
66
+ fallback: DEFAULTS.cacheTtlMs
67
+ }),
68
+ cacheMaxEntries: readNumber("LYRICSCOM_CACHE_MAX_ENTRIES", env, {
69
+ min: 0,
70
+ max: 1e4,
71
+ fallback: DEFAULTS.cacheMaxEntries
72
+ }),
73
+ logLevel
74
+ };
75
+ }
76
+ var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
77
+ function createLogger(level) {
78
+ const emit = (at, message) => {
79
+ if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
80
+ process.stderr.write(`[mcp-lyricscom] ${message}
81
+ `);
82
+ }
83
+ };
84
+ return {
85
+ error: (message) => emit("error", message),
86
+ info: (message) => emit("info", message),
87
+ debug: (message) => emit("debug", message)
88
+ };
89
+ }
90
+
91
+ // src/lyricscom/cache.ts
92
+ var TtlLruCache = class {
93
+ constructor(maxEntries, ttlMs) {
94
+ this.maxEntries = maxEntries;
95
+ this.ttlMs = ttlMs;
96
+ }
97
+ maxEntries;
98
+ ttlMs;
99
+ store = /* @__PURE__ */ new Map();
100
+ get(key) {
101
+ const entry = this.store.get(key);
102
+ if (!entry) return void 0;
103
+ if (Date.now() >= entry.expiresAt) {
104
+ this.store.delete(key);
105
+ return void 0;
106
+ }
107
+ this.store.delete(key);
108
+ this.store.set(key, entry);
109
+ return entry.value;
110
+ }
111
+ set(key, value) {
112
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
113
+ this.store.delete(key);
114
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
115
+ while (this.store.size > this.maxEntries) {
116
+ const oldest = this.store.keys().next();
117
+ if (oldest.done) break;
118
+ this.store.delete(oldest.value);
119
+ }
120
+ }
121
+ get size() {
122
+ return this.store.size;
123
+ }
124
+ clear() {
125
+ this.store.clear();
126
+ }
127
+ };
128
+
129
+ // src/errors.ts
130
+ var LyricsComError = class extends Error {
131
+ constructor(code, message, details = {}) {
132
+ super(message);
133
+ this.code = code;
134
+ this.details = details;
135
+ this.name = "LyricsComError";
136
+ }
137
+ code;
138
+ details;
139
+ };
140
+ var ISSUES_URL = "https://github.com/smeet666/mcp-lyricscom/issues";
141
+ function throttled(url, retryAfterMs) {
142
+ return new LyricsComError(
143
+ "throttled",
144
+ "lyrics.com is rate limiting this client. It answered with an empty response instead of a page. This does NOT mean there are no results for this query.",
145
+ {
146
+ url,
147
+ retryAfterMs,
148
+ hint: "Wait at least a minute before calling the same tool again with the same arguments. Once lyrics.com starts throttling, the window commonly lasts several minutes, so retrying immediately will fail again. If it keeps happening, raise LYRICSCOM_MIN_INTERVAL_MS in your MCP client configuration."
149
+ }
150
+ );
151
+ }
152
+ function blockedUserAgent(url) {
153
+ return new LyricsComError(
154
+ "blocked_user_agent",
155
+ "lyrics.com refused the request because of the User-Agent this server sends (HTTP 403).",
156
+ {
157
+ url,
158
+ status: 403,
159
+ hint: "Set the LYRICSCOM_USER_AGENT environment variable in your MCP client configuration to a different value. See the Configuration section of the project README."
160
+ }
161
+ );
162
+ }
163
+ function notFound(url) {
164
+ return new LyricsComError("not_found", "lyrics.com has no page at this address.", {
165
+ url,
166
+ status: 404,
167
+ hint: "Check the song id. Ids come from the search tools; they are not stable to guess by hand."
168
+ });
169
+ }
170
+ function parseFailure(url, what) {
171
+ return new LyricsComError(
172
+ "parse_failure",
173
+ `The page loaded but the expected lyrics.com markup was not found (${what}). The site layout may have changed.`,
174
+ { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` }
175
+ );
176
+ }
177
+ function invalidInput(message, hint) {
178
+ return new LyricsComError("invalid_input", message, hint ? { hint } : {});
179
+ }
180
+
181
+ // src/lyricscom/parsers/detectThrottle.ts
182
+ var MIN_PLAUSIBLE_HTML = 2e3;
183
+ function classifyResponse(status, body) {
184
+ if (status === 202) return { kind: "throttled", reason: "status-202" };
185
+ if (status === 429) return { kind: "throttled", reason: "status-429" };
186
+ if (status === 503) return { kind: "throttled", reason: "status-503" };
187
+ if (status === 403) return { kind: "blocked", status };
188
+ if (status === 404) return { kind: "not-found" };
189
+ if (status >= 500) return { kind: "server-error", status };
190
+ const trimmed = body.trim();
191
+ if (trimmed.length === 0) return { kind: "throttled", reason: "empty-body" };
192
+ if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\/html>/i.test(trimmed)) {
193
+ return { kind: "throttled", reason: "short-body" };
194
+ }
195
+ return { kind: "ok", html: body };
196
+ }
197
+
198
+ // src/lyricscom/rateLimiter.ts
199
+ var DEFAULT_MAX_INTERVAL_MS = 1e4;
200
+ var RateLimiter = class {
201
+ baseIntervalMs;
202
+ maxIntervalMs;
203
+ intervalMs;
204
+ /** Tail of the queue: each task chains onto the previous one. */
205
+ tail = Promise.resolve();
206
+ lastStart = 0;
207
+ constructor(options) {
208
+ this.baseIntervalMs = Math.max(0, options.minIntervalMs);
209
+ this.maxIntervalMs = Math.max(
210
+ this.baseIntervalMs,
211
+ options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
212
+ );
213
+ this.intervalMs = this.baseIntervalMs;
214
+ }
215
+ get currentIntervalMs() {
216
+ return this.intervalMs;
217
+ }
218
+ /** Queue a task. Tasks run in call order, one at a time. */
219
+ schedule(task) {
220
+ const run = this.tail.then(async () => {
221
+ await this.waitForSlot();
222
+ this.lastStart = Date.now();
223
+ return task();
224
+ });
225
+ this.tail = run.then(
226
+ () => void 0,
227
+ () => void 0
228
+ );
229
+ return run;
230
+ }
231
+ /** Called after the site signals it is under load. */
232
+ penalize() {
233
+ const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
234
+ this.intervalMs = Math.min(this.maxIntervalMs, next);
235
+ }
236
+ /** Called after a success, so a single hiccup does not slow things down forever. */
237
+ relax() {
238
+ this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
239
+ }
240
+ async waitForSlot() {
241
+ if (this.intervalMs === 0 || this.lastStart === 0) return;
242
+ const elapsed = Date.now() - this.lastStart;
243
+ const remaining = this.intervalMs - elapsed;
244
+ if (remaining > 0) await sleep(remaining);
245
+ }
246
+ };
247
+ function sleep(ms) {
248
+ return new Promise((resolve) => setTimeout(resolve, ms));
249
+ }
250
+
251
+ // src/lyricscom/http.ts
252
+ var BACKOFF_BASE_MS = 2e3;
253
+ var BACKOFF_FACTOR = 2;
254
+ var BACKOFF_MAX_MS = 2e4;
255
+ function backoffDelay(attempt, random = Math.random) {
256
+ const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;
257
+ const capped = Math.min(BACKOFF_MAX_MS, uncapped);
258
+ return Math.round(capped * (0.5 + random() * 0.5));
259
+ }
260
+ async function fetchHtml(url, deps) {
261
+ const { config, limiter, logger } = deps;
262
+ const doFetch = deps.fetchImpl ?? fetch;
263
+ return limiter.schedule(async () => {
264
+ let lastError;
265
+ for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
266
+ if (attempt > 0) {
267
+ const delay = backoffDelay(attempt - 1);
268
+ logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
269
+ await sleep(delay);
270
+ }
271
+ let status;
272
+ let body;
273
+ try {
274
+ const response = await doFetch(url, {
275
+ headers: {
276
+ "User-Agent": config.userAgent,
277
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
278
+ "Accept-Language": "en-US,en;q=0.9,fr;q=0.8"
279
+ },
280
+ redirect: "follow",
281
+ signal: AbortSignal.timeout(config.timeoutMs)
282
+ });
283
+ status = response.status;
284
+ body = await response.text();
285
+ } catch (error) {
286
+ lastError = asTransportError(error, url);
287
+ logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
288
+ continue;
289
+ }
290
+ const verdict = classifyResponse(status, body);
291
+ switch (verdict.kind) {
292
+ case "ok":
293
+ limiter.relax();
294
+ return verdict.html;
295
+ case "throttled":
296
+ limiter.penalize();
297
+ logger.info(
298
+ `throttled (${verdict.reason}) on ${url}, interval now ${limiter.currentIntervalMs}ms`
299
+ );
300
+ lastError = throttled(url, backoffDelay(attempt));
301
+ continue;
302
+ case "server-error":
303
+ lastError = new LyricsComError(
304
+ "network_error",
305
+ `lyrics.com returned HTTP ${verdict.status}.`,
306
+ {
307
+ url,
308
+ status: verdict.status
309
+ }
310
+ );
311
+ continue;
312
+ // Retrying these would only repeat the same answer.
313
+ case "blocked":
314
+ throw blockedUserAgent(url);
315
+ case "not-found":
316
+ throw notFound(url);
317
+ }
318
+ }
319
+ throw lastError ?? new LyricsComError("network_error", `Could not fetch ${url}.`, { url });
320
+ });
321
+ }
322
+ function asTransportError(error, url) {
323
+ if (error instanceof LyricsComError) return error;
324
+ const name = error instanceof Error ? error.name : "";
325
+ if (name === "TimeoutError" || name === "AbortError") {
326
+ return new LyricsComError("timeout", "lyrics.com did not answer in time.", {
327
+ url,
328
+ hint: "Raise LYRICSCOM_TIMEOUT_MS if this happens often on a slow connection."
329
+ });
330
+ }
331
+ const message = error instanceof Error ? error.message : String(error);
332
+ return new LyricsComError("network_error", `Could not reach lyrics.com: ${message}`, { url });
333
+ }
334
+
335
+ // src/lyricscom/parsers/searchResults.ts
336
+ import * as cheerio from "cheerio";
337
+
338
+ // src/text/normalize.ts
339
+ var HTML_ENTITIES = {
340
+ amp: "&",
341
+ lt: "<",
342
+ gt: ">",
343
+ quot: '"',
344
+ apos: "'",
345
+ nbsp: "\xA0",
346
+ hellip: "\u2026",
347
+ mdash: "\u2014",
348
+ ndash: "\u2013",
349
+ rsquo: "\u2019",
350
+ lsquo: "\u2018",
351
+ ldquo: "\u201C",
352
+ rdquo: "\u201D"
353
+ };
354
+ function unescapeHtml(text) {
355
+ if (!text) return text;
356
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity) => {
357
+ if (entity.startsWith("#")) {
358
+ const isHex = entity[1] === "x" || entity[1] === "X";
359
+ const codePoint = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
360
+ if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 1114111) return match;
361
+ try {
362
+ return String.fromCodePoint(codePoint);
363
+ } catch {
364
+ return match;
365
+ }
366
+ }
367
+ return HTML_ENTITIES[entity.toLowerCase()] ?? match;
368
+ });
369
+ }
370
+ function fixEncodingIssues(text) {
371
+ if (!text || !/[ÃÂ]/.test(text)) return text;
372
+ try {
373
+ const repaired = Buffer.from(text, "latin1").toString("utf8");
374
+ if (repaired.includes("\uFFFD")) return text;
375
+ return repaired;
376
+ } catch {
377
+ return text;
378
+ }
379
+ }
380
+ function collapseWhitespace(text) {
381
+ return text.replace(/\s+/g, " ").trim();
382
+ }
383
+ function cleanInlineText(text) {
384
+ if (!text) return "";
385
+ return collapseWhitespace(fixEncodingIssues(unescapeHtml(text)));
386
+ }
387
+ function cleanLyricsText(text) {
388
+ if (!text) return "";
389
+ const normalized = fixEncodingIssues(unescapeHtml(text)).replace(/\r\n?/g, "\n");
390
+ const lines = normalized.split("\n").map((line) => line.trim());
391
+ const out = [];
392
+ for (const line of lines) {
393
+ if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
394
+ out.push(line);
395
+ }
396
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
397
+ return out.join("\n");
398
+ }
399
+
400
+ // src/lyricscom/urls.ts
401
+ var BASE_URL = "https://www.lyrics.com";
402
+ var RESULTS_PER_PAGE = 24;
403
+ var SONG_HREF_RE = /^\/lyric(?:-lf)?\/(\d+)\//;
404
+ var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["lyrics.com", "www.lyrics.com"]);
405
+ function encodeTerm(term) {
406
+ return encodeURIComponent(term).replace(/%20/g, "+");
407
+ }
408
+ function buildSearchUrl(term, page) {
409
+ const encoded = encodeTerm(term);
410
+ if (page <= 1) return `${BASE_URL}/lyrics/${encoded}`;
411
+ return `${BASE_URL}/serp.php?st=${encoded}&p=${page}`;
412
+ }
413
+ function buildSongUrl(id) {
414
+ return `${BASE_URL}/lyric-lf/${id}/`;
415
+ }
416
+ function isLyricsComHost(rawUrl) {
417
+ let parsed;
418
+ try {
419
+ parsed = new URL(rawUrl);
420
+ } catch {
421
+ return false;
422
+ }
423
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
424
+ return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
425
+ }
426
+ function extractSongId(hrefOrUrl) {
427
+ let path = hrefOrUrl;
428
+ if (/^https?:\/\//i.test(hrefOrUrl)) {
429
+ if (!isLyricsComHost(hrefOrUrl)) return null;
430
+ path = new URL(hrefOrUrl).pathname;
431
+ }
432
+ if (!path.startsWith("/")) path = `/${path}`;
433
+ const match = SONG_HREF_RE.exec(path);
434
+ return match?.[1] ?? null;
435
+ }
436
+ function resolveSongRef(input) {
437
+ if (input.id) {
438
+ if (!/^\d+$/.test(input.id)) {
439
+ throw invalidInput(
440
+ `"${input.id}" is not a lyrics.com song id.`,
441
+ "Ids are digits only, as returned by the search tools."
442
+ );
443
+ }
444
+ return { id: input.id, url: buildSongUrl(input.id) };
445
+ }
446
+ if (input.url) {
447
+ if (!isLyricsComHost(input.url)) {
448
+ throw invalidInput(
449
+ "Only www.lyrics.com URLs are accepted.",
450
+ "Pass the source_url returned by search_lyrics or search_songs, or use the numeric id instead."
451
+ );
452
+ }
453
+ const id = extractSongId(input.url);
454
+ if (!id) {
455
+ throw invalidInput(
456
+ "That lyrics.com URL is not a song page.",
457
+ "Song pages look like https://www.lyrics.com/lyric-lf/1234567/Artist/Title"
458
+ );
459
+ }
460
+ return { id, url: input.url };
461
+ }
462
+ throw invalidInput("Either 'id' or 'url' must be provided.");
463
+ }
464
+ function toAbsoluteUrl(href) {
465
+ return new URL(href, BASE_URL).toString();
466
+ }
467
+
468
+ // src/lyricscom/parsers/selectors.ts
469
+ var SEL = {
470
+ /** One search result row. */
471
+ serpItem: "div.sec-lyric",
472
+ /**
473
+ * Page chrome that is present whether or not there are results. Used to tell
474
+ * "zero results" apart from "the markup changed", which must not look alike.
475
+ */
476
+ serpShell: "#lyric-search, .lyric-search, .pagination, #main-content, form[action*='serp']",
477
+ resultTitle: "p.lyric-meta-title a",
478
+ /**
479
+ * Rows tied to an album expose the artist under `lyric-meta-album-artist`,
480
+ * rows without one under `lyric-meta-artists`. Both shapes appear on the same
481
+ * page, so missing the second silently drops results.
482
+ */
483
+ resultArtist: "p.lyric-meta-album-artist a, p.lyric-meta-artists a",
484
+ resultAlbum: "p.lyric-meta-album",
485
+ resultYear: "p.lyric-meta-album-year",
486
+ resultSnippet: "pre.lyric-body",
487
+ /** Full lyrics on a song page, with the historical fallback. */
488
+ songBody: "pre#lyric-body-text, pre.lyric-body",
489
+ /** Marker for a valid song page that simply has no lyrics on file. */
490
+ songNoData: "div.lyric-no-data",
491
+ songTitle: "#lyric-title-text, .lyric-title",
492
+ songArtist: ".lyric-artist a, .lyric-artist"
493
+ };
494
+
495
+ // src/lyricscom/parsers/searchResults.ts
496
+ function parseSearchResults(html, options) {
497
+ const $ = cheerio.load(html);
498
+ const rows = $(SEL.serpItem);
499
+ if (rows.length === 0) {
500
+ if ($(SEL.serpShell).length === 0) {
501
+ throw parseFailure(options.url, "no result rows and no recognizable search page structure");
502
+ }
503
+ return { results: [], page: options.page, rawCount: 0, hasMore: false };
504
+ }
505
+ const results = [];
506
+ let skipped = 0;
507
+ rows.each((_, element) => {
508
+ const row = $(element);
509
+ const titleLink = row.find(SEL.resultTitle).first();
510
+ const href = titleLink.attr("href");
511
+ const title = cleanInlineText(titleLink.text());
512
+ const artist = cleanInlineText(row.find(SEL.resultArtist).first().text());
513
+ const id = href ? extractSongId(href) : null;
514
+ if (!id || !title || !artist) {
515
+ skipped += 1;
516
+ return;
517
+ }
518
+ const album = cleanInlineText(row.find(SEL.resultAlbum).first().text()) || null;
519
+ const year = parseYear(row.find(SEL.resultYear).first().text());
520
+ const snippetRaw = row.find(SEL.resultSnippet).first().text();
521
+ const snippet = snippetRaw ? cleanLyricsText(snippetRaw) || null : null;
522
+ results.push({
523
+ id,
524
+ url: toAbsoluteUrl(href),
525
+ title,
526
+ artist,
527
+ album,
528
+ year,
529
+ snippet
530
+ });
531
+ });
532
+ if (results.length === 0) {
533
+ throw parseFailure(options.url, `${rows.length} result rows matched but none could be read`);
534
+ }
535
+ if (skipped > 0) {
536
+ process.stderr.write(
537
+ `[mcp-lyricscom] skipped ${skipped} unreadable result rows on ${options.url}
538
+ `
539
+ );
540
+ }
541
+ return {
542
+ results,
543
+ page: options.page,
544
+ rawCount: rows.length,
545
+ hasMore: rows.length >= RESULTS_PER_PAGE
546
+ };
547
+ }
548
+ function parseYear(raw) {
549
+ const match = /\b(1[89]\d{2}|20\d{2})\b/.exec(raw);
550
+ if (!match?.[1]) return null;
551
+ return Number.parseInt(match[1], 10);
552
+ }
553
+
554
+ // src/lyricscom/parsers/songPage.ts
555
+ import * as cheerio2 from "cheerio";
556
+ function parseSongPage(html, options) {
557
+ const $ = cheerio2.load(html);
558
+ const title = cleanInlineText($(SEL.songTitle).first().text()) || null;
559
+ const artist = cleanInlineText($(SEL.songArtist).first().text()) || null;
560
+ const body = $(SEL.songBody).first();
561
+ if (body.length === 0) {
562
+ const declaresNoLyrics = $(SEL.songNoData).length > 0 || /no lyrics found/i.test($("title").text());
563
+ if (!declaresNoLyrics) {
564
+ throw parseFailure(options.url, "no lyrics container and no 'no lyrics' marker");
565
+ }
566
+ return { id: options.id, url: options.url, title, artist, lyrics: "", hasLyrics: false };
567
+ }
568
+ const lyrics = cleanLyricsText(extractBodyText($, body));
569
+ if (!lyrics) {
570
+ return { id: options.id, url: options.url, title, artist, lyrics: "", hasLyrics: false };
571
+ }
572
+ return { id: options.id, url: options.url, title, artist, lyrics, hasLyrics: true };
573
+ }
574
+ function extractBodyText($, body) {
575
+ const rawHtml = $.html(body);
576
+ const withoutAnchors = rawHtml.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, "$1");
577
+ const withNewlines = withoutAnchors.replace(/<br\s*\/?>/gi, "\n");
578
+ return cheerio2.load(withNewlines).root().text();
579
+ }
580
+
581
+ // src/lyricscom/client.ts
582
+ var LyricsComClient = class {
583
+ config;
584
+ logger;
585
+ limiter;
586
+ cache;
587
+ fetchImpl;
588
+ constructor(options = {}) {
589
+ this.config = options.config ?? loadConfig();
590
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
591
+ this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
592
+ this.cache = new TtlLruCache(this.config.cacheMaxEntries, this.config.cacheTtlMs);
593
+ this.fetchImpl = options.fetchImpl;
594
+ }
595
+ async search(term, page) {
596
+ const url = buildSearchUrl(term, page);
597
+ const { html, cached } = await this.fetchPage(url);
598
+ return { data: parseSearchResults(html, { page, url }), cached };
599
+ }
600
+ async getSong(ref) {
601
+ const { id, url } = resolveSongRef(ref);
602
+ const { html, cached } = await this.fetchPage(url);
603
+ return { data: parseSongPage(html, { id, url }), cached };
604
+ }
605
+ async fetchPage(url) {
606
+ const hit = this.cache.get(url);
607
+ if (hit !== void 0) {
608
+ this.logger.debug(`cache hit ${url}`);
609
+ return { html: hit, cached: true };
610
+ }
611
+ const html = await fetchHtml(url, {
612
+ config: this.config,
613
+ limiter: this.limiter,
614
+ logger: this.logger,
615
+ ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
616
+ });
617
+ this.cache.set(url, html);
618
+ return { html, cached: false };
619
+ }
620
+ };
621
+ export {
622
+ LyricsComClient
623
+ };
624
+ //# sourceMappingURL=client.js.map