diff --git a/backend/src/services/telegramCancellationParser.js b/backend/src/services/telegramCancellationParser.js index 2dd6c90..7163b92 100644 --- a/backend/src/services/telegramCancellationParser.js +++ b/backend/src/services/telegramCancellationParser.js @@ -46,8 +46,8 @@ function parseDate(text, publishedAt) { const [full, d, m] = args; const y = i === 1 ? undefined : args[3]; const offset = args.at(-2); - // In carrier posts “с остановки ... на 09.10” is a departure, not a date. - if (i === 1 && /с\s+остановк[иа]\s+[^\n]*$/i.test(dateText.slice(0, offset))) return full; + // “С/от остановки ... на 09.10” describes a departure, not a date. + if (i === 1 && /(?:с|от)\s+остановк[иа]\s+[^\n]*$/i.test(dateText.slice(0, offset))) return full; dates.push(inferYear(d, i === 2 ? MONTHS.indexOf(m.toLowerCase()) + 1 : m, y, publicationDate)); return ' '; }); @@ -95,7 +95,7 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }, { prese const groups = []; for (const [i, route] of routeMatches.entries()) { const section = parsedDate.dateText.slice(route.index + route[0].length, routeMatches[i + 1]?.index); - const stopPattern = /с\s+остановк[иа]\s+(.+?)\s+(?:на|в)\s+((?:\d{1,2}[:.]\d{2}(?:\s*[,;]\s*|\s+и\s+|\s+(?=\d))?)+)/giu; + const stopPattern = /(?:с|от)\s+остановк[иа]\s+(.+?)\s+(?:на|в)\s+((?:\d{1,2}[:.]\d{2}(?:\s*[,;]\s*|\s+и\s+|\s+(?=\d))?)+)/giu; const departures = []; for (const stop of section.matchAll(stopPattern)) { const times = [...stop[2].matchAll(TIME)].map(t => `${t[1].padStart(2, '0')}:${t[2]}`); diff --git a/backend/test/fixtures/telegram-hospital-cancellation.txt b/backend/test/fixtures/telegram-hospital-cancellation.txt new file mode 100644 index 0000000..3b86f29 --- /dev/null +++ b/backend/test/fixtures/telegram-hospital-cancellation.txt @@ -0,0 +1,4 @@ +Уважаемые пассажиры! +НЕ БУДУТ ВЫПОЛНЯТЬСЯ рейсы по маршруту №2 ОТ ОСТАНОВКИ больница ксты на 7.15,8.45. + ОТ ОСТАНОВКИ больница(новка) на 8.00,9.30. +Филиал "Автобусный парк №2 г.Полоцка" ОАО "Витебскоблавтотранс" diff --git a/backend/test/telegramCancellationParser.test.js b/backend/test/telegramCancellationParser.test.js index eb3f73b..4ba0a46 100644 --- a/backend/test/telegramCancellationParser.test.js +++ b/backend/test/telegramCancellationParser.test.js @@ -1,6 +1,9 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser'); +const hospitalText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8'); const text = `Уважаемые пассажиры! Не будут выполняться рейсы:  @@ -35,6 +38,31 @@ test('09.10 in a departure list remains a time', () => { assert.deepEqual(parse(text.replace('15.43', '09.10')).groups[0].departures[0].times, ['09:10', '16:43']); }); +test('hospital post with ОТ ОСТАНОВКИ preserves all four times and uses the publication date', () => { + const result = parse(hospitalText, '2026-10-05T04:16:53Z'); + assert.equal(result.status, 'ready'); + assert.equal(result.serviceDate, '2026-10-05'); + assert.equal(result.inferredDate, true); + assert.deepEqual(result.groups, [{ routeNumber: '2', departures: [ + { stop: 'больница ксты', times: ['07:15', '08:45'] }, + { stop: 'больница(новка)', times: ['08:00', '09:30'] } + ] }]); + assert.equal(dayBounds(result.serviceDate).end, '2026-10-05T21:00:00.000Z'); +}); + +test('с/от wording may be mixed; real dates and invalid times remain validated', () => { + for (const prefix of ['На 06.10\n', '06.10.2026\n', 'Завтра\n']) { + const result = parse(prefix + hospitalText.replace('ОТ ОСТАНОВКИ', 'с остановки'), '2026-10-05T04:16:53Z'); + assert.equal(result.status, 'ready', prefix); + assert.equal(result.serviceDate, '2026-10-06', prefix); + } + const invalidDate = parse('На 31.02.2026\n' + hospitalText, '2026-10-05T04:16:53Z'); + assert.deepEqual(invalidDate.reasons, ['Некорректная дата отмены.']); + const invalidTime = parse(hospitalText.replace('7.15', '25.15'), '2026-10-05T04:16:53Z'); + assert.deepEqual(invalidTime.reasons, ['Некорректное время отправления.']); + assert.deepEqual(parse(text.replace('15.43', '09.10').replace(/С ОСТАНОВКИ/g, 'ОТ ОСТАНОВКИ')).groups[0].departures[0].times, ['09:10', '16:43']); +}); + test('ambiguous dates, conditions, invalid times and unspecified departures require review', () => { for (const value of [ 'Сегодня и завтра\n' + text, 'На 31.02.2026\n' + text, diff --git a/backend/test/telegramImport.integration.test.js b/backend/test/telegramImport.integration.test.js index 75678b3..18d6b8b 100644 --- a/backend/test/telegramImport.integration.test.js +++ b/backend/test/telegramImport.integration.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); const path = require('node:path'); -const { TelegramImportService } = require('../src/services/telegramImportService'); +const { TelegramImportService, contentHash } = require('../src/services/telegramImportService'); const { CLAIM_DUE_ALERTS_SQL } = require('../src/services/alertSchedulerService'); // Run against an isolated in-memory PostgreSQL engine, never the working database. @@ -12,6 +12,35 @@ const carrierText = 'Не будут выполняться рейсы:\nПО М const config = { enabled: true, autoPublish: true, apiId: 123, apiHash: 'a'.repeat(32), sessionFile: '/tmp/private/account.session', channel: 'avp2Polotsk', citySlug: 'polotsk', intervalMs: 60_000 }; const message = (id = 1166, text = carrierText) => ({ channelId: '1234567', channelUsername: 'avp2Polotsk', messageId: id, publishedAt: '2026-10-02T12:52:00Z', editedAt: null, hasMedia: false, text }); +test('fixed parser reprocesses a stored invalid-date review without a Telegram text edit or duplicate alerts', { skip: !modulePath }, async () => { + const { db, pool, service, clock } = await setup(); + try { + clock('2026-10-05T04:20:00Z'); + await pool.query("UPDATE routes SET route_number='2' WHERE city_id=1"); + await pool.query("UPDATE stops SET name=CASE id WHEN 1 THEN 'Больница (Ксты)' ELSE 'Больница (Новка)' END WHERE city_id=1"); + await service.stopAliases.save(1, { alias: 'больница ксты', stop_id: 1 }); + await service.stopAliases.save(1, { alias: 'больница(новка)', stop_id: 2 }); + const text = await fs.readFile(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8'); + const input = { ...message(1400, text), publishedAt: '2026-10-05T04:16:53Z' }; + const oldParsed = { status: 'review', reasons: ['Некорректная дата отмены.'], groups: [], plans: [] }; + await pool.query(`INSERT INTO telegram_import_messages + (city_id,channel_id,channel_username,message_id,published_at,content_hash,raw_text,status,reasons,parsed) + VALUES (1,$1,$2,$3,$4,$5,$6,'review',$7::jsonb,$8::jsonb)`, + [input.channelId,input.channelUsername,input.messageId,input.publishedAt,contentHash(input),input.text,JSON.stringify(oldParsed.reasons),JSON.stringify(oldParsed)]); + assert.equal((await service.processMessage(input, 1)).status, 'published'); + const row = (await service.list(1))[0]; + assert.equal(row.parsed.serviceDate, '2026-10-05'); + assert.deepEqual(row.reasons, []); + const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_import_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY route_id', [row.id])).rows; + assert.equal(alerts.length, 2); + assert.match(alerts[0].message, /Больница \(Ксты\) — 07:15, 08:45/); + assert.match(alerts[1].message, /Больница \(Новка\) — 08:00, 09:30/); + assert.ok(alerts.every(alert => alert.end_time.toISOString() === '2026-10-05T21:00:00.000Z')); + assert.equal((await service.processMessage(input, 1)).status, 'unchanged'); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_alerts WHERE message_id=$1', [row.id])).rows[0].n, 2); + } finally { await db.close(); } +}); + async function setup() { const { PGlite } = require(modulePath); const db = new PGlite(); diff --git a/docs/telegram-cancellation-import.md b/docs/telegram-cancellation-import.md index 979c23f..83fd851 100644 --- a/docs/telegram-cancellation-import.md +++ b/docs/telegram-cancellation-import.md @@ -6,6 +6,8 @@ Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится. +Поддерживаются формулировки **«С ОСТАНОВКИ»** и **«ОТ ОСТАНОВКИ»**, включая их сочетание в одном сообщении. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или неоднозначными направлениями останутся на проверке с уточнённой причиной. + Если у номера несколько направлений, они сопоставляются по начальной остановке в базе. Неоднозначный номер, неизвестное направление, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний. Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.