Files
crm.clientright.ru/parsers/MoscowCourtParser.php
Fedor a6747b1dca fix: Улучшена защита от дубликатов уведомлений и событий
- Изменена логика проверки уведомлений: теперь проверяются ВСЕ уведомления (не только непрочитанные)
- Если уведомление прочитано - дубликат НЕ создаётся (ранее создавался)
- Добавлена проверка статуса уведомления перед обновлением
- Добавлены уведомления для RegionalCourtParser (ранее только для MoscowCourtParser)
- Создана документация DUPLICATE_PREVENTION_GUIDE.md с описанием 3 уровней защиты

Теперь система полностью защищена от дубликатов:
1. Уровень событий в таблице subject
2. Уровень уведомлений в vtiger_vdnotifierpro (с проверкой статуса)
3. Уровень календаря CRM

Для продакшена: НЕ передавать skip_duplicate_check=true (по умолчанию false)
2025-10-17 19:53:05 +03:00

553 lines
24 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, status FROM vtiger_vdnotifierpro
WHERE userid = ? AND crmid = ? AND title = ?
ORDER BY id DESC LIMIT 1";
$checkStmt = $crmPdo->prepare($checkQuery);
$checkStmt->execute([$userId, $projectId, $notificationTitle]);
$existing = $checkStmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
// Если уведомление уже есть - проверяем его статус
if ($existing['status'] == 5) {
// Уведомление непрочитанное - обновляем только время
$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 {
// Уведомление прочитано - не создаём дубликат
$this->log("Уведомление ID: {$existing['id']} уже существует (статус: {$existing['status']}), дубликат не создан");
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;
}
}
?>