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();