Files
neon-bar86.ru/site/scripts/repair-stoplist-mixes.mjs
2026-10-11 22:24:54 +05:00

418 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Починка патча стоп-листа миксов: многострочные замены первого скрипта
// записались урезанными. Здесь каждый блок собирается из ОДНОСТРОЧНЫХ
// литералов (устойчиво к искажению записи), каждый фикс идемпотентен.
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
const L = (...lines) => lines.join("\n");
const log = [];
const ensureBak = (f) => { if (!existsSync(f + ".bak")) copyFileSync(f, f + ".bak"); };
const save = (f, src) => { ensureBak(f); writeFileSync(f, src); };
function fix(src, tag, has, re, to) {
if (src.includes(has)) { log.push(tag + "=skip"); return src; }
if (!re.test(src)) { log.push(tag + "=NOMATCH"); return src; }
log.push(tag + "=ok");
return src.replace(re, () => to);
}
/* --------------------------- mixEngine.ts --------------------------- */
{
const F = "src/lib/mixEngine.ts";
let s = readFileSync(F, "utf8");
s = fix(s, "E1-signature",
"generateShowcaseMixes(excludedFlavorIds",
/export function generateShowcaseMixes\(\): ShowcaseMix\[\] \{/,
"export function generateShowcaseMixes(excludedFlavorIds: string[] = []): ShowcaseMix[] {");
s = fix(s, "E2-helpers",
"withStopWarnings",
/export interface ShowcaseMix \{/,
L(
"/* ---------------- CRM stop-list: предупреждения в выдаче ------------------- */",
"",
"/** Предупреждение на карточке микса, чей табак ушёл в стоп-лист CRM. */",
"export const STOP_LIST_WARNING =",
' "В составе есть табак из стоп-листа — кальянщик подберёт замену.";',
"",
"/**",
" * Помечает миксы, в составе которых есть табак из стоп-листа CRM",
" * (сверка по catalogId вкуса движка). Если менять нечего, возвращает",
" * те же ссылки — безопасно вызывать на каждом опросе стоп-листа.",
" */",
"export function withStopWarnings<T extends { items: { flavorId: string }[]; warning?: string }>(",
" mixes: T[],",
" stoppedCatalogIds: ReadonlySet<string>,",
"): T[] {",
" if (stoppedCatalogIds.size === 0) return mixes;",
" let changed = false;",
" const next = mixes.map((mix) => {",
" const hit = mix.items.some((item) => {",
" const flavor = byId.get(item.flavorId);",
" return !!flavor && stoppedCatalogIds.has(flavor.catalogId ?? flavor.id);",
" });",
" if (!hit || mix.warning === STOP_LIST_WARNING) return mix;",
" changed = true;",
" return { ...mix, warning: STOP_LIST_WARNING };",
" });",
" return changed ? next : mixes;",
"}",
"",
"export interface ShowcaseMix {",
));
s = fix(s, "E3-opts",
"excludedFlavorIds,\n }, seed++);",
/excludedCategories: \[\],\n excludedFlavorIds: \[\],\n \}, seed\+\+\);/,
"excludedCategories: [],\n excludedFlavorIds,\n }, seed++);");
s = fix(s, "E3-picks",
"const picks = mixes",
/const pick = mixes\.find\(\(m\) => !m\.unverified && !seen\.has\(m\.signature\)\);[\s\S]*?out\.push\(\{ mix: \{ \.\.\.pick, name \}, strength: v\.level, cooling: v\.cold \}\);/,
L(
"const picks = mixes",
" .filter((m) => !m.unverified && !seen.has(m.signature))",
" .slice(0, 2);",
" for (const pick of picks) {",
" seen.add(pick.signature);",
" let name = pick.name;",
" if (seenNames.has(name)) {",
" name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;",
" let n = 2;",
" while (seenNames.has(name)) {",
" name = `${pick.name} · вариант ${n}`;",
" n += 1;",
" }",
" }",
" seenNames.add(name);",
" out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });",
" }",
));
save(F, s);
console.log("mixEngine: " + log.join(" "));
log.length = 0;
}
/* ---------------------------- catalog.ts ---------------------------- */
{
const F = "src/data/catalog.ts";
const s = readFileSync(F, "utf8");
const okCall = s.includes("generateShowcaseMixes(siteUnavailableFlavorIds)");
const okImport = s.includes('CATEGORY_LABELS, FLAVORS } from "./flavors"');
const okOrder = s.indexOf("const tobaccoFlavors") !== -1 &&
s.indexOf("const showcase = generateShowcaseMixes") > s.indexOf("const tobaccoFlavors");
console.log("catalog: call=" + (okCall ? 1 : 0) + " import=" + (okImport ? 1 : 0) + " order=" + (okOrder ? 1 : 0));
if (!okCall || !okImport || !okOrder) die("catalog needs manual repair");
}
/* --------------------------- MixesPage.tsx -------------------------- */
{
const F = "src/pages/MixesPage.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "M1-import",
'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";',
/import \{ useCustomCatalog, type CustomMix \} from "\.\.\/lib\/crm";/,
'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";');
s = fix(s, "M2-item-fields",
"catalogId: string | null;",
/( \/\*\* вкус-холодок \(синяя точка и роль «Холодок»\) \*\/\n cold: boolean;\n\})/,
L(
" /** вкус-холодок (синяя точка и роль «Холодок») */",
" cold: boolean;",
" /** табак в каталоге сайта — по нему сверяется стоп-лист CRM */",
" catalogId: string | null;",
" /** этот вкус ушёл в стоп-лист */",
" stopped: boolean;",
"}",
));
s = fix(s, "M3-mix-field",
"карточка гасится",
/( items: UnifiedItem\[\];\n\})/,
L(
" items: UnifiedItem[];",
" /** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */",
" stopped: boolean;",
"}",
));
s = fix(s, "M4-items-ai",
"catalogId: flavor ? flavor.catalogId ?? flavor.id : null,",
/( cold: !!flavor && isColdFlavor\(flavor\),\n \};)/,
L(
" cold: !!flavor && isColdFlavor(flavor),",
" catalogId: flavor ? flavor.catalogId ?? flavor.id : null,",
" stopped: false,",
" };",
));
s = fix(s, "M5-items-master",
" catalogId: f.id,",
/( cold: !!siteFlavor && isColdFlavor\(siteFlavor\),\n \};)/,
L(
" cold: !!siteFlavor && isColdFlavor(siteFlavor),",
" catalogId: f.id,",
" stopped: false,",
" };",
));
s = fix(s, "M6-aicards",
" items: itemsFromAi(mix),\n stopped: false,\n}));",
/( items: itemsFromAi\(mix\),\n\}\)\);)/,
" items: itemsFromAi(mix),\n stopped: false,\n}));");
s = fix(s, "M7-mastercards",
" items: itemsFromMaster(mix),\n stopped: false,\n })),",
/( items: itemsFromMaster\(mix\),\n \}\)\),)/,
" items: itemsFromMaster(mix),\n stopped: false,\n })),");
s = fix(s, "M8-hook",
" const stoplist = useStoplist();",
/( const catalog = useCustomCatalog\(\);)/,
" const catalog = useCustomCatalog();\n const stoplist = useStoplist();");
s = fix(s, "M9-marked",
"const markedCards = useMemo(",
/( const allCards = useMemo\(\(\) => \[\.\.\.masterCards, \.\.\.aiCards\], \[masterCards\]\);)/,
L(
" const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);",
"",
" // стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —",
" // карточка гасится, стоп-вкусы в составе подсвечиваются красным",
" const markedCards = useMemo(",
" () =>",
" allCards.map((card) => {",
" const items = card.items.map((item) => ({",
" ...item,",
" stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),",
" }));",
" return { ...card, items, stopped: items.some((item) => item.stopped) };",
" }),",
" [allCards, stoplist.tobacco],",
" );",
));
s = fix(s, "M10-filter",
"return markedCards.filter((m) => {",
/return allCards\.filter\(\(m\) => \{/,
"return markedCards.filter((m) => {");
s = fix(s, "M11-deps",
"[markedCards, query, strength, cooling, flavor]",
/\}, \[allCards, query, strength, cooling, flavor\]\);/,
"}, [markedCards, query, strength, cooling, flavor]);");
s = fix(s, "M12-counter",
"из {markedCards.length}",
/из \{allCards\.length\}/,
"из {markedCards.length}");
s = fix(s, "M13-dim",
'card.stopped && "opacity-70 saturate-[0.65]",',
/(: "hover:border-neon-violet\/40 hover:shadow-\[0_18px_50px_-24px_rgba\(176,38,255,0\.5\)\]",\n \)\})/,
': "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",\n card.stopped && "opacity-70 saturate-[0.65]",\n )}');
s = fix(s, "M14-badge",
"bg-red-400/12",
/(\{isMaster \? "Микс от мастера" : "Микс от ИИ"\}\n <\/span>)/,
L(
'{isMaster ? "Микс от мастера" : "Микс от ИИ"}',
" </span>",
" {card.stopped && (",
' <span className="inline-flex items-center gap-1 rounded-full border border-red-400/50 bg-red-400/12 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-red-200">',
" В стоп-листе",
" </span>",
" )}",
));
s = fix(s, "M15-dot",
'"linear-gradient(90deg,#f87171,#ef4444)"',
/style=\{\{ background: segmentGradient\(i, item\.cold\) \}\}/,
'style={{ background: item.stopped ? "linear-gradient(90deg,#f87171,#ef4444)" : segmentGradient(i, item.cold) }}');
s = fix(s, "M16-title",
'item.stopped && "text-red-300"',
/<span className="block truncate font-semibold text-white\/90">\{item\.title\}<\/span>/,
'<span className={cn("block truncate font-semibold text-white/90", item.stopped && "text-red-300")}>{item.title}</span>');
s = fix(s, "M17-warning",
"Часть табаков из состава сейчас в стоп-листе",
/(\{\/\* крепость · холодок · цена \*\/\})/,
L(
" {card.stopped && (",
' <p className="mt-3 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-[11px] leading-relaxed text-red-100">',
" Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.",
" </p>",
" )}",
"",
" {/* крепость · холодок · цена */}",
));
save(F, s);
console.log("MixesPage: " + log.join(" "));
log.length = 0;
}
/* --------------------------- AiMixPage.tsx -------------------------- */
{
const F = "src/pages/AiMixPage.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "A1-import",
" withStopWarnings,\n} from",
/\} from "\.\.\/lib\/mixEngine";/,
' withStopWarnings,\n} from "../lib/mixEngine";');
s = fix(s, "A2-note-fn",
"function withMasterStopNote",
/function pickMasterMix\(/,
L(
"/** Предупреждение о стоп-табаке в миксе мастера: дописывается в note его",
" * карточки (note рендерится на карточке «Микс от мастера» в выдаче).",
" * Идемпотентно и обратимо: табак вернулся из стопа — note откатывается. */",
"const MASTER_STOP_NOTE =",
' "⚠ В составе есть табак из стоп-листа — мастер подберёт замену.";',
"",
"function withMasterStopNote(mix: CustomMix, stopped: ReadonlySet<string>): CustomMix {",
" const hit = mix.flavors.some((f) => stopped.has(f.id));",
' const base = (mix.note ?? "").split(MASTER_STOP_NOTE)[0].trim();',
" if (!hit) {",
' return base === (mix.note ?? "").trim() ? mix : { ...mix, note: base || null };',
" }",
' const next = [base, MASTER_STOP_NOTE].filter(Boolean).join(" ");',
' return next === (mix.note ?? "").trim() ? mix : { ...mix, note: next };',
"}",
"",
"function pickMasterMix(",
));
s = fix(s, "A3-stopkey",
"const stopKey = useMemo(",
/\[currentOptions, stopExcludedIds\],\n \);/,
L(
"[currentOptions, stopExcludedIds],",
" );",
"",
" /* Выдача и микс мастера перепроверяются на стоп-табак при каждом обновлении",
" стоп-листа: восстановление из share-ссылки собирается без стоп-исключений",
" (у каждого получателя свой стоп-лист), поэтому состав может содержать",
" остановленный табак — тогда на карточке появляется предупреждение. */",
' const stopKey = useMemo(() => [...stoplist.tobacco].sort().join("|"), [stoplist]);',
" useEffect(() => {",
' const stopped = new Set(stopKey ? stopKey.split("|") : []);',
" setResults((prev) => (prev ? withStopWarnings(prev, stopped) : prev));",
" setMasterPick((prev) => (prev ? withMasterStopNote(prev, stopped) : prev));",
" }, [stopKey]);",
));
s = fix(s, "A4-finish-results",
"setResults(withStopWarnings(mixes, stoplist.tobacco));",
/setResults\(mixes\);/,
"setResults(withStopWarnings(mixes, stoplist.tobacco));");
s = fix(s, "A5-share",
"withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco)",
/setResults\(generateMixSet\(shared\.profile, options, shared\.seed\)\.mixes\);/,
L(
"setResults(",
" withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),",
" );",
));
// первый патч урезал замену до «setResults(» — чиним урезанный вариант
if (!s.includes("withStopWarnings(generateMixSet(shared.profile")) {
const re5b = /setResults\(\s*\n\s*setLastRun\(\{ seed: shared\.seed, profile: shared\.profile, options \}\);/;
if (re5b.test(s)) {
s = s.replace(re5b, () =>
L(
"setResults(",
" withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),",
" );",
" setLastRun({ seed: shared.seed, profile: shared.profile, options });",
));
log.push("A5b-share-truncated=ok");
} else {
log.push("A5b-share-truncated=NOMATCH");
const i = s.indexOf("setLastRun({ seed: shared.seed");
console.log("CTX5: " + JSON.stringify(s.slice(Math.max(0, i - 300), i + 120)));
}
}
s = fix(s, "A6-finish-master",
"withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed)",
/setMasterPick\(\s*\n\s*setHistory\(newHistory\);/,
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed), stoplist.tobacco),",
" );",
" setHistory(newHistory);",
));
s = fix(s, "A7-load-master",
"withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options",
/setMasterPick\(pickMasterMix\(catalog\.mixes, lastRun\.options, lastRun\.profile, lastRun\.seed\)\);/,
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),",
" );",
));
// первый патч урезал и эту замену — чиним урезанный вариант
if (!s.includes("withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options")) {
const re7b = /setMasterPick\(\s*\n\s*\}, \[catalog\.mixes, lastRun, masterPick\]\);/;
if (re7b.test(s)) {
s = s.replace(re7b, () =>
L(
"setMasterPick(",
" withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),",
" );",
" }, [catalog.mixes, lastRun, masterPick]);",
));
log.push("A7b-load-master-truncated=ok");
} else {
log.push("A7b-load-master-truncated=NOMATCH");
const j = s.indexOf("[catalog.mixes, lastRun, masterPick]");
console.log("CTX7: " + JSON.stringify(s.slice(Math.max(0, j - 300), j + 60)));
}
}
save(F, s);
console.log("AiMixPage: " + log.join(" "));
log.length = 0;
}
/* ------------------------- HeroActions.tsx -------------------------- */
{
const F = "src/components/HeroActions.tsx";
let s = readFileSync(F, "utf8");
s = fix(s, "H1-helper",
"const mixPlural = (n: number): string => {",
/const chips: Chip\[\] = \[/,
L(
"/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */",
"const mixPlural = (n: number): string => {",
" const mod10 = n % 10;",
" const mod100 = n % 100;",
" if (mod10 === 1 && mod100 !== 11) return `${n} микс`;",
" if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;",
" return `${n} миксов`;",
"};",
"",
"const chips: Chip[] = [",
));
s = fix(s, "H2-label",
"label: mixPlural(hookahMixes.length)",
/label: `\$\{hookahMixes\.length\} миксов`/,
"label: mixPlural(hookahMixes.length)");
save(F, s);
console.log("HeroActions: " + log.join(" "));
}
console.log("REPAIR_DONE");