Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -0,0 +1,298 @@
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
namespace Music;
// Похожие артисты с music-map.com (проект Gnod/Gnoosic): один GET на артиста,
// парс якорей #gnodMap, отсортированных по музыкальной близости (краудсорсная
// коллаборативная фильтрация). Используется ТОЛЬКО как additive-слой «специи»
// в «Миксах недели»: не источник в UI, не участник радио, любой сбой — пустой
// список и микс собирается без внешнего слоя. Живой прототип 2026-07-16
// подтвердил стабильность парсинга и RU/транслит-покрытие (10/10 сидов по 48
// артистов, включая кириллические запросы и выдачу).
public static class MusicMapSupport
{
const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
const string cacheVersion = "mm-v1";
// похожесть артистов почти статична; 14 дней — быстро самовосстановится,
// если сайт/парсер временно отдал мусор (совет Кодекса)
static readonly TimeSpan similarCacheTtl = TimeSpan.FromDays(14);
static readonly HttpClient httpClient = MusicHttp.CreateClient("musicmap");
static readonly Regex mapBlockRegex = new(@"<div id=gnodMap>(.*?)</div>", RegexOptions.Singleline | RegexOptions.Compiled);
static readonly Regex anchorRegex = new("<a href=\"[^\"]*\" class=S id=s(\\d+)>([^<]*)</a>", RegexOptions.Compiled);
static MusicMapSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public static async Task<List<string>> GetSimilarArtistsAsync(string artist, CancellationToken cancellationToken = default)
{
string normalized = NormalizeName(artist);
if (string.IsNullOrWhiteSpace(normalized))
return new List<string>();
try
{
// пустой ответ кэшируется коротким emptyTtl (20 мин) — разовый
// сбой сайта не залипает на 14 дней
return await MusicMetadataCacheService.GetOrCreateAsync(
"musicmap",
"similar_artists",
$"{cacheVersion}|{normalized}",
similarCacheTtl,
() => FetchSimilarArtistsAsync(artist, cancellationToken),
cancellationToken
) ?? new List<string>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<string>();
}
}
static async Task<List<string>> FetchSimilarArtistsAsync(string artist, CancellationToken cancellationToken)
{
var result = new List<string>();
try
{
// формат пути music-map: имя в lower, пробелы -> '+', остальное
// percent-encoded (кириллица и '&' включительно)
string path = Uri.EscapeDataString(artist.Trim().ToLowerInvariant()).Replace("%20", "+");
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://www.music-map.com/{path}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return result;
string html = await response.Content.ReadAsStringAsync(cancellationToken);
var block = mapBlockRegex.Match(html ?? string.Empty);
if (!block.Success)
return result;
foreach (Match anchor in anchorRegex.Matches(block.Groups[1].Value))
{
// s0 — сам запрошенный артист, не кандидат
if (anchor.Groups[1].Value == "0")
continue;
string name = System.Net.WebUtility.HtmlDecode(anchor.Groups[2].Value).Trim();
if (!string.IsNullOrWhiteSpace(name))
result.Add(name);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
return result;
}
/// <summary>
/// Фильтр кандидатов карты: выбрасывает алиасы/проекты сид-артистов
/// (containment + fuzzy + потокенно — ловит «Myagi», «Mijagi»,
/// «Gruppa Skryptonite»), артистов истории (кросс-скрипт через транслит:
/// «Скриптонит» == «Scriptonite») и дубли по нормализованному имени.
/// Порядок карты (по близости) сохраняется.
/// </summary>
public static List<string> FilterCandidates(IEnumerable<string> candidates, IEnumerable<string> aliasReferences, IEnumerable<string> excludeNames)
{
var references = (aliasReferences ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excludedRaw = (excludeNames ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excluded = excludedRaw.Select(NormalizeName).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates ?? Enumerable.Empty<string>())
{
string normalized = NormalizeName(candidate);
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
continue;
if (references.Any(reference => IsAliasOf(candidate, reference)))
continue;
if (excluded.Any(name => name == normalized || FuzzyClose(name, normalized))
|| excludedRaw.Any(name => IsAliasOf(candidate, name)))
continue;
result.Add(candidate);
}
return result;
}
internal static bool IsAliasOf(string candidate, string reference)
{
string c = NormalizeName(candidate);
string r = NormalizeName(reference);
if (string.IsNullOrWhiteSpace(c) || string.IsNullOrWhiteSpace(r))
return string.Equals(candidate?.Trim(), reference?.Trim(), StringComparison.OrdinalIgnoreCase);
if (c.Contains(r, StringComparison.Ordinal) || r.Contains(c, StringComparison.Ordinal))
return true;
// опечаточные алиасы (Myagi/Mijagi) и транслит-варианты
if (FuzzyClose(c, r))
return true;
// потокенно: алиас внутри многословного имени («Gruppa Skryptonite»
// для сида «Скриптонит»)
return TokensOf(candidate).Any(token => FuzzyClose(token, r))
|| TokensOf(reference).Any(token => FuzzyClose(c, token));
}
internal static bool FuzzyClose(string a, string b)
=> Levenshtein(a, b) <= Math.Max(1, Math.Min(a.Length, b.Length) / 4);
static IEnumerable<string> TokensOf(string name)
=> Regex.Split(name ?? string.Empty, @"[^\p{L}\p{Nd}]+")
.Select(NormalizeName)
.Where(token => !string.IsNullOrWhiteSpace(token));
// RU->latin транслит + только [a-z0-9]: кросс-скрипт сравнение имён
// («Скриптонит» и «Scriptonite» сводятся к сравнимым строкам)
internal static string NormalizeName(string name)
{
var builder = new StringBuilder((name ?? string.Empty).Length);
foreach (char raw in (name ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
builder.Append(mapped);
else if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
builder.Append(raw);
}
return builder.ToString();
}
internal static bool ContainsCyrillic(string value)
=> (value ?? string.Empty).Any(ch => ch is >= 'а' and <= 'я' or 'ё' or >= 'А' and <= 'Я' or 'Ё'
or >= 'і' and <= 'ї' or 'є' or 'ґ' or >= 'І' and <= 'Ї' or 'Є' or 'Ґ');
// Для поискового fallback нельзя использовать NormalizeName: он склеивает
// слова. MusicBrainz лучше понимает "maikl dzhekson", чем
// "maikldzhekson", поэтому сохраняем пробелы/разделители.
internal static string TransliterateCyrillicToLatinForSearch(string value)
{
var builder = new StringBuilder((value ?? string.Empty).Length);
bool lastWasSpace = true;
foreach (char raw in (value ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
{
if (!string.IsNullOrEmpty(mapped))
{
builder.Append(mapped);
lastWasSpace = false;
}
continue;
}
if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
{
builder.Append(raw);
lastWasSpace = false;
continue;
}
if (!lastWasSpace)
{
builder.Append(' ');
lastWasSpace = true;
}
}
return builder.ToString().Trim();
}
static readonly Dictionary<char, string> translit = new()
{
['а'] = "a",
['б'] = "b",
['в'] = "v",
['г'] = "g",
['д'] = "d",
['е'] = "e",
['ё'] = "e",
['ж'] = "zh",
['з'] = "z",
['и'] = "i",
['й'] = "i",
['к'] = "k",
['л'] = "l",
['м'] = "m",
['н'] = "n",
['о'] = "o",
['п'] = "p",
['р'] = "r",
['с'] = "s",
['т'] = "t",
['у'] = "u",
['ф'] = "f",
['х'] = "h",
['ц'] = "c",
['ч'] = "ch",
['ш'] = "sh",
['щ'] = "sch",
['ъ'] = "",
['ы'] = "y",
['ь'] = "",
['э'] = "e",
['ю'] = "yu",
['я'] = "ya",
['і'] = "i",
['ї'] = "i",
['є'] = "e",
['ґ'] = "g"
};
static int Levenshtein(string a, string b)
{
a ??= string.Empty;
b ??= string.Empty;
if (a.Length < b.Length)
(a, b) = (b, a);
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (int j = 0; j <= b.Length; j++)
previous[j] = j;
for (int i = 1; i <= a.Length; i++)
{
current[0] = i;
for (int j = 1; j <= b.Length; j++)
{
int substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
current[j] = Math.Min(Math.Min(previous[j] + 1, current[j - 1] + 1), substitution);
}
(previous, current) = (current, previous);
}
return previous[b.Length];
}
}