Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a03dd1e5e
|
||
|
|
c3a8900283
|
+1
-1
@@ -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 } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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', () => {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user