Compare commits

...
2 Commits
Author SHA1 Message Date
mates 9a03dd1e5e 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
2026-07-07 10:15:10 +02:00
mates c3a8900283 fix: oprava výběru preferovaného času odchodu u podniku bez načteného menu 2026-07-07 10:02:03 +02:00
5 changed files with 448 additions and 57 deletions
+1 -1
View File
@@ -513,7 +513,7 @@ function App() {
} }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) { if (choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } }); await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
} }
} }
+4
View File
@@ -0,0 +1,4 @@
[
"Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu",
"Oprava: načítání denního menu podniku TechTower po změně jejich webu"
]
+96 -56
View File
@@ -283,7 +283,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
let thirdTry = false; let thirdTry = false;
// První pokus - varianta "Obědy" // První pokus - varianta "Obědy"
let fonts = $('font.wsw-41'); let fonts = $('font.wsw-41');
let font = undefined; let font: any = undefined;
fonts.each((i, f) => { fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) { if ($(f).text().trim().startsWith('Obědy')) {
font = f; 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) { if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = []; 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> // Č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 // 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 => { 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); .filter(line => line.length > 0);
for (const text of itemLines) { for (const text of itemLines) {
let price = 'na\xA0váhu'; result[dayIndex].push(buildFoodItem(text));
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,
});
} }
}); });
} else { } 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 ¯\_(ツ)_/¯ // Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue; 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] ??= [];
result[currentDayIndex].push({ result[currentDayIndex].push(buildFoodItem(text));
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
})
} }
} }
} }
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu return finalizeTechTower(result, $(font).text().trim(), firstDayOfWeek);
const headerText = $(font).text().trim(); }
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
/**
* 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) { if (dateMatch) {
const foundDay = parseInt(dateMatch[1]); const foundDay = parseInt(dateMatch[1]);
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based 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); throw new StaleWeekError(result);
} }
} }
return result; return result;
} }
File diff suppressed because one or more lines are too long
+42
View File
@@ -87,6 +87,48 @@ describe('TechTower parser', () => {
}); });
}); });
describe('TechTower parser nový formát 07/2026', () => {
// Pondělí 6.7.2026 (pondělí je svátek, hlavička začíná úterým "7. 7.-10. 7.")
const MONDAY_2026 = new Date('2026-07-06');
beforeEach(() => {
mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower-2026-07.html') });
});
test('vrátí pole o délce 5', async () => {
const menu = await getMenuTechTower(MONDAY_2026);
expect(menu).toHaveLength(5);
});
test('středeční menu obsahuje polévku a hlavní jídla s cenou', async () => {
const menu = await getMenuTechTower(MONDAY_2026);
// index 2 = středa (Boršč + hlavní jídla)
expect(menu[2].some(f => f.isSoup)).toBe(true);
expect(menu[2].some(f => !f.isSoup && f.price.includes('Kč'))).toBe(true);
});
test('cena i alergeny jsou správně odděleny od názvu jídla', async () => {
const menu = await getMenuTechTower(MONDAY_2026);
const polevka = menu[2].find(f => f.isSoup);
expect(polevka).toBeDefined();
// "Boršč 1,7,9 — 45 Kč" → název bez ceny a bez alergenů, cena a alergeny zvlášť
expect(polevka!.name).not.toMatch(/Kč/);
expect(polevka!.name).not.toMatch(/\d/);
expect(polevka!.price).toContain('Kč');
expect(Array.isArray(polevka!.allergens)).toBe(true);
expect(polevka!.allergens!.length).toBeGreaterThan(0);
});
test('nehodí StaleWeekError pro aktuální týden, i když pondělí je svátek', async () => {
await expect(getMenuTechTower(MONDAY_2026)).resolves.toHaveLength(5);
});
test('hodí StaleWeekError pro jiný týden', async () => {
const jinyTyden = new Date('2026-06-01');
await expect(getMenuTechTower(jinyTyden)).rejects.toBeInstanceOf(StaleWeekError);
});
});
describe('SenkSerikova parser', () => { describe('SenkSerikova parser', () => {
beforeEach(() => { beforeEach(() => {
// SenkSerikova parsuje arraybuffer musíme vrátit Buffer, ne string // SenkSerikova parsuje arraybuffer musíme vrátit Buffer, ne string