feat: enhance schedule import functionality with support for day sections and restrictions
This commit is contained in:
@@ -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('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user