fix: oprava parsování TechTower po změně HTML struktury
CI / Generate TypeScript types (push) Failing after 13m15s
CI / Server unit tests (push) Has been skipped
CI / Build server (push) Has been skipped
CI / Build client (push) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s

This commit is contained in:
mates committed 2026-07-07 10:15:10 +02:00
1 parent c3a8900283
commit 9a03dd1e5e
4 files changed
+445 -57

No files matched your search

+96 -56
View File
@@ -283,7 +283,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
let thirdTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined;
let font: any = undefined;
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) {
font = f;
@@ -309,12 +309,83 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}
})
}
// Pátý pokus - formát z 07/2026: hlavička "Jídelní lístek" je v libovolném <font>
// (typicky wsw-31), jednotlivé položky jsou v samostatných <p> uvnitř vnořeného
// <div><font class="wsw-31">, den a jeho první položka mohou být ve stejném <p>
// oddělené <br>. Třída wsw-41 nyní obsahuje otevírací dobu, nikoli menu.
let fifthTry = false;
if (!font) {
$('font').each((_, f) => {
if (font) return;
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
fifthTry = true;
}
})
}
if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
// Z textu jedné položky odvodí název, cenu a alergeny (sdíleno napříč formáty).
const buildFoodItem = (text: string): Food => {
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`;
nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`;
nameRaw = split[0].replace('•', '');
}
if (nameRaw.endsWith('–') || nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
return {
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
};
};
// Pátý pokus (parsing): projdeme všechny <p> ve společném kontejneru, každý rozdělíme
// podle <br> na jednotlivé řádky. Řádek s názvem dne přepne aktuální den, ostatní řádky
// jsou položky. Zvládne tak i den a první položku ve stejném <p>.
if (fifthTry) {
let parsing = false;
let currentDayIndex = 0;
const container = $(font).parent().parent();
container.find('p').each((_, p) => {
const pHtml = $(p).html() ?? '';
const lines = pHtml.split(/<br\s*\/?>/i)
.map(part => $(`<span>${part}</span>`).text().trim())
.filter(line => line.length > 0);
for (const text of lines) {
const lower = text.toLocaleLowerCase();
if (DAYS_IN_WEEK.includes(lower)) {
currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
parsing = true;
result[currentDayIndex] ??= [];
continue;
}
if (!parsing) {
continue;
}
result[currentDayIndex].push(buildFoodItem(text));
}
});
return finalizeTechTower(result, $(font).text().trim(), firstDayOfWeek);
}
// Čtvrtý pokus (detekce): thirdTry našel <font>, ale nový formát má každý den v jednom <p>
// s položkami oddělenými <br> místo separátních <p> pro každou položku
const fourthTry = thirdTry && $(font).parent().siblings('p').toArray().some(el => {
@@ -345,31 +416,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
.filter(line => line.length > 0);
for (const text of itemLines) {
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`;
nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`;
nameRaw = split[0].replace('•', '');
}
if (nameRaw.endsWith('–') || nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[dayIndex].push({
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
});
result[dayIndex].push(buildFoodItem(text));
}
});
} else {
@@ -389,47 +436,40 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue;
}
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`
nameRaw = split[0].replace('•', '');
}
if (nameRaw.endsWith('–')|| nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
})
result[currentDayIndex].push(buildFoodItem(text));
}
}
}
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
const headerText = $(font).text().trim();
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
return finalizeTechTower(result, $(font).text().trim(), firstDayOfWeek);
}
/**
* Ověří, že datum v hlavičce TechTower spadá do požadovaného týdne, jinak vyhodí StaleWeekError.
*
* Datum v hlavičce nemusí být přesně pondělí (např. je-li pondělí svátek, začíná hlavička
* úterým), proto stačí, aby první nalezené datum padlo do libovolného dne požadovaného týdne.
* Toleruje i mezery za tečkami (formát "7. 7.-10. 7.").
*/
const finalizeTechTower = (result: Food[][], headerText: string, firstDayOfWeek: Date): Food[][] => {
const dateMatch = headerText.match(/(\d{1,2})\.\s*(\d{1,2})\./);
if (dateMatch) {
const foundDay = parseInt(dateMatch[1]);
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based
if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) {
let matchesWeek = false;
for (let d = 0; d < 7; d++) {
const day = new Date(firstDayOfWeek);
day.setDate(firstDayOfWeek.getDate() + d);
if (day.getDate() === foundDay && day.getMonth() === foundMonth) {
matchesWeek = true;
break;
}
}
if (!matchesWeek) {
throw new StaleWeekError(result);
}
}
return result;
}