Files
crm.clientright.ru/parsers/MoscowCourtParser.php
Fedor 3db9d06c86 feat: Интеграция уведомлений для событий судов
- Добавлена поддержка project_id в parscourt.php для создания уведомлений
- Создана система парсеров судов (BaseCourtParser, MoscowCourtParser, RegionalCourtParser)
- Интегрирован Browserless для парсинга московских судов (mos-sud.ru, mos-gorsud.ru)
- Добавлены уведомления VDNotifierPro при обнаружении новых событий судов
- Создан ParseAndCreateEvent.php для интеграции с CRM workflow
- Создан CreateCourtEvent_v2.php для прямого создания событий в календаре CRM
- Поддержка проверки дубликатов событий (можно отключить для тестирования)
- Автоматическое определение типа суда и выбор подходящего парсера

Функции:
- Парсинг региональных судов (*.sudrf.ru) через HTML
- Парсинг московских судов через Browserless API
- Создание событий в CRM календаре с привязкой к проектам
- Уведомления ответственных пользователей о новых событиях
- Сохранение событий в таблицу subject для истории
2025-10-17 19:45:11 +03:00

543 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
require_once 'BaseCourtParser.php';
/**
* Парсер для московских судов (mos-gorsud.ru)
*/
class MoscowCourtParser extends BaseCourtParser {
public function canHandle($url) {
// Московские суды имеют домены mos-gorsud.ru и mos-sud.ru
return preg_match('/mos-(gorsud|sud)\.ru/', $url);
}
public function parse($url, $status) {
$this->log("Старт парсинга {$this->case_number} для статуса: $status (МОСКОВСКИЙ СУД)");
$this->log("Парсим данные из ссылки: $url");
// Используем Browserless для получения структурированных данных
$data = $this->loadPageContentViaBrowserless($url);
if ($data === false) {
$this->log("Ошибка: не удалось получить данные через Browserless");
return null;
}
$this->log("Данные успешно получены через Browserless. Начинаем обработку...");
// Извлекаем последнее событие из структурированных данных
$last_event = $this->extractLastEventFromData($data);
if ($last_event === null) {
$this->log("ВНИМАНИЕ: Не удалось извлечь события из данных Browserless.");
}
return $last_event;
}
/**
* Извлекает последнее событие из структурированных данных Browserless
*/
private function extractLastEventFromData($data) {
$last_event = null;
// Проверяем наличие заседаний (hearings)
if (isset($data['hearings']) && is_array($data['hearings']) && !empty($data['hearings'])) {
$this->log("Найдено заседаний: " . count($data['hearings']));
// Берем последнее заседание
$hearing = end($data['hearings']);
if (!empty($hearing['datetime'])) {
// Парсим дату и время
$datetime = $hearing['datetime'];
$event_date = '';
$event_time = '';
// Формат: "27.10.2025 09:30" или "27.10.2025"
if (preg_match('/(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})/', $datetime, $matches)) {
$event_date = $matches[1];
$event_time = $matches[2];
} elseif (preg_match('/(\d{2}\.\d{2}\.\d{4})/', $datetime, $matches)) {
$event_date = $matches[1];
$event_time = '';
}
if (!empty($event_date)) {
$event_name = $hearing['stage'] ?? 'Судебное заседание';
$event_result = $hearing['result'] ?? '';
$location = $hearing['hall'] ?? '';
$basis = $hearing['basis'] ?? '';
$this->log("Найдено заседание: $event_name на $event_date в $event_time");
// Форматируем дату для БД
$formatted_date = date('Y-m-d', strtotime(str_replace('.', '-', $event_date)));
$eventData = [
'event_name' => $event_name,
'event_date' => $formatted_date,
'event_time' => $event_time,
'location' => $location,
'event_result' => $event_result,
'event_basis' => $basis,
'note' => '',
'publication_date' => $formatted_date,
];
// Сохраняем событие в БД
$this->saveEvent($eventData);
$last_event = $eventData;
// Создаём уведомление (если указан project_id)
if ($this->project_id) {
$notificationId = $this->createCourtEventNotification($this->project_id, $eventData);
if ($notificationId) {
$this->log("Создано уведомление ID: $notificationId для события: $event_name");
}
}
}
}
}
// Если заседаний нет, проверяем историю состояний
if ($last_event === null && isset($data['history']['states']) && is_array($data['history']['states'])) {
$this->log("Заседаний нет, проверяем историю состояний: " . count($data['history']['states']));
// Берем последнее состояние
$state = end($data['history']['states']);
if (!empty($state['date']) && !empty($state['state'])) {
$event_date = $state['date'];
$event_name = $state['state'];
$basis = $state['basis_doc'] ?? '';
$this->log("Найдено состояние: $event_name на $event_date");
// Форматируем дату для БД
$formatted_date = date('Y-m-d', strtotime(str_replace('.', '-', $event_date)));
$eventData = [
'event_name' => $event_name,
'event_date' => $formatted_date,
'event_time' => '',
'location' => '',
'event_result' => '',
'event_basis' => $basis,
'note' => '',
'publication_date' => $formatted_date,
];
// Сохраняем событие в БД
$this->saveEvent($eventData);
$last_event = $eventData;
// Создаём уведомление (если указан project_id)
if ($this->project_id) {
$notificationId = $this->createCourtEventNotification($this->project_id, $eventData);
if ($notificationId) {
$this->log("Создано уведомление ID: $notificationId для состояния: $event_name");
}
}
}
}
// Логируем информацию о деле для отладки
if (isset($data['case'])) {
$case = $data['case'];
$this->log("Информация о деле:");
$this->log(" UID: " . ($case['uid'] ?? 'не указан'));
$this->log(" Номер дела: " . ($case['case_number'] ?? 'не указан'));
$this->log(" Статус: " . ($case['current_status'] ?? 'не указан'));
$this->log(" Истец: " . ($case['plaintiff'] ?? 'не указан'));
$this->log(" Ответчик: " . ($case['defendant'] ?? 'не указан'));
}
return $last_event;
}
/**
* Создаёт уведомление о новом событии суда
*/
private function createCourtEventNotification($projectId, $eventData) {
try {
// Создаём отдельное соединение с основной БД CRM для уведомлений
$crmPdo = new PDO('mysql:host=localhost;dbname=ci20465_72new;charset=utf8mb4', 'ci20465_72new', 'EcY979Rn');
$crmPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Получаем ответственного по проекту
$query = "SELECT e.smownerid, p.projectname FROM vtiger_crmentity e
JOIN vtiger_project p ON p.projectid = e.crmid
WHERE e.crmid = ? AND e.deleted = 0";
$stmt = $crmPdo->prepare($query);
$stmt->execute([$projectId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$result) {
$this->log("Проект $projectId не найден для уведомления");
return false;
}
$userId = $result['smownerid'];
$projectName = $result['projectname'];
$this->log("Создаем уведомление для пользователя $userId о событии в проекте $projectName");
// Формируем текст уведомления
$eventName = $eventData['event_name'];
$eventDate = $eventData['event_date'];
$eventTime = $eventData['event_time'];
$timeStr = !empty($eventTime) ? " в $eventTime" : "";
$notificationTitle = "Событие суда: $eventName на $eventDate$timeStr";
// Формируем ссылку на проект
$projectLink = "module=Project&view=Detail&record=$projectId";
// Проверяем, нет ли уже непрочитанного уведомления для этого события
$checkQuery = "SELECT id FROM vtiger_vdnotifierpro WHERE userid = ? AND crmid = ? AND title LIKE ? AND status = 5";
$checkStmt = $crmPdo->prepare($checkQuery);
$checkStmt->execute([$userId, $projectId, "%$eventName%$eventDate%"]);
$existing = $checkStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
// Обновляем время существующего уведомления
$updateQuery = "UPDATE vtiger_vdnotifierpro SET modifiedtime = NOW() WHERE id = ?";
$updateStmt = $crmPdo->prepare($updateQuery);
$updateStmt->execute([$existing['id']]);
$this->log("Обновлено существующее уведомление ID: {$existing['id']}");
return $existing['id'];
} else {
// Создаем новое уведомление
$insertQuery = "INSERT INTO vtiger_vdnotifierpro (userid, modulename, crmid, modiuserid, link, title, action, modifiedtime, status) VALUES (?, 'Project', ?, 0, ?, ?, '', NOW(), 5)";
$insertStmt = $crmPdo->prepare($insertQuery);
$insertStmt->execute([$userId, $projectId, $projectLink, $notificationTitle]);
$notificationId = $crmPdo->lastInsertId();
$this->log("Создано новое уведомление ID: $notificationId для пользователя $userId");
return $notificationId;
}
} catch (Exception $e) {
$this->log("Ошибка создания уведомления: " . $e->getMessage());
return false;
}
}
/**
* Загружает содержимое страницы через cURL
*/
private function loadPageContent($url) {
// Сначала пробуем обычный cURL
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_ENCODING, ''); // Автоматически обрабатывает gzip, deflate, br
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language: ru-RU,ru;q=0.9,en;q=0.8',
'Connection: keep-alive',
'Upgrade-Insecure-Requests: 1',
'Sec-Fetch-Dest: document',
'Sec-Fetch-Mode: navigate',
'Sec-Fetch-Site: none',
'Cache-Control: max-age=0'
]);
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Если cURL успешен, возвращаем результат
if ($html !== false && $httpCode === 200) {
$this->log("Страница загружена через cURL (HTTP $httpCode)");
return $html;
}
$this->log("cURL не удался (HTTP $httpCode), пробуем Browserless...");
// Если cURL не сработал, пробуем Browserless
return $this->loadPageContentViaBrowserless($url);
}
/**
* Загружает структурированные данные через Browserless function
*/
private function loadPageContentViaBrowserless($url) {
$browserlessUrl = 'http://147.45.146.17:3000/function';
$browserlessToken = '9ahhnpjkchxtcho9';
// JavaScript код функции (тот же, что мы тестировали)
$jsFunction = '
export default async function ({ page, context }) {
const caseUrl =
context.case_url ||
"' . addslashes($url) . '";
// --- Установка заголовков и поведения браузера ---
await page.setViewport({ width: 1920, height: 1080 });
await page.setExtraHTTPHeaders({
"Referer": "https://mos-sud.ru/",
"Origin": "https://mos-sud.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(caseUrl, { waitUntil: "networkidle2", timeout: 60000 });
// закрыть баннеры 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 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) => (el ? 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 uid = byLeft("Уникальный идентификатор дела") || byLeft("UID");
const numberRaw = byLeft("Номер дела") || byLeft("№ дела") || byLeft("Номер дела ~ материала");
let case_number = null, material_number = null;
if (numberRaw) {
if (numberRaw.includes("")) {
const parts = numberRaw.split("").map((s) => s.trim()).filter(Boolean);
case_number = parts[0] || null;
material_number = parts[1] || null;
} else {
case_number = numberRaw;
}
}
const intake_date = byLeft("Дата поступления") || byLeft("Поступило");
const partiesRaw = byLeft("Стороны") || byLeft("Участники") || "";
let plaintiff = null, defendant = null;
if (partiesRaw) {
const m1 = partiesRaw.match(/Истец:\\s*([^<\\n]+)/i);
const m2 = partiesRaw.match(/Ответчик:\\s*([^<\\n]+)/i);
plaintiff = m1 ? m1[1].trim() : null;
defendant = m2 ? m2[1].trim() : null;
}
const judge = byLeft("Судья") || byLeft("Cудья") || byLeft("Председательствующий судья");
const category = byLeft("Категория дела") || byLeft("Категория");
const statusRaw = byLeft("Текущее состояние") || byLeft("Состояние");
let current_status = null, current_status_date = null;
if (statusRaw) {
const m = statusRaw.match(/^(.+?),\\s*([\\d.]{10})$/);
current_status = m ? m[1].trim() : statusRaw;
current_status_date = m ? m[2] : null;
}
const first_instance_date =
byLeft("Дата рассмотрения дела в первой инстанции") || byLeft("Дата рассмотрения (1 инстанция)");
const first_inst_decision_raw =
byLeft("Решение первой инстанции") || byLeft("Решение (1 инстанция)");
let first_instance_decision = null, first_instance_decision_date = null;
if (first_inst_decision_raw) {
const m = first_inst_decision_raw.match(/^(.+?),\\s*([\\d.]{10})$/);
first_instance_decision = m ? m[1].trim() : first_inst_decision_raw;
first_instance_decision_date = m ? m[2] : null;
}
// таблицы
function tableToRows(tbody) {
return Array.from(tbody.querySelectorAll("tr")).map((tr) => {
const tds = tr.querySelectorAll("td");
return Array.from(tds).map((td) => norm(td.querySelector("div") || td));
});
}
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,
}));
const tab1Tbodies = document.querySelectorAll("#tabs-1 table tbody");
const placeTbody = tab1Tbodies.length > 1 ? tab1Tbodies[1] : null;
const locationRows = placeTbody ? tableToRows(placeTbody) : [];
const locations = locationRows.map((cols) => ({
date: cols[0] || null,
location: cols[1] || null,
comment: cols[2] || null,
}));
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,
av_record: cols[5] || null,
type: cols[6] || null,
}));
const docsTbody = document.querySelector("#tabs-3 table tbody");
const docsRows = docsTbody ? tableToRows(docsTbody) : [];
const documents = docsRows.map((cols) => ({
date: cols[0] || null,
kind: cols[1] || null,
text_status: cols[2] || null,
}));
const court =
(document.querySelector(".court-name")?.textContent || "").trim() ||
(document.querySelector(\'[class*="court"] [class*="name"]\')?.textContent || "").trim() ||
(document.querySelector("title")?.textContent.match(/суд[^|]*/i)?.[0] || "").trim() ||
null;
const title = (document.querySelector("h1")?.textContent || "")
.replace(/\\s+/g, " ")
.trim();
return {
case: {
uid,
case_number,
material_number,
intake_date,
plaintiff,
defendant,
judge,
category,
current_status,
current_status_date,
first_instance_date,
first_instance_decision,
first_instance_decision_date,
},
history: { states, locations },
hearings,
documents,
meta: { court, title },
};
});
return { source_url: caseUrl, ...data };
}';
// Подготавливаем данные для отправки
$postData = [
'code' => $jsFunction,
'context' => [
'case_url' => $url
]
];
$this->log("Отправляем запрос в Browserless для URL: $url");
// Отправляем запрос в Browserless
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $browserlessUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 120); // Увеличиваем таймаут
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $browserlessToken
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$this->log("Browserless ответ: HTTP $httpCode, длина ответа: " . strlen($response));
if ($error) {
$this->log("cURL ошибка: $error");
return false;
}
if ($httpCode !== 200) {
$this->log("Browserless вернул HTTP $httpCode: " . substr($response, 0, 200));
return false;
}
// Парсим ответ
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->log("Ошибка декодирования JSON: " . json_last_error_msg());
$this->log("Ответ: " . substr($response, 0, 500));
return false;
}
$this->log("Данные успешно получены от Browserless");
return $data;
}
}
?>