From 9a03dd1e5ebebc4484dec959f29116b4f7418e05 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 7 Jul 2026 10:15:10 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20oprava=20parsov=C3=A1n=C3=AD=20TechTower?= =?UTF-8?q?=20po=20zm=C4=9Bn=C4=9B=20HTML=20struktury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/changelogs/2026-07-07.json | 3 +- server/src/restaurants.ts | 152 +++++---- .../src/tests/fixtures/techtower-2026-07.html | 305 ++++++++++++++++++ server/src/tests/scrapers.test.ts | 42 +++ 4 files changed, 445 insertions(+), 57 deletions(-) create mode 100644 server/src/tests/fixtures/techtower-2026-07.html diff --git a/server/changelogs/2026-07-07.json b/server/changelogs/2026-07-07.json index d9d4a66..0b60f06 100644 --- a/server/changelogs/2026-07-07.json +++ b/server/changelogs/2026-07-07.json @@ -1,3 +1,4 @@ [ - "Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu" + "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" ] diff --git a/server/src/restaurants.ts b/server/src/restaurants.ts index fabbe6b..450c917 100644 --- a/server/src/restaurants.ts +++ b/server/src/restaurants.ts @@ -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 + // (typicky wsw-31), jednotlivé položky jsou v samostatných

uvnitř vnořeného + //

, den a jeho první položka mohou být ve stejném

+ // oddělené
. 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 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

ve společném kontejneru, každý rozdělíme + // podle
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

. + 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(//i) + .map(part => $(`${part}`).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 , ale nový formát má každý den v jednom

// s položkami oddělenými
místo separátních

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; } diff --git a/server/src/tests/fixtures/techtower-2026-07.html b/server/src/tests/fixtures/techtower-2026-07.html new file mode 100644 index 0000000..c65d94a --- /dev/null +++ b/server/src/tests/fixtures/techtower-2026-07.html @@ -0,0 +1,305 @@ + + + + + Restaurace :: Equifarm + + + + + + + + + + + +

+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+

Restaurace 

+
+
+
+
+
+
+

Otevírací doba

po – pá 10.00 - 14.00

obědy 10.00 - 13:45

+
+

Jídelní lístek 7. 7.-10. 7.


Pondělí
SVÁTEK


Úterý

Brokolicový krém 1,7 — 45 Kč

Rizoto s kuřecím masem, hráškem, paprikou a sýrem 7 — 140 Kč

Špagety s bazalkovým pestem, grilovanými cherry rajčaty a trhanou burratou 1,7,8

Kuřecí placičky s bylinkami a kurkumou, hranolky, řecký salát, bylinková majonéza 1,3,7

Krkovička na česneku, jasmínová rýže 1


Středa

Boršč 1,7,9 — 45 Kč

Halušky s uzeným masem a zelím, restovaná cibulka 1 — 140 Kč

Cheddarové kuličky s jalapeños, vařené brambory, tatarská omáčka 1,3,7

Zvěřinové ragú se zeleninou, karlovarský knedlík 1,3,7,9

Africký sumeček na grilu, pečená zelenina s brambůrky 4


Čtvrtek
Thajská polévka s chilli a kokosovým mlékem 1,7 — 45 Kč

Vepřová plec na smetaně, houskový knedlík 1,3,7,9 — 140 Kč

Buchtičky s vanilkovým krémem a ovocem 1,3,7

Vepřové medailonky s omáčkou z pečených paprik, jasmínová rýže 1

Kachní stehýnko se silnou omáčkou, gratinované brambory 1,3,7


Pátek

Hovězí vývar s drožďovou zavářkou a zeleninou 1,3,7,9 — 45 Kč

Vepřové výpečky, špenát, bramborový knedlík 1,3,7 — 140 Kč

Cuketové placičky s nivou, salát coleslaw 1,3,7

Flank steak se sladkokyselou omáčkou, jasmínová rýže 1,6,11

Sekaný řízek s mozzarellou a bazalkou, bramborové pyré


+
+

+
+

www.equifarm.cz

techtower@equifarm.cz

+420 608 180 311

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ © 2022 Equifarm, s.r.o. Všechna práva vyhrazena. +
+
+ +Vytvořeno službou Webnode + + + Cookies + + + + +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+ + + + + + + diff --git a/server/src/tests/scrapers.test.ts b/server/src/tests/scrapers.test.ts index 06c0669..b6840f3 100644 --- a/server/src/tests/scrapers.test.ts +++ b/server/src/tests/scrapers.test.ts @@ -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', () => { beforeEach(() => { // SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string