feat: add schedule import functionality and quality control page

- Implemented the schedule import feature with a dedicated page for importing schedules from the transport website.
- Added a new QualityPage to check the quality of routes, stops, schedules, and notifications without making automatic changes.
- Created CSS styles for both the ScheduleImportPage and QualityPage to enhance the user interface.
- Introduced API calls for fetching schedule data and quality reports.
- Added error handling and loading states for better user experience during data fetching.
- Included functionality for filtering quality issues and displaying detailed reports.
This commit is contained in:
2026-09-16 21:55:50 +03:00
parent 6804ca5cdb
commit 595ad6456b
36 changed files with 2496 additions and 72 deletions

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
logs
test
.env
.env.*
*.log
.DS_Store

View File

@@ -0,0 +1,24 @@
-- Import previews are immutable snapshots; applying one is an explicit admin action.
CREATE TABLE IF NOT EXISTS schedule_imports (
id UUID PRIMARY KEY,
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
source_url TEXT NOT NULL,
source_hash TEXT NOT NULL,
source JSONB NOT NULL,
status TEXT NOT NULL CHECK (status IN ('inspection', 'ready', 'applied')),
payload JSONB,
created_by INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
applied_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_schedule_imports_created ON schedule_imports(created_at DESC);
CREATE TABLE IF NOT EXISTS schedule_import_news (
url TEXT PRIMARY KEY,
title TEXT NOT NULL,
published_date TEXT,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
reviewed_hash TEXT,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,19 @@
-- Delivery state is separate from visibility. A scheduled alert becomes visible
-- at start_time; this state makes sure its push notification is sent once.
ALTER TABLE alerts
ADD COLUMN IF NOT EXISTS notification_sent_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS notification_claimed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS notification_attempts INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_alerts_pending_notification
ON alerts (start_time)
WHERE is_active = true AND notification_sent_at IS NULL;
-- Earlier versions sent every active alert immediately on creation. Mark those
-- already-created future alerts as dispatched, so upgrading does not notify
-- passengers a second time when the scheduler starts.
UPDATE alerts
SET notification_sent_at = created_at
WHERE notification_sent_at IS NULL
AND is_active = true
AND start_time > created_at;

View File

@@ -21,6 +21,7 @@
"dependencies": {
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"cheerio": "^1.2.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",

View File

@@ -0,0 +1,21 @@
const logger = require('../config/logger');
const { runQualityChecks } = require('../services/qualityReport');
class QualityController {
constructor({ pool, report = runQualityChecks } = {}) {
this.pool = pool || require('../config/database');
this.report = report;
}
async getReport(req, res) {
try {
res.json(await this.report(this.pool));
} catch (error) {
logger.error('Error building quality report', { message: error.message });
res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' });
}
}
}
module.exports = new QualityController();
module.exports.QualityController = QualityController;

View File

@@ -277,7 +277,7 @@ class SchedulesController {
// Copy schedule from one day type to another
async copySchedule(req, res) {
const { routeId } = req.params;
const { fromDayType, toDayType } = req.body;
const { fromDayType, toDayType, sourceScheduleId } = req.body;
if (!fromDayType || !toDayType) {
return res.status(400).json({
@@ -289,11 +289,14 @@ class SchedulesController {
try {
// Get source schedule
const source = await pool.query(`
SELECT departure_times, trips FROM schedules
SELECT departure_times, trips, valid_from, valid_until FROM schedules
WHERE route_id = $1 AND day_type = $2
ORDER BY created_at DESC
AND ($3::integer IS NULL OR id = $3)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
ORDER BY CASE WHEN valid_from IS NULL OR valid_from <= CURRENT_DATE THEN 0 ELSE 1 END,
valid_from DESC NULLS LAST, created_at DESC
LIMIT 1
`, [routeId, fromDayType]);
`, [routeId, fromDayType, sourceScheduleId || null]);
if (source.rows.length === 0) {
return res.status(404).json({
@@ -307,34 +310,42 @@ class SchedulesController {
const existing = await pool.query(`
SELECT id FROM schedules
WHERE route_id = $1 AND day_type = $2
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
`, [routeId, toDayType]);
if (existing.rows.length > 1) {
return res.status(409).json({ error: 'У типа дня несколько версий расписания. Измените нужную версию вручную.' });
}
if (existing.rows.length > 0) {
// Update existing
const result = await pool.query(`
UPDATE schedules
SET departure_times = $1, trips = $2::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE route_id = $3 AND day_type = $4
SET departure_times = $1, trips = $2::jsonb, valid_from = $3, valid_until = $4, updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING *
`, [
sourceSchedule.departure_times,
JSON.stringify(sourceSchedule.trips),
routeId,
toDayType
sourceSchedule.valid_from,
sourceSchedule.valid_until,
existing.rows[0].id
]);
res.json(this.withScheduleTrips(result.rows[0]));
} else {
// Create new
const result = await pool.query(`
INSERT INTO schedules (route_id, day_type, departure_times, trips)
VALUES ($1, $2, $3, $4::jsonb)
INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
RETURNING *
`, [
routeId,
toDayType,
sourceSchedule.departure_times,
JSON.stringify(sourceSchedule.trips)
JSON.stringify(sourceSchedule.trips),
sourceSchedule.valid_from,
sourceSchedule.valid_until
]);
res.status(201).json(this.withScheduleTrips(result.rows[0]));

View File

@@ -12,10 +12,12 @@ const createRateLimiter = require('./middleware/rateLimiter');
const { ensureRealtimeSchema } = require('./services/realtimeSchema');
const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService');
const { ensureBootstrapAdmin } = require('./utils/bootstrapAdmin');
const { startAlertScheduler } = require('./services/alertSchedulerService');
const app = express();
const PORT = process.env.PORT || 3000;
let server;
let stopAlertScheduler = () => {};
// Security middleware
app.use(helmet({
@@ -121,6 +123,7 @@ function shutdown(signal) {
logger.info(`${signal} signal received: closing HTTP server`);
closeRealtimeServer();
stopAlertScheduler();
if (!server) {
process.exit(0);
@@ -160,6 +163,7 @@ async function startServer() {
await waitForDatabase();
await ensureRealtimeSchema();
await ensureBootstrapAdmin();
stopAlertScheduler = startAlertScheduler();
server = app.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`);

View File

@@ -3,6 +3,17 @@ const router = express.Router();
const pool = require('../config/database');
const logger = require('../config/logger');
const { getAlertCreationService } = require('../services/alertCreationService');
const { listAlertTemplates } = require('../services/alertTemplates');
function validateAlertTimes({ start_time, end_time }) {
const start = new Date(start_time);
if (Number.isNaN(start.getTime())) return 'Укажите корректное время начала.';
if (!end_time) return null;
const end = new Date(end_time);
if (Number.isNaN(end.getTime())) return 'Укажите корректное время окончания.';
if (end <= start) return 'Время окончания должно быть позже времени начала.';
return null;
}
// Get all alerts (with optional filters)
router.get('/', async (req, res) => {
@@ -76,6 +87,10 @@ router.get('/active', async (req, res) => {
}
});
router.get('/templates', (req, res) => {
res.json(listAlertTemplates());
});
// Get alert by ID
router.get('/:id', async (req, res) => {
try {
@@ -109,6 +124,9 @@ function createAlertHandler({ alertCreationService = null } = {}) {
return res.status(400).json({ error: 'Missing required fields' });
}
const timeError = validateAlertTimes({ start_time, end_time });
if (timeError) return res.status(400).json({ error: timeError });
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
if (!validTypes.includes(alert_type)) {
return res.status(400).json({ error: 'Invalid alert_type' });
@@ -145,7 +163,7 @@ router.put('/:id', async (req, res) => {
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
// Check if alert exists
const checkResult = await pool.query('SELECT id FROM alerts WHERE id = $1', [id]);
const checkResult = await pool.query('SELECT * FROM alerts WHERE id = $1', [id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
@@ -158,6 +176,12 @@ router.put('/:id', async (req, res) => {
}
}
const timeError = validateAlertTimes({
start_time: start_time || checkResult.rows[0].start_time,
end_time: end_time === undefined ? checkResult.rows[0].end_time : end_time
});
if (timeError) return res.status(400).json({ error: timeError });
const result = await pool.query(
`UPDATE alerts
SET route_id = COALESCE($1, route_id),

View File

@@ -12,6 +12,11 @@ const syncController = require('../controllers/syncController');
const telemetryController = require('../controllers/telemetryController');
const passengerTelemetryController = require('../controllers/passengerTelemetryController');
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const qualityController = require('../controllers/qualityController');
router.use('/schedule-import', require('./scheduleImport'));
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
// Routes
router.get('/routes', routesController.getAllRoutes.bind(routesController));

View File

@@ -0,0 +1,29 @@
const express = require('express');
const pool = require('../config/database');
const redis = require('../config/redis');
const logger = require('../config/logger');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { ScheduleImportService } = require('../services/scheduleImportService');
const service = new ScheduleImportService({ pool, invalidate: async routeId => {
if (!redis.isReady) throw new Error('Redis unavailable');
await redis.del([`route:${routeId}`, 'routes:all']);
} });
const router = express.Router();
router.use(authenticateToken, requireAdmin);
const handle = action => async (req, res) => {
try { res.json(await action(req)); } catch (error) {
logger.error('Schedule import failed', { message: error.message });
res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось выполнить импорт. Попробуйте ещё раз.' });
}
};
router.get('/catalog', handle(() => service.catalog()));
router.post('/inspect', handle(req => service.inspect({ ...req.body, userId: req.user.id })));
router.post('/preview', handle(req => service.preview({ ...req.body, userId: req.user.id })));
router.post('/:id/apply', handle(req => service.apply(req.params.id)));
router.get('/history', handle(() => service.history()));
router.get('/news', handle(() => service.news()));
router.post('/news/refresh', handle(() => service.news(true)));
router.post('/news/review', handle(req => service.reviewNews(req.body)));
module.exports = router;

View File

@@ -96,6 +96,24 @@ function parseAlertSnapshot(value) {
return value;
}
function isAlertDue(alert, now = new Date()) {
if (alert.is_active === false) {
return false;
}
const startTime = new Date(alert.start_time);
if (Number.isNaN(startTime.getTime()) || startTime > now) {
return false;
}
if (!alert.end_time) {
return true;
}
const endTime = new Date(alert.end_time);
return !Number.isNaN(endTime.getTime()) && endTime >= now;
}
class AlertCreationService {
constructor({ pool, notificationService, logger }) {
this.pool = pool;
@@ -110,7 +128,7 @@ class AlertCreationService {
? await this.createIdempotentAlert(alertInput, idempotencyKey)
: await this.insertAlert(this.pool, alertInput);
if (result.created && result.alert.is_active !== false) {
if (result.created && isAlertDue(result.alert)) {
await this.sendNotification(result.notificationAlert);
}
@@ -263,5 +281,6 @@ module.exports = {
normalizeAlertInput,
normalizeIdempotencyKey,
parseAlertSnapshot,
isAlertDue,
setAlertCreationServiceForTesting
};

View File

@@ -0,0 +1,109 @@
const CLAIM_DUE_ALERTS_SQL = `
WITH due AS (
SELECT a.id
FROM alerts a
WHERE a.is_active = true
AND a.start_time > a.created_at
AND a.start_time <= CURRENT_TIMESTAMP
AND (a.end_time IS NULL OR a.end_time >= CURRENT_TIMESTAMP)
AND a.notification_sent_at IS NULL
AND a.notification_attempts < 5
AND (
a.notification_claimed_at IS NULL
OR a.notification_claimed_at < CURRENT_TIMESTAMP - INTERVAL '5 minutes'
)
ORDER BY a.start_time ASC, a.id ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE alerts a
SET notification_claimed_at = CURRENT_TIMESTAMP,
notification_attempts = a.notification_attempts + 1
FROM due
WHERE a.id = due.id
RETURNING a.*, (
SELECT r.route_number
FROM routes r
WHERE r.id = a.route_id
) AS notification_route_number
`;
class AlertSchedulerService {
constructor({ pool, notificationService, logger, batchSize = 50 } = {}) {
this.pool = pool || require('../config/database');
this.notificationService = notificationService || require('./alertNotificationService').getAlertNotificationService();
this.logger = logger || require('../config/logger');
this.batchSize = batchSize;
this.running = false;
}
async dispatchDueAlerts() {
if (this.running || !this.notificationService.enabled) {
return { dispatched: 0, skipped: this.running ? 'running' : 'disabled' };
}
this.running = true;
try {
const claimed = await this.pool.query(CLAIM_DUE_ALERTS_SQL, [this.batchSize]);
let dispatched = 0;
for (const row of claimed.rows) {
const { notification_route_number: routeNumber, ...alert } = row;
try {
const result = await this.notificationService.sendAlertCreated({
...alert,
route_number: routeNumber || ''
});
if (result.sent) {
await this.pool.query(
`UPDATE alerts
SET notification_sent_at = CURRENT_TIMESTAMP,
notification_claimed_at = NULL
WHERE id = $1`,
[alert.id]
);
dispatched += 1;
} else {
await this.releaseClaim(alert.id);
}
} catch (error) {
await this.releaseClaim(alert.id);
this.logger.error('Failed to dispatch scheduled alert notification', {
alertId: String(alert.id),
reason: error.message || 'Unknown notification error',
firebaseCode: error.code || 'unknown'
});
}
}
return { dispatched, claimed: claimed.rows.length };
} finally {
this.running = false;
}
}
async releaseClaim(alertId) {
await this.pool.query(
`UPDATE alerts
SET notification_claimed_at = NULL
WHERE id = $1 AND notification_sent_at IS NULL`,
[alertId]
);
}
}
function startAlertScheduler({ intervalMs = 30_000, service } = {}) {
const scheduler = service || new AlertSchedulerService();
const run = () => scheduler.dispatchDueAlerts().catch((error) => {
scheduler.logger.error('Scheduled alert delivery cycle failed', {
reason: error.message || 'Unknown scheduler error'
});
});
run();
const timer = setInterval(run, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
module.exports = { AlertSchedulerService, CLAIM_DUE_ALERTS_SQL, startAlertScheduler };

View File

@@ -0,0 +1,40 @@
const ALERT_TEMPLATES = Object.freeze([
Object.freeze({
id: 'delay',
label: 'Задержка',
description: 'Сообщить о задержке движения',
alert_type: 'delay',
title: 'Задержка движения',
message: 'На маршруте возможна задержка до 15 минут. Приносим извинения за неудобства.'
}),
Object.freeze({
id: 'cancellation',
label: 'Отмена рейса',
description: 'Сообщить об отмене или переносе',
alert_type: 'cancellation',
title: 'Отмена рейса',
message: 'Один из рейсов временно отменён. Пожалуйста, выберите ближайшее подходящее отправление.'
}),
Object.freeze({
id: 'detour',
label: 'Изменение маршрута',
description: 'Предупредить об объезде',
alert_type: 'detour',
title: 'Временное изменение маршрута',
message: 'Маршрут временно следует по изменённой схеме. Уточните детали ниже перед публикацией.'
}),
Object.freeze({
id: 'info',
label: 'Информация',
description: 'Нейтральное сообщение пассажирам',
alert_type: 'info',
title: 'Информация для пассажиров',
message: 'Пожалуйста, ознакомьтесь с важной информацией о работе транспорта.'
})
]);
function listAlertTemplates() {
return ALERT_TEMPLATES.map((template) => ({ ...template }));
}
module.exports = { ALERT_TEMPLATES, listAlertTemplates };

View File

@@ -0,0 +1,285 @@
const DAY_TYPES = [
{ value: 'weekday', label: 'будних дней' },
{ value: 'saturday', label: 'субботы' },
{ value: 'sunday', label: 'воскресенья' },
];
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
const qualityChecks = [
{
name: 'routesWithoutStops',
query: `
SELECT r.id AS route_id, r.route_number, r.name AS route_name,
COUNT(s.id)::integer AS active_stops_count
FROM routes r
LEFT JOIN route_stops rs ON rs.route_id = r.id
LEFT JOIN stops s ON s.id = rs.stop_id AND s.is_active = true
WHERE r.is_active = true
GROUP BY r.id
HAVING COUNT(s.id) < 2
ORDER BY r.route_number, r.name
`,
},
{
name: 'missingSchedules',
query: `
SELECT r.id AS route_id, r.route_number, r.name AS route_name, day_types.day_type
FROM routes r
CROSS JOIN (VALUES ('weekday'), ('saturday'), ('sunday')) AS day_types(day_type)
LEFT JOIN schedules s ON s.route_id = r.id
AND s.day_type = day_types.day_type
AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE)
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
WHERE r.is_active = true
GROUP BY r.id, r.route_number, r.name, day_types.day_type
HAVING COUNT(s.id) = 0
ORDER BY r.route_number, r.name, day_types.day_type
`,
},
{
name: 'inactiveRouteStops',
query: `
SELECT r.id AS route_id, r.route_number, r.name AS route_name,
s.id AS stop_id, s.name AS stop_name
FROM route_stops rs
JOIN routes r ON r.id = rs.route_id AND r.is_active = true
JOIN stops s ON s.id = rs.stop_id AND s.is_active = false
ORDER BY r.route_number, r.name, rs.sequence
`,
},
{
name: 'invalidStopOrder',
query: `
SELECT r.id AS route_id, r.route_number, r.name AS route_name,
COUNT(*)::integer AS stops_count, MIN(rs.sequence)::integer AS first_sequence,
MAX(rs.sequence)::integer AS last_sequence
FROM routes r
JOIN route_stops rs ON rs.route_id = r.id
WHERE r.is_active = true
GROUP BY r.id
HAVING MIN(rs.sequence) <> 1 OR MAX(rs.sequence) <> COUNT(*) OR COUNT(DISTINCT rs.sequence) <> COUNT(*)
ORDER BY r.route_number, r.name
`,
},
{
name: 'invalidTimeOffsets',
query: `
SELECT r.id AS route_id, r.route_number, r.name AS route_name,
s.id AS stop_id, s.name AS stop_name, rs.sequence, rs.time_offset_minutes
FROM route_stops rs
JOIN routes r ON r.id = rs.route_id AND r.is_active = true
JOIN stops s ON s.id = rs.stop_id
WHERE rs.time_offset_minutes < 0
OR (rs.sequence = 1 AND rs.time_offset_minutes <> 0)
ORDER BY r.route_number, r.name, rs.sequence
`,
},
{
name: 'overlappingSchedules',
query: `
SELECT newer.route_id, r.route_number, r.name AS route_name, newer.day_type,
newer.id AS schedule_id, older.id AS conflicting_schedule_id
FROM schedules newer
JOIN schedules older ON older.route_id = newer.route_id
AND older.day_type = newer.day_type
AND older.id < newer.id
AND daterange(
COALESCE(older.valid_from, '-infinity'::date),
COALESCE(older.valid_until, 'infinity'::date),
'[]'
) && daterange(
COALESCE(newer.valid_from, '-infinity'::date),
COALESCE(newer.valid_until, 'infinity'::date),
'[]'
)
JOIN routes r ON r.id = newer.route_id AND r.is_active = true
ORDER BY r.route_number, r.name, newer.day_type, newer.id
`,
},
{
name: 'invalidSchedulePeriods',
query: `
SELECT s.id AS schedule_id, s.route_id, r.route_number, r.name AS route_name, s.day_type
FROM schedules s
JOIN routes r ON r.id = s.route_id AND r.is_active = true
WHERE s.valid_from IS NOT NULL
AND s.valid_until IS NOT NULL
AND s.valid_until < s.valid_from
ORDER BY r.route_number, r.name, s.day_type, s.id
`,
},
{
name: 'emptySchedules',
query: `
SELECT s.id AS schedule_id, s.route_id, r.route_number, r.name AS route_name, s.day_type
FROM schedules s
JOIN routes r ON r.id = s.route_id AND r.is_active = true
WHERE COALESCE(cardinality(s.departure_times), 0) = 0
AND COALESCE(jsonb_array_length(s.trips), 0) = 0
ORDER BY r.route_number, r.name, s.day_type, s.id
`,
},
{
name: 'unusedStops',
query: `
SELECT s.id AS stop_id, s.name AS stop_name
FROM stops s
WHERE s.is_active = true
AND NOT EXISTS (
SELECT 1
FROM route_stops rs
JOIN routes r ON r.id = rs.route_id AND r.is_active = true
WHERE rs.stop_id = s.id
)
ORDER BY s.name
`,
},
{
name: 'expiredAlerts',
query: `
SELECT a.id AS alert_id, a.title
FROM alerts a
WHERE a.is_active = true
AND a.end_time IS NOT NULL
AND a.end_time < CURRENT_TIMESTAMP
ORDER BY a.end_time DESC
`,
},
];
const dayTypeLabel = (value) => DAY_TYPES.find((dayType) => dayType.value === value)?.label || value;
function routeLabel(row) {
return `№${row.route_number} · ${row.route_name}`;
}
function routeAction(routeId, label) {
return { to: `/routes/${routeId}/edit`, label };
}
function scheduleAction(routeId) {
return { to: `/schedules?route=${routeId}`, label: 'Открыть расписания' };
}
function createIssue({ code, severity, title, description, entity, action }) {
return { id: `${code}:${entity.type}:${entity.id}`, code, severity, title, description, entity, action };
}
function toIssues(rowsByCheck) {
const issues = [];
for (const row of rowsByCheck.routesWithoutStops || []) {
issues.push(createIssue({
code: 'route_without_stops', severity: 'critical', title: 'Маршрут не готов к работе',
description: `${routeLabel(row)}: активных остановок — ${row.active_stops_count}. Для направления нужны как минимум две.`,
entity: { type: 'route', id: row.route_id, label: routeLabel(row) },
action: routeAction(row.route_id, 'Настроить остановки'),
}));
}
for (const row of rowsByCheck.missingSchedules || []) {
issues.push(createIssue({
code: 'missing_schedule', severity: 'critical', title: 'Нет действующего расписания',
description: `${routeLabel(row)}: не задана версия для ${dayTypeLabel(row.day_type)}.`,
entity: { type: 'route-day-type', id: `${row.route_id}:${row.day_type}`, label: routeLabel(row) },
action: scheduleAction(row.route_id),
}));
}
for (const row of rowsByCheck.inactiveRouteStops || []) {
issues.push(createIssue({
code: 'inactive_stop_in_route', severity: 'warning', title: 'В маршруте неактивная остановка',
description: `${routeLabel(row)}: «${row.stop_name}» исключена из данных, которые получают пассажиры.`,
entity: { type: 'route-stop', id: `${row.route_id}:${row.stop_id}`, label: row.stop_name },
action: routeAction(row.route_id, 'Проверить маршрут'),
}));
}
for (const row of rowsByCheck.invalidStopOrder || []) {
issues.push(createIssue({
code: 'invalid_stop_order', severity: 'warning', title: 'Нарушен порядок остановок',
description: `${routeLabel(row)}: порядковые номера должны идти без пропусков от 1 до ${row.stops_count}.`,
entity: { type: 'route', id: row.route_id, label: routeLabel(row) },
action: routeAction(row.route_id, 'Исправить порядок'),
}));
}
for (const row of rowsByCheck.invalidTimeOffsets || []) {
issues.push(createIssue({
code: 'invalid_time_offset', severity: 'warning', title: 'Проверьте время проезда',
description: `${routeLabel(row)}: у остановки «${row.stop_name}» задано смещение ${row.time_offset_minutes} мин. Первая остановка должна иметь 0, остальные — неотрицательное значение.`,
entity: { type: 'route-stop', id: `${row.route_id}:${row.stop_id}:${row.sequence}`, label: row.stop_name },
action: routeAction(row.route_id, 'Исправить время'),
}));
}
for (const row of rowsByCheck.overlappingSchedules || []) {
issues.push(createIssue({
code: 'overlapping_schedules', severity: 'critical', title: 'Пересекаются версии расписания',
description: `${routeLabel(row)}: версии №${row.schedule_id} и №${row.conflicting_schedule_id} одновременно действуют для ${dayTypeLabel(row.day_type)}.`,
entity: { type: 'schedule-pair', id: `${row.schedule_id}:${row.conflicting_schedule_id}`, label: routeLabel(row) },
action: scheduleAction(row.route_id),
}));
}
for (const row of rowsByCheck.invalidSchedulePeriods || []) {
issues.push(createIssue({
code: 'invalid_schedule_period', severity: 'critical', title: 'Некорректный период расписания',
description: `${routeLabel(row)}: у версии №${row.schedule_id} дата окончания раньше даты начала.`,
entity: { type: 'schedule', id: row.schedule_id, label: routeLabel(row) },
action: scheduleAction(row.route_id),
}));
}
for (const row of rowsByCheck.emptySchedules || []) {
issues.push(createIssue({
code: 'empty_schedule', severity: 'critical', title: 'Пустое расписание',
description: `${routeLabel(row)}: в версии №${row.schedule_id} для ${dayTypeLabel(row.day_type)} нет ни одного рейса.`,
entity: { type: 'schedule', id: row.schedule_id, label: routeLabel(row) },
action: scheduleAction(row.route_id),
}));
}
for (const row of rowsByCheck.unusedStops || []) {
issues.push(createIssue({
code: 'unused_stop', severity: 'info', title: 'Остановка не используется',
description: `«${row.stop_name}» не привязана ни к одному активному маршруту.`,
entity: { type: 'stop', id: row.stop_id, label: row.stop_name },
action: { to: '/stops', label: 'Открыть остановки' },
}));
}
for (const row of rowsByCheck.expiredAlerts || []) {
issues.push(createIssue({
code: 'expired_alert', severity: 'warning', title: 'Истёкшее уведомление активно',
description: `«${row.title}» уже завершилось, но продолжает быть помечено активным.`,
entity: { type: 'alert', id: row.alert_id, label: row.title },
action: { to: '/alerts', label: 'Открыть уведомления' },
}));
}
return issues.sort((left, right) =>
SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity]
|| left.title.localeCompare(right.title, 'ru')
|| left.id.localeCompare(right.id)
);
}
function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) {
const issues = toIssues(rowsByCheck);
const summary = issues.reduce((result, issue) => {
result[issue.severity] += 1;
result.total += 1;
return result;
}, { total: 0, critical: 0, warning: 0, info: 0 });
return { checked_at: checkedAt, summary, issues };
}
async function runQualityChecks(pool) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows]));
return buildQualityReport(Object.fromEntries(results));
}
module.exports = { buildQualityReport, qualityChecks, runQualityChecks };

View File

@@ -0,0 +1,300 @@
const { randomUUID } = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const { getLocalDateTime } = require('../utils/etaTime');
const { HOME_URL, CATALOG_URL, hash, normalizeName, normalizeNumber, sourceError,
safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews } = require('./scheduleSource');
const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' };
const tripKey = trip => JSON.stringify({
departureTime: trip.departureTime?.length === 5 ? `${trip.departureTime}:00` : trip.departureTime,
startStopId: trip.startStopId || null, endStopId: trip.endStopId || null,
viaStopIds: trip.viaStopIds || [], daysOfWeek: trip.daysOfWeek || [],
exceptDaysOfWeek: trip.exceptDaysOfWeek || [], note: trip.note || ''
});
const blankTrip = (time, startStopId = null, endStopId = null) => ({
departureTime: time, startStopId, endStopId, viaStopIds: [], daysOfWeek: [], exceptDaysOfWeek: [], note: ''
});
function compareTrips(before, after) {
// Multisets preserve duplicate departures in legacy data in the removal preview.
const remaining = before.map(trip => ({ trip, key: tripKey(trip) }));
const added = [];
let unchanged = 0;
for (const trip of after) {
const index = remaining.findIndex(item => item.key === tripKey(trip));
if (index === -1) added.push(trip);
else { remaining.splice(index, 1); unchanged += 1; }
}
return { added, removed: remaining.map(item => item.trip), unchanged };
}
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
|| validFrom < today) throw sourceError('Выберите дату начала не раньше сегодняшнего дня.');
if (source.warnings.length) throw sourceError('В источнике есть нераспознанные условия. Автоматический импорт заблокирован.');
if (normalizeNumber(source.number) !== normalizeNumber(snapshot.route.route_number)) {
throw sourceError('Номер маршрута в базе не совпадает с номером на сайте.');
}
const stops = new Set(snapshot.stops.map(stop => Number(stop.stop_id)));
const groups = {};
const notices = new Set();
for (const row of source.rows) {
if (!mappings || !Object.hasOwn(mappings, row.stopName)) throw sourceError(`Укажите назначение строки «${row.stopName}».`);
const mapping = mappings[row.stopName];
for (const day of row.days) groups[day] ||= [];
if (mapping?.mode === 'checkpoint') continue;
if (mapping?.mode !== 'departure') throw sourceError(`Укажите назначение строки «${row.stopName}».`);
const start = Number(mapping.startStopId);
const end = Number(mapping.endStopId);
if (!stops.has(start) || !stops.has(end) || start === end) {
throw sourceError(`Для «${row.stopName}» выберите разные начальную и конечную остановки этого маршрута.`);
}
const departures = row.departures || row.times.map(departureTime => ({ departureTime }));
for (const departure of departures) {
const trip = { ...blankTrip(departure.departureTime, start, end),
daysOfWeek: departure.daysOfWeek || [], exceptDaysOfWeek: departure.exceptDaysOfWeek || [] };
const unresolved = [];
for (const name of departure.viaStopNames || []) {
const matches = snapshot.stops.filter(stop => normalizeName(stop.name) === normalizeName(name));
if (matches.length === 1) trip.viaStopIds.push(Number(matches[0].stop_id));
else {
unresolved.push(name);
notices.add(`«${name}»: ${matches.length ? 'найдено несколько остановок с этим именем' : 'нет в остановках направления'}. Условие «через» сохранится текстовым примечанием без привязки к остановке.`);
}
}
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;
groups[day].push(trip);
included = true;
}
if (!included) throw sourceError(`${departure.departureTime.slice(0, 5)}: условие по дням противоречит разделу «${row.days.map(day => DAY_LABELS[day]).join(', ')}». Проверьте источник.`);
}
}
const changes = [];
for (const [dayType, trips] of Object.entries(groups)) {
if (!trips.length) throw sourceError(`${DAY_LABELS[dayType]}: выберите хотя бы одну строку отправлений.`);
if (trips.length > 2000 || new Set(trips.map(tripKey)).size !== trips.length) {
throw sourceError('После сопоставления появились повторяющиеся рейсы или слишком много отправлений.');
}
trips.sort((a, b) => a.departureTime.localeCompare(b.departureTime) || a.startStopId - b.startStopId);
const candidates = snapshot.schedules.filter(s => s.day_type === dayType && (!s.valid_until || s.valid_until >= validFrom));
if (candidates.length > 1 || candidates.some(s => s.valid_from && s.valid_from > validFrom)) {
throw sourceError(`${DAY_LABELS[dayType]}: есть пересекающиеся или будущие расписания. Сначала проверьте их в разделе «Расписания».`);
}
const current = candidates[0];
const before = current?.trips?.length ? current.trips : (current?.departure_times || []).map(time => blankTrip(time));
changes.push({ dayType, label: DAY_LABELS[dayType], existingId: current?.id || null,
previousValidFrom: current?.valid_from || null, validFrom, validUntil: current?.valid_until || null,
before, after: trips, ...compareTrips(before, trips) });
}
return { changes, mappings, validFrom, notices: [...notices], before: snapshot, beforeHash: hash(snapshot),
hasChanges: changes.some(change => change.added.length || change.removed.length) };
}
function departureStops(source) {
const names = [...new Set(source.rows.map(row => row.stopName))];
const origins = names.filter(name => source.directions.some(direction => normalizeName(direction.stops[0]) === normalizeName(name)));
return origins.length ? origins : names;
}
function buildDirectionPlan(source, sourceStopName, snapshot, validFrom, today) {
if (!departureStops(source).includes(sourceStopName)) throw sourceError('Выберите строку отправлений для каждого направления.');
const first = snapshot.stops[0];
const last = snapshot.stops.at(-1);
if (!first || !last || first.stop_id === last.stop_id) {
throw sourceError(`В маршруте «${snapshot.route.name}» должны быть заданы разные первая и последняя остановки.`);
}
const rows = source.rows.filter(row => row.stopName === sourceStopName);
const mappings = { [sourceStopName]: { mode: 'departure', startStopId: first.stop_id, endStopId: last.stop_id } };
return { ...buildPlan({ ...source, rows }, mappings, snapshot, validFrom, today),
routeId: snapshot.route.id, route: snapshot.route, stops: snapshot.stops, sourceStopName };
}
class ScheduleImportService {
constructor({ pool, load = fetchHtml, invalidate = async () => {} }) {
this.pool = pool;
this.load = load;
this.invalidate = invalidate;
this.schemaPromise = null;
this.catalogCache = null;
}
ensureSchema() {
this.schemaPromise ||= fs.readFile(path.join(__dirname, '../../migrations/008_schedule_imports.sql'), 'utf8')
.then(sql => this.pool.query(sql)).catch(error => { this.schemaPromise = null; throw error; });
return this.schemaPromise;
}
async catalog() {
if (this.catalogCache && this.catalogCache.expires > Date.now()) return this.catalogCache.value;
const page = await this.load(CATALOG_URL);
const value = parseCatalog(page.html);
this.catalogCache = { value, expires: Date.now() + 5 * 60000 };
return value;
}
async snapshot(routeId, db = this.pool) {
const route = (await db.query('SELECT id, route_number, name FROM routes WHERE id = $1', [routeId])).rows[0];
if (!route) throw sourceError('Маршрут не найден.', 404);
const stops = (await db.query(`SELECT rs.stop_id, rs.sequence, s.name FROM route_stops rs
JOIN stops s ON s.id = rs.stop_id WHERE rs.route_id = $1 ORDER BY rs.sequence, rs.id`, [routeId])).rows;
const schedules = (await db.query(`SELECT id, day_type, departure_times, trips,
to_char(valid_from, 'YYYY-MM-DD') AS valid_from, to_char(valid_until, 'YYYY-MM-DD') AS valid_until,
updated_at::text FROM schedules WHERE route_id = $1 ORDER BY id`, [routeId])).rows;
return { route, stops, schedules };
}
async store({ routeId, source, status, payload = null, userId }) {
const id = randomUUID();
await this.pool.query(`INSERT INTO schedule_imports
(id, route_id, source_url, source_hash, source, status, payload, created_by)
VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7::jsonb,$8)`,
[id, routeId, source.url, hash(source), JSON.stringify(source), status, JSON.stringify(payload), userId]);
return id;
}
async getDraft(id, db = this.pool, lock = false) {
if (!/^[0-9a-f-]{36}$/i.test(id || '')) throw sourceError('Некорректный идентификатор импорта.', 400);
const draft = (await db.query(`SELECT * FROM schedule_imports WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, [id])).rows[0];
if (!draft) throw sourceError('Предпросмотр не найден. Загрузите расписание заново.', 404);
if (draft.status !== 'applied' && Date.now() - new Date(draft.created_at).getTime() > 30 * 60000) {
throw sourceError('Предпросмотр устарел. Загрузите расписание заново.', 409);
}
return draft;
}
async inspect({ routeIds, sourceUrl, userId }) {
await this.ensureSchema();
if (!Array.isArray(routeIds) || !routeIds.length || routeIds.length > 30
|| routeIds.some(id => !Number.isInteger(Number(id)) || Number(id) <= 0)
|| new Set(routeIds.map(Number)).size !== routeIds.length) throw sourceError('Выберите направления в админке.', 400);
routeIds = routeIds.map(Number).sort((a, b) => a - b);
const url = safeUrl(sourceUrl);
if (!(await this.catalog()).some(route => route.url === url)) throw sourceError('Выберите маршрут из списка сайта.');
const page = await this.load(url);
const source = parseSchedule(page.html, page.url);
const snapshots = await Promise.all(routeIds.map(id => this.snapshot(id)));
if (snapshots.some(snapshot => normalizeNumber(source.number) !== normalizeNumber(snapshot.route.route_number))) {
throw sourceError('Все выбранные направления должны иметь тот же номер, что и маршрут на сайте.');
}
const mappings = {};
const origins = departureStops(source);
for (const snapshot of snapshots) {
const matches = origins.filter(name => normalizeName(name) === normalizeName(snapshot.stops[0]?.name));
mappings[snapshot.route.id] = matches.length === 1 ? matches[0] : '';
}
await this.pool.query("DELETE FROM schedule_imports WHERE status <> 'applied' AND created_at < now() - interval '7 days'");
const id = await this.store({ routeId: null, source, status: 'inspection', payload: { version: 2, routeIds }, userId });
return { id, source, mappings, directions: snapshots.map(({ route, stops }) => ({ route, stops })),
departureStops: origins, today: getLocalDateTime().date };
}
async preview({ inspectionId, mappings, validFrom, userId }) {
await this.ensureSchema();
const inspection = await this.getDraft(inspectionId);
if (inspection.status !== 'inspection') throw sourceError('Загрузите исходную таблицу заново.', 409);
if (inspection.payload?.version !== 2) throw sourceError('Формат импорта обновлён. Загрузите таблицы заново.', 409);
const routeIds = inspection.payload.routeIds;
if (!mappings || Object.keys(mappings).length !== routeIds.length
|| routeIds.some(id => !Object.hasOwn(mappings, id))) throw sourceError('Сопоставьте каждое выбранное направление.');
const snapshots = await Promise.all(routeIds.map(id => this.snapshot(id)));
const plans = snapshots.map(snapshot => buildDirectionPlan(inspection.source, mappings[snapshot.route.id], snapshot, validFrom));
const payload = { version: 2, plans, validFrom, hasChanges: plans.some(plan => plan.hasChanges) };
const id = await this.store({ routeId: null, source: inspection.source, status: 'ready', payload, userId });
return { id, ...payload, sourceUrl: inspection.source_url };
}
async apply(id) {
await this.ensureSchema();
const draft = await this.getDraft(id);
if (draft.status === 'applied') return { id, applied: true, alreadyApplied: true };
if (draft.payload?.version !== 2) throw sourceError('Формат импорта обновлён. Загрузите таблицы заново.', 409);
if (draft.status !== 'ready' || !draft.payload?.hasChanges) throw sourceError('Нет подготовленных изменений.', 409);
// A changed website invalidates the preview rather than silently importing a different table.
const page = await this.load(draft.source_url);
if (hash(parseSchedule(page.html, page.url)) !== draft.source_hash) throw sourceError('Расписание на сайте изменилось. Загрузите новый предпросмотр.', 409);
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const locked = await this.getDraft(id, client, true);
if (locked.status === 'applied') {
await client.query('COMMIT');
return { id, applied: true, alreadyApplied: true };
}
// Existing CRUD endpoints do not share an advisory lock; table locks also cover inserts.
await client.query('LOCK TABLE schedules, route_stops IN SHARE ROW EXCLUSIVE MODE');
for (const plan of locked.payload.plans) {
const snapshot = await this.snapshot(plan.routeId, client);
if (hash(snapshot) !== plan.beforeHash) throw sourceError(`Направление «${plan.route.name}» изменилось после предпросмотра. Постройте его заново.`, 409);
}
if (locked.payload.validFrom < getLocalDateTime().date) throw sourceError('Дата начала уже прошла. Постройте новый предпросмотр.', 409);
for (const plan of locked.payload.plans) {
for (const change of plan.changes) {
if (!change.added.length && !change.removed.length) continue;
if (change.existingId && change.previousValidFrom === change.validFrom) {
await client.query(`UPDATE schedules SET departure_times = $1::time[], trips = $2::jsonb,
updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
[change.after.map(trip => trip.departureTime), JSON.stringify(change.after), change.existingId]);
} else {
if (change.existingId) await client.query(`UPDATE schedules SET valid_until = $1::date - 1,
updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [change.validFrom, change.existingId]);
await client.query(`INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until)
VALUES ($1,$2,$3::time[],$4::jsonb,$5,$6)`,
[plan.routeId, change.dayType, change.after.map(trip => trip.departureTime), JSON.stringify(change.after), change.validFrom, change.validUntil]);
}
}
}
await client.query("UPDATE schedule_imports SET status = 'applied', applied_at = now() WHERE id = $1", [id]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally { client.release(); }
let cacheWarning = null;
const cacheResults = await Promise.allSettled(draft.payload.plans.map(plan => this.invalidate(plan.routeId)));
if (cacheResults.some(result => result.status === 'rejected')) {
cacheWarning = 'Расписание сохранено, но кеш не удалось обновить. Данные в кеше обновятся после истечения его срока.';
}
return { id, applied: true, cacheWarning };
}
async history() {
await this.ensureSchema();
return (await this.pool.query(`SELECT i.id, i.source_url, i.applied_at, i.payload->>'validFrom' AS valid_from,
(SELECT jsonb_agg(p->'route') FROM jsonb_array_elements(COALESCE(i.payload->'plans', '[]'::jsonb)) p) AS directions,
r.route_number, r.name AS route_name FROM schedule_imports i LEFT JOIN routes r ON r.id = i.route_id
WHERE i.status = 'applied' ORDER BY i.applied_at DESC LIMIT 30`)).rows;
}
async news(refresh = false) {
await this.ensureSchema();
if (refresh) {
const page = await this.load(HOME_URL);
const items = parseNews(page.html);
for (const item of items) {
await this.pool.query(`INSERT INTO schedule_import_news (url,title,published_date,content,content_hash)
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (url) DO UPDATE SET title = EXCLUDED.title,
published_date = EXCLUDED.published_date, content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash, checked_at = now()`,
[item.url, item.title, item.publishedDate, item.content, item.contentHash]);
}
}
return (await this.pool.query(`SELECT *, (reviewed_hash = content_hash) IS TRUE AS reviewed
FROM schedule_import_news ORDER BY published_date DESC NULLS LAST, url DESC LIMIT 50`)).rows;
}
async reviewNews({ url, contentHash }) {
await this.ensureSchema();
const result = await this.pool.query(`UPDATE schedule_import_news SET reviewed_hash = content_hash
WHERE url = $1 AND content_hash = $2 RETURNING url`, [safeUrl(url), contentHash]);
if (!result.rows.length) throw sourceError('Новость изменилась. Обновите список перед проверкой.', 409);
return { reviewed: true };
}
}
module.exports = { ScheduleImportService, buildPlan, compareTrips, buildDirectionPlan, departureStops };

View File

@@ -0,0 +1,220 @@
const cheerio = require('cheerio');
const { createHash } = require('node:crypto');
const HOME_URL = 'https://ap2polotsk.of.by/ap2/';
const CATALOG_URL = `${HOME_URL}rasp/rasp-polotsk/polotsk-gorod/`;
const clean = value => String(value || '').replace(/\s+/g, ' ').trim();
// JSONB can reorder object keys, so content hashes must be canonical across DB round trips.
const canonical = value => Array.isArray(value) ? value.map(canonical)
: value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
const hash = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
const normalizeName = value => clean(value).toLowerCase().replace(/ё/g, 'е').replace(/[«»"()]/g, '').replace(/\s*[–—-]\s*/g, '-').trim();
const normalizeNumber = value => clean(value).toLowerCase().replace(/a/g, 'а').replace(/\s/g, '');
function sourceError(message, status = 422) {
return Object.assign(new Error(message), { status });
}
function safeUrl(value, base = HOME_URL) {
let url;
try { url = new URL(value, base); } catch { throw sourceError('Некорректная ссылка источника.'); }
if (!['http:', 'https:'].includes(url.protocol) || url.hostname !== 'ap2polotsk.of.by'
|| url.port || url.username || url.password || !url.pathname.startsWith('/ap2/')) {
throw sourceError('Разрешены только страницы официального сайта автопарка.');
}
url.protocol = 'https:';
url.hash = '';
return url.href;
}
// Bound redirects, total response time and decoded body size. Never forward credentials.
async function fetchHtml(value, fetchImpl = fetch) {
let url = safeUrl(value);
const signal = AbortSignal.timeout(20000);
try {
for (let redirects = 0; redirects <= 5; redirects += 1) {
const response = await fetchImpl(url, {
redirect: 'manual', signal,
headers: { 'User-Agent': 'PolotskTransitAdmin/1.0', Accept: 'text/html' }
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
if (!response.headers.get('location')) throw sourceError('Пустое перенаправление источника.', 502);
url = safeUrl(response.headers.get('location'), url);
continue;
}
if (!response.ok) {
await response.body?.cancel();
throw sourceError(`Сайт автопарка ответил HTTP ${response.status}. Попробуйте позже.`, 502);
}
if (!/text\/html|application\/xhtml\+xml/i.test(response.headers.get('content-type') || '')) {
await response.body?.cancel();
throw sourceError('Источник вернул не HTML-страницу.', 502);
}
const chunks = [];
let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > 2 * 1024 * 1024) throw sourceError('Страница источника слишком большая.', 502);
chunks.push(chunk);
}
return { url, html: Buffer.concat(chunks).toString('utf8') };
}
throw sourceError('Слишком много перенаправлений источника.', 502);
} catch (error) {
if (error.status) throw error;
throw sourceError('Не удалось загрузить сайт автопарка. Проверьте доступ сервера к интернету и повторите попытку.', 502);
}
}
function parseCatalog(html) {
const $ = cheerio.load(html);
const routes = new Map();
$('.entry-content a[href]').each((_, node) => {
const a = $(node);
let url;
try { url = safeUrl(a.attr('href')); } catch { return; }
const match = new URL(url).pathname.match(/\/m-(\d+)([aаэ]?)(?:-\d+)?\/$/i);
if (!match) return;
const label = clean(a.text() || a.find('img').attr('alt'));
// The site's image for 8A is incorrectly labelled "8".
const number = match[2] ? `${match[1]}${match[2]}` : (/^\d+[аaэ]?$/i.test(label) ? label : match[1]);
routes.set(url, { url, number: normalizeNumber(number).toUpperCase() });
});
if (!routes.size) throw sourceError('На сайте не найден список городских маршрутов.');
return [...routes.values()];
}
function dayTypes(text) {
const label = clean(text).toLowerCase();
if (/^рабочи[йе] (день|дни)$|^будни[йе] (день|дни)$/.test(label)) return ['weekday'];
if (/^выходно[йе] (день|дни)$|^выходные дни$/.test(label)) return ['saturday', 'sunday'];
if (/^суббота$/.test(label)) return ['saturday'];
if (/^воскресенье$/.test(label)) return ['sunday'];
return null;
}
const WEEKDAY_FORMS = [
['понедельник', 'понедельникам', 'понедельника'], ['вторник', 'вторникам', 'вторника'],
['среда', 'средам', 'среды', 'среду'], ['четверг', 'четвергам', 'четверга'],
['пятница', 'пятницам', 'пятницы', 'пятницу'], ['суббота', 'субботам', 'субботы', 'субботу'],
['воскресенье', 'воскресеньям', 'воскресенья'],
];
function parseDeparture(value) {
const match = clean(value).match(/^([01]?\d|2[0-3]):([0-5]\d)(?:\s+(.+))?$/);
if (!match) return null;
const trip = { departureTime: `${match[1].padStart(2, '0')}:${match[2]}:00`,
viaStopNames: [], daysOfWeek: [], exceptDaysOfWeek: [] };
let rest = match[3] || '';
// Consume the entire annotation. Unknown suffixes must never silently lose conditions.
while (rest) {
const clause = rest.match(/^(через|(?:только\s+)?по|кроме)\s+(.+?)(?=\s+(?:через|(?:только\s+)?по|кроме)\s+|$)/i);
if (!clause) return null;
const kind = clause[1].toLowerCase();
const content = clause[2];
if (kind === 'через') {
// Quoted names allow punctuation; unquoted names must contain no unexplained syntax.
const quoted = content.match(/^(?:«([^«»]+)»|"([^"\n]+)"|“([^“”]+)”|„([^„“]+)“)$/);
const name = clean(quoted ? quoted.slice(1).find(Boolean) : content);
if (!name || (!quoted && !/^[а-яёa-z][а-яёa-z\s–—-]*$/i.test(name)) || trip.viaStopNames.length) return null;
trip.viaStopNames.push(name);
} else {
const field = kind === 'кроме' ? 'exceptDaysOfWeek' : 'daysOfWeek';
if (trip[field].length) return null;
const words = content.toLowerCase().split(/\s*,\s*|\s+и\s+/);
const days = words.map(word => WEEKDAY_FORMS.findIndex(forms => forms.includes(word)) + 1);
if (!days.length || days.includes(0)) return null;
trip[field] = [...new Set(days)].sort((a, b) => a - b);
}
rest = clean(rest.slice(clause[0].length));
}
if (trip.daysOfWeek.some(day => trip.exceptDaysOfWeek.includes(day))) return null;
return trip;
}
function parseSchedule(html, url) {
const $ = cheerio.load(html);
const content = $('.entry-content').first();
const title = clean($('.entry-title').first().text());
const number = title.match(/маршрут\s*№\s*(\d+[аaэ]?)/i)?.[1];
if (!number || /\/\d{4}\/\d{2}\/\d{2}\//.test(new URL(safeUrl(url)).pathname)) {
throw sourceError('Выбрана не страница основного расписания маршрута.');
}
const rows = [];
const warnings = [];
content.find('table').each((tableIndex, table) => {
let days = 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; }
if (texts.join(' ').includes('к списку маршрутов')) return;
if (cells.length !== 2 || !days) {
warnings.push(`Не распознана строка таблицы: ${texts.join(' — ').slice(0, 240)}`);
return;
}
const [stopName, rawTimes] = texts;
const tokens = rawTimes.split('|').map(clean).filter(Boolean);
const departures = tokens.map(parseDeparture);
const invalid = !tokens.length || departures.some(trip => !trip);
// Marked/crossed-out times require human interpretation, even if their text looks normal.
const marked = $(tr).find('sup, del, s, strike, [style*="line-through"]').length > 0;
if (!stopName || invalid || marked) {
warnings.push(`«${stopName}»: есть примечания или нераспознанные времена: ${rawTimes.slice(0, 240)}`);
}
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 });
});
});
if (!rows.length) throw sourceError('Таблицы расписания не распознаны. Существующие данные сохранены.');
const directions = [];
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 });
});
// Footnotes outside tables must not silently disappear during import.
const outside = content.clone();
outside.find('table').remove();
const outsideText = clean(outside.text());
if (/кроме|только|примечани|отмен|перенос|по пятниц|по понедельник|с \d{1,2}\.\d{1,2}|\d{1,2}:\d{2}/i.test(outsideText)) {
warnings.push('Вне таблиц есть условия или изменения расписания. Проверьте их на сайте; автоматическое применение заблокировано.');
}
return { title, number: normalizeNumber(number).toUpperCase(), url: safeUrl(url), rows, directions, warnings };
}
function parseNews(html) {
const $ = cheerio.load(html);
const news = [];
$('article').each((_, node) => {
const article = $(node);
const title = clean(article.find('.entry-title').text());
const href = article.find('.entry-title a').attr('href');
if (!title || !href) return;
let url;
try { url = safeUrl(href); } catch { return; }
const content = article.find('.entry-content').clone();
content.find('script, style').remove();
content.find('table').each((_, table) => {
const rows = $(table).find('tr').toArray().map(tr => $(tr).children('td, th').toArray()
.map(cell => clean($(cell).text())).filter(Boolean).join(' — ')).filter(Boolean);
$(table).replaceWith($('<p>').text(rows.join('\n')));
});
content.find('br').replaceWith('\n');
content.find('p, tr, div').append('\n');
const text = content.text().split('\n').map(clean).filter(Boolean).join('\n').slice(0, 30000);
const publishedDate = article.find('time').first().attr('datetime')?.slice(0, 10)
|| url.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//)?.slice(1).join('-') || null;
news.push({ url, title, content: text, publishedDate, contentHash: hash({ title, text }) });
});
if (!news.length) throw sourceError('Не удалось распознать новости автопарка.');
return news;
}
module.exports = { HOME_URL, CATALOG_URL, clean, hash, normalizeName, normalizeNumber,
sourceError, safeUrl, fetchHtml, parseCatalog, parseSchedule, parseNews, parseDeparture };

View File

@@ -4,7 +4,8 @@ const test = require('node:test');
const {
AlertCreationService,
createAlertRequestHash,
normalizeAlertInput
normalizeAlertInput,
isAlertDue
} = require('../src/services/alertCreationService');
const ALERT_INPUT = {
@@ -96,6 +97,22 @@ test('inactive alert is saved without dispatching a notification', async () => {
assert.equal(notificationCalls, 0);
});
test('future alert is saved but does not dispatch before its start time', async () => {
let notificationCalls = 0;
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const service = new AlertCreationService({
pool: { async query() { return { rows: [createStoredAlert({ start_time: new Date(future) })] }; } },
notificationService: { async sendAlertCreated() { notificationCalls += 1; } },
logger: createLogger()
});
const result = await service.createAlert({ ...ALERT_INPUT, start_time: future });
assert.equal(result.created, true);
assert.equal(notificationCalls, 0);
assert.equal(isAlertDue(result.alert), false);
});
test('database failure prevents notification dispatch', async () => {
const databaseError = new Error('database unavailable');
let notificationCalls = 0;

View File

@@ -0,0 +1,57 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { AlertSchedulerService } = require('../src/services/alertSchedulerService');
function createLogger() {
return { error() {} };
}
test('scheduled delivery claims a due alert, sends it, then records delivery', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
const normalized = String(sql).replace(/\s+/g, ' ').trim();
calls.push({ normalized, params });
if (normalized.startsWith('WITH due AS')) {
return { rows: [{
id: 17, route_id: 3, route_number: '4', notification_route_number: '4',
alert_type: 'detour', title: 'Объезд', message: 'Через центр',
start_time: new Date(), end_time: null, is_active: true
}] };
}
return { rows: [] };
}
};
const notifications = [];
const service = new AlertSchedulerService({
pool,
notificationService: {
enabled: true,
async sendAlertCreated(alert) {
notifications.push(alert);
return { sent: true, messageId: 'test-message' };
}
},
logger: createLogger()
});
const result = await service.dispatchDueAlerts();
assert.deepEqual(result, { dispatched: 1, claimed: 1 });
assert.equal(notifications.length, 1);
assert.equal(notifications[0].route_number, '4');
assert.equal(calls.length, 2);
assert.match(calls[1].normalized, /^UPDATE alerts SET notification_sent_at/);
});
test('scheduler leaves the database untouched while notifications are disabled', async () => {
let queries = 0;
const service = new AlertSchedulerService({
pool: { async query() { queries += 1; return { rows: [] }; } },
notificationService: { enabled: false },
logger: createLogger()
});
assert.deepEqual(await service.dispatchDueAlerts(), { dispatched: 0, skipped: 'disabled' });
assert.equal(queries, 0);
});

View File

@@ -0,0 +1,11 @@
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
<article id="post-52" class="post-52 page type-page status-publish hentry">
<header class="entry-header"><h1 class="entry-title">Городские маршруты</h1></header><!-- .entry-header -->
<div class="entry-content">
<div><center>Выберите маршрут для просмотра расписания:</center></div>
<div><center><a href="http://ap2polotsk.of.by/ap2/m-1/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png'" alt="1" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-2/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png'" alt="2" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-3/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png'" alt="3" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-4/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png'" alt="4" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-6/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png'" alt="6" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-7/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png'" alt="7" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8a/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-9/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png'" alt="9" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-11/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png'" alt="11" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-22/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png'" alt="22" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-23/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png'" alt="23" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-24/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png'" alt="24" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-27/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png'" alt="27" hspace="5" vspace="5"></a><br>
<a href="http://ap2polotsk.of.by/ap2/rasp/gorod/m-28-00/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png'" alt="28э" hspace="5" vspace="5"></a><br>
<center><b><a href="http://ap2polotsk.of.by/ap2/deg/">ДЕЖУРНЫЕ</a></b></center><br>
<center><a href="http://ap2polotsk.of.by/ap2/rasp-polotsk/">◄ к списку маршрутов</a></center><p></p>
</center></div><!-- .entry-content -->
</div></article>

View File

@@ -0,0 +1,94 @@
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
<article id="post-8492" class="post-8492 post type-post status-publish format-standard hentry category-raspisanie">
<header class="entry-header">
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark">Изменения в расписании движения городского транспорта 21.09.2026 (г.Полоцк)</a></h1>
<div class="entry-meta">
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-15T13:12:35+03:00">15.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
</header><!-- .entry-header -->
<div class="entry-content">
<p>В связи с ремонтом ж/д переезда по ул. Красноармейской 21.09.2026 года с 09:00 до 18:00 в схему и расписание движения городских автобусов вносятся следующие изменения:<br>
– отменяются следующие рейсы:</p>
<table>
<tbody>
<tr bgcolor="#00B7FF">
<td colspan="2">
<p><strong>Маршрут №8 «Автовокзал – Бабушкина»</strong></p>
</td>
</tr>
<tr>
<td>
<p>Швейная фабрика</p>
</td>
<td>
<p>08:50 | 09:30 | 10:00 | 10:30 | 12:10 | 13:40 | 14:40 | 15:10 |</p>
<p>15:50 | 16:44 | 17:20 |</p>
</td>
</tr>
<tr>
<td>
<p>Бабушкина</p>
</td>
<td>
<p>09:06 | 09:46 | 10:20 | 10:50 | 11:40 | 12:30 | 14:00 | 15:00 |</p>
<p>15:30 | 16:10 | 17:00 | 17:40 |</p>
</td>
</tr>
</tbody>
</table>
<table>
<tbody>
<tr bgcolor="#00B7FF">
<td colspan="2">
<p><strong>Маршрут №8А«Автовокзал – Бабушкина ч/з Аэропорт»</strong></p>
</td>
</tr>
<tr>
<td>
<p>Автовокзал</p>
</td>
<td>
<p>10:55 | 13:20 | 16:30 |</p>
</td>
</tr>
<tr>
<td>
<p>Бабушкина</p>
</td>
<td>
<p>14:20 | 17:20 |</p>
</td>
</tr>
</tbody>
</table>
</div><!-- .entry-content -->
</article>
<article id="post-8486" class="post-8486 post type-post status-publish format-standard hentry category-raspisanie">
<header class="entry-header">
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark">Изменения в расписании пригородного сезонного транспорта с 18.09.2026 (г. Полоцк)</a></h1>
<div class="entry-meta">
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-14T10:46:05+03:00">14.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
</header><!-- .entry-header -->
<div class="entry-content">
<p>С 18.09.2026 по пятницам отменяется рейс автобуса по пригородному сезонному маршруту №256с Полоцк – Казимирово, отправлением с автовокзала в 18:40, с остановки Казимирово в 18:25.</p>
</div><!-- .entry-content -->
</article>
<article id="post-8468" class="post-8468 post type-post status-publish format-standard hentry category-raspisanie">
<header class="entry-header">
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark">С 14.09.2026 вносятся изменения в расписание движения по следующим городским маршрутам:</a></h1>
<div class="entry-meta">
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark"><time class="entry-date" datetime="2026-09-08T10:25:51+03:00">08.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
</header><!-- .entry-header -->
<div class="entry-content">
<p>Маршрут №22 «Мариненко – Больница (Новка) ч/з Аэропорт» по будним и выходным дням:<br>
— рейс, отправлением от остановочного пункта «Больница (Новка)» в 23:20, переносится на 23:00.</p>
</div><!-- .entry-content -->
</article>

View File

@@ -0,0 +1,42 @@
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
<article id="post-153" class="post-153 page type-page status-publish hentry">
<header class="entry-header"><h1 class="entry-title">Маршрут №1</h1></header><!-- .entry-header -->
<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> <strong> Рабочий день </strong> </center></td>
</tr>
<tr>
<td> Швейная фабрика</td>
<td><span class="notranslate">05:20 | 06:30 | 06:50 | 07:30 | 08:00 | 08:38 | 09:05 | 09:55 | 11:25 | 13:45 | 14:55 | 15:35 | 16:10 | 16:45 | 17:35 | 18:15 | 19:10 | 20:20</span></td>
</tr>
<tr>
<td> Богатырская</td>
<td><span class="notranslate">05:40 | 06:50 | 07:20 | 08:05 | 08:33 | 09:10 | 09:37 | 10:25 | 11:55 | 14:10 | 15:25 | 16:10 | 16:40 | 17:10 | 17:55 | 18:35 | 19:30 | 20:37</span></td>
</tr>
</tbody>
</table>
<table>
<tbody>
<tr>
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
</tr>
<tr>
<td> Швейная фабрика</td>
<td><span class="notranslate">06:05 | 07:06 | 08:10 | 09:12 | 10:10 | 11:10 | 12:12 | 13:10 | 14:20 | 15:30 | 16:45 | 17:50 | 19:12 | 20:20</span></td>
</tr>
<tr>
<td> Богатырская</td>
<td><span class="notranslate">06:35 | 07:25 | 08:40 | 09:45 | 10:40 | 11:40 | 12:42 | 13:45 | 14:50 | 16:05 | 17:15 | 18:25 | 19:33 | 20:37</span></td>
</tr>
</tbody>
</table>
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>.</p>
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
<div><center><b>Схема движения</b></center></div>
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"></a></p>
</div><!-- .entry-content -->
</article>

View File

@@ -0,0 +1,58 @@
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
<article id="post-167" class="post-167 page type-page status-publish hentry">
<header class="entry-header"><h1 class="entry-title">Маршрут №2</h1></header><!-- .entry-header -->
<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> <strong> Рабочий день </strong> </center></td>
</tr>
<tr>
<td> Больница (Ксты)</td>
<td><span class="notranslate">06:15 | 06:45 | 07:15 | 07:45 | 08:15 | 08:45 | 09:35 | 10:30 | 11:55 | 13:35 | 14:25 | 15:25 | 16:20 | 16:55 | 17:10 | 18:10 | 20:10</span></td>
</tr>
<tr>
<td> Швейная фабрика</td>
<td><span class="notranslate">06:36 | 07:06 | 07:36 | 08:06 | 08:36 | 09:06 | 09:56 | 10:51 | 12:16 | 13:56 | 14:46 | 15:46 | 16:41 | 17:16 | 17:31 | 18:31 | 20:31</span></td>
</tr>
<tr>
<td> Больница (Новка)</td>
<td><span class="notranslate">05:50 | 06:50 | 07:20 | 08:00 | 08:25 | 09:00 | 09:30 | 10:15 | 11:15 | 12:45 | 14:15 | 15:15 | 16:15 | 17:00 | 17:35 | 18:55</span></td>
</tr>
<tr>
<td> Автовокзал</td>
<td><span class="notranslate">06:02 | 07:02 | 07:32 | 08:12 | 08:37 | 09:12 | 09:42 | 10:27 | 11:27 | 12:57 | 14:27 | 15:27 | 16:27 | 17:12 | 17:47 | 19:07</span></td>
</tr>
</tbody>
</table>
<table id="table1">
<tbody>
<tr>
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
</tr>
<tr>
<td> Больница (Ксты)</td>
<td><span class="notranslate">05:23 | 07:07 | 07:57 | 08:47 | 09:43 | 10:37 | 11:34 | 12:30 | 13:31 | 14:24 | 15:31 | 16:25 | 17:22 | 18:18 | 18:55 | 20:10</span></td>
</tr>
<tr>
<td> Швейная фабрика</td>
<td><span class="notranslate">05:44 | 07:28 | 08:18 | 09:08 | 10:04 | 10:58 | 11:55 | 12:51 | 13:52 | 14:45 | 15:52 | 16:46 | 17:43 | 18:39 | 19:16 | 20:31</span></td>
</tr>
<tr>
<td> Больница (Новка)</td>
<td><span class="notranslate">05:50 | 07:10 | 08:10 | 10:00 | 10:57 | 11:56 | 12:48 | 13:45 | 14:12 | 15:40 | 16:37 | 17:42 | 18:04 | 19:37</span></td>
</tr>
<tr>
<td> Автовокзал</td>
<td><span class="notranslate">06:02 | 07:22 | 08:22 | 10:12 | 11:09 | 12:08 | 13:00 | 13:57 | 14:24 | 15:52 | 16:49 | 17:54 | 18:16 | 19:49</span></td>
</tr>
</tbody>
</table>
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Райисполком<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Красное кладбище<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>.</p>
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>.</p>
<div><center><b>Схема движения</b></center></div>
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"></a></p>
</div><!-- .entry-content -->
</article>

View File

@@ -0,0 +1,40 @@
<h1 class="entry-title">Маршрут №9</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> <strong> Рабочий день </strong> </center></td>
</tr>
<tr>
<td> Швейная фабрика</td>
<td><span class="notranslate">06:05 </span> через «Мариненко» <span class="notranslate"> | 06:20 | 07:08 | 07:40 | 07:55 | 08:55 | 11:00 | 13:00 | 14:00 | 14:10 | 15:10 | 15:50 </span> по пятницам <span class="notranslate"> | 15:55 </span> кроме пятницы <span class="notranslate"> | 16:52 | 16:58 </span> кроме пятницы <span class="notranslate"> | 18:00 </span> кроме пятницы <span class="notranslate"> | 19:10 | 22:05 </span> через «Мариненко»</td>
</tr>
<tr>
<td> Полоцк – Стекловолокно</td>
<td><span class="notranslate">06:40 | 07:15 | 07:31 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 09:20 | 11:25 | 13:25 | 14:40 | 15:25 | 15:35 </span> кроме пятницы <span class="notranslate"> | 16:15 </span> по пятницам <span class="notranslate"> | 16:25 | 17:25 </span> кроме пятницы <span class="notranslate"> | 17:30 | 18:30 </span> кроме пятницы <span class="notranslate"> | 20:20 | 23:30 </span> через «Мариненко»</td>
</tr>
</tbody>
</table>
<table>
<tbody>
<tr>
<td colspan="2" bgcolor="#FFF56E"><b><center>Выходной день</center></b></td>
</tr>
<tr>
<td>
Швейная фабрика</td>
<td><span class="notranslate">06:10 </span> через «Мариненко» <span class="notranslate"> | 07:20 | 14:00 | 19:10 | 22:10 </span> через «Мариненко»</td>
</tr>
<tr>
<td>
Полоцк – Стекловолокно</td>
<td><span class="notranslate">07:25 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 15:35 | 20:20 | 23:25 </span> через «Мариненко»</td>
</tr>
</tbody>
</table>
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>.</p>
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>ТРЦ Манеж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
<div><center><b>Схема движения</b></center></div>
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"></a></p>
</div>

View File

@@ -0,0 +1,22 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildQualityReport, qualityChecks } = require('../src/services/qualityReport');
test('quality report makes actionable, stable issues and summary', () => {
const rows = Object.fromEntries(qualityChecks.map(({ name }) => [name, []]));
rows.routesWithoutStops.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', active_stops_count: 0 });
rows.missingSchedules.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', day_type: 'weekday' });
rows.overlappingSchedules.push({ route_id: 9, route_number: '5', route_name: 'А → Б', day_type: 'sunday', schedule_id: 12, conflicting_schedule_id: 10 });
rows.unusedStops.push({ stop_id: 3, stop_name: 'Резервная' });
rows.expiredAlerts.push({ alert_id: 15, title: 'Временный объезд' });
const report = buildQualityReport(rows, '2026-09-16T10:00:00.000Z');
assert.deepEqual(report.summary, { total: 5, critical: 3, warning: 1, info: 1 });
assert.equal(report.checked_at, '2026-09-16T10:00:00.000Z');
assert.deepEqual(report.issues.map((issue) => issue.severity), ['critical', 'critical', 'critical', 'warning', 'info']);
assert.deepEqual(report.issues.find((issue) => issue.code === 'missing_schedule').action, {
to: '/schedules?route=8', label: 'Открыть расписания',
});
assert.match(report.issues.find((issue) => issue.code === 'overlapping_schedules').description, /№12 и №10/);
});

View File

@@ -0,0 +1,162 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { Pool } = require('pg');
const { ScheduleImportService } = require('../src/services/scheduleImportService');
const { getLocalDateTime } = require('../src/utils/etaTime');
const { parseSchedule, CATALOG_URL, HOME_URL } = require('../src/services/scheduleSource');
// Opt-in only. Use a disposable database; every run gets its own temporary schema.
test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news review', {
skip: !process.env.IMPORT_TEST_DATABASE_URL,
}, async () => {
const url = process.env.IMPORT_TEST_DATABASE_URL;
assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
const admin = new Pool({ connectionString: url });
const schema = `import_test_${Date.now()}`;
let pool;
try {
await admin.query('CREATE EXTENSION IF NOT EXISTS postgis');
await admin.query(`CREATE SCHEMA ${schema}`);
pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
for (const file of (await fs.readdir(path.join(__dirname, '../migrations'))).filter(f => f.endsWith('.sql')).sort()) {
await pool.query(await fs.readFile(path.join(__dirname, '../migrations', file), 'utf8'));
}
const fixture = name => fs.readFile(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8');
let sourceHtml = await fixture('route1');
let newsHtml = await fixture('news');
const catalogHtml = await fixture('catalog');
const sourceUrl = 'https://ap2polotsk.of.by/ap2/m-1/';
const load = async url => ({ url, html: url === CATALOG_URL ? catalogHtml : url === HOME_URL ? newsHtml : sourceHtml });
const invalidations = [];
const service = new ScheduleImportService({ pool, load, invalidate: async id => invalidations.push(id) });
const source = parseSchedule(sourceHtml, sourceUrl);
sourceHtml = sourceHtml.replace('05:20', `05:20 через «${source.directions[0].stops[1]}»`)
.replace('06:30', '06:30 по пятницам').replace('06:50', '06:50 кроме пятницы')
.replace('07:30', '07:30 через «Нет в базе»');
const routeIds = [];
for (const direction of source.directions) {
const routeId = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('1',$1,'bus') RETURNING id", [direction.stops[0] + ' → ' + direction.stops.at(-1)])).rows[0].id;
routeIds.push(routeId);
for (const [i, name] of direction.stops.entries()) {
const stopId = (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,time_offset_minutes) VALUES ($1,$2,$3,$4)', [routeId, stopId, i + 1, i * 2]);
}
}
const routeId = routeIds[0];
const validFrom = getLocalDateTime().date;
const prepare = async () => {
const inspection = await service.inspect({ routeIds, sourceUrl, userId: 1 });
return service.preview({ inspectionId: inspection.id, mappings: inspection.mappings, validFrom, userId: 1 });
};
const single = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
assert.equal(single.directions.length, 1);
assert.equal(single.mappings[routeId], 'Швейная фабрика');
await assert.rejects(service.preview({ inspectionId: single.id, mappings: { ...single.mappings, [routeIds[1]]: 'Богатырская' }, validFrom, userId: 1 }), /каждое выбранное/);
await assert.rejects(service.inspect({ routeIds: [], sourceUrl, userId: 1 }), /направления/);
await assert.rejects(service.inspect({ routeIds: [routeId, routeId], sourceUrl, userId: 1 }), /направления/);
const other = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('99','Другой маршрут','bus') RETURNING id")).rows[0].id;
await assert.rejects(service.inspect({ routeIds: [routeId, other], sourceUrl, userId: 1 }), /тот же номер/);
const first = await prepare();
assert.equal(first.hasChanges, true);
assert.deepEqual(first.plans.map(p => p.changes.map(c => c.after.length)), [[18, 14, 14], [18, 14, 14]]);
assert.deepEqual(first.plans.map(p => p.sourceStopName), ['Швейная фабрика', 'Богатырская']);
const concurrent = await Promise.all([service.apply(first.id), service.apply(first.id)]);
assert.equal(concurrent.filter(r => r.alreadyApplied).length, 1);
assert.equal(Number((await pool.query('SELECT count(*) FROM schedules')).rows[0].count), 6);
assert.deepEqual(invalidations.sort((a,b) => a-b), routeIds);
const savedTrips = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [routeId])).rows[0].trips;
assert.equal(savedTrips.find(t => t.departureTime === '05:20:00').viaStopIds.length, 1);
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:30:00').daysOfWeek, [5]);
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:50:00').exceptDaysOfWeek, [5]);
assert.equal(savedTrips.find(t => t.departureTime === '07:30:00').note, 'через «Нет в базе»');
assert.equal((await service.history()).length, 1);
assert.equal((await service.history())[0].directions.length, 2);
assert.equal((await prepare()).hasChanges, false);
assert.equal((await service.apply(first.id)).alreadyApplied, true);
assert.equal(Number((await pool.query("SELECT count(*) FROM sync_log WHERE table_name='schedules'")).rows[0].count), 6);
sourceHtml = sourceHtml.replace('05:20', '05:21');
const changed = await prepare();
sourceHtml = sourceHtml.replace('05:21', '05:22');
await assert.rejects(service.apply(changed.id), /на сайте изменилось/);
const stale = await prepare();
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Ручная правка\"'::jsonb) WHERE day_type = 'weekday'");
const beforeConflict = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
await assert.rejects(service.apply(stale.id), /после предпросмотра/);
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeConflict);
// Failure while writing the second direction must also roll back the first direction.
sourceHtml = sourceHtml.replace('05:40', '05:41');
const toFail = await prepare();
const beforeFailure = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
const failingPool = { query: (...args) => pool.query(...args), connect: async () => {
const client = await pool.connect();
let updates = 0;
return { release: () => client.release(), query: (sql, args) => {
if (sql.startsWith('UPDATE schedules SET departure_times') && ++updates === 2) throw new Error('Injected failure');
return client.query(sql, args);
} };
} };
await assert.rejects(new ScheduleImportService({ pool: failingPool, load }).apply(toFail.id), /Injected failure/);
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeFailure);
assert.equal((await service.getDraft(toFail.id)).status, 'ready');
await service.apply(toFail.id);
const reverseBefore = JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows);
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Правка только прямого направления\"'::jsonb) WHERE day_type = 'weekday' AND route_id=$1", [routeId]);
const oneInspection = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
const onePreview = await service.preview({ inspectionId: oneInspection.id, mappings: oneInspection.mappings, validFrom, userId: 1 });
assert.equal(onePreview.plans.length, 1);
assert.equal(onePreview.hasChanges, true);
await service.apply(onePreview.id);
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows), reverseBefore);
// A future import closes old versions on the preceding day, keeping today's data intact.
sourceHtml = sourceHtml.replace('05:22', '05:23');
const inspected = await service.inspect({ routeIds, sourceUrl, userId: 1 });
const tomorrow = new Date(`${validFrom}T12:00:00Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const future = await service.preview({ inspectionId: inspected.id, mappings: inspected.mappings, validFrom: tomorrow.toISOString().slice(0, 10), userId: 1 });
await service.apply(future.id);
const versions = (await pool.query("SELECT to_char(valid_until,'YYYY-MM-DD') AS until FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id", [routeId])).rows;
assert.equal(versions.length, 2);
assert.equal(versions[0].until, validFrom);
assert.equal(versions[1].until, null);
// Copying the selected version must not choose a newer future version or overwrite history.
await pool.query(`INSERT INTO schedules (route_id,day_type,departure_times,trips,valid_from,valid_until)
VALUES ($1,'holiday',ARRAY['04:00'::time],'[]','2020-01-01','2020-01-02')`, [routeId]);
const vm = require('node:vm');
const moduleStub = { exports: {} };
vm.runInNewContext(await fs.readFile(path.join(__dirname, '../src/controllers/schedulesController.js'), 'utf8'), {
module: moduleStub, require: name => {
if (name.endsWith('/database')) return pool;
if (name.endsWith('/redis')) return { del: async () => {} };
if (name.endsWith('/logger')) return { info() {}, error() {} };
throw new Error(`Unexpected dependency: ${name}`);
}
});
const selectedId = (await pool.query("SELECT id FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id LIMIT 1", [routeId])).rows[0].id;
let responseBody;
const response = { status(code) { assert.ok(code < 400); return this; }, json(body) { responseBody = body; } };
await moduleStub.exports.copySchedule({ params: { routeId }, body: { fromDayType: 'weekday', toDayType: 'holiday', sourceScheduleId: selectedId } }, response);
assert.equal(responseBody.trips.some(t => t.departureTime === '05:22:00'), true);
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);
let news = await service.news(true);
assert.equal(news.length, 3);
await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash });
assert.equal((await service.news(true))[0].reviewed, true);
newsHtml = newsHtml.replace('08:50', '08:51');
news = await service.news(true);
assert.equal(news[0].reviewed, false);
assert.equal(news.length, 3);
} finally {
if (pool) await pool.end();
await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
await admin.end();
}
});

View File

@@ -0,0 +1,164 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const { parseSchedule, parseCatalog, parseNews, safeUrl, fetchHtml } = require('../src/services/scheduleSource');
const { buildPlan, compareTrips, buildDirectionPlan, departureStops } = require('../src/services/scheduleImportService');
const fixture = name => fs.readFileSync(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8');
const source = parseSchedule(fixture('route2'), 'https://ap2polotsk.of.by/ap2/m-2/');
const snapshot = { route: { id: 2, route_number: '2' }, schedules: [], stops: [
{ stop_id: 1, name: 'Больница (Ксты)' }, { stop_id: 2, name: 'Швейная фабрика' },
{ stop_id: 3, name: 'Больница (Новка)' }, { stop_id: 4, name: 'Автовокзал' },
] };
const mappings = { 'Больница (Ксты)': { mode: 'departure', startStopId: 1, endStopId: 3 },
'Больница (Новка)': { mode: 'departure', startStopId: 3, endStopId: 1 },
'Швейная фабрика': { mode: 'checkpoint' }, 'Автовокзал': { mode: 'checkpoint' } };
const plan = (data = source, map = mappings, state = snapshot, date = '2026-09-16') => buildPlan(data, map, state, date, '2026-09-16');
test('each admin direction imports only its departures and derives its endpoints from stop order', () => {
const outbound = { ...snapshot, stops: snapshot.stops.slice(0, 3) };
const inbound = { ...snapshot, route: { id: 5, route_number: '2' }, stops: [...outbound.stops].reverse() };
const forward = buildDirectionPlan(source, 'Больница (Ксты)', outbound, '2026-09-16', '2026-09-16');
const backward = buildDirectionPlan(source, 'Больница (Новка)', inbound, '2026-09-16', '2026-09-16');
assert.deepEqual(forward.changes.map(c => c.after.length), [17, 16, 16]);
assert.deepEqual(backward.changes.map(c => c.after.length), [16, 14, 14]);
assert.ok(forward.changes.every(c => c.after.every(t => t.startStopId === 1 && t.endStopId === 3)));
assert.ok(backward.changes.every(c => c.after.every(t => t.startStopId === 3 && t.endStopId === 1)));
assert.deepEqual(departureStops(source), ['Больница (Ксты)', 'Больница (Новка)']);
assert.throws(() => buildDirectionPlan(source, 'Швейная фабрика', outbound, '2026-09-16', '2026-09-16'), /строку отправлений/);
assert.throws(() => buildDirectionPlan(source, 'Больница (Ксты)', { ...outbound, stops: [] }, '2026-09-16', '2026-09-16'), /первая и последняя/);
});
test('real catalog finds 15 routes and distinguishes wrongly labelled 8A', () => {
const routes = parseCatalog(fixture('catalog'));
assert.equal(routes.length, 15);
assert.equal(routes.find(r => r.url.endsWith('/m-8a/')).number, '8А');
assert.equal(routes.at(-1).number, '28Э');
});
test('real route tables retain directions, checkpoint rows and weekend categories', () => {
assert.equal(source.rows.length, 8);
assert.equal(source.directions.length, 2);
assert.deepEqual(source.warnings, []);
const changes = plan().changes;
assert.deepEqual(changes.map(c => [c.dayType, c.after.length]), [['weekday', 33], ['saturday', 30], ['sunday', 30]]);
assert.equal(changes[0].after.filter(t => t.startStopId === 1).length, 17);
assert.equal(changes[0].after.filter(t => t.startStopId === 3).length, 16);
});
test('every row needs a decision and route stop ids are validated', () => {
assert.throws(() => plan(source, {}), /назначение/);
assert.throws(() => plan(source, { ...mappings, 'Больница (Ксты)': { mode: 'departure', startStopId: 999, endStopId: 3 } }), /остановки/);
assert.throws(() => plan(source, Object.fromEntries(Object.keys(mappings).map(k => [k, { mode: 'checkpoint' }]))), /хотя бы одну/);
assert.throws(() => plan(source, mappings, { ...snapshot, route: { route_number: '3' } }), /не совпадает/);
});
test('marked, malformed and footnoted departures block the entire import', () => {
for (const replacement of ['06:15*', '25:15', '<sup>06:15</sup>', '<del>06:15</del>']) {
const marked = parseSchedule(fixture('route2').replace('06:15', replacement), source.url);
assert.ok(marked.warnings.length);
assert.throws(() => plan(marked), /нераспознанные/);
}
const footnote = parseSchedule(fixture('route2').replace('Остановочные пункты:', 'Примечание: только по пятницам. Остановочные пункты:'), source.url);
assert.ok(footnote.warnings.length);
});
test('unrecognized day sections cannot become partial silent imports', () => {
const changed = parseSchedule(fixture('route2').replace('Рабочий день', 'Только по пятницам'), source.url);
assert.ok(changed.warnings.length);
assert.throws(() => plan(changed), /нераспознанные/);
});
test('same trips produce no changes; restriction removal is visible in the diff', () => {
const schedules = plan().changes.map((c, i) => ({ id: i + 1, day_type: c.dayType, trips: c.after, valid_from: '2026-09-16' }));
assert.equal(plan(source, mappings, { ...snapshot, schedules }).hasChanges, false);
schedules[0].trips = schedules[0].trips.map((trip, i) => i === 0 ? { ...trip, exceptDaysOfWeek: [5] } : trip);
const diff = plan(source, mappings, { ...snapshot, schedules }).changes[0];
assert.equal(diff.added.length, 1);
assert.deepEqual(diff.removed[0].exceptDaysOfWeek, [5]);
assert.equal(compareTrips([diff.after[0], diff.after[0]], [diff.after[0]]).removed.length, 1);
});
test('future overlaps, invalid and historical dates are rejected', () => {
for (const date of ['2026-09-15', '2026-02-30', 'invalid']) assert.throws(() => plan(source, mappings, snapshot, date), /дату/);
const schedules = [{ id: 1, day_type: 'weekday', valid_from: '2026-09-20', trips: [] }];
assert.throws(() => plan(source, mappings, { ...snapshot, schedules }), /будущие/);
});
test('news keeps cancellation context, publication date and textual changes', () => {
const items = parseNews(fixture('news'));
assert.equal(items.length, 3);
assert.equal(items[0].publishedDate, '2026-09-15');
assert.match(items[0].content, /отменяются следующие рейсы/);
assert.match(items[0].content, /21.09.2026/);
assert.match(items[2].content, /23:20.*23:00/);
assert.throws(() => parseSchedule(fixture('news'), items[0].url), /не страница/);
});
test('source fetch rejects external URLs and external redirect targets', async () => {
for (const url of ['https://example.com/ap2/', 'http://localhost/ap2/', 'https://ap2polotsk.of.by:8443/ap2/', 'https://x:pass@ap2polotsk.of.by/ap2/', 'file:///ap2/']) {
assert.throws(() => safeUrl(url), /Разрешены/);
}
let requests = 0;
await assert.rejects(fetchHtml(source.url, async () => {
requests++;
return new Response(null, { status: 302, headers: { location: 'https://example.com/ap2/' } });
}), /Разрешены/);
assert.equal(requests, 1);
await assert.rejects(fetchHtml(source.url, async () => new Response('error', { status: 503 })), /503/);
await assert.rejects(fetchHtml(source.url, async () => new Response('pdf', { headers: { 'content-type': 'application/pdf' } })), /не HTML/);
});
test('route 9 annotations preserve per-trip weekdays, exclusions and via stops', () => {
const annotated = parseSchedule(fixture('route9'), 'https://ap2polotsk.of.by/ap2/m-9/');
assert.deepEqual(annotated.warnings, []);
const state = { route: { id: 9, route_number: '9', name: 'Швейная фабрика → Стекловолокно' }, schedules: [],
stops: [{ stop_id: 10, name: 'Швейная фабрика' }, { stop_id: 11, name: 'Мариненко' }, { stop_id: 12, name: 'Полоцк – Стекловолокно' }] };
const forward = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16');
assert.deepEqual(forward.changes.map(c => c.after.length), [18, 5, 5]);
const trips = forward.changes[0].after;
assert.deepEqual(trips.find(t => t.departureTime === '06:05:00').viaStopIds, [11]);
assert.deepEqual(trips.find(t => t.departureTime === '15:50:00').daysOfWeek, [5]);
for (const time of ['15:55:00', '16:58:00', '18:00:00']) assert.deepEqual(trips.find(t => t.departureTime === time).exceptDaysOfWeek, [5]);
const reverse = buildDirectionPlan(annotated, 'Полоцк – Стекловолокно', { ...state, stops: [...state.stops].reverse() }, '2026-09-16', '2026-09-16');
assert.deepEqual(reverse.changes.map(c => c.after.length), [17, 5, 5]);
assert.deepEqual(reverse.changes[0].after.find(t => t.departureTime === '16:15:00').daysOfWeek, [5]);
for (const stops of [state.stops.filter(s => s.stop_id !== 11), [...state.stops, { stop_id: 13, name: 'Мариненко' }]]) {
const unbound = buildDirectionPlan(annotated, 'Швейная фабрика', { ...state, stops }, '2026-09-16', '2026-09-16');
assert.equal(unbound.notices.length, 1);
const via = unbound.changes[0].after.find(t => t.departureTime === '06:05:00');
assert.deepEqual(via.viaStopIds, []);
assert.equal(via.note, 'через «Мариненко»');
}
});
test('weekday grammar handles all ISO weekdays and combined conditions, rejects unknown suffixes', () => {
const { parseDeparture } = require('../src/services/scheduleSource');
['понедельникам', 'вторникам', 'средам', 'четвергам', 'пятницам', 'субботам', 'воскресеньям'].forEach((word, i) => {
assert.deepEqual(parseDeparture(`6:05 по ${word}`).daysOfWeek, [i + 1]);
});
['понедельника', 'вторника', 'среды', 'четверга', 'пятницы', 'субботы', 'воскресенья'].forEach((word, i) => {
assert.deepEqual(parseDeparture(`06:05 кроме ${word}`).exceptDaysOfWeek, [i + 1]);
});
const combined = parseDeparture('06:05 через «Мариненко» только по понедельникам, средам и пятницам кроме среды');
// Contradictory inclusion/exclusion is not silently normalized.
assert.equal(combined, null);
const valid = parseDeparture('06:05 по понедельникам и пятницам через Мариненко');
assert.deepEqual(valid.daysOfWeek, [1, 5]);
assert.deepEqual(valid.viaStopNames, ['Мариненко']);
for (const value of ['06:05 кроме праздников', '06:05 по пятницам временно', '06:05*', '06:05 через «Мариненко» до Кстов', '25:00', '06:05 кроме пятницы кроме среды']) {
assert.equal(parseDeparture(value), null, value);
}
const unknown = parseSchedule(fixture('route9').replace('по пятницам', 'кроме праздников'), 'https://ap2polotsk.of.by/ap2/m-9/');
assert.ok(unknown.warnings.length);
});
test('weekend day restrictions split correctly and contradictory day sections are blocked', () => {
const annotated = parseSchedule(fixture('route9').replace('06:10 </span> через «Мариненко»', '06:10 </span> кроме воскресенья'), 'https://ap2polotsk.of.by/ap2/m-9/');
const state = { route: { id: 9, route_number: '9' }, schedules: [], stops: [{ stop_id: 1, name: 'Швейная фабрика' }, { stop_id: 2, name: 'Полоцк – Стекловолокно' }] };
const built = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16');
assert.deepEqual(built.changes.map(c => c.after.length), [18, 5, 4]);
const invalid = parseSchedule(fixture('route9').replace('по пятницам', 'по субботам'), annotated.url);
assert.throws(() => buildDirectionPlan(invalid, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'), /противоречит/);
});