Госзакупки: парсер PHP
Промпт
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
class ZakupkiParser {
private $curl;
private $cookies;
private $cache = [];
private $regionMapping = [
// Федеральные города
'77' => 'Москва',
'78' => 'Санкт-Петербург',
'92' => 'Севастополь',
// Области
'29' => 'Архангельская область',
'30' => 'Астраханская область',
'31' => 'Белгородская область',
'32' => 'Брянская область',
'33' => 'Владимирская область',
'34' => 'Волгоградская область',
'35' => 'Вологодская область',
'36' => 'Воронежская область',
'37' => 'Ивановская область',
'38' => 'Иркутская область',
'39' => 'Калининградская область',
'40' => 'Калужская область',
'42' => 'Кемеровская область',
'43' => 'Кировская область',
'44' => 'Костромская область',
'45' => 'Курганская область',
'46' => 'Курская область',
'47' => 'Ленинградская область',
'48' => 'Липецкая область',
'49' => 'Магаданская область',
'50' => 'Московская область',
'51' => 'Мурманская область',
'52' => 'Нижегородская область',
'53' => 'Новгородская область',
'54' => 'Новосибирская область',
'55' => 'Омская область',
'56' => 'Оренбургская область',
'57' => 'Орловская область',
'58' => 'Пензенская область',
'60' => 'Псковская область',
'61' => 'Ростовская область',
'62' => 'Рязанская область',
'63' => 'Самарская область',
'64' => 'Саратовская область',
'65' => 'Сахалинская область',
'66' => 'Свердловская область',
'67' => 'Смоленская область',
'68' => 'Тамбовская область',
'69' => 'Тверская область',
'70' => 'Томская область',
'71' => 'Тульская область',
'72' => 'Тюменская область',
'73' => 'Ульяновская область',
'74' => 'Челябинская область',
'75' => 'Забайкальский край',
'76' => 'Ярославская область',
// Республики
'01' => 'Республика Адыгея',
'02' => 'Республика Башкортостан',
'03' => 'Республика Бурятия',
'04' => 'Республика Алтай',
'05' => 'Республика Дагестан',
'06' => 'Республика Ингушетия',
'07' => 'Кабардино-Балкарская Республика',
'08' => 'Республика Калмыкия',
'09' => 'Карачаево-Черкесская Республика',
'10' => 'Республика Карелия',
'11' => 'Республика Коми',
'12' => 'Республика Марий Эл',
'13' => 'Республика Мордовия',
'14' => 'Республика Саха (Якутия)',
'15' => 'Республика Северная Осетия — Алания',
'16' => 'Республика Татарстан',
'17' => 'Республика Тыва',
'18' => 'Удмуртская Республика',
'19' => 'Республика Хакасия',
'20' => 'Чеченская Республика',
'21' => 'Чувашская Республика',
'91' => 'Республика Крым',
// Края
'22' => 'Алтайский край',
'23' => 'Краснодарский край',
'24' => 'Красноярский край',
'25' => 'Приморский край',
'26' => 'Ставропольский край',
'27' => 'Хабаровский край',
'41' => 'Камчатский край',
'59' => 'Пермский край',
// Автономные округа
'79' => 'Еврейская автономная область',
'83' => 'Ненецкий автономный округ',
'86' => 'Ханты-Мансийский автономный округ',
'87' => 'Чукотский автономный округ',
'89' => 'Ямало-Ненецкий автономный округ'
];
// Добавить массив городов для улучшенного поиска
private $cityMapping = [
// Москва и МО
'москва' => '77',
'московская' => '50',
'подольск' => '50',
'химки' => '50',
'королев' => '50',
'мытищи' => '50',
'люберцы' => '50',
'красногорск' => '50',
'электросталь' => '50',
'коломна' => '50',
'одинцово' => '50',
'серпухов' => '50',
// Санкт-Петербург и ЛО
'петербург' => '78',
'спб' => '78',
'ленинградская' => '47',
'гатчина' => '47',
'выборг' => '47',
'тосно' => '47',
'кингисепп' => '47',
// Крупные города
'новосибирск' => '54',
'екатеринбург' => '66',
'казань' => '16',
'нижний новгород' => '52',
'челябинск' => '74',
'самара' => '63',
'омск' => '55',
'ростов-на-дону' => '61',
'ростов' => '61',
'уфа' => '02',
'красноярск' => '24',
'воронеж' => '36',
'пермь' => '59',
'волгоград' => '34',
'краснодар' => '23',
'саратов' => '64',
'тюмень' => '72',
'тольятти' => '63',
'ижевск' => '18',
'барнаул' => '22',
'ульяновск' => '73',
'иркутск' => '38',
'хабаровск' => '27',
'ярославль' => '76',
'владивосток' => '25',
'махачкала' => '05',
'томск' => '70',
'оренбург' => '56',
'кемерово' => '42',
'новокузнецк' => '42',
'рязань' => '62',
'астрахань' => '30',
'пенза' => '58',
'липецк' => '48',
'тула' => '71',
'киров' => '43',
'чебоксары' => '21',
'калининград' => '39',
'брянск' => '32',
'курск' => '46',
'иваново' => '37',
'магнитогорск' => '74',
'тверь' => '69',
'ставрополь' => '26',
'белгород' => '31',
'сочи' => '23',
'нижний тагил' => '66',
'архангельск' => '29',
'владимир' => '33',
'калуга' => '40',
'смоленск' => '67',
'волжский' => '34',
'курган' => '45',
'орел' => '57',
'череповец' => '35',
'вологда' => '35',
'мурманск' => '51',
'тамбов' => '68',
'стерлитамак' => '02',
'нижневартовск' => '86',
'кострома' => '44',
'новороссийск' => '23',
'йошкар-ола' => '12',
'таганрог' => '61',
'комсомольск-на-амуре' => '27',
'сыктывкар' => '11',
'нижнекамск' => '16',
'дзержинск' => '52',
'орск' => '56',
'ангарск' => '38',
'балаково' => '64',
'благовещенск' => '28',
'прокопьевск' => '42',
'псков' => '60',
'бийск' => '22',
'энгельс' => '64',
'рыбинск' => '76',
'балашиха' => '50',
'северодвинск' => '29',
'армавир' => '23',
'подольск' => '50',
'королев' => '50',
'петрозаводск' => '10',
'железнодорожный' => '50',
'видное' => '50',
'ковров' => '33',
'электросталь' => '50',
'миасс' => '74',
'первоуральск' => '66',
'копейск' => '74',
'коломна' => '50',
'химки' => '50',
'серпухов' => '50',
'новочеркасск' => '61',
'батайск' => '61',
'красногорск' => '50',
'мытищи' => '50',
'камышин' => '34',
'новошахтинск' => '61',
'октябрьский' => '02',
'ачинск' => '24',
'первомайский' => '27',
'елец' => '48',
'междуреченск' => '42',
'нефтекамск' => '02',
'димитровград' => '73',
'кисловодск' => '26',
'ессентуки' => '26',
'пятигорск' => '26',
'невинномысск' => '26',
'черкесск' => '09',
'нальчик' => '07',
'владикавказ' => '15',
'грозный' => '20',
'элиста' => '08',
'майкоп' => '01',
'горно-алтайск' => '04',
'абакан' => '19',
'кызыл' => '17',
'улан-удэ' => '03',
'чита' => '75',
'якутск' => '14',
'магадан' => '49',
'южно-сахалинск' => '65',
'петропавловск-камчатский' => '41',
'анадырь' => '87',
'салехард' => '89',
'ханты-мансийск' => '86',
'нарьян-мар' => '83',
'биробиджан' => '79',
'симферополь' => '91',
'севастополь' => '92'
];
public function __construct() {
$this->cookies = tempnam(sys_get_temp_dir(), 'zakupki_');
$this->curl = curl_init();
curl_setopt_array($this->curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_ENCODING => 'gzip, deflate',
CURLOPT_COOKIEJAR => $this->cookies,
CURLOPT_COOKIEFILE => $this->cookies
]);
}
private function fetch($url, $post = false, $data = null) {
curl_setopt($this->curl, CURLOPT_URL, $url);
curl_setopt($this->curl, CURLOPT_POST, $post);
if ($post && $data) {
curl_setopt($this->curl, CURLOPT_POSTFIELDS, $data);
}
$response = curl_exec($this->curl);
if (curl_errno($this->curl)) {
throw new Exception('CURL error: ' . curl_error($this->curl));
}
$httpCode = curl_getinfo($this->curl, CURLINFO_HTTP_CODE);
if ($httpCode !== 200) {
throw new Exception("HTTP error {$httpCode} for URL: {$url}");
}
return $response;
}
// Извлечение ID тендера из различных источников
private function extractTenderId($block, $xpath) {
$tenderId = '';
// Попытка 1: из ссылки на номер тендера
$linkNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a/@href", $block);
if ($linkNode->length) {
$href = $linkNode->item(0)->nodeValue;
if (preg_match('/regNumber=([^&]+)/', $href, $matches)) {
$tenderId = $matches[1];
}
}
// Попытка 2: из атрибута onclick кнопки "Подробнее"
if (empty($tenderId)) {
$buttonNode = $xpath->query(".//button[contains(@onclick, 'showTenderDetails')]", $block);
if ($buttonNode->length) {
$onclick = $buttonNode->item(0)->getAttribute('onclick');
if (preg_match("/showTenderDetails$$'([^']+)'$$/", $onclick, $matches)) {
$tenderId = $matches[1];
}
}
}
// Попытка 3: из data-атрибутов
if (empty($tenderId)) {
$dataNode = $xpath->query(".//*[@data-tender-id]", $block);
if ($dataNode->length) {
$tenderId = $dataNode->item(0)->getAttribute('data-tender-id');
}
}
return $tenderId;
}
// Определение региона из адреса или других данных
private function extractRegion($block, $xpath) {
$region = '';
// Поиск региона в адресе заказчика
$addressNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-href')]/following-sibling::div", $block);
if ($addressNode->length) {
$address = mb_strtolower(trim($addressNode->item(0)->nodeValue), 'UTF-8');
// Сначала ищем по точным названиям регионов
foreach ($this->regionMapping as $code => $name) {
if (stripos($address, mb_strtolower($name, 'UTF-8')) !== false) {
$region = $code;
break;
}
}
// Если не нашли, ищем по городам
if (empty($region)) {
foreach ($this->cityMapping as $city => $code) {
if (stripos($address, $city) !== false) {
$region = $code;
break;
}
}
}
}
// Поиск в других местах
if (empty($region)) {
$regionNode = $xpath->query(".//span[contains(@class, 'region-info')]", $block);
if ($regionNode->length) {
$regionText = mb_strtolower(trim($regionNode->item(0)->nodeValue), 'UTF-8');
foreach ($this->regionMapping as $code => $name) {
if (stripos($regionText, mb_strtolower($name, 'UTF-8')) !== false) {
$region = $code;
break;
}
}
}
}
return $region;
}
// Основной метод поиска с поддержкой регионов
public function searchTenders($params = []) {
$cacheKey = md5(serialize($params));
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
// Параметры поиска
$searchParams = [
'morphology' => 'on',
'search-filter' => 'Дате+размещения',
'pageNumber' => $params['page'] ?? 1,
'sortDirection' => 'false',
'recordsPerPage' => '_' . ($params['size'] ?? 50),
'showLotsInfoHidden' => 'false',
'sortBy' => 'UPDATE_DATE',
'fz44' => 'on',
'fz223' => 'on',
'af' => 'on',
'ca' => 'on',
'pc' => 'on',
'pa' => 'on',
'currencyIdGeneral' => '-1'
];
// Добавляем параметры поиска
if (!empty($params['search_text'])) {
$searchParams['searchString'] = $params['search_text'];
}
if (!empty($params['inn'])) {
$searchParams['customerId'] = $params['inn'];
}
if (!empty($params['price_min'])) {
$searchParams['priceFrom'] = $params['price_min'];
}
if (!empty($params['price_max'])) {
$searchParams['priceTo'] = $params['price_max'];
}
if (!empty($params['date_from'])) {
$searchParams['publishDateFrom'] = $params['date_from'];
}
if (!empty($params['date_to'])) {
$searchParams['publishDateTo'] = $params['date_to'];
}
// Добавляем фильтр по регионам
if (!empty($params['regions']) && is_array($params['regions'])) {
foreach ($params['regions'] as $region) {
$searchParams["selectedSubjectsIdNameHidden"] = $region;
}
}
// Добавляем поиск по городу/региону
if (!empty($params['city_search'])) {
$searchParams['searchString'] = $params['city_search'];
}
$url = 'https://zakupki.gov.ru/epz/order/extendedsearch/results.html?' . http_build_query($searchParams);
$html = $this->fetch($url);
$result = $this->parseTenderList($html, $params);
// Кэшируем результат на 5 минут
$this->cache[$cacheKey] = $result;
return $result;
}
// Улучшенный парсинг списка тендеров
private function parseTenderList($html, $params = []) {
$dom = new DOMDocument();
@$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xpath = new DOMXPath($dom);
$tenders = [];
$blocks = $xpath->query("//div[contains(@class, 'registry-entry__form')]");
$selectedRegions = $params['regions'] ?? [];
foreach ($blocks as $block) {
$tender = [];
// Улучшенное извлечение ID тендера
$tender['tenderId'] = $this->extractTenderId($block, $xpath);
$tender['region'] = $this->extractRegion($block, $xpath);
// Фильтрация по регионам на стороне клиента (дополнительная проверка)
if (!empty($selectedRegions) && !empty($tender['region']) && !in_array($tender['region'], $selectedRegions)) {
continue;
}
// Номер тендера
$numberNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a", $block);
$tender['purchaseNumber'] = $numberNode->length ? trim($numberNode->item(0)->nodeValue) : '';
// Название тендера
$titleNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-value')]", $block);
$tender['purchaseObjectInfo'] = $titleNode->length ? trim($titleNode->item(0)->nodeValue) : '';
// Заказчик
$customerNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-href')]/a", $block);
$tender['customer'] = ['fullName' => $customerNode->length ? trim($customerNode->item(0)->nodeValue) : ''];
// Цена
$priceNode = $xpath->query(".//div[contains(@class, 'price-block__value')]", $block);
$price = $priceNode->length ? trim($priceNode->item(0)->nodeValue) : '';
$tender['lot'] = ['price' => preg_replace('/[^\d,.]/', '', $price)];
// Даты
$dateNodes = $xpath->query(".//div[contains(@class, 'data-block__value')]", $block);
$tender['publishDate'] = $dateNodes->length > 0 ? trim($dateNodes->item(0)->nodeValue) : '';
$tender['updateDate'] = $dateNodes->length > 1 ? trim($dateNodes->item(1)->nodeValue) : '';
$tender['submissionCloseDateTime'] = $dateNodes->length > 2 ? trim($dateNodes->item(2)->nodeValue) : '';
// Регистрационный номер (из ссылки)
$linkNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a/@href", $block);
if ($linkNode->length) {
$href = $linkNode->item(0)->nodeValue;
if (preg_match('/regNumber=([^&]+)/', $href, $matches)) {
$tender['regNumber'] = $matches[1];
}
}
// Используем tenderId как основной идентификатор
if (empty($tender['regNumber']) && !empty($tender['tenderId'])) {
$tender['regNumber'] = $tender['tenderId'];
}
$tenders[] = $tender;
}
// Пагинация
$totalItems = 0;
$paginationNode = $xpath->query("//div[contains(@class, 'paginator-block')]//span");
if ($paginationNode->length) {
$text = $paginationNode->item(0)->textContent;
if (preg_match('/из (\d+)/', $text, $matches)) {
$totalItems = (int)$matches[1];
}
}
return [
'content' => $tenders,
'totalElements' => $totalItems,
'regions' => $this->regionMapping
];
}
// Получение деталей с кэшированием
public function getTenderDetails($regNumber) {
$cacheKey = "details_" . $regNumber;
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
$url = "https://zakupki.gov.ru/epz/order/notice/ea20/view/common-info.html?regNumber={$regNumber}";
$html = $this->fetch($url);
$result = $this->parseTenderDetails($html, $regNumber);
// Кэшируем детали на 10 минут
$this->cache[$cacheKey] = $result;
return $result;
}
// Полный парсинг всех деталей тендера
private function parseTenderDetails($html, $regNumber) {
$dom = new DOMDocument();
@$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xpath = new DOMXPath($dom);
$details = [
'regNumber' => $regNumber,
'generalInfo' => [],
'contactInfo' => [],
'procedureInfo' => [],
'priceInfo' => [],
'objectInfo' => [],
'requirements' => [],
'customerRequirements' => [],
'financialInfo' => [],
'applicationSecurity' => [],
'contractConditions' => [],
'contractSecurity' => [],
'bankingSupport' => [],
'documents' => [],
'lots' => [],
'region' => '',
'regionName' => ''
];
// 1. ОСНОВНАЯ ИНФОРМАЦИЯ ТЕНДЕРА
$this->parseMainInfo($xpath, $details);
// 2. ОБЩАЯ ИНФОРМАЦИЯ О ЗАКУПКЕ
$this->parseGeneralInfo($xpath, $details);
// 3. КОНТАКТНАЯ ИНФОРМАЦИЯ
$this->parseContactInfo($xpath, $details);
// 4. ИНФОРМАЦИЯ О ПРОЦЕДУРЕ ЗАКУПКИ
$this->parseProcedureInfo($xpath, $details);
// 5. НАЧАЛЬНАЯ ЦЕНА КОНТРАКТА
$this->parsePriceInfo($xpath, $details);
// 6. ИНФОРМАЦИЯ ОБ ОБЪЕКТЕ ЗАКУПКИ (КТРУ)
$this->parseObjectInfo($xpath, $details);
// 7. ПРЕИМУЩЕСТВА И ТРЕБОВАНИЯ
$this->parseRequirements($xpath, $details);
// 8. ТРЕБОВАНИЯ ЗАКАЗЧИКА
$this->parseCustomerRequirements($xpath, $details);
// 9. ФИНАНСОВАЯ ИНФОРМАЦИЯ
$this->parseFinancialInfo($xpath, $details);
// 10. ОБЕСПЕЧЕНИЕ ЗАЯВКИ
$this->parseApplicationSecurity($xpath, $details);
// 11. УСЛОВИЯ КОНТРАКТА
$this->parseContractConditions($xpath, $details);
// 12. ОБЕСПЕЧЕНИЕ ИСПОЛНЕНИЯ КОНТРАКТА
$this->parseContractSecurity($xpath, $details);
// 13. БАНКОВСКОЕ СОПРОВОЖДЕНИЕ
$this->parseBankingSupport($xpath, $details);
// 14. ДОКУМЕНТЫ
$this->parseDocuments($xpath, $details);
return $details;
}
// Парсинг основной информации тендера
private function parseMainInfo($xpath, &$details) {
// Извлекаем регион из блока time-zone
$this->extractRegionFromTimeZone($xpath, $details);
// Заголовок и номер
$titleNode = $xpath->query("//div[contains(@class, 'cardMainInfo__title')]");
$details['generalInfo']['title'] = $titleNode->length ? trim($titleNode->item(0)->textContent) : '';
$numberNode = $xpath->query("//span[contains(@class, 'cardMainInfo__purchaseLink')]/a");
$details['generalInfo']['number'] = $numberNode->length ? trim($numberNode->item(0)->textContent) : '';
// Статус
$statusNode = $xpath->query("//span[contains(@class, 'cardMainInfo__state')]");
$details['generalInfo']['status'] = $statusNode->length ? trim($statusNode->item(0)->textContent) : '';
// Объект закупки
$objectNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]/span[contains(text(), 'Объект закупки')]/following-sibling::span");
if (!$objectNode->length) {
// Альтернативный поиск в заголовке
$objectNode = $xpath->query("//h1[contains(@class, 'cardMainInfo__title')] | //div[contains(@class, 'cardMainInfo__title')]");
}
$details['generalInfo']['object'] = $objectNode->length ? trim($objectNode->item(0)->textContent) : 'Не указан';
// Организация
$orgNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]/span[contains(text(), 'Организация') or contains(text(), 'размещение')]/following-sibling::span/a");
if (!$orgNode->length) {
// Альтернативный поиск организации
$orgNode = $xpath->query("//a[contains(@href, 'organization/view')]");
}
$details['generalInfo']['organization'] = $orgNode->length ? trim($orgNode->item(0)->textContent) : 'Не указана';
// Цена
$priceNode = $xpath->query("//span[contains(@class, 'cost')]");
$details['generalInfo']['price'] = $priceNode->length ? trim($priceNode->item(0)->textContent) : '';
// Даты из основной карточки
$publishDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Размещено')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
if ($publishDateNode->length) {
$details['generalInfo']['publishDate'] = trim($publishDateNode->item(0)->textContent);
}
$updateDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Обновлено')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
if ($updateDateNode->length) {
$details['generalInfo']['updateDate'] = trim($updateDateNode->item(0)->textContent);
}
$endDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Окончание')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
if ($endDateNode->length) {
$details['generalInfo']['endDate'] = trim($endDateNode->item(0)->textContent);
}
// Если даты не найдены в основной карточке, ищем в других местах
if (empty($details['generalInfo']['publishDate'])) {
$altPublishNode = $xpath->query("//span[contains(text(), 'Размещено')]/following-sibling::span | //td[contains(text(), 'Размещено')]/following-sibling::td");
if ($altPublishNode->length) {
$details['generalInfo']['publishDate'] = trim($altPublishNode->item(0)->textContent);
}
}
if (empty($details['generalInfo']['updateDate'])) {
$altUpdateNode = $xpath->query("//span[contains(text(), 'Обновлено')]/following-sibling::span | //td[contains(text(), 'Обновлено')]/following-sibling::td");
if ($altUpdateNode->length) {
$details['generalInfo']['updateDate'] = trim($altUpdateNode->item(0)->textContent);
}
}
if (empty($details['generalInfo']['endDate'])) {
$altEndNode = $xpath->query("//span[contains(text(), 'Окончание')]/following-sibling::span | //td[contains(text(), 'Окончание')]/following-sibling::td");
if ($altEndNode->length) {
$details['generalInfo']['endDate'] = trim($altEndNode->item(0)->textContent);
}
}
// Добавляем информацию о часовом поясе
$timeZoneNode = $xpath->query("//div[contains(@class, 'time-zone__value')]/span");
if ($timeZoneNode->length) {
$details['generalInfo']['timeZone'] = trim($timeZoneNode->item(0)->textContent);
}
}
// Парсинг общей информации о закупке
private function parseGeneralInfo($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Общая информация о закупке')]/following-sibling::section | //h2[contains(text(), 'Общая информация о закупке')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['generalInfo'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг контактной информации
private function parseContactInfo($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Контактная информация')]/following-sibling::section | //h2[contains(text(), 'Контактная информация')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['contactInfo'][$this->normalizeKey($title)] = $info;
// Определение региона из адреса, только если он еще не определен
if (empty($details['region']) && (stripos($title, 'адрес') !== false || stripos($title, 'нахождение') !== false)) {
foreach ($this->regionMapping as $code => $name) {
if (stripos($info, $name) !== false) {
$details['region'] = $code;
$details['regionName'] = $name;
break;
}
}
}
}
}
}
// Парсинг информации о процедуре закупки
private function parseProcedureInfo($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Информация о процедуре закупки')]/following-sibling::section | //h2[contains(text(), 'Информация о процедуре закупки')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['procedureInfo'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг информации о цене
private function parsePriceInfo($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Начальная') and contains(text(), 'цена')]/following-sibling::section | //h2[contains(text(), 'Начальная') and contains(text(), 'цена')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['priceInfo'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг информации об объекте закупки (КТРУ)
private function parseObjectInfo($xpath, &$details) {
$table = $xpath->query("//table[contains(@class, 'tableBlock')]//tbody[contains(@class, 'tableBlock__body')]");
if ($table->length) {
$rows = $xpath->query(".//tr[contains(@class, 'tableBlock__row')]", $table->item(0));
foreach ($rows as $row) {
$cols = $xpath->query(".//td[contains(@class, 'tableBlock__col')]", $row);
if ($cols->length >= 6) {
$item = [
'code' => trim($cols->item(1)->textContent),
'name' => trim($cols->item(2)->textContent),
'unit' => $cols->length > 3 ? trim($cols->item(3)->textContent) : '',
'quantity' => $cols->length > 4 ? trim($cols->item(4)->textContent) : '',
'price' => $cols->length > 5 ? trim($cols->item(5)->textContent) : '',
'sum' => $cols->length > 6 ? trim($cols->item(6)->textContent) : ''
];
// Извлечение характеристик
$characteristics = [];
$charRows = $xpath->query(".//tr[contains(@style, 'display: none')]//table//tr", $row->parentNode);
foreach ($charRows as $charRow) {
$charCols = $xpath->query(".//td", $charRow);
if ($charCols->length >= 2) {
$charName = trim($charCols->item(0)->textContent);
$charValue = trim($charCols->item(1)->textContent);
if (!empty($charName) && !empty($charValue)) {
$characteristics[$charName] = $charValue;
}
}
}
$item['characteristics'] = $characteristics;
$details['objectInfo'][] = $item;
}
}
}
// Итоговая сумма
$totalNode = $xpath->query("//tfoot//span[contains(@class, 'cost')]");
if ($totalNode->length) {
$details['objectInfo']['total'] = trim($totalNode->item(0)->textContent);
}
}
// Парсинг требований и преимуществ
private function parseRequirements($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Преимущества') or contains(text(), 'требования')]/following-sibling::section | //h2[contains(text(), 'Преимущества') or contains(text(), 'требования')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['requirements'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг требований заказчика
private function parseCustomerRequirements($xpath, &$details) {
$sections = $xpath->query("//span[contains(text(), 'Требования заказчика')]/ancestor::div[contains(@class, 'collapse__title')]/following-sibling::div//section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['customerRequirements'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг финансовой информации
private function parseFinancialInfo($xpath, &$details) {
// Финансовое обеспечение
$tables = $xpath->query("//h2[contains(text(), 'финансирования') or contains(text(), 'Финансовое обеспечение')]/following-sibling::table | //h2[contains(text(), 'финансирования') or contains(text(), 'Финансовое обеспечение')]/parent::*/following-sibling::*/table");
foreach ($tables as $table) {
$rows = $xpath->query(".//tr", $table);
$tableData = [];
foreach ($rows as $row) {
$cols = $xpath->query(".//td | .//th", $row);
$rowData = [];
foreach ($cols as $col) {
$rowData[] = trim($col->textContent);
}
if (!empty($rowData)) {
$tableData[] = $rowData;
}
}
$details['financialInfo']['tables'][] = $tableData;
}
// Дополнительные секции
$sections = $xpath->query("//h2[contains(text(), 'финансирования')]/following-sibling::section | //h2[contains(text(), 'финансирования')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['financialInfo'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг обеспечения заявки
private function parseApplicationSecurity($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Обеспечение заявки')]/following-sibling::section | //h2[contains(text(), 'Обеспечение заявки')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['applicationSecurity'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг условий контракта
private function parseContractConditions($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Условия контракта')]/following-sibling::section | //h2[contains(text(), 'Условия контракта')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['contractConditions'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг обеспечения исполнения контракта
private function parseContractSecurity($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'Обеспечение исполнения контракта')]/following-sibling::section | //h2[contains(text(), 'Обеспечение исполнения контракта')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['contractSecurity'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг банковского сопровождения
private function parseBankingSupport($xpath, &$details) {
$sections = $xpath->query("//h2[contains(text(), 'банковском') or contains(text(), 'казначейском')]/following-sibling::section | //h2[contains(text(), 'банковском') or contains(text(), 'казначейском')]/parent::*/following-sibling::*/section");
foreach ($sections as $section) {
$titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
$infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
if ($titleNode->length && $infoNode->length) {
$title = trim($titleNode->item(0)->textContent);
$info = trim($infoNode->item(0)->textContent);
$details['bankingSupport'][$this->normalizeKey($title)] = $info;
}
}
}
// Парсинг документов
private function parseDocuments($xpath, &$details) {
$docNodes = $xpath->query("//a[contains(@class, 'section__doc-link')] | //a[contains(@href, '/document/')]");
foreach ($docNodes as $node) {
$docHref = $node->getAttribute('href');
if (!preg_match('/^https?:\/\//', $docHref)) {
$docHref = 'https://zakupki.gov.ru' . $docHref;
}
$details['documents'][] = [
'fileName' => trim($node->textContent),
'url' => $docHref
];
}
}
// Извлечение региона из блока time-zone
private function extractRegionFromTimeZone($xpath, &$details) {
$timeZoneNode = $xpath->query("//div[contains(@class, 'time-zone__value')]/span");
if ($timeZoneNode->length) {
$timeZoneText = trim($timeZoneNode->item(0)->textContent);
// Извлекаем название региона из строки типа "Москва МСК (UTC+3)"
if (preg_match('/^([^М\s]+)/', $timeZoneText, $matches)) {
$regionName = mb_strtolower(trim($matches[1]), 'UTF-8');
// Ищем соответствие в нашем маппинге регионов
foreach ($this->regionMapping as $code => $name) {
if (stripos($regionName, mb_strtolower($name, 'UTF-8')) !== false ||
stripos(mb_strtolower($name, 'UTF-8'), $regionName) !== false) {
$details['region'] = $code;
$details['regionName'] = $name;
return true;
}
}
// Ищем в городах
foreach ($this->cityMapping as $city => $code) {
if (stripos($regionName, $city) !== false) {
$details['region'] = $code;
$details['regionName'] = $this->regionMapping[$code];
return true;
}
}
// Если не нашли в маппинге, но регион есть, сохраняем его название
if (!empty($regionName)) {
$details['regionName'] = ucfirst($regionName);
return true;
}
}
}
return false;
}
// Нормализация ключей для массивов
private function normalizeKey($key) {
$key = trim($key);
$key = preg_replace('/[^\w\s-]/u', '', $key);
$key = preg_replace('/\s+/', '_', $key);
$key = mb_strtolower($key, 'UTF-8');
return $key;
}
// Получение списка доступных регионов
public function getRegions() {
return $this->regionMapping;
}
public function __destruct() {
if ($this->curl) {
curl_close($this->curl);
}
if (file_exists($this->cookies)) {
@unlink($this->cookies);
}
}
}
// Обработка запросов
if (isset($_GET['action'])) {
header('Content-Type: application/json; charset=utf-8');
try {
$parser = new ZakupkiParser();
if ($_GET['action'] === 'search') {
$params = [
'page' => $_GET['page'] ?? 1,
'size' => $_GET['per_page'] ?? 20,
'search_text' => $_GET['search_text'] ?? '',
'inn' => $_GET['inn'] ?? '',
'price_min' => $_GET['price_min'] ?? null,
'price_max' => $_GET['price_max'] ?? null,
'date_from' => $_GET['date_from'] ?? null,
'date_to' => $_GET['date_to'] ?? null,
'regions' => isset($_GET['regions']) ? explode(',', $_GET['regions']) : [],
'city_search' => $_GET['city_search'] ?? ''
];
$result = $parser->searchTenders($params);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} elseif ($_GET['action'] === 'detail' && !empty($_GET['regNumber'])) {
$details = $parser->getTenderDetails($_GET['regNumber']);
echo json_encode($details, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} elseif ($_GET['action'] === 'regions') {
$regions = $parser->getRegions();
echo json_encode($regions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
exit;
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Полный парсер госзакупок</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<style>
.card-detail {
transition: all 0.3s;
border-left: 4px solid transparent;
}
.card-detail:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
border-left-color: #0d6efd;
}
.section-title {
border-left: 4px solid #0d6efd;
padding-left: 15px;
margin: 25px 0 15px;
}
.loader { width: 3rem; height: 3rem; }
.document-badge {
cursor: pointer;
transition: all 0.2s;
margin: 2px;
}
.document-badge:hover {
background: #0d6efd!important;
transform: scale(1.05);
}
.pagination .page-item.active .page-link {
background: #0d6efd;
border-color: #0d6efd;
}
.lot-badge { font-size: 0.85rem; }
.region-badge {
font-size: 0.75rem;
margin-left: 5px;
}
.select2-container {
width: 100% !important;
}
.tender-stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
.cache-info {
position: fixed;
top: 10px;
right: 10px;
z-index: 1000;
}
.detail-section {
margin-bottom: 30px;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
}
.detail-section-header {
background: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
font-weight: 600;
color: #495057;
}
.detail-section-content {
padding: 20px;
}
.info-table {
width: 100%;
margin-bottom: 0;
}
.info-table td {
padding: 8px 12px;
border-bottom: 1px solid #f1f3f4;
}
.info-table td:first-child {
font-weight: 500;
color: #6c757d;
width: 30%;
}
.object-item {
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 15px;
overflow: hidden;
}
.object-item-header {
background: #f8f9fa;
padding: 10px 15px;
font-weight: 500;
}
.object-item-content {
padding: 15px;
}
.characteristics-table {
font-size: 0.9em;
margin-top: 10px;
}
.financial-table {
font-size: 0.9em;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container py-4">
<h1 class="text-center mb-4">
<i class="fas fa-file-contract me-2"></i>Полный парсер госзакупок
</h1>
<!-- Информация о кэше -->
<div id="cacheInfo" class="cache-info d-none">
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="fas fa-info-circle me-2"></i>
<span id="cacheMessage">Данные загружены из кэша</span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</div>
<!-- Статистика -->
<div id="tenderStats" class="tender-stats d-none">
<div class="row text-center">
<div class="col-md-3">
<h3 id="totalTenders">0</h3>
<p class="mb-0">Всего тендеров</p>
</div>
<div class="col-md-3">
<h3 id="totalValue">0 ₽</h3>
<p class="mb-0">Общая стоимость</p>
</div>
<div class="col-md-3">
<h3 id="avgValue">0 ₽</h3>
<p class="mb-0">Средняя стоимость</p>
</div>
<div class="col-md-3">
<h3 id="regionsCount">0</h3>
<p class="mb-0">Регионов</p>
</div>
</div>
</div>
<!-- Форма поиска -->
<div class="card mb-4">
<div class="card-body">
<form id="searchForm" class="row g-3">
<div class="col-md-6">
<label class="form-label">Ключевые слова</label>
<input type="text" name="search_text" class="form-control" placeholder="Номер или название закупки...">
</div>
<div class="col-md-6">
<label class="form-label">ИНН заказчика</label>
<input type="text" name="inn" class="form-control" placeholder="ИНН организации...">
</div>
<div class="col-md-6">
<label class="form-label">Регионы</label>
<select name="regions" id="regionSelect" class="form-select" multiple>
<!-- Опции будут загружены динамически -->
</select>
<div class="form-text">Выберите один или несколько регионов для фильтрации</div>
</div>
<div class="col-md-6">
<label class="form-label">Поиск по городу/региону</label>
<input type="text" name="city_search" class="form-control" placeholder="Введите название города или региона...">
<div class="form-text">Например: Москва, Санкт-Петербург, Екатеринбург</div>
</div>
<div class="col-md-3">
<label class="form-label">Минимальная цена</label>
<div class="input-group">
<input type="number" name="price_min" class="form-control" placeholder="₽">
<span class="input-group-text">₽</span>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Максимальная цена</label>
<div class="input-group">
<input type="number" name="price_max" class="form-control" placeholder="₽">
<span class="input-group-text">₽</span>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Дата от</label>
<input type="date" name="date_from" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Дата до</label>
<input type="date" name="date_to" class="form-control">
</div>
<div class="col-md-2">
<label class="form-label">На странице</label>
<select name="per_page" class="form-select">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-8 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-2"></i>Найти тендеры
</button>
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="button" class="btn btn-outline-secondary w-100" onclick="app.clearCache()">
<i class="fas fa-trash me-2"></i>Очистить кэш
</button>
</div>
</form>
</div>
</div>
<!-- Результаты -->
<div id="results" class="mb-4">
<div class="text-center py-5">
<div class="spinner-border text-primary loader" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="mt-3">Загружаем список тендеров...</p>
</div>
</div>
<!-- Пагинация -->
<nav id="pagination" class="d-none">
<ul class="pagination justify-content-center">
<!-- Номера страниц будут вставлены здесь -->
</ul>
</nav>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
class ZakupkiApp {
constructor() {
this.currentPage = 1;
this.totalPages = 1;
this.perPage = 20;
this.totalItems = 0;
this.currentTender = null;
this.cache = new Map();
this.regions = {};
this.initializeRegions();
this.searchTenders();
}
async initializeRegions() {
try {
const response = await fetch('?action=regions');
if (response.ok) {
this.regions = await response.json();
this.populateRegionSelect();
}
} catch (error) {
console.error('Ошибка загрузки регионов:', error);
}
}
populateRegionSelect() {
const select = document.getElementById('regionSelect');
select.innerHTML = '';
Object.entries(this.regions).forEach(([code, name]) => {
const option = document.createElement('option');
option.value = code;
option.textContent = name;
select.appendChild(option);
});
// Инициализация Select2
$('#regionSelect').select2({
placeholder: 'Выберите регионы...',
allowClear: true,
width: '100%'
});
}
async searchTenders(page = 1) {
try {
this.showLoader('Загружаем список тендеров...');
this.currentPage = page;
const formData = new FormData(document.getElementById('searchForm'));
const selectedRegions = $('#regionSelect').val() || [];
const params = {
action: 'search',
page: page,
per_page: formData.get('per_page') || 20,
search_text: formData.get('search_text') || '',
inn: formData.get('inn') || '',
price_min: formData.get('price_min') || null,
price_max: formData.get('price_max') || null,
date_from: formData.get('date_from') || null,
date_to: formData.get('date_to') || null,
regions: selectedRegions.join(','),
city_search: formData.get('city_search') || ''
};
// Проверяем кэш
const cacheKey = JSON.stringify(params);
if (this.cache.has(cacheKey)) {
const cachedResult = this.cache.get(cacheKey);
this.renderTenders(cachedResult);
this.showCacheInfo('Данные загружены из кэша');
return;
}
// Формируем параметры запроса
const queryString = Object.keys(params)
.filter(key => params[key] !== null && params[key] !== '')
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join('&');
const response = await fetch(`?${queryString}`);
if (!response.ok) throw new Error('Ошибка сети');
const result = await response.json();
// Сохраняем в кэш
this.cache.set(cacheKey, result);
this.renderTenders(result);
} catch (error) {
this.showError(error.message);
}
}
renderTenders(data) {
const tenders = data.content || [];
this.totalItems = data.totalElements || tenders.length;
this.totalPages = Math.ceil(this.totalItems / this.perPage);
// Обновляем статистику
this.updateStats(tenders);
let html = `<h2 class="mb-3">Найдено тендеров: ${this.totalItems.toLocaleString('ru-RU')}</h2>`;
if (tenders.length === 0) {
html += `<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
По вашему запросу тендеров не найдено
</div>`;
} else {
html += '<div class="row g-4">';
tenders.forEach(tender => {
const regNumber = tender.regNumber || tender.tenderId || '';
const number = tender.purchaseNumber || '';
const title = tender.purchaseObjectInfo || 'Без названия';
const customer = tender.customer?.fullName || 'Заказчик не указан';
// Цена
let price = 'Цена не указана';
if (tender.lot?.price) {
const numPrice = parseFloat(tender.lot.price.replace(/[^\d.]/g, ''));
if (!isNaN(numPrice)) {
price = numPrice.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + ' ₽';
}
}
// Регион
const regionBadge = tender.region && this.regions[tender.region]
? `<span class="badge bg-secondary region-badge">${this.regions[tender.region]}</span>`
: '';
// Даты
const formatDate = (dateString) => {
if (!dateString) return 'Н/Д';
try {
const date = new Date(dateString);
return isNaN(date) ? dateString : date.toLocaleDateString('ru-RU');
} catch {
return dateString;
}
};
const posted = formatDate(tender.publishDate);
const updated = formatDate(tender.updateDate);
const deadline = formatDate(tender.submissionCloseDateTime);
html += `
<div class="col-md-6">
<div class="card card-detail h-100" data-tender-id="${regNumber}">
<div class="card-body">
<h5 class="card-title text-primary">${title}${regionBadge}</h5>
<h6 class="card-subtitle mb-2 text-muted">${number}</h6>
<div class="mb-2">
<span class="badge bg-info">${price}</span>
</div>
<p class="card-text">
<strong>Заказчик:</strong> ${customer}<br>
<strong>Размещено:</strong> ${posted}<br>
<strong>Обновлено:</strong> ${updated}<br>
<strong>Окончание подачи:</strong> ${deadline}
</p>
<button class="btn btn-sm btn-outline-primary"
onclick="app.showTenderDetails('${regNumber}')">
<i class="fas fa-info-circle me-1"></i> Подробнее
</button>
</div>
</div>
</div>`;
});
html += '</div>';
}
document.getElementById('results').innerHTML = html;
this.renderPagination();
}
updateStats(tenders) {
const statsContainer = document.getElementById('tenderStats');
if (tenders.length === 0) {
statsContainer.classList.add('d-none');
return;
}
let totalValue = 0;
const regions = new Set();
tenders.forEach(tender => {
if (tender.lot?.price) {
const price = parseFloat(tender.lot.price.replace(/[^\d.]/g, ''));
if (!isNaN(price)) {
totalValue += price;
}
}
// Учитываем регион, если он определен
if (tender.region && tender.region !== '') {
regions.add(tender.region);
}
// Также проверяем regionName для случаев, когда код региона не определен
if (!tender.region && tender.regionName && tender.regionName !== 'Не определен') {
regions.add(tender.regionName);
}
});
const avgValue = tenders.length > 0 ? totalValue / tenders.length : 0;
document.getElementById('totalTenders').textContent = this.totalItems.toLocaleString('ru-RU');
document.getElementById('totalValue').textContent = totalValue.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}) + ' ₽';
document.getElementById('avgValue').textContent = avgValue.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}) + ' ₽';
document.getElementById('regionsCount').textContent = regions.size;
statsContainer.classList.remove('d-none');
}
showCacheInfo(message) {
const cacheInfo = document.getElementById('cacheInfo');
const cacheMessage = document.getElementById('cacheMessage');
cacheMessage.textContent = message;
cacheInfo.classList.remove('d-none');
setTimeout(() => {
cacheInfo.classList.add('d-none');
}, 3000);
}
clearCache() {
this.cache.clear();
this.showCacheInfo('Кэш очищен');
}
renderPagination() {
const paginationContainer = document.querySelector('#pagination ul');
paginationContainer.innerHTML = '';
if (this.totalPages <= 1) {
document.getElementById('pagination').classList.add('d-none');
return;
}
document.getElementById('pagination').classList.remove('d-none');
// Кнопка "Назад"
const prevBtn = document.createElement('li');
prevBtn.className = `page-item ${this.currentPage === 1 ? 'disabled' : ''}`;
prevBtn.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${this.currentPage - 1})">«</a>`;
paginationContainer.appendChild(prevBtn);
// Номера страниц
const startPage = Math.max(1, this.currentPage - 2);
const endPage = Math.min(this.totalPages, startPage + 4);
for (let i = startPage; i <= endPage; i++) {
const pageItem = document.createElement('li');
pageItem.className = `page-item ${i === this.currentPage ? 'active' : ''}`;
pageItem.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${i})">${i}</a>`;
paginationContainer.appendChild(pageItem);
}
// Кнопка "Вперед"
const nextBtn = document.createElement('li');
nextBtn.className = `page-item ${this.currentPage === this.totalPages ? 'disabled' : ''}`;
nextBtn.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${this.currentPage + 1})">»</a>`;
paginationContainer.appendChild(nextBtn);
}
goToPage(page) {
if (page < 1) page = 1;
if (page > this.totalPages) page = this.totalPages;
this.searchTenders(page);
}
async showTenderDetails(identifier) {
try {
this.showLoader('Загружаем детали тендера...');
this.currentTender = identifier;
// Проверяем кэш
const cacheKey = `details_${identifier}`;
if (this.cache.has(cacheKey)) {
const cachedDetails = this.cache.get(cacheKey);
this.renderTenderDetails(cachedDetails);
this.showCacheInfo('Детали загружены из кэша');
return;
}
const response = await fetch(`?action=detail®Number=${encodeURIComponent(identifier)}`);
if (!response.ok) throw new Error('Ошибка загрузки деталей');
const details = await response.json();
// Сохраняем в кэш
this.cache.set(cacheKey, details);
this.renderTenderDetails(details);
} catch (error) {
this.showError(error.message);
}
}
renderTenderDetails(details) {
if (!details) {
this.showError('Не удалось загрузить детали тендера');
return;
}
const regionInfo = details.region && this.regions[details.region]
? `<span class="badge bg-info">${this.regions[details.region]}</span>`
: '';
let html = `
<div class="mb-4">
<button class="btn btn-secondary" onclick="app.backToList()">
<i class="fas fa-arrow-left me-2"></i>Назад к списку
</button>
</div>
<!-- Основная информация -->
<div class="detail-section">
<div class="detail-section-header">
<h4 class="mb-0">
<i class="fas fa-info-circle me-2"></i>
${details.generalInfo?.object || 'Детальная информация о тендере'} ${regionInfo}
</h4>
</div>
<div class="detail-section-content">
<table class="info-table">
<tr><td>Регистрационный номер</td><td>${details.regNumber || 'Не указан'}</td></tr>
<tr><td>Номер закупки</td><td>${details.generalInfo?.number || 'Не указан'}</td></tr>
<tr><td>Статус</td><td><span class="badge bg-info">${details.generalInfo?.status || 'Неизвестен'}</span></td></tr>
<tr><td>Организация</td><td>${details.generalInfo?.organization || 'Не указана'}</td></tr>
<tr><td>Начальная цена</td><td>${details.generalInfo?.price || 'Не указана'}</td></tr>
<tr><td>Дата размещения</td><td>${details.generalInfo?.publishDate || 'Не указана'}</td></tr>
<tr><td>Дата обновления</td><td>${details.generalInfo?.updateDate || 'Не указана'}</td></tr>
<tr><td>Окончание подачи заявок</td><td>${details.generalInfo?.endDate || 'Не указана'}</td></tr>
<tr><td>Регион</td><td>${details.regionName || 'Не определен'}</td></tr>
</table>
</div>
</div>`;
// Общая информация о закупке
if (details.generalInfo && Object.keys(details.generalInfo).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-clipboard-list me-2"></i>Общая информация о закупке
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.generalInfo).forEach(([key, value]) => {
if (value && !['title', 'number', 'status', 'object', 'organization', 'price', 'publishDate', 'updateDate', 'endDate'].includes(key)) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Контактная информация
if (details.contactInfo && Object.keys(details.contactInfo).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-address-book me-2"></i>Контактная информация
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.contactInfo).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Информация о процедуре закупки
if (details.procedureInfo && Object.keys(details.procedureInfo).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-calendar-alt me-2"></i>Информация о процедуре закупки
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.procedureInfo).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Информация о цене
if (details.priceInfo && Object.keys(details.priceInfo).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-ruble-sign me-2"></i>Информация о цене контракта
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.priceInfo).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Информация об объекте закупки (КТРУ)
if (details.objectInfo && Array.isArray(details.objectInfo) && details.objectInfo.length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-boxes me-2"></i>Информация об объекте закупки (КТРУ)
</div>
<div class="detail-section-content">`;
details.objectInfo.forEach((item, index) => {
if (typeof item === 'object' && item.code) {
html += `
<div class="object-item">
<div class="object-item-header">
<strong>Позиция ${index + 1}:</strong> ${item.code} - ${item.name}
</div>
<div class="object-item-content">
<div class="row">
<div class="col-md-6">
<table class="info-table">
<tr><td>Код КТРУ</td><td>${item.code}</td></tr>
<tr><td>Наименование</td><td>${item.name}</td></tr>
<tr><td>Единица измерения</td><td>${item.unit}</td></tr>
</table>
</div>
<div class="col-md-6">
<table class="info-table">
<tr><td>Количество</td><td>${item.quantity}</td></tr>
<tr><td>Цена за единицу</td><td>${item.price}</td></tr>
<tr><td>Общая стоимость</td><td>${item.sum}</td></tr>
</table>
</div>
</div>`;
// Характеристики
if (item.characteristics && Object.keys(item.characteristics).length > 0) {
html += `
<h6 class="mt-3 mb-2">Характеристики:</h6>
<table class="characteristics-table table table-sm">
<thead>
<tr>
<th>Характеристика</th>
<th>Значение</th>
</tr>
</thead>
<tbody>`;
Object.entries(item.characteristics).forEach(([charName, charValue]) => {
html += `<tr><td>${charName}</td><td>${charValue}</td></tr>`;
});
html += `</tbody></table>`;
}
html += `</div></div>`;
}
});
if (details.objectInfo.total) {
html += `<div class="alert alert-info mt-3">
<strong>Итого:</strong> ${details.objectInfo.total}
</div>`;
}
html += `</div></div>`;
}
// Требования и преимущества
if (details.requirements && Object.keys(details.requirements).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-check-circle me-2"></i>Преимущества и требования к участникам
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.requirements).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Требования заказчика
if (details.customerRequirements && Object.keys(details.customerRequirements).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-user-tie me-2"></i>Требования заказчика
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.customerRequirements).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Финансовая информация
if (details.financialInfo && Object.keys(details.financialInfo).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-chart-line me-2"></i>Финансовая информация
</div>
<div class="detail-section-content">`;
// Таблицы финансирования
if (details.financialInfo.tables && Array.isArray(details.financialInfo.tables)) {
details.financialInfo.tables.forEach((table, index) => {
if (Array.isArray(table) && table.length > 0) {
html += `<table class="financial-table table table-striped">`;
table.forEach((row, rowIndex) => {
if (Array.isArray(row)) {
html += `<tr>`;
row.forEach(cell => {
const tag = rowIndex === 0 ? 'th' : 'td';
html += `<${tag}>${cell}</${tag}>`;
});
html += `</tr>`;
}
});
html += `</table>`;
}
});
}
// Дополнительная финансовая информация
html += `<table class="info-table">`;
Object.entries(details.financialInfo).forEach(([key, value]) => {
if (value && key !== 'tables') {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Обеспечение заявки
if (details.applicationSecurity && Object.keys(details.applicationSecurity).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-shield-alt me-2"></i>Обеспечение заявки
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.applicationSecurity).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Условия контракта
if (details.contractConditions && Object.keys(details.contractConditions).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-file-contract me-2"></i>Условия контракта
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.contractConditions).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Обеспечение исполнения контракта
if (details.contractSecurity && Object.keys(details.contractSecurity).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-lock me-2"></i>Обеспечение исполнения контракта
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.contractSecurity).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Банковское сопровождение
if (details.bankingSupport && Object.keys(details.bankingSupport).length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-university me-2"></i>Банковское и казначейское сопровождение
</div>
<div class="detail-section-content">
<table class="info-table">`;
Object.entries(details.bankingSupport).forEach(([key, value]) => {
if (value) {
const label = this.formatLabel(key);
html += `<tr><td>${label}</td><td>${value}</td></tr>`;
}
});
html += `</table></div></div>`;
}
// Документы
if (details.documents && details.documents.length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-header">
<i class="fas fa-file-pdf me-2"></i>Документы
</div>
<div class="detail-section-content">
<div class="d-flex flex-wrap gap-2">`;
details.documents.forEach(doc => {
html += `<a href="${doc.url}" target="_blank" class="document-badge badge bg-success text-decoration-none">
<i class="fas fa-file-pdf me-1"></i> ${doc.fileName || 'Документ'}
</a>`;
});
html += `</div></div></div>`;
}
document.getElementById('results').innerHTML = html;
document.getElementById('pagination').classList.add('d-none');
}
formatLabel(key) {
return key.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.replace(/([a-z])([A-Z])/g, '$1 $2');
}
backToList() {
this.searchTenders(this.currentPage);
}
showLoader(message = 'Загрузка данных...') {
document.getElementById('results').innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary loader" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="mt-3">${message}</p>
</div>`;
document.getElementById('pagination').classList.add('d-none');
}
showError(message) {
document.getElementById('results').innerHTML = `
<div class="alert alert-danger">
<h4><i class="fas fa-exclamation-circle me-2"></i> Ошибка</h4>
<p>${message}</p>
<button class="btn btn-secondary" onclick="app.searchTenders()">
<i class="fas fa-sync me-1"></i> Попробовать снова
</button>
</div>`;
document.getElementById('pagination').classList.add('d-none');
}
}
// Инициализация приложения
const app = new ZakupkiApp();
// Обработка формы
document.getElementById('searchForm').addEventListener('submit', function(e) {
e.preventDefault();
app.searchTenders();
});
</script>
</body>
</html>
Сгенерированный Результат
Больше Рекомендаций
Посмотреть ВсеСоздание хакерского приложения
Сделай "хакерское" приложение с успокаивающей музыкой на фоне и терминалом с зеленым текстом, к терминалу будет подключена нейросеть deepseek через api, сделай что бы приложение можно было вызвать комбинацией клавиш windows+shift+f, а так же сделай что бы в терминале (именно в самом терминале) можно было воспроизводить видео из ютуба, сделай что бы в приложении можно было вводить текст не только текстом но и голосом. Сделай что бы по приложению была инструкция на английском и русском языках (в самом приложении) а еще что бы было 2 страницы - главная (с терминалом) и дополнительная с кнопками которые вызывают команды из обычного терминала но только по нажатию. Добавь в терминал команды для управления системой, всего команд должно быть 200+, еще на 2 страница справа сделай анимацию ноутбука на экране которого бесконечно взламывается система
передать форму в приложение
передать содержимое формы в другое приложение
