Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a03dd1e5e
|
||
|
|
c3a8900283
|
+1
-1
@@ -513,7 +513,7 @@ function App() {
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+305
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user