feat: enhance schedule import functionality with support for day sections and restrictions

This commit is contained in:
2026-09-16 23:16:35 +03:00
parent 595ad6456b
commit d32c15c85e
7 changed files with 174 additions and 14 deletions

View File

@@ -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('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.');

View File

@@ -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 <br>.
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();

View File

@@ -0,0 +1,53 @@
<h1 class="entry-title">Маршрут №24</h1><div class="entry-content">
<table>
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
<tbody>
<tr>
<td colspan="2" bgcolor="#00B7FF"><center><b>Понедельник – четверг</b></center></td>
</tr>
<tr>
<td>Автовокзал</td>
<td><span class="notranslate">06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 16:02 | 17:00 | 22:05</span></td>
</tr>
<tr>
<td>Полоцк – Стекловолокно</td>
<td><span class="notranslate">06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:30 | 17:25 | 23:30</span></td>
</tr>
</tbody>
</table>
<table>
<tbody>
<tr>
<td colspan="2" bgcolor="#00B7FF"><center><b>Пятница</b></center></td>
</tr>
<tr>
<td>Автовокзал</td>
<td><span class="notranslate">06:00 | 06:14 | 07:00 | 07:47 | 07:53 | 10:00 | 12:00 | 14:00 | 15:45 | 16:02 | 22:05</span></td>
</tr>
<tr>
<td>Полоцк – Стекловолокно</td>
<td><span class="notranslate">06:35 | 07:20 | 07:30 | 08:12 | 08:19 | 08:25 | 10:30 | 12:30 | 15:35 | 16:15 | 16:30 | 23:30</span></td>
</tr>
</tbody>
</table>
<table>
<tbody>
<tr>
<td colspan="2" bgcolor="#FFF56E"><center><b>Выходной день</b></center></td>
</tr>
<tr>
<td>Автовокзал</td>
<td><span class="notranslate">06:00 | 14:05 | 22:05</span></td>
</tr>
<tr>
<td>Полоцк – Стекловолокно</td>
<td><span class="notranslate">07:30 | 15:30 | 23:30</span></td>
</tr>
</tbody>
</table>
<div style="text-indent: 40px;" align="justify"><b>Остановочные пункты:</b></div>
<p>Прямое направление: «Автовокзал», «Железнодорожный вокзал», «Экономический колледж», «Олимпиец», «Спортклуб», «Энергосбыт», «Заполотье», «Экимань», «Курган Бессмертия», «Вильнюсское шоссе», «Ксты», «Полоцкгаз», «Полоцк – Стекловолокно».<br>
Обратное направление: «Полоцк – Стекловолокно», «Полоцкгаз», «Ксты», «Вильнюсское шоссе», «ТРЦ Манеж», «Курган Бессмертия», «Экимань», «Заполотье», «Энергосбыт», «Спортклуб», «Олимпиец», «Экономический колледж», «Железнодорожный вокзал», «Автовокзал»</p>
<div><center><b>Схема движения</b></center></div>
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/07/24-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/07/24-shema.png"></a></p>
</div>

View File

@@ -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 });

View File

@@ -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);
});

View File

@@ -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` нужно проверять в редакторе маршрута.

View File

@@ -173,7 +173,7 @@ export default function ScheduleImportPage() {
</label>
{mappings[route.id] && <p className="text-sm text-gray-600">В этот маршрут попадут времена из строки «{mappings[route.id]}».</p>}
{inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row => <details key={row.key}>
<summary className="text-sm text-gray-600 cursor-pointer">{row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён</summary>
<summary className="text-sm text-gray-600 cursor-pointer">{row.dayLabel || row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён</summary>
<p className="text-sm text-gray-600 mt-2 leading-7">{row.rawTimes}</p>
</details>)}
</div>)}