Save all currently accumulated repository changes as a backup snapshot for Gitea so no local work is lost.
1532 lines
71 KiB
JavaScript
1532 lines
71 KiB
JavaScript
// JavaScript функция для парсинга судов (аналог parscourt.php)
|
||
// Используется в n8n workflow ноде "парсим суд"
|
||
// Поддерживает: региональные суды (*.sudrf.ru) и московские суды (mos-gorsud.ru)
|
||
|
||
export default async function ({ page, context }) {
|
||
// Получаем данные из переменных n8n workflow
|
||
const url = '{{ $json.link }}';
|
||
const status = '{{ $json.status }}';
|
||
|
||
if (!url) throw new Error('❌ Не передан url');
|
||
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
// Определяем тип суда по URL
|
||
const isMoscowCourt = /mos-(gorsud|sud)\.ru/.test(url);
|
||
const isRegionalCourt = /\.sudrf\.ru/.test(url) && !isMoscowCourt;
|
||
|
||
// Установка заголовков и поведения браузера
|
||
await page.setViewport({ width: 1920, height: 1080 });
|
||
await page.setExtraHTTPHeaders({
|
||
"Referer": isMoscowCourt ? "https://mos-sud.ru/" : "https://sudrf.ru/",
|
||
"Origin": isMoscowCourt ? "https://mos-sud.ru" : "https://sudrf.ru",
|
||
"Accept-Language": "ru,en;q=0.9",
|
||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||
"Upgrade-Insecure-Requests": "1",
|
||
});
|
||
await page.setUserAgent(
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||
);
|
||
|
||
await page.goto(url, { waitUntil: "networkidle2", timeout: 60000 });
|
||
|
||
// Проверяем на ошибки страницы (битая ссылка, неверный формат запроса и т.д.)
|
||
// Проверяем более точно - ищем конкретные сообщения об ошибках, а не просто слова
|
||
const pageText = await page.evaluate(() => {
|
||
const body = document.body?.textContent || '';
|
||
const title = document.title || '';
|
||
const h1 = document.querySelector('h1')?.textContent || '';
|
||
return (body + ' ' + title + ' ' + h1).toLowerCase();
|
||
});
|
||
|
||
// Более точные паттерны ошибок - ищем конкретные сообщения
|
||
const errorPatterns = [
|
||
/неверный формат запроса/i, // Точное сообщение об ошибке
|
||
/ошибка.*формат.*запрос/i, // Сообщение с ошибкой и форматом запроса
|
||
/страница не найдена/i, // 404 ошибка
|
||
/404.*not found/i, // 404 в английском
|
||
/дело не найдено/i, // Дело не найдено
|
||
/информация.*не.*найдена/i, // Информация не найдена
|
||
/ошибка доступа/i, // Ошибка доступа
|
||
/access.*denied/i, // Доступ запрещён
|
||
/forbidden/i, // Запрещено
|
||
/internal.*server.*error/i, // Внутренняя ошибка сервера
|
||
/ошибка.*сервера/i // Ошибка сервера
|
||
];
|
||
|
||
// Проверяем, что это действительно ошибка, а не просто упоминание слова "ошибка" в тексте
|
||
// Ищем паттерны в заголовках или в начале страницы
|
||
const pageTitle = await page.evaluate(() => document.title || '');
|
||
const pageH1 = await page.evaluate(() => document.querySelector('h1')?.textContent || '');
|
||
const pageH2 = await page.evaluate(() => document.querySelector('h2')?.textContent || '');
|
||
|
||
// Проверяем заголовки на ошибки (более надёжно)
|
||
const titleHasError = errorPatterns.some(pattern =>
|
||
pattern.test(pageTitle) || pattern.test(pageH1) || pattern.test(pageH2)
|
||
);
|
||
|
||
// Также проверяем, если страница очень короткая (меньше 200 символов) - вероятно ошибка
|
||
const pageLength = pageText.length;
|
||
const isVeryShort = pageLength < 200;
|
||
|
||
// Если в заголовках есть ошибка ИЛИ страница очень короткая с ошибкой в тексте
|
||
if (titleHasError || (isVeryShort && errorPatterns.some(pattern => pattern.test(pageText)))) {
|
||
return {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: isMoscowCourt ? 'moscow' : (isRegionalCourt ? 'regional' : 'unknown'),
|
||
status: 'error',
|
||
error_type: 'invalid_request',
|
||
error_message: 'НЕВЕРНЫЙ ФОРМАТ ЗАПРОСА или битая ссылка',
|
||
last_event: null,
|
||
message: 'Ссылка на дело оказалась битой или запрос неверный'
|
||
};
|
||
}
|
||
|
||
// Закрыть баннеры cookies, если есть
|
||
try {
|
||
await page.waitForSelector("#cookie-disclaimer .cd-close-button, .cookie-accept, .cookie__close", { timeout: 3000 });
|
||
const btns = await page.$$("#cookie-disclaimer .cd-close-button, .cookie-accept, .cookie__close");
|
||
if (btns[0]) await btns[0].click();
|
||
} catch (_) {}
|
||
|
||
await sleep(2000);
|
||
|
||
// Универсальная функция для форматирования даты в YYYY-MM-DD (для БД)
|
||
// Поддерживает форматы: DD.MM.YYYY, YYYY-MM-DD, DD-MM-YYYY
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return '';
|
||
|
||
// Если уже в формате YYYY-MM-DD
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||
return dateStr;
|
||
}
|
||
|
||
// Формат DD.MM.YYYY (российский формат)
|
||
const matchDDMMYYYY = dateStr.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||
if (matchDDMMYYYY) {
|
||
const day = matchDDMMYYYY[1];
|
||
const month = matchDDMMYYYY[2];
|
||
const year = matchDDMMYYYY[3];
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
// Формат DD-MM-YYYY
|
||
const matchDDMMYYYY_dash = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
||
if (matchDDMMYYYY_dash) {
|
||
const day = matchDDMMYYYY_dash[1];
|
||
const month = matchDDMMYYYY_dash[2];
|
||
const year = matchDDMMYYYY_dash[3];
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
// Пытаемся использовать Date, но только если формат распознан
|
||
try {
|
||
// Если дата в формате, который Date может распознать (YYYY-MM-DD или ISO)
|
||
const date = new Date(dateStr);
|
||
if (!isNaN(date.getTime())) {
|
||
return date.toISOString().split('T')[0];
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Если ничего не подошло, возвращаем как есть
|
||
return dateStr;
|
||
};
|
||
|
||
// Функция для форматирования даты в DD.MM.YYYY (для отображения, кириллические ключи)
|
||
const formatDateDisplay = (dateStr) => {
|
||
if (!dateStr) return '';
|
||
|
||
// Если уже в формате DD.MM.YYYY, возвращаем как есть
|
||
if (/^\d{2}\.\d{2}\.\d{4}$/.test(dateStr)) {
|
||
return dateStr;
|
||
}
|
||
|
||
// Если в формате YYYY-MM-DD, преобразуем в DD.MM.YYYY
|
||
const matchYYYYMMDD = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (matchYYYYMMDD) {
|
||
const year = matchYYYYMMDD[1];
|
||
const month = matchYYYYMMDD[2];
|
||
const day = matchYYYYMMDD[3];
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
// Если в формате DD.MM.YYYY (с точками), возвращаем как есть
|
||
if (/^\d{2}\.\d{2}\.\d{4}$/.test(dateStr)) {
|
||
return dateStr;
|
||
}
|
||
|
||
// Пытаемся преобразовать через formatDate и обратно
|
||
const dbDate = formatDate(dateStr);
|
||
if (dbDate && dbDate !== dateStr) {
|
||
const match = dbDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (match) {
|
||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||
}
|
||
}
|
||
|
||
// Если ничего не подошло, возвращаем как есть
|
||
return dateStr;
|
||
};
|
||
|
||
// ========================================
|
||
// ПАРСИНГ РЕГИОНАЛЬНЫХ СУДОВ (*.sudrf.ru)
|
||
// ========================================
|
||
if (isRegionalCourt) {
|
||
// Определяем div для парсинга (аналогично parscourt.php)
|
||
const divId = (status === 'представительство в суде 1й инстанции' ||
|
||
status === 'выдача листа' ||
|
||
status === 'исполнительное производство' ||
|
||
status === 'заявление на лист') ? 'cont2' : 'cont3';
|
||
|
||
// Парсим все данные со страницы
|
||
const pageData = await page.evaluate((divId) => {
|
||
const clean = (str) => (str ? str.replace(/\s+/g, ' ').trim() : '');
|
||
|
||
// ========================================
|
||
// ПАРСИНГ РАЗДЕЛА "ДЕЛО"
|
||
// ========================================
|
||
const caseInfo = {};
|
||
|
||
// Название суда - ищем в заголовке или в начале страницы
|
||
const courtNameEl = document.querySelector('h1, .court-name, [class*="court"] [class*="name"], title');
|
||
if (courtNameEl) {
|
||
let courtName = clean(courtNameEl.textContent);
|
||
// Убираем лишнее из title
|
||
if (courtName.includes('|')) {
|
||
courtName = courtName.split('|')[0].trim();
|
||
}
|
||
caseInfo.court_name = courtName;
|
||
}
|
||
|
||
// Номер дела - ищем в заголовке или в тексте "ДЕЛО №"
|
||
const caseNumberMatch = document.body?.textContent?.match(/ДЕЛО\s*№\s*([^\s~]+(?:\s*~\s*[^\s]+)?)/i);
|
||
if (caseNumberMatch) {
|
||
caseInfo.case_number = clean(caseNumberMatch[1]);
|
||
}
|
||
|
||
// Ищем таблицу с информацией о деле (раздел "ДЕЛО")
|
||
// Ищем таблицу, которая находится после заголовка "ДЕЛО"
|
||
const allTables = document.querySelectorAll('table');
|
||
let foundCaseTable = false;
|
||
|
||
allTables.forEach((table) => {
|
||
// Проверяем, есть ли в таблице заголовок "ДЕЛО" или ключевые поля
|
||
const tableText = clean(table.textContent || '').toLowerCase();
|
||
if (tableText.includes('уникальный идентификатор') ||
|
||
tableText.includes('судья') ||
|
||
tableText.includes('дата рассмотрения') ||
|
||
tableText.includes('результат рассмотрения')) {
|
||
foundCaseTable = true;
|
||
|
||
const rows = Array.from(table.querySelectorAll('tr'));
|
||
rows.forEach((row) => {
|
||
const cells = Array.from(row.querySelectorAll('td, th'));
|
||
if (cells.length >= 2) {
|
||
const label = clean(cells[0]?.textContent || '').toLowerCase();
|
||
const value = clean(cells[1]?.textContent || '');
|
||
|
||
if ((label.includes('уникальный идентификатор') || label.includes('uid')) && !caseInfo.uid) {
|
||
caseInfo.uid = value;
|
||
} else if (label.includes('судья') && !caseInfo.judge) {
|
||
caseInfo.judge = value;
|
||
} else if (label.includes('дата рассмотрения') && !caseInfo.consideration_date) {
|
||
caseInfo.consideration_date = value;
|
||
} else if (label.includes('результат рассмотрения') && !caseInfo.consideration_result) {
|
||
caseInfo.consideration_result = value;
|
||
} else if (label.includes('категория дела') && !caseInfo.category) {
|
||
caseInfo.category = value;
|
||
} else if (label.includes('дата поступления') && !caseInfo.intake_date) {
|
||
caseInfo.intake_date = value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Если не нашли в таблице, ищем в тексте страницы
|
||
if (!caseInfo.uid) {
|
||
const uidMatch = document.body?.textContent?.match(/Уникальный\s+идентификатор\s+дела[\s\S]{0,200}?([A-Z0-9\-]+)/i);
|
||
if (uidMatch) {
|
||
caseInfo.uid = clean(uidMatch[1]);
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// ПАРСИНГ РАЗДЕЛА "СТОРОНЫ ПО ДЕЛУ"
|
||
// ========================================
|
||
const parties = [];
|
||
const PARTY_TYPES = ['истец', 'ответчик', 'третье лицо', 'представитель', 'соистец', 'соответчик'];
|
||
|
||
// Ищем таблицу по строкам: первая колонка = ИСТЕЦ/ОТВЕТЧИК/ТРЕТЬЕ ЛИЦО/ПРЕДСТАВИТЕЛЬ
|
||
const allTbl = document.querySelectorAll('table');
|
||
allTbl.forEach((table) => {
|
||
const rows = Array.from(table.querySelectorAll('tr'));
|
||
|
||
rows.forEach((row) => {
|
||
const cells = Array.from(row.querySelectorAll('td, th'));
|
||
if (cells.length < 2) return;
|
||
|
||
const col0 = clean(cells[0]?.textContent || '');
|
||
const col1 = clean(cells[1]?.textContent || '');
|
||
const typeLower = col0.toLowerCase();
|
||
|
||
// Проверяем: первая ячейка — известный тип стороны, вторая — имя
|
||
const isPartyType = PARTY_TYPES.some(t => typeLower === t || typeLower.startsWith(t + ' '));
|
||
const hasName = col1 && col1.length > 1;
|
||
|
||
if (isPartyType && hasName) {
|
||
// Не заголовок: "Фамилия", "наименование", "вид лица" и т.п.
|
||
const nameLower = col1.toLowerCase();
|
||
if (nameLower.includes('фамилия') || nameLower.includes('наименование') ||
|
||
nameLower.includes('вид лица') || nameLower === 'инн' || nameLower === 'кпп') {
|
||
return;
|
||
}
|
||
|
||
const inn = cells[2] ? clean(cells[2].textContent || '') : '';
|
||
const kpp = cells[3] ? clean(cells[3].textContent || '') : '';
|
||
const ogrn = cells[4] ? clean(cells[4].textContent || '') : '';
|
||
const ogrnip = cells[5] ? clean(cells[5].textContent || '') : '';
|
||
|
||
parties.push({
|
||
type: col0,
|
||
name: col1,
|
||
inn: inn || null,
|
||
kpp: kpp || null,
|
||
ogrn: ogrn || null,
|
||
ogrnip: ogrnip || null
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// ========================================
|
||
// ПАРСИНГ РАЗДЕЛА "СУДЕБНЫЕ АКТЫ"
|
||
// ========================================
|
||
const courtActs = [];
|
||
|
||
// Функция для очистки текста от JavaScript кода
|
||
const cleanActText = (text) => {
|
||
// Обрезаем текст до маркеров конца акта
|
||
const endMarkers = [
|
||
/опубликовано\s+\d{2}\.\d{2}\.\d{4}/i,
|
||
/изменено\s+\d{2}\.\d{2}\.\d{4}/i,
|
||
/судья\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.\s*[А-ЯЁа-яё]+/i,
|
||
/function\s+\w+\s*\(/i,
|
||
/var\s+\w+\s*=/i,
|
||
/document\./i,
|
||
/getElementById/i,
|
||
/addEventListener/i
|
||
];
|
||
|
||
let cleanText = text;
|
||
let minIndex = text.length;
|
||
|
||
// Находим самое раннее вхождение маркера конца
|
||
endMarkers.forEach(marker => {
|
||
const match = text.match(marker);
|
||
if (match && match.index < minIndex) {
|
||
minIndex = match.index;
|
||
}
|
||
});
|
||
|
||
// Если нашли маркер, обрезаем до него
|
||
if (minIndex < text.length) {
|
||
cleanText = text.substring(0, minIndex).trim();
|
||
}
|
||
|
||
// Дополнительно удаляем JavaScript код, если он остался
|
||
cleanText = cleanText.replace(/\s*function\s+\w+[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*var\s+\w+[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*document\.[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*getElementById[^]*$/i, '');
|
||
|
||
return cleanText.trim();
|
||
};
|
||
|
||
// Ищем все тексты, начинающиеся с "Именем Российской Федерации"
|
||
const bodyText = document.body?.textContent || '';
|
||
|
||
// Ищем все вхождения "Именем Российской Федерации" (с учетом возможных пробелов/без пробелов)
|
||
const actPattern = /(Именем\s*Российской\s*Федерации[\s\S]{1,100000}?)(?=Именем\s*Российской\s*Федерации|$)/gi;
|
||
const actMatches = bodyText.matchAll(actPattern);
|
||
|
||
for (const match of actMatches) {
|
||
let actText = match[1];
|
||
if (actText.length > 100) {
|
||
// Очищаем текст от JavaScript кода
|
||
actText = cleanActText(actText);
|
||
|
||
if (actText.length < 100) continue; // Пропускаем слишком короткие тексты
|
||
|
||
actText = clean(actText);
|
||
|
||
// Определяем тип акта по тексту
|
||
let actType = 'Судебный акт';
|
||
if (actText.match(/ЗАОЧНОЕ\s+РЕШЕНИЕ/i)) {
|
||
actType = 'Заочное решение';
|
||
} else if (actText.match(/РЕШЕНИЕ/i)) {
|
||
actType = 'Решение';
|
||
} else if (actText.match(/ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Определение';
|
||
} else if (actText.match(/ПОСТАНОВЛЕНИЕ/i)) {
|
||
actType = 'Постановление';
|
||
}
|
||
|
||
// Извлекаем номер дела и дату из текста акта
|
||
const caseNumberMatch = actText.match(/№\s*([^\s]+(?:\s*~\s*[^\s]+)?)/i);
|
||
const dateMatch = actText.match(/(\d{2}\.\d{2}\.\d{4})/);
|
||
const uidMatch = actText.match(/УИД\s*([^\s]+)/i);
|
||
|
||
// Извлекаем резолютивную часть (что суд решил)
|
||
let decision = null;
|
||
// Ищем "РЕШИЛ:" или "РЕШИЛ" и извлекаем текст до конца решения
|
||
const decisionPatterns = [
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Судья\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.\s*[А-ЯЁа-яё]+)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*опубликовано\s+\d{2}\.\d{2}\.\d{4})/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*изменено\s+\d{2}\.\d{2}\.\d{4})/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Ответчик\s+вправе)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Иными\s+лицами)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=function\s+\w+\s*\()/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)$/i
|
||
];
|
||
|
||
for (const pattern of decisionPatterns) {
|
||
const decisionMatch = actText.match(pattern);
|
||
if (decisionMatch && decisionMatch[1]) {
|
||
decision = clean(decisionMatch[1]).trim();
|
||
// Убираем лишние пробелы и переносы
|
||
decision = decision.replace(/\s+/g, ' ').trim();
|
||
// Обрезаем до разумной длины
|
||
if (decision.length > 10000) {
|
||
decision = decision.substring(0, 10000) + '...';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
courtActs.push({
|
||
type: actType,
|
||
title: `${actType}${caseNumberMatch ? ' № ' + caseNumberMatch[1] : ''}${dateMatch ? ' от ' + dateMatch[1] : ''}`,
|
||
text: actText,
|
||
decision: decision,
|
||
case_number: caseNumberMatch ? caseNumberMatch[1] : null,
|
||
date: dateMatch ? dateMatch[1] : null,
|
||
uid: uidMatch ? uidMatch[1] : null,
|
||
link: ''
|
||
});
|
||
}
|
||
}
|
||
|
||
// Если не нашли через паттерн, ищем ссылки на акты и текст рядом
|
||
if (courtActs.length === 0) {
|
||
// Ищем ссылки на акты
|
||
const actLinks = document.querySelectorAll('a[href*="#"], a[href*="act"], a[href*="document"], a');
|
||
actLinks.forEach((link) => {
|
||
const linkText = clean(link.textContent || '');
|
||
if (linkText.match(/судебный\s*акт|решение|определение|постановление/i)) {
|
||
// Пытаемся найти текст акта после ссылки или в родительском элементе
|
||
let actText = '';
|
||
|
||
// Ищем в родительском элементе и следующих
|
||
let searchEl = link.closest('div, section, article, li');
|
||
if (!searchEl) searchEl = link.parentElement;
|
||
|
||
let depth = 0;
|
||
while (searchEl && depth < 15) {
|
||
const text = clean(searchEl.textContent || '');
|
||
// Ищем текст с "Именем" и достаточной длиной
|
||
if ((text.includes('Именем') || text.includes('ИменемРоссийской')) && text.length > 1000) {
|
||
// Извлекаем текст акта
|
||
const actMatch = text.match(/(Именем\s*Российской\s*Федерации[\s\S]{1,100000})/i);
|
||
if (actMatch) {
|
||
actText = clean(actMatch[1]);
|
||
break;
|
||
}
|
||
}
|
||
searchEl = searchEl.nextElementSibling || searchEl.parentElement;
|
||
depth++;
|
||
}
|
||
|
||
if (actText || linkText) {
|
||
// Очищаем текст от JavaScript кода
|
||
if (actText) {
|
||
actText = cleanActText(actText);
|
||
actText = clean(actText);
|
||
}
|
||
|
||
let actType = 'Судебный акт';
|
||
if (actText && actText.length > 100) {
|
||
if (actText.match(/ЗАОЧНОЕ\s+РЕШЕНИЕ/i)) {
|
||
actType = 'Заочное решение';
|
||
} else if (actText.match(/РЕШЕНИЕ/i)) {
|
||
actType = 'Решение';
|
||
} else if (actText.match(/ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Определение';
|
||
} else if (actText.match(/ПОСТАНОВЛЕНИЕ/i)) {
|
||
actType = 'Постановление';
|
||
}
|
||
}
|
||
|
||
if (actText && actText.length > 100) {
|
||
// Извлекаем резолютивную часть (что суд решил)
|
||
let decision = null;
|
||
const decisionPatterns = [
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Судья\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.\s*[А-ЯЁа-яё]+)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*опубликовано\s+\d{2}\.\d{2}\.\d{4})/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*изменено\s+\d{2}\.\d{2}\.\d{4})/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Ответчик\s+вправе)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Иными\s+лицами)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=function\s+\w+\s*\()/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)$/i
|
||
];
|
||
|
||
for (const pattern of decisionPatterns) {
|
||
const decisionMatch = actText.match(pattern);
|
||
if (decisionMatch && decisionMatch[1]) {
|
||
decision = clean(decisionMatch[1]).trim();
|
||
decision = decision.replace(/\s+/g, ' ').trim();
|
||
if (decision.length > 10000) {
|
||
decision = decision.substring(0, 10000) + '...';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
courtActs.push({
|
||
type: actType,
|
||
title: linkText,
|
||
text: actText,
|
||
decision: decision,
|
||
link: link.href || ''
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Если всё ещё пусто — пробуем найти документы в таблицах (часто для 2-й инстанции)
|
||
if (courtActs.length === 0) {
|
||
const actKeyword = /решение|определение|постановление|судебный\s*акт/i;
|
||
const datePattern = /(\d{2}\.\d{2}\.\d{4})/;
|
||
|
||
document.querySelectorAll('table').forEach((table) => {
|
||
const rows = Array.from(table.querySelectorAll('tr'));
|
||
rows.forEach((row) => {
|
||
const rowText = clean(row.textContent || '');
|
||
if (!actKeyword.test(rowText)) return;
|
||
|
||
const linkEl = row.querySelector('a[href]');
|
||
if (!linkEl) return;
|
||
|
||
let link = linkEl.getAttribute('href') || '';
|
||
if (!link || link.includes('#')) {
|
||
// Попробуем вытащить ссылку из onclick
|
||
const onclick = linkEl.getAttribute('onclick') || '';
|
||
const urlMatch = onclick.match(/['"](https?:\/\/[^'"]+|\.\/[^'"]+|\/[^'"]+)['"]/);
|
||
link = urlMatch ? urlMatch[1] : '';
|
||
}
|
||
if (!link || link.includes('javascript:')) return;
|
||
|
||
// Приводим ссылку к абсолютной
|
||
if (link.startsWith('/')) {
|
||
link = window.location.origin + link;
|
||
} else if (!link.startsWith('http')) {
|
||
const baseUrl = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/'));
|
||
link = baseUrl + '/' + link.replace(/^\.\//, '');
|
||
}
|
||
|
||
let actType = 'Судебный акт';
|
||
if (rowText.match(/ЗАОЧНОЕ\s+РЕШЕНИЕ/i)) {
|
||
actType = 'Заочное решение';
|
||
} else if (rowText.match(/РЕШЕНИЕ/i)) {
|
||
actType = 'Решение';
|
||
} else if (rowText.match(/ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Определение';
|
||
} else if (rowText.match(/ПОСТАНОВЛЕНИЕ/i)) {
|
||
actType = 'Постановление';
|
||
}
|
||
|
||
const dateMatch = rowText.match(datePattern);
|
||
courtActs.push({
|
||
type: actType,
|
||
title: `${actType}${dateMatch ? ' от ' + dateMatch[1] : ''}`,
|
||
text: '',
|
||
decision: null,
|
||
case_number: caseInfo.case_number || null,
|
||
date: dateMatch ? dateMatch[1] : null,
|
||
uid: caseInfo.uid || null,
|
||
link: link
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Если всё ещё пусто — ищем текст акта в блоках cont_doc* (часто для 2-й инстанции)
|
||
if (courtActs.length === 0) {
|
||
const docBlocks = document.querySelectorAll('[id^="cont_doc"], .contentt div[id^="cont_doc"]');
|
||
docBlocks.forEach((block) => {
|
||
const rawText = clean(block.textContent || '');
|
||
if (!rawText || rawText.length < 200) return;
|
||
|
||
// Определяем тип акта по заголовку
|
||
let actType = 'Судебный акт';
|
||
if (rawText.match(/АПЕЛЛЯЦИОННОЕ\s+ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Апелляционное определение';
|
||
} else if (rawText.match(/КАССАЦИОННОЕ\s+ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Кассационное определение';
|
||
} else if (rawText.match(/ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Определение';
|
||
} else if (rawText.match(/ЗАОЧНОЕ\s+РЕШЕНИЕ/i)) {
|
||
actType = 'Заочное решение';
|
||
} else if (rawText.match(/РЕШЕНИЕ/i)) {
|
||
actType = 'Решение';
|
||
} else if (rawText.match(/ПОСТАНОВЛЕНИЕ/i)) {
|
||
actType = 'Постановление';
|
||
}
|
||
|
||
// Пробуем вытащить дату
|
||
let date = null;
|
||
const dateNumeric = rawText.match(/(\d{2}\.\d{2}\.\d{4})/);
|
||
if (dateNumeric) {
|
||
date = dateNumeric[1];
|
||
} else {
|
||
const dateText = rawText.match(/(\d{1,2}\s+[а-яё]+\s+\d{4}\s+года)/i);
|
||
if (dateText) date = dateText[1];
|
||
}
|
||
|
||
// Извлекаем резолютивную часть (РЕШИЛ / ОПРЕДЕЛИЛА / ПОСТАНОВИЛ)
|
||
let decision = null;
|
||
const decisionPatterns = [
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)(?=\s*Судья\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.\s*[А-ЯЁа-яё]+)/i,
|
||
/ОПРЕДЕЛИЛА\s*:?\s*([\s\S]+?)(?=\s*Председательствующий|Судьи|Апелляционное определение)/i,
|
||
/ПОСТАНОВИЛ\s*:?\s*([\s\S]+?)(?=\s*Председательствующий|Судьи|Постановление)/i,
|
||
/РЕШИЛ\s*:?\s*([\s\S]+?)$/i,
|
||
/ОПРЕДЕЛИЛА\s*:?\s*([\s\S]+?)$/i,
|
||
/ПОСТАНОВИЛ\s*:?\s*([\s\S]+?)$/i
|
||
];
|
||
for (const pattern of decisionPatterns) {
|
||
const decisionMatch = rawText.match(pattern);
|
||
if (decisionMatch && decisionMatch[1]) {
|
||
decision = clean(decisionMatch[1]).trim();
|
||
decision = decision.replace(/\s+/g, ' ').trim();
|
||
if (decision.length > 10000) {
|
||
decision = decision.substring(0, 10000) + '...';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
const actText = cleanActText(rawText);
|
||
if (actText.length > 100) {
|
||
courtActs.push({
|
||
type: actType,
|
||
title: `${actType}${date ? ' от ' + date : ''}`,
|
||
text: actText,
|
||
decision: decision,
|
||
case_number: caseInfo.case_number || null,
|
||
date: date,
|
||
uid: caseInfo.uid || null,
|
||
link: ''
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========================================
|
||
// ПАРСИНГ СОБЫТИЙ (ДВИЖЕНИЕ ДЕЛА)
|
||
// ========================================
|
||
const div = document.querySelector(`#${divId}`);
|
||
const events = [];
|
||
|
||
if (div) {
|
||
const rows = Array.from(div.querySelectorAll('tr'));
|
||
|
||
rows.forEach((row) => {
|
||
const tds = row.querySelectorAll('td');
|
||
if (tds.length < 2) return;
|
||
|
||
const event_name = clean(tds[0]?.textContent || '');
|
||
const event_date = clean(tds[1]?.textContent || '');
|
||
const event_time = clean(tds[2]?.textContent || '');
|
||
const location = clean(tds[3]?.textContent || '');
|
||
const event_result = clean(tds[4]?.textContent || '');
|
||
const event_basis = clean(tds[5]?.textContent || '');
|
||
const note = clean(tds[6]?.textContent || '');
|
||
const publication_date = clean(tds[7]?.textContent || '');
|
||
|
||
// Пропускаем записи, если название события не указано или дата неверная
|
||
if (!event_name || !event_date || event_date === '1970-01-01') {
|
||
return;
|
||
}
|
||
|
||
events.push({
|
||
event_name,
|
||
event_date,
|
||
event_time,
|
||
location,
|
||
event_result,
|
||
event_basis,
|
||
note,
|
||
publication_date
|
||
});
|
||
});
|
||
}
|
||
|
||
return {
|
||
caseInfo,
|
||
parties,
|
||
courtActs,
|
||
events
|
||
};
|
||
}, divId);
|
||
|
||
const caseInfoText = JSON.stringify(pageData.caseInfo || {}).toLowerCase();
|
||
const isSecondInstance = /апелляц|кассац|надзор|втор(ой|ая)\s+инстанц|апелляцион|кассацион/.test(caseInfoText);
|
||
const decisionFound = pageData.courtActs?.some(act => {
|
||
const type = (act?.type || act?.title || '').toLowerCase();
|
||
// Если есть любое решение
|
||
if (type.includes('решение')) return true;
|
||
// Если тип акта сам по себе апелляционный/кассационный — это 2-я инстанция
|
||
const isActSecondInstance = /апелляцион|кассацион/.test(type);
|
||
// Если дело 2-й инстанции (по case_info или по типу акта) и есть определение/постановление
|
||
if ((isSecondInstance || isActSecondInstance) && (type.includes('определение') || type.includes('постановление'))) return true;
|
||
return false;
|
||
});
|
||
|
||
// Формируем результат
|
||
const result = {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: 'regional',
|
||
status: 'success',
|
||
case_info: pageData.caseInfo,
|
||
parties: pageData.parties,
|
||
court_acts: pageData.courtActs,
|
||
all_events: pageData.events,
|
||
decision_found: !!decisionFound
|
||
};
|
||
|
||
// Добавляем последнее событие для обратной совместимости
|
||
if (pageData.events.length > 0) {
|
||
const lastEvent = pageData.events[pageData.events.length - 1];
|
||
result.last_event = {
|
||
event_name: lastEvent.event_name,
|
||
event_date: formatDate(lastEvent.event_date),
|
||
event_time: lastEvent.event_time,
|
||
location: lastEvent.location,
|
||
event_result: lastEvent.event_result,
|
||
event_basis: lastEvent.event_basis,
|
||
note: lastEvent.note,
|
||
publication_date: formatDate(lastEvent.publication_date),
|
||
// Для совместимости с parscourt.php (кириллические ключи)
|
||
Наименование: lastEvent.event_name,
|
||
Дата: formatDateDisplay(lastEvent.event_date),
|
||
Время: lastEvent.event_time,
|
||
Место: lastEvent.location,
|
||
Результат: lastEvent.event_result,
|
||
Основание: lastEvent.event_basis,
|
||
Примечание: lastEvent.note,
|
||
'Дата размещения': formatDateDisplay(lastEvent.publication_date)
|
||
};
|
||
} else {
|
||
result.last_event = null;
|
||
}
|
||
|
||
// Проверяем, есть ли хотя бы какие-то данные
|
||
const hasCaseInfo = pageData.caseInfo && Object.keys(pageData.caseInfo).length > 0;
|
||
const hasParties = pageData.parties && pageData.parties.length > 0;
|
||
const hasCourtActs = pageData.courtActs && pageData.courtActs.length > 0;
|
||
const hasEvents = pageData.events && pageData.events.length > 0;
|
||
|
||
// Если нет вообще никаких данных - возвращаем ошибку
|
||
if (!hasCaseInfo && !hasParties && !hasCourtActs && !hasEvents) {
|
||
// Проверяем, может быть страница пустая или битая ссылка
|
||
const pageText = await page.evaluate(() => document.body?.textContent || '');
|
||
const isEmpty = !pageText || pageText.trim().length < 100;
|
||
|
||
return {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: 'regional',
|
||
status: isEmpty ? 'error' : 'no_data',
|
||
error_type: isEmpty ? 'empty_page' : 'no_data_found',
|
||
error_message: isEmpty ? 'Страница пустая или битая ссылка' : 'Данные не найдены',
|
||
last_event: null,
|
||
message: isEmpty ? 'Ссылка на дело оказалась битой' : 'Данные не найдены',
|
||
case_info: {},
|
||
parties: [],
|
||
court_acts: [],
|
||
all_events: [],
|
||
decision_found: false
|
||
};
|
||
}
|
||
|
||
// Если есть хотя бы какие-то данные - возвращаем успех
|
||
return result;
|
||
}
|
||
|
||
// ========================================
|
||
// ПАРСИНГ МОСКОВСКИХ СУДОВ (mos-gorsud.ru)
|
||
// ========================================
|
||
if (isMoscowCourt) {
|
||
// Ждём карточку
|
||
await page.waitForSelector(
|
||
".detail-cart .row_card, .case-card, .case-details, .content, main .wrapper_innercontent",
|
||
{ timeout: 20000 }
|
||
);
|
||
|
||
// Активируем вкладки
|
||
try {
|
||
for (const id of ["#ui-id-1", "#ui-id-2", "#ui-id-3"]) {
|
||
if (await page.$(id)) await page.click(id);
|
||
}
|
||
const tabLinks = await page.$$(`a[href^="#tabs-"], .tabs_wrapper a.ui-tabs-anchor`);
|
||
if (tabLinks.length) for (const a of tabLinks) await a.click();
|
||
await page.waitForTimeout(300);
|
||
} catch (_) {}
|
||
|
||
const data = await page.evaluate(() => {
|
||
const norm = (el) => {
|
||
if (!el) return "";
|
||
if (typeof el === 'string') return el.replace(/\s+/g, " ").trim();
|
||
if (el.textContent === undefined || el.textContent === null) return "";
|
||
return String(el.textContent).replace(/\s+/g, " ").trim();
|
||
};
|
||
const qsa = (sel) => Array.from(document.querySelectorAll(sel));
|
||
|
||
function collectRows() {
|
||
const rows = [];
|
||
qsa(".detail-cart .row_card").forEach((r) => {
|
||
const left = norm(r.querySelector(".left"));
|
||
const right = norm(r.querySelector(".right"));
|
||
if (left && right) rows.push({ left, right });
|
||
});
|
||
if (!rows.length) {
|
||
qsa("table, .case-card, .case-details").forEach((tbl) => {
|
||
qsa("tr", tbl).forEach((tr) => {
|
||
const tds = tr.querySelectorAll("td, th");
|
||
if (tds.length === 2) {
|
||
const left = norm(tds[0]);
|
||
const right = norm(tds[1]);
|
||
if (left && right) rows.push({ left, right });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
if (!rows.length) {
|
||
qsa(".case-card__row, .kv-row").forEach((row) => {
|
||
const left = norm(row.querySelector(".case-card__key, .kv-key, .left"));
|
||
const right = norm(row.querySelector(".case-card__val, .kv-val, .right"));
|
||
if (left && right) rows.push({ left, right });
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
const rows = collectRows();
|
||
const byLeft = (start) => {
|
||
const row = rows.find((r) =>
|
||
r.left.toLowerCase().startsWith(start.toLowerCase())
|
||
);
|
||
return row ? row.right : null;
|
||
};
|
||
|
||
// ========================================
|
||
// ПАРСИНГ ИНФОРМАЦИИ О ДЕЛЕ
|
||
// ========================================
|
||
const caseInfo = {};
|
||
|
||
// Название суда
|
||
const courtNameEl = document.querySelector('h1, .court-name, [class*="court"] [class*="name"], title');
|
||
if (courtNameEl) {
|
||
let courtName = norm(courtNameEl);
|
||
if (courtName.includes('|')) {
|
||
courtName = courtName.split('|')[0].trim();
|
||
}
|
||
caseInfo.court_name = courtName;
|
||
}
|
||
|
||
// Извлекаем данные из структурированных полей
|
||
caseInfo.uid = byLeft("Уникальный идентификатор") || byLeft("УИД") || byLeft("уникальный идентификатор дела");
|
||
caseInfo.case_number = byLeft("Номер дела") || byLeft("№ дела") || byLeft("Номер дела ~ материала");
|
||
caseInfo.intake_date = byLeft("Дата поступления") || byLeft("Поступило");
|
||
caseInfo.judge = byLeft("Судья") || byLeft("Cудья") || byLeft("Председательствующий судья");
|
||
caseInfo.category = byLeft("Категория дела") || byLeft("Категория");
|
||
|
||
const statusRaw = byLeft("Текущее состояние") || byLeft("Состояние");
|
||
if (statusRaw) {
|
||
const m = statusRaw.match(/^(.+?),\s*([\d.]{10})$/);
|
||
caseInfo.current_status = m ? m[1].trim() : statusRaw;
|
||
caseInfo.current_status_date = m ? m[2] : null;
|
||
}
|
||
|
||
caseInfo.consideration_date = byLeft("Дата рассмотрения") || byLeft("Дата рассмотрения дела в первой инстанции");
|
||
|
||
const decisionRaw = byLeft("Решение первой инстанции") || byLeft("Решение (1 инстанция)");
|
||
if (decisionRaw) {
|
||
const m = decisionRaw.match(/^(.+?),\s*([\d.]{10})$/);
|
||
caseInfo.consideration_result = m ? m[1].trim() : decisionRaw;
|
||
caseInfo.consideration_result_date = m ? m[2] : null;
|
||
}
|
||
|
||
caseInfo.decision_effective_date = byLeft("Дата вступления решения в силу") || byLeft("Дата вступления в силу");
|
||
|
||
// ========================================
|
||
// ПАРСИНГ СТОРОН
|
||
// ========================================
|
||
const parties = [];
|
||
|
||
// Ищем текст со сторонами - ищем в структурированных полях и в тексте страницы
|
||
const partiesFromRows = byLeft("Стороны");
|
||
const bodyText = document.body?.textContent || '';
|
||
|
||
// Пробуем извлечь из структурированного поля
|
||
if (partiesFromRows) {
|
||
const partiesText = partiesFromRows;
|
||
|
||
// Ищем истцов
|
||
const plaintiffMatches = partiesText.matchAll(/Истец\s*:?\s*([^\n\r]+?)(?=\s*Ответчик|$)/gi);
|
||
for (const match of plaintiffMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
// Убираем лишние символы вроде кавычек HTML
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2 && !name.toLowerCase().includes('ответчик')) {
|
||
parties.push({
|
||
type: 'ИСТЕЦ',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
|
||
// Ищем ответчиков
|
||
const defendantMatches = partiesText.matchAll(/Ответчик\s*:?\s*([^\n\r]+?)(?=\s*Истец|\s*Третье|\s*Представитель|$)/gi);
|
||
for (const match of defendantMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2) {
|
||
parties.push({
|
||
type: 'ОТВЕТЧИК',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не нашли в структурированных полях, ищем в тексте страницы
|
||
if (parties.length === 0) {
|
||
const partiesSection = bodyText.match(/Стороны[\s\S]{0,2000}?(?=Cудья|Категория|Текущее|Дата|$)/i);
|
||
|
||
if (partiesSection) {
|
||
const partiesText = partiesSection[0];
|
||
|
||
// Ищем истцов
|
||
const plaintiffMatches = partiesText.matchAll(/Истец\s*:?\s*([^\n\r]+?)(?=\s*Ответчик|$)/gi);
|
||
for (const match of plaintiffMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2 && !name.toLowerCase().includes('ответчик')) {
|
||
parties.push({
|
||
type: 'ИСТЕЦ',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
|
||
// Ищем ответчиков
|
||
const defendantMatches = partiesText.matchAll(/Ответчик\s*:?\s*([^\n\r]+?)(?=\s*Истец|\s*Третье|\s*Представитель|$)/gi);
|
||
for (const match of defendantMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2) {
|
||
parties.push({
|
||
type: 'ОТВЕТЧИК',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ищем третьих лиц
|
||
const thirdPartyText = byLeft("Третье лицо") || bodyText.match(/Третье\s+лицо[\s\S]{0,500}/i)?.[0];
|
||
if (thirdPartyText) {
|
||
const thirdPartyMatches = thirdPartyText.matchAll(/Третье\s+лицо\s*:?\s*([^\n\r]+)/gi);
|
||
for (const match of thirdPartyMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2) {
|
||
parties.push({
|
||
type: 'ТРЕТЬЕ ЛИЦО',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ищем представителей
|
||
const repText = byLeft("Представитель") || bodyText.match(/Представитель[\s\S]{0,500}/i)?.[0];
|
||
if (repText) {
|
||
const repMatches = repText.matchAll(/Представитель\s*:?\s*([^\n\r]+)/gi);
|
||
for (const match of repMatches) {
|
||
if (!match || !match[1]) continue;
|
||
let name = norm(match[1]);
|
||
name = name.replace(/"/g, '"').replace(/&/g, '&').trim();
|
||
if (name && name.length > 2) {
|
||
parties.push({
|
||
type: 'ПРЕДСТАВИТЕЛЬ',
|
||
name: name,
|
||
inn: null,
|
||
kpp: null,
|
||
ogrn: null,
|
||
ogrnip: null
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Таблицы - функция для преобразования tbody в массив строк
|
||
function tableToRows(tbody) {
|
||
return Array.from(tbody.querySelectorAll("tr")).map((tr) => {
|
||
const tds = tr.querySelectorAll("td");
|
||
return Array.from(tds).map((td) => {
|
||
if (!td) return '';
|
||
const div = td.querySelector("div");
|
||
return norm(div || td);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ========================================
|
||
// ПАРСИНГ СУДЕБНЫХ АКТОВ
|
||
// ========================================
|
||
const courtActs = [];
|
||
|
||
// Функция для очистки текста от JavaScript кода
|
||
const cleanActText = (text) => {
|
||
const endMarkers = [
|
||
/опубликовано\s+\d{2}\.\d{2}\.\d{4}/i,
|
||
/изменено\s+\d{2}\.\d{2}\.\d{4}/i,
|
||
/судья\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.\s*[А-ЯЁа-яё]+/i,
|
||
/function\s+\w+\s*\(/i,
|
||
/var\s+\w+\s*=/i,
|
||
/document\./i,
|
||
/getElementById/i,
|
||
/addEventListener/i
|
||
];
|
||
|
||
let cleanText = text;
|
||
let minIndex = text.length;
|
||
|
||
endMarkers.forEach(marker => {
|
||
const match = text.match(marker);
|
||
if (match && match.index < minIndex) {
|
||
minIndex = match.index;
|
||
}
|
||
});
|
||
|
||
if (minIndex < text.length) {
|
||
cleanText = text.substring(0, minIndex).trim();
|
||
}
|
||
|
||
cleanText = cleanText.replace(/\s*function\s+\w+[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*var\s+\w+[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*document\.[^]*$/i, '');
|
||
cleanText = cleanText.replace(/\s*getElementById[^]*$/i, '');
|
||
|
||
return cleanText.trim();
|
||
};
|
||
|
||
// Ищем таблицу документов (#tabs-3)
|
||
const docsTbody = document.querySelector("#tabs-3 table tbody");
|
||
if (docsTbody) {
|
||
const docsRows = Array.from(docsTbody.querySelectorAll("tr"));
|
||
docsRows.forEach((row) => {
|
||
const cells = row.querySelectorAll("td");
|
||
if (cells.length < 2) return;
|
||
|
||
const docDate = cells[0] ? norm(cells[0]) : '';
|
||
const docType = cells[1] ? norm(cells[1]) : '';
|
||
const docTextCell = cells[2] || null;
|
||
|
||
// Проверяем все ячейки на наличие ссылок (может быть ссылка в любой ячейке)
|
||
let allCellsLinks = [];
|
||
Array.from(cells).forEach((cell, idx) => {
|
||
const cellLinks = cell.querySelectorAll('a[href]');
|
||
cellLinks.forEach(link => {
|
||
const href = link.getAttribute('href');
|
||
if (href && !href.includes('#') && !href.includes('javascript:')) {
|
||
allCellsLinks.push({href: href, cellIndex: idx, text: norm(link)});
|
||
}
|
||
});
|
||
});
|
||
|
||
// Ищем только решения, определения, постановления, мотивированные решения
|
||
if (docType && (docType.match(/решение|определение|постановление/i))) {
|
||
// Ищем ссылку на файл во всей строке таблицы
|
||
let docLink = '';
|
||
|
||
// Если не нашли явную ссылку, но есть текст "Готовится к публикации" - значит файла пока нет
|
||
const docTextContent = docTextCell ? norm(docTextCell) : '';
|
||
const isPreparing = docTextContent && docTextContent.toLowerCase().includes('готовится к публикации');
|
||
|
||
// Сначала проверяем ссылки, найденные во всех ячейках
|
||
if (allCellsLinks.length > 0) {
|
||
// Берем первую подходящую ссылку
|
||
for (const linkInfo of allCellsLinks) {
|
||
const href = linkInfo.href;
|
||
const linkText = linkInfo.text || '';
|
||
const hrefLower = href.toLowerCase();
|
||
|
||
// Игнорируем навигационные ссылки
|
||
if (hrefLower.includes('javascript:') ||
|
||
linkText.toLowerCase().includes('вернуться') ||
|
||
linkText.toLowerCase().includes('назад') ||
|
||
linkText.toLowerCase().includes('следующ') ||
|
||
linkText.toLowerCase().includes('предыдущ')) {
|
||
continue;
|
||
}
|
||
|
||
// Принимаем ссылку если она похожа на документ
|
||
if (href.match(/\.(pdf|doc|docx|rtf|txt|html)$/i) ||
|
||
hrefLower.includes('/document/') ||
|
||
hrefLower.includes('/download/') ||
|
||
hrefLower.includes('/file/') ||
|
||
hrefLower.includes('/documents/') ||
|
||
hrefLower.includes('/files/') ||
|
||
linkText.match(/скачать|загрузить|открыть|просмотр|документ|файл/i) ||
|
||
(hrefLower.includes('mos-gorsud.ru') && !hrefLower.includes('details'))) {
|
||
docLink = href;
|
||
// Если ссылка относительная, делаем её абсолютной
|
||
if (docLink.startsWith('/')) {
|
||
docLink = window.location.origin + docLink;
|
||
} else if (docLink.startsWith('./') || (!docLink.startsWith('http') && !docLink.startsWith('mailto') && !docLink.startsWith('#'))) {
|
||
const baseUrl = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/'));
|
||
docLink = baseUrl + '/' + docLink.replace(/^\.\//, '');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не нашли в ячейках, ищем все ссылки в строке
|
||
if (!docLink) {
|
||
const allLinks = row.querySelectorAll('a[href]');
|
||
for (const link of allLinks) {
|
||
const href = link.getAttribute('href');
|
||
if (!href || href.trim() === '') continue;
|
||
|
||
// Проверяем, что это ссылка на документ (не навигация)
|
||
const linkText = norm(link) || '';
|
||
const hrefLower = href.toLowerCase();
|
||
|
||
// Игнорируем навигационные ссылки
|
||
if (hrefLower.includes('#') ||
|
||
hrefLower.includes('javascript:') ||
|
||
linkText.toLowerCase().includes('вернуться') ||
|
||
linkText.toLowerCase().includes('назад') ||
|
||
linkText.toLowerCase().includes('следующ') ||
|
||
linkText.toLowerCase().includes('предыдущ')) {
|
||
continue;
|
||
}
|
||
|
||
// Принимаем ссылку если:
|
||
// 1. Это файл с расширением
|
||
// 2. Содержит ключевые слова в пути
|
||
// 3. Содержит ключевые слова в тексте ссылки
|
||
// 4. Это любая ссылка в ячейке с документом (кроме навигации)
|
||
if (href.match(/\.(pdf|doc|docx|rtf|txt|html)$/i) ||
|
||
hrefLower.includes('/document/') ||
|
||
hrefLower.includes('/download/') ||
|
||
hrefLower.includes('/file/') ||
|
||
hrefLower.includes('/documents/') ||
|
||
hrefLower.includes('/files/') ||
|
||
linkText.match(/скачать|загрузить|открыть|просмотр|документ|файл/i) ||
|
||
(docTextCell && docTextCell.contains(link))) {
|
||
docLink = href;
|
||
// Если ссылка относительная, делаем её абсолютной
|
||
if (docLink.startsWith('/')) {
|
||
docLink = window.location.origin + docLink;
|
||
} else if (docLink.startsWith('./') || (!docLink.startsWith('http') && !docLink.startsWith('mailto') && !docLink.startsWith('#'))) {
|
||
const baseUrl = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/'));
|
||
docLink = baseUrl + '/' + docLink.replace(/^\.\//, '');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не нашли ссылку в <a>, проверяем data-атрибуты или onclick
|
||
if (!docLink && !isPreparing) {
|
||
// Проверяем data-атрибуты на ссылках
|
||
const linksWithData = row.querySelectorAll('a[data-href], a[data-url], [data-document-id], [data-file-id]');
|
||
for (const link of linksWithData) {
|
||
const dataHref = link.getAttribute('data-href') ||
|
||
link.getAttribute('data-url') ||
|
||
link.getAttribute('data-document-id') ||
|
||
link.getAttribute('data-file-id');
|
||
if (dataHref && !dataHref.includes('#')) {
|
||
docLink = dataHref;
|
||
if (docLink.startsWith('/')) {
|
||
docLink = window.location.origin + docLink;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Проверяем onclick для JavaScript-ссылок
|
||
if (!docLink) {
|
||
const linksWithOnclick = row.querySelectorAll('a[onclick]');
|
||
for (const link of linksWithOnclick) {
|
||
const onclick = link.getAttribute('onclick') || '';
|
||
// Ищем URL в onclick
|
||
const urlMatch = onclick.match(/['"](https?:\/\/[^'"]+|\.\/[^'"]+|\/[^'"]+)['"]/);
|
||
if (urlMatch && !urlMatch[1].includes('#')) {
|
||
docLink = urlMatch[1];
|
||
if (docLink.startsWith('/') || docLink.startsWith('./')) {
|
||
docLink = window.location.origin + (docLink.startsWith('./') ? docLink.substring(1) : docLink);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Определяем тип акта
|
||
let actType = 'Судебный акт';
|
||
if (docType.match(/МОТИВИРОВАННОЕ\s+РЕШЕНИЕ/i) || docType.match(/мотивированное\s+решение/i)) {
|
||
actType = 'Мотивированное решение';
|
||
} else if (docType.match(/ЗАОЧНОЕ\s+РЕШЕНИЕ/i)) {
|
||
actType = 'Заочное решение';
|
||
} else if (docType.match(/РЕШЕНИЕ/i)) {
|
||
actType = 'Решение';
|
||
} else if (docType.match(/ОПРЕДЕЛЕНИЕ/i)) {
|
||
actType = 'Определение';
|
||
} else if (docType.match(/ПОСТАНОВЛЕНИЕ/i)) {
|
||
actType = 'Постановление';
|
||
}
|
||
|
||
// Извлекаем номер дела из текста ячейки, если есть
|
||
const caseNumberMatch = docTextContent.match(/(\d{2}-\d+\/\d{4})/);
|
||
|
||
// Если всё ещё не нашли ссылку, но документ не готовится - возможно ссылка в другом формате
|
||
// Проверяем, может быть это кнопка или элемент с классом, указывающим на документ
|
||
if (!docLink && !isPreparing && docTextCell) {
|
||
// Ищем любые элементы с классами, связанными с документами
|
||
const docElements = docTextCell.querySelectorAll('[class*="doc"], [class*="file"], [class*="download"], [id*="doc"], [id*="file"]');
|
||
for (const el of docElements) {
|
||
const href = el.getAttribute('href') || el.getAttribute('data-href') || el.getAttribute('data-url');
|
||
if (href && !href.includes('#') && !href.includes('javascript:')) {
|
||
docLink = href;
|
||
if (docLink.startsWith('/')) {
|
||
docLink = window.location.origin + docLink;
|
||
} else if (!docLink.startsWith('http')) {
|
||
docLink = window.location.origin + '/' + docLink.replace(/^\.\//, '');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Добавляем акт только если есть ссылка или документ готовится к публикации
|
||
if (docLink || isPreparing) {
|
||
courtActs.push({
|
||
type: actType,
|
||
title: `${actType}${caseNumberMatch ? ' № ' + caseNumberMatch[1] : ''}${docDate ? ' от ' + docDate : ''}`,
|
||
text: '', // Текст не парсим, это файл - нужно скачивать отдельно
|
||
decision: null, // Резолютивная часть не парсим, это файл
|
||
date: docDate,
|
||
case_number: caseNumberMatch ? caseNumberMatch[1] : null,
|
||
uid: null,
|
||
link: docLink || '', // Ссылка на файл для скачивания
|
||
status: isPreparing ? 'preparing' : (docLink ? 'available' : 'unknown') // Статус документа
|
||
});
|
||
} else {
|
||
// Если нет ссылки и не готовится - всё равно добавляем, но со статусом unknown
|
||
// Может быть ссылка появится позже или находится в другом месте
|
||
courtActs.push({
|
||
type: actType,
|
||
title: `${actType}${caseNumberMatch ? ' № ' + caseNumberMatch[1] : ''}${docDate ? ' от ' + docDate : ''}`,
|
||
text: '',
|
||
decision: null,
|
||
date: docDate,
|
||
case_number: caseNumberMatch ? caseNumberMatch[1] : null,
|
||
uid: null,
|
||
link: '', // Ссылка не найдена
|
||
status: 'unknown'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Для московских судов не ищем текст актов на странице - они в файлах
|
||
// Если нужно будет парсить текст из файлов - это будет отдельная задача
|
||
|
||
// Дополнительный поиск ссылок на документы по всей странице
|
||
// Может быть ссылки находятся вне таблицы #tabs-3
|
||
if (courtActs.length > 0) {
|
||
// Ищем все ссылки на странице, которые могут быть связаны с документами
|
||
const allPageLinks = document.querySelectorAll('a[href]');
|
||
const documentLinks = [];
|
||
|
||
for (const link of allPageLinks) {
|
||
const href = link.getAttribute('href');
|
||
if (!href || href.includes('#') || href.includes('javascript:')) continue;
|
||
|
||
const linkText = norm(link);
|
||
const hrefLower = href.toLowerCase();
|
||
|
||
// Проверяем, похожа ли ссылка на документ
|
||
if (href.match(/\.(pdf|doc|docx|rtf|txt|html)$/i) ||
|
||
hrefLower.includes('/document/') ||
|
||
hrefLower.includes('/download/') ||
|
||
hrefLower.includes('/file/') ||
|
||
hrefLower.includes('/documents/') ||
|
||
hrefLower.includes('/files/') ||
|
||
(hrefLower.includes('mos-gorsud.ru') &&
|
||
(hrefLower.includes('/document') || hrefLower.includes('/file') || hrefLower.includes('/download')))) {
|
||
|
||
// Пытаемся сопоставить ссылку с документом по дате или номеру дела
|
||
const linkDateMatch = linkText.match(/(\d{2}\.\d{2}\.\d{4})/);
|
||
const linkCaseMatch = linkText.match(/(\d{2}-\d+\/\d{4})/);
|
||
|
||
documentLinks.push({
|
||
href: href,
|
||
text: linkText,
|
||
date: linkDateMatch ? linkDateMatch[1] : null,
|
||
caseNumber: linkCaseMatch ? linkCaseMatch[1] : null
|
||
});
|
||
}
|
||
}
|
||
|
||
// Пытаемся сопоставить найденные ссылки с документами по дате
|
||
documentLinks.forEach(docLink => {
|
||
if (!docLink.date) return;
|
||
|
||
courtActs.forEach(act => {
|
||
if (act.link || act.status === 'preparing') return; // Уже есть ссылка или готовится
|
||
|
||
// Если даты совпадают, добавляем ссылку
|
||
if (act.date === docLink.date) {
|
||
let finalLink = docLink.href;
|
||
if (finalLink.startsWith('/')) {
|
||
finalLink = window.location.origin + finalLink;
|
||
} else if (!finalLink.startsWith('http')) {
|
||
finalLink = window.location.origin + '/' + finalLink.replace(/^\.\//, '');
|
||
}
|
||
act.link = finalLink;
|
||
act.status = 'available';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
const sessionsTbody = document.querySelector("#tabs-2 table tbody");
|
||
const hearingsRows = sessionsTbody ? tableToRows(sessionsTbody) : [];
|
||
const hearings = hearingsRows.map((cols) => ({
|
||
datetime: cols[0] || null,
|
||
hall: cols[1] || null,
|
||
stage: cols[2] || null,
|
||
result: cols[3] || null,
|
||
basis: cols[4] || null,
|
||
}));
|
||
|
||
const stTbody = document.querySelector("#tabs-1 #state-history table tbody");
|
||
const stateRows = stTbody ? tableToRows(stTbody) : [];
|
||
const states = stateRows.map((cols) => ({
|
||
date: cols[0] || null,
|
||
state: cols[1] || null,
|
||
basis_doc: cols[2] || null,
|
||
}));
|
||
|
||
return {
|
||
caseInfo,
|
||
parties,
|
||
courtActs,
|
||
hearings,
|
||
history: { states },
|
||
};
|
||
});
|
||
|
||
// Извлекаем последнее событие (аналогично MoscowCourtParser)
|
||
let lastEvent = null;
|
||
|
||
// Проверяем заседания (hearings)
|
||
if (data.hearings && data.hearings.length > 0) {
|
||
const hearing = data.hearings[data.hearings.length - 1];
|
||
|
||
if (hearing.datetime) {
|
||
const datetime = hearing.datetime;
|
||
let event_date = '';
|
||
let event_time = '';
|
||
|
||
// Формат: "27.10.2025 09:30" или "27.10.2025"
|
||
const match1 = datetime.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})/);
|
||
if (match1) {
|
||
event_date = match1[1];
|
||
event_time = match1[2];
|
||
} else {
|
||
const match2 = datetime.match(/(\d{2}\.\d{2}\.\d{4})/);
|
||
if (match2) {
|
||
event_date = match2[1];
|
||
}
|
||
}
|
||
|
||
if (event_date) {
|
||
lastEvent = {
|
||
event_name: hearing.stage || 'Судебное заседание',
|
||
event_date: formatDate(event_date),
|
||
event_time: event_time,
|
||
location: hearing.hall || '',
|
||
event_result: hearing.result || '',
|
||
event_basis: hearing.basis || '',
|
||
note: '',
|
||
publication_date: formatDate(event_date),
|
||
// Для совместимости с parscourt.php
|
||
Наименование: hearing.stage || 'Судебное заседание',
|
||
Дата: formatDateDisplay(event_date),
|
||
Время: event_time,
|
||
Место: hearing.hall || '',
|
||
Результат: hearing.result || '',
|
||
Основание: hearing.basis || '',
|
||
Примечание: '',
|
||
'Дата размещения': formatDateDisplay(event_date)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если заседаний нет, проверяем историю состояний
|
||
if (!lastEvent && data.history?.states && data.history.states.length > 0) {
|
||
const state = data.history.states[data.history.states.length - 1];
|
||
|
||
if (state.date && state.state) {
|
||
lastEvent = {
|
||
event_name: state.state,
|
||
event_date: formatDate(state.date),
|
||
event_time: '',
|
||
location: '',
|
||
event_result: '',
|
||
event_basis: state.basis_doc || '',
|
||
note: '',
|
||
publication_date: formatDate(state.date),
|
||
// Для совместимости с parscourt.php
|
||
Наименование: state.state,
|
||
Дата: formatDateDisplay(state.date),
|
||
Время: '',
|
||
Место: '',
|
||
Результат: '',
|
||
Основание: state.basis_doc || '',
|
||
Примечание: '',
|
||
'Дата размещения': formatDateDisplay(state.date)
|
||
};
|
||
}
|
||
}
|
||
|
||
const caseInfoText = JSON.stringify(data.caseInfo || {}).toLowerCase();
|
||
const isSecondInstance = /апелляц|кассац|надзор|втор(ой|ая)\s+инстанц|апелляцион|кассацион/.test(caseInfoText);
|
||
const decisionFound = data.courtActs?.some(act => {
|
||
const type = (act?.type || act?.title || '').toLowerCase();
|
||
// Если есть любое решение
|
||
if (type.includes('решение')) return true;
|
||
// Если тип акта сам по себе апелляционный/кассационный — это 2-я инстанция
|
||
const isActSecondInstance = /апелляцион|кассацион/.test(type);
|
||
// Если дело 2-й инстанции (по case_info или по типу акта) и есть определение/постановление
|
||
if ((isSecondInstance || isActSecondInstance) && (type.includes('определение') || type.includes('постановление'))) return true;
|
||
return false;
|
||
});
|
||
|
||
// Формируем результат
|
||
const result = {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: 'moscow',
|
||
status: 'success',
|
||
case_info: data.caseInfo || {},
|
||
parties: data.parties || [],
|
||
court_acts: data.courtActs || [],
|
||
all_hearings: data.hearings || [],
|
||
all_states: data.history?.states || [],
|
||
decision_found: !!decisionFound
|
||
};
|
||
|
||
// Добавляем последнее событие для обратной совместимости
|
||
if (lastEvent) {
|
||
result.last_event = lastEvent;
|
||
} else {
|
||
result.last_event = null;
|
||
}
|
||
|
||
// Проверяем, есть ли хотя бы какие-то данные
|
||
const hasCaseInfo = data.caseInfo && Object.keys(data.caseInfo).length > 0;
|
||
const hasParties = data.parties && data.parties.length > 0;
|
||
const hasCourtActs = data.courtActs && data.courtActs.length > 0;
|
||
const hasHearings = data.hearings && data.hearings.length > 0;
|
||
const hasStates = data.history?.states && data.history.states.length > 0;
|
||
|
||
// Если нет вообще никаких данных - возвращаем ошибку
|
||
if (!hasCaseInfo && !hasParties && !hasCourtActs && !hasHearings && !hasStates) {
|
||
const pageText = await page.evaluate(() => document.body?.textContent || '');
|
||
const isEmpty = !pageText || pageText.trim().length < 100;
|
||
|
||
return {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: 'moscow',
|
||
status: isEmpty ? 'error' : 'no_data',
|
||
error_type: isEmpty ? 'empty_page' : 'no_data_found',
|
||
error_message: isEmpty ? 'Страница пустая или битая ссылка' : 'Данные не найдены',
|
||
last_event: null,
|
||
message: isEmpty ? 'Ссылка на дело оказалась битой' : 'Данные не найдены',
|
||
case_info: {},
|
||
parties: [],
|
||
court_acts: [],
|
||
all_hearings: [],
|
||
all_states: [],
|
||
decision_found: false
|
||
};
|
||
}
|
||
|
||
// Если есть хотя бы какие-то данные - возвращаем успех
|
||
return result;
|
||
}
|
||
|
||
// Если тип суда не определён
|
||
return {
|
||
url,
|
||
source: new URL(url).hostname,
|
||
court_type: 'unknown',
|
||
status: 'error',
|
||
error_type: 'unknown_court',
|
||
error_message: `Неизвестный тип суда для URL: ${url}`,
|
||
last_event: null,
|
||
message: 'Неизвестный тип суда'
|
||
};
|
||
}
|