diff --git a/backend/src/services/scheduleImportService.js b/backend/src/services/scheduleImportService.js
index b858015..a907faf 100644
--- a/backend/src/services/scheduleImportService.js
+++ b/backend/src/services/scheduleImportService.js
@@ -6,6 +6,8 @@ const { HOME_URL, CATALOG_URL, hash, normalizeName, normalizeNumber, sourceError
safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews } = require('./scheduleSource');
const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' };
+const DAY_VALUES = { weekday: [1, 2, 3, 4, 5], saturday: [6], sunday: [7] };
+const activeOn = (trip, day) => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(day)) && !trip.exceptDaysOfWeek.includes(day);
const tripKey = trip => JSON.stringify({
departureTime: trip.departureTime?.length === 5 ? `${trip.departureTime}:00` : trip.departureTime,
startStopId: trip.startStopId || null, endStopId: trip.endStopId || null,
@@ -29,6 +31,25 @@ function compareTrips(before, after) {
return { added, removed: remaining.map(item => item.trip), unchanged };
}
+function mergeDaySections(trips, dayType) {
+ const groups = new Map();
+ for (const trip of trips) {
+ const key = tripKey({ ...trip, daysOfWeek: [], exceptDaysOfWeek: [] });
+ const activeDays = DAY_VALUES[dayType].filter(day => activeOn(trip, day));
+ const existing = groups.get(key);
+ if (!existing) groups.set(key, { trip, activeDays });
+ else {
+ if (activeDays.some(day => existing.activeDays.includes(day))) {
+ throw sourceError(`${trip.departureTime.slice(0, 5)}: разделы расписания содержат повторяющийся рейс в один и тот же день. Проверьте источник.`);
+ }
+ existing.activeDays = [...existing.activeDays, ...activeDays].sort((a, b) => a - b);
+ existing.trip = { ...existing.trip, exceptDaysOfWeek: [],
+ daysOfWeek: existing.activeDays.length === DAY_VALUES[dayType].length ? [] : existing.activeDays };
+ }
+ }
+ return [...groups.values()].map(group => group.trip);
+}
+
function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTime().date) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom || '')
|| Number.isNaN(Date.parse(validFrom)) || new Date(validFrom).toISOString().slice(0, 10) !== validFrom
@@ -55,6 +76,12 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi
for (const departure of departures) {
const trip = { ...blankTrip(departure.departureTime, start, end),
daysOfWeek: departure.daysOfWeek || [], exceptDaysOfWeek: departure.exceptDaysOfWeek || [] };
+ if (row.daysOfWeek?.length) {
+ const allowed = row.daysOfWeek.filter(day => activeOn(trip, day));
+ if (!allowed.length) throw sourceError(`${departure.departureTime.slice(0, 5)}: примечание противоречит разделу «${row.dayLabel}». Проверьте источник.`);
+ trip.daysOfWeek = allowed;
+ trip.exceptDaysOfWeek = [];
+ }
const unresolved = [];
for (const name of departure.viaStopNames || []) {
const matches = snapshot.stops.filter(stop => normalizeName(stop.name) === normalizeName(name));
@@ -67,8 +94,7 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi
trip.note = unresolved.map(name => `через «${name}»`).join('; ');
let included = false;
for (const day of row.days) {
- const activeDays = { weekday: [1, 2, 3, 4, 5], saturday: [6], sunday: [7] }[day];
- if (!activeDays.some(value => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(value)) && !trip.exceptDaysOfWeek.includes(value))) continue;
+ if (!DAY_VALUES[day].some(value => activeOn(trip, value))) continue;
groups[day].push(trip);
included = true;
}
@@ -76,7 +102,8 @@ function buildPlan(source, mappings, snapshot, validFrom, today = getLocalDateTi
}
}
const changes = [];
- for (const [dayType, trips] of Object.entries(groups)) {
+ for (const [dayType, departures] of Object.entries(groups)) {
+ const trips = mergeDaySections(departures, dayType);
if (!trips.length) throw sourceError(`${DAY_LABELS[dayType]}: выберите хотя бы одну строку отправлений.`);
if (trips.length > 2000 || new Set(trips.map(tripKey)).size !== trips.length) {
throw sourceError('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.');
diff --git a/backend/src/services/scheduleSource.js b/backend/src/services/scheduleSource.js
index 6476436..d8c22c5 100644
--- a/backend/src/services/scheduleSource.js
+++ b/backend/src/services/scheduleSource.js
@@ -94,6 +94,19 @@ function dayTypes(text) {
return null;
}
+function daySection(text) {
+ const label = clean(text);
+ const days = dayTypes(label);
+ if (days) return { days, daysOfWeek: [], label };
+ const weekdays = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница'];
+ const range = label.toLowerCase().split(/\s*[–—-]\s*/);
+ if (range.length > 2) return null;
+ const first = weekdays.indexOf(range[0]) + 1;
+ const last = weekdays.indexOf(range.at(-1)) + 1;
+ if (!first || !last || last < first) return null;
+ return { days: ['weekday'], daysOfWeek: Array.from({ length: last - first + 1 }, (_, i) => first + i), label };
+}
+
const WEEKDAY_FORMS = [
['понедельник', 'понедельникам', 'понедельника'], ['вторник', 'вторникам', 'вторника'],
['среда', 'средам', 'среды', 'среду'], ['четверг', 'четвергам', 'четверга'],
@@ -144,16 +157,17 @@ function parseSchedule(html, url) {
const rows = [];
const warnings = [];
content.find('table').each((tableIndex, table) => {
- let days = null;
+ let section = null;
$(table).find('tr').each((rowIndex, tr) => {
const cells = $(tr).children('td, th').toArray();
const texts = cells.map(cell => clean($(cell).text()));
if (!texts.some(Boolean)) return;
- const parsedDays = dayTypes(texts.join(' '));
- if (parsedDays) { days = parsedDays; return; }
+ const parsedSection = daySection(texts.join(' '));
+ if (parsedSection) { section = parsedSection; return; }
if (texts.join(' ').includes('к списку маршрутов')) return;
- if (cells.length !== 2 || !days) {
+ if (cells.length !== 2 || !section) {
warnings.push(`Не распознана строка таблицы: ${texts.join(' — ').slice(0, 240)}`);
+ section = null;
return;
}
const [stopName, rawTimes] = texts;
@@ -167,7 +181,8 @@ function parseSchedule(html, url) {
}
const times = invalid ? [] : departures.map(trip => trip.departureTime);
if (new Set(times).size !== times.length) warnings.push(`«${stopName}»: повторяющиеся времена требуют проверки.`);
- rows.push({ key: `${tableIndex}:${rowIndex}`, stopName, days, times, departures: invalid ? [] : departures, rawTimes });
+ rows.push({ key: `${tableIndex}:${rowIndex}`, stopName, days: section.days, daysOfWeek: section.daysOfWeek,
+ dayLabel: section.label, times, departures: invalid ? [] : departures, rawTimes });
});
});
if (!rows.length) throw sourceError('Таблицы расписания не распознаны. Существующие данные сохранены.');
@@ -175,8 +190,13 @@ function parseSchedule(html, url) {
content.find('p, div').each((_, node) => {
const text = clean($(node).text());
if (!/^(Прямое|Обратное) направление:/i.test(text)) return;
- const stops = [...text.matchAll(/«\s*([^»]+?)\s*»/g)].map(match => clean(match[1]));
- if (stops.length) directions.push({ label: text.split(':')[0], stops });
+ // Both directions may share one paragraph, separated only by a
.
+ for (const part of text.matchAll(/((?:Прямое|Обратное) направление):([\s\S]*?)(?=(?:Прямое|Обратное) направление:|$)/gi)) {
+ const stops = [...part[2].matchAll(/«\s*([^»]+?)\s*»/g)].map(match => clean(match[1]));
+ if (stops.length && !directions.some(direction => direction.label === part[1] && hash(direction.stops) === hash(stops))) {
+ directions.push({ label: part[1], stops });
+ }
+ }
});
// Footnotes outside tables must not silently disappear during import.
const outside = content.clone();
diff --git a/backend/test/fixtures/schedule-import/route24.html b/backend/test/fixtures/schedule-import/route24.html
new file mode 100644
index 0000000..c5211e7
--- /dev/null
+++ b/backend/test/fixtures/schedule-import/route24.html
@@ -0,0 +1,53 @@
+
Маршрут №24
+
+ ◄ к списку маршрутов
+
+
+| Понедельник – четверг |
+
+
+| Автовокзал |
+06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 16:02 | 17:00 | 22:05 |
+
+
+| Полоцк – Стекловолокно |
+06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:30 | 17:25 | 23:30 |
+
+
+
+
+
+
+| Пятница |
+
+
+| Автовокзал |
+06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 15:45 | 16:02 | 22:05 |
+
+
+| Полоцк – Стекловолокно |
+06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:15 | 16:30 | 23:30 |
+
+
+
+
+
+
+| Выходной день |
+
+
+| Автовокзал |
+06:00 | 14:05 | 22:05 |
+
+
+| Полоцк – Стекловолокно |
+07:30 | 15:30 | 23:30 |
+
+
+
+
Остановочные пункты:
+
Прямое направление: «Автовокзал», «Железнодорожный вокзал», «Экономический колледж», «Олимпиец», «Спортклуб», «Энергосбыт», «Заполотье», «Экимань», «Курган Бессмертия», «Вильнюсское шоссе», «Ксты», «Полоцкгаз», «Полоцк – Стекловолокно».
+Обратное направление: «Полоцк – Стекловолокно», «Полоцкгаз», «Ксты», «Вильнюсское шоссе», «ТРЦ Манеж», «Курган Бессмертия», «Экимань», «Заполотье», «Энергосбыт», «Спортклуб», «Олимпиец», «Экономический колледж», «Железнодорожный вокзал», «Автовокзал»
+
Схема движения
+

+
\ No newline at end of file
diff --git a/backend/test/scheduleImport.integration.test.js b/backend/test/scheduleImport.integration.test.js
index fb56942..cb1cea3 100644
--- a/backend/test/scheduleImport.integration.test.js
+++ b/backend/test/scheduleImport.integration.test.js
@@ -146,6 +146,27 @@ test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news rev
assert.equal(responseBody.trips.some(t => t.departureTime === '05:23:00'), false);
assert.equal(Number((await pool.query("SELECT count(*) FROM schedules WHERE day_type='holiday'")).rows[0].count), 2);
+ // Route 24 uses separate Mon–Thu and Friday tables; persist their restrictions together.
+ sourceHtml = await fixture('route24');
+ const source24 = parseSchedule(sourceHtml, 'https://ap2polotsk.of.by/ap2/m-24/');
+ const ids24 = [];
+ for (const direction of source24.directions) {
+ const id = (await pool.query("INSERT INTO routes(route_number,name,type) VALUES('24',$1,'bus') RETURNING id", [direction.label])).rows[0].id;
+ ids24.push(id);
+ for (const [index, name] of [direction.stops[0], direction.stops.at(-1)].entries()) {
+ const stop = (await pool.query("INSERT INTO stops(name,location) VALUES($1,ST_GeogFromText('POINT(28.77 55.48)')) RETURNING id", [name])).rows[0].id;
+ await pool.query('INSERT INTO route_stops(route_id,stop_id,sequence) VALUES($1,$2,$3)', [id,stop,index+1]);
+ }
+ }
+ const inspection24 = await service.inspect({ routeIds: ids24, sourceUrl: 'https://ap2polotsk.of.by/ap2/m-24/', userId:1 });
+ const preview24 = await service.preview({ inspectionId:inspection24.id, mappings:inspection24.mappings, validFrom, userId:1 });
+ await service.apply(preview24.id);
+ const saved24 = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [ids24[0]])).rows[0].trips;
+ assert.equal(saved24.filter(t => t.departureTime === '06:00:00').length, 1);
+ assert.deepEqual(saved24.find(t => t.departureTime === '17:00:00').daysOfWeek, [1,2,3,4]);
+ assert.deepEqual(saved24.find(t => t.departureTime === '15:45:00').daysOfWeek, [5]);
+ assert.equal(Number((await pool.query('SELECT count(*) FROM schedules WHERE route_id=ANY($1::int[])', [ids24])).rows[0].count), 6);
+
let news = await service.news(true);
assert.equal(news.length, 3);
await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash });
diff --git a/backend/test/scheduleImport.test.js b/backend/test/scheduleImport.test.js
index bf2baf3..73d9d09 100644
--- a/backend/test/scheduleImport.test.js
+++ b/backend/test/scheduleImport.test.js
@@ -162,3 +162,40 @@ test('weekend day restrictions split correctly and contradictory day sections ar
const invalid = parseSchedule(fixture('route9').replace('по пятницам', 'по субботам'), annotated.url);
assert.throws(() => buildDirectionPlan(invalid, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'), /противоречит/);
});
+
+test('route 24 splits Monday–Thursday, Friday and weekends without duplicate daily departures', () => {
+ const source24 = parseSchedule(fixture('route24'), 'https://ap2polotsk.of.by/ap2/m-24/');
+ assert.deepEqual(source24.warnings, []);
+ assert.equal(source24.directions.length, 2);
+ assert.deepEqual(departureStops(source24), ['Автовокзал', 'Полоцк – Стекловолокно']);
+ assert.deepEqual(source24.rows.map(row => row.daysOfWeek), [[1,2,3,4], [1,2,3,4], [5], [5], [], []]);
+ for (const direction of source24.directions) {
+ const snapshot24 = { route: { id: 24, route_number: '24' }, schedules: [], stops: direction.stops.map((name,i) => ({ name, stop_id: i + 1 })) };
+ const built = buildDirectionPlan(source24, direction.stops[0], snapshot24, '2026-09-16', '2026-09-16');
+ const weekdayTrips = built.changes.find(change => change.dayType === 'weekday').after;
+ assert.equal(new Set(weekdayTrips.map(trip => trip.departureTime)).size, weekdayTrips.length);
+ for (let day = 1; day <= 7; day++) {
+ const dayType = day <= 5 ? 'weekday' : day === 6 ? 'saturday' : 'sunday';
+ const active = built.changes.find(change => change.dayType === dayType).after
+ .filter(trip => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(day)) && !trip.exceptDaysOfWeek.includes(day))
+ .map(trip => trip.departureTime).sort();
+ const row = source24.rows.find(row => row.stopName === direction.stops[0] && row.days.includes(dayType)
+ && (!row.daysOfWeek.length || row.daysOfWeek.includes(day)));
+ assert.deepEqual(active, [...row.times].sort(), `${direction.label}, ISO day ${day}`);
+ }
+ const again = buildDirectionPlan(source24, direction.stops[0], { ...snapshot24, schedules: built.changes.map((change,i) => ({ id:i+1, day_type:change.dayType, trips:change.after, valid_from:'2026-09-16' })) }, '2026-09-16', '2026-09-16');
+ assert.equal(again.hasChanges, false);
+ }
+});
+
+test('section restrictions intersect trip notes and overlapping sections are rejected', () => {
+ const parsed = html => parseSchedule(html, 'https://ap2polotsk.of.by/ap2/m-24/');
+ const state = { route:{id:24,route_number:'24'}, schedules:[], stops:[{stop_id:1,name:'Автовокзал'},{stop_id:2,name:'Полоцк – Стекловолокно'}] };
+ const build = html => buildDirectionPlan(parsed(html), 'Автовокзал', state, '2026-09-16', '2026-09-16');
+ const narrowed = build(fixture('route24').replace('17:00', '17:00 кроме среды'));
+ assert.deepEqual(narrowed.changes[0].after.find(t => t.departureTime === '17:00:00').daysOfWeek, [1,2,4]);
+ assert.throws(() => build(fixture('route24').replace('17:00','17:00 по пятницам')), /противоречит/);
+ assert.throws(() => build(fixture('route24').replace('Понедельник – четверг','Понедельник – пятница')), /повторяющийся/);
+ for (const label of ['Понедельник-четверг', 'Понедельник — четверг']) assert.deepEqual(parsed(fixture('route24').replace('Понедельник – четверг',label)).warnings, []);
+ assert.ok(parsed(fixture('route24').replace('Понедельник – четверг','Четверг – понедельник')).warnings.length);
+});
diff --git a/docs/schedule-import.md b/docs/schedule-import.md
index 4260ac8..9e6b7d7 100644
--- a/docs/schedule-import.md
+++ b/docs/schedule-import.md
@@ -23,11 +23,13 @@
## Ограничения первой версии
-Поддерживаются обычные HTML-таблицы с блоками «Рабочий день», «Выходной день», «Суббота», «Воскресенье» и временами через `|`. Поддерживаются примечания у конкретного времени: «по пятницам», «только по понедельникам и средам», «кроме пятницы» и «через «Мариненко»», в том числе их сочетания. Дни недели сохраняются в `daysOfWeek` / `exceptDaysOfWeek` (ISO 1–7). Остановка «через» сопоставляется по имени с остановками направления и сохраняется в `viaStopIds`. Если совпадения нет или оно неоднозначно, условие сохраняется в `note`, а предпросмотр сообщает об отсутствии привязки. Это не добавляет остановку, геометрию или время проезда в маршрут.
+Поддерживаются обычные HTML-таблицы с блоками «Рабочий день», «Выходной день», «Суббота», «Воскресенье», отдельными будними днями и диапазонами (например, «Понедельник – четверг», «Пятница» у №24), и временами через `|`. Поддерживаются примечания у конкретного времени: «по пятницам», «только по понедельникам и средам», «кроме пятницы» и «через «Мариненко»», в том числе их сочетания. Дни недели сохраняются в `daysOfWeek` / `exceptDaysOfWeek` (ISO 1–7). Остановка «через» сопоставляется по имени с остановками направления и сохраняется в `viaStopIds`. Если совпадения нет или оно неоднозначно, условие сохраняется в `note`, а предпросмотр сообщает об отсутствии привязки. Это не добавляет остановку, геометрию или время проезда в маршрут.
-Неизвестные условия, отдельные сноски, зачёркивания, неизвестные заголовки и неоднозначные строки блокируют применение всей страницы и показываются администратору. Это касается, например, отдельных разделов по пятницам и отправлений из автопарка. Их следует редактировать вручную через **Расписания**.
+Одинаковые отправления из непересекающихся разделов объединяются с сохранением дней выполнения. Примечание рейса дополнительно сужает дни его раздела; противоречия и повтор одного рейса в один день блокируют импорт.
-Проверка живого сайта 16.09.2026: каталог содержит 15 маршрутов. Без замечаний разобраны №1, 2, 4, 6, 8, 9, 11, 27 и 28Э. У остальных показаны блокирующие замечания; №6 требует ручного сопоставления направлений. Разметка сайта может измениться.
+Неизвестные условия, отдельные сноски, зачёркивания, неизвестные заголовки и неоднозначные строки блокируют применение всей страницы и показываются администратору. Это касается, например, нестандартных отправлений из автопарка. Их следует редактировать вручную через **Расписания**.
+
+Проверка живого сайта 16.09.2026: каталог содержит 15 маршрутов. Без замечаний разобраны №1, 2, 4, 6, 8, 9, 11, 24, 27 и 28Э. У остальных показаны блокирующие замечания; №6 требует ручного сопоставления направлений. Разметка сайта может измениться.
Каждое направление приложения использует свой порядок остановок. Импорт сохраняет `startStopId` и `endStopId` первой и последней остановки, но не создаёт недостающие направления. Корректность порядка остановок и существующих `time_offset_minutes` нужно проверять в редакторе маршрута.
diff --git a/frontend/src/pages/ScheduleImportPage.jsx b/frontend/src/pages/ScheduleImportPage.jsx
index b47bbaa..5ca8d61 100644
--- a/frontend/src/pages/ScheduleImportPage.jsx
+++ b/frontend/src/pages/ScheduleImportPage.jsx
@@ -173,7 +173,7 @@ export default function ScheduleImportPage() {
{mappings[route.id] && В этот маршрут попадут времена из строки «{mappings[route.id]}».
}
{inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row =>
- {row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён
+ {row.dayLabel || row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён
{row.rawTimes}
)}
)}