USER
class CableParser {
private const BASE_URL = 'https://cable.ru';
private const CATALOG_URL = 'https://cable.ru/products/';
private const DB_FILE = __DIR__ . '/catalog3.sqlite';
private const LOG_FILE = __DIR__ . '/parser3.log';
private const LOG_FILE2 = __DIR__ . '/curlRequest.log';
private const PRODUCTS_DIR = __DIR__ . '/products3/';
private const IMAGES_DIR = __DIR__ . '/images3/';
private const PROCESS_FILE = __DIR__ . '/process3.txt';
private const BATCH_SIZE = 20; // Количество товаров для парсинга за один шаг
private const LOCK_FILE = __DIR__ . '/parser3.lock'; // Файл блокировки
private string $lastRequestUrl = '';
private SQLite3 $db;
private int $currentStep;
private int $productStep = 0; // Добавлено для отслеживания шага парсинга товаров
public function __construct() {
// Проверяем, запущен ли уже скрипт
if ($this->isAlreadyRunning()) {
$this->log("Скрипт уже запущен. Завершение.");
exit(0); // Завершаем работу, если скрипт уже запущен
}
// Создаем файл блокировки
$this->createLockFile();
// Создаем необходимые папки, если их нет
if (!is_dir(self::PRODUCTS_DIR)) {
mkdir(self::PRODUCTS_DIR, 0777, true);
}
if (!is_dir(self::IMAGES_DIR)) {
mkdir(self::IMAGES_DIR, 0777, true);
}
$this->db = new SQLite3(self::DB_FILE);
$this->createTables();
// Определяем текущий шаг процесса и шаг парсинга товаров
$processData = $this->loadProcessData();
$this->currentStep = $processData['step'];
$this->productStep = $processData['productStep'];
}
private function createTables(): void
{
// Таблица разделов (categories)
$this->db->exec('
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT UNIQUE NOT NULL,
image TEXT,
parent INTEGER DEFAULT 0,
last_update DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
// Таблица товаров (products)
$this->db->exec('
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT UNIQUE NOT NULL,
description TEXT,
price REAL,
price_unit TEXT,
attributes TEXT,
properties TEXT,
image TEXT,
last_update DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
// Таблица связей категорий и товаров (category_product)
$this->db->exec('
CREATE TABLE IF NOT EXISTS category_product (
category_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
PRIMARY KEY (category_id, product_id),
FOREIGN KEY (category_id) REFERENCES categories(id),
FOREIGN KEY (product_id) REFERENCES products(id)
)
');
// Таблица свойств (properties)
$this->db->exec('
CREATE TABLE IF NOT EXISTS properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
)
');
}
// Проверяем, запущен ли уже скрипт
private function isAlreadyRunning(): bool
{
return file_exists(self::LOCK_FILE);
}
// Создаем файл блокировки
private function createLockFile(): void
{
touch(self::LOCK_FILE);
}
// Удаляем файл блокировки
private function deleteLockFile(): void
{
if (file_exists(self::LOCK_FILE)) {
unlink(self::LOCK_FILE);
}
}
// Загружаем шаг процесса и шаг парсинга товаров из файла
private function loadProcessData(): array
{
if (file_exists(self::PROCESS_FILE)) {
$data = explode(':', file_get_contents(self::PROCESS_FILE), 2);
return [
'step' => (int)$data[0],
'productStep' => (int)($data[1] ?? 0), // Если нет второго значения, то 0
];
} else {
return [
'step' => 0,
'productStep' => 0,
];
}
}
// Сохраняем текущий шаг процесса и шаг парсинга товаров в файл
private function saveProcessData(int $step, int $productStep = 0): void
{
$data = $step . ':' . $productStep;
file_put_contents(self::PROCESS_FILE, $data);
$this->currentStep = $step;
$this->productStep = $productStep;
$this->log("Сохранен шаг процесса: " . $step . ", шаг парсинга товаров: " . $productStep);
}
// Логирование
private function log(string $message): void {
$logMessage = date('Y-m-d H:i:s') . " | " . $message . "\n";
$result = file_put_contents(self::LOG_FILE, $logMessage, FILE_APPEND);
if ($result === false) {
error_log("Не удалось записать в лог-файл.");
}
}
// Закрываем соединение с БД и удаляем файл блокировки при завершении работы скрипта
public function __destruct() {
if ($this->db) {
$this->db->close();
}
$this->deleteLockFile(); // Удаляем файл блокировки
}
// Функция для получения HTML-кода страницы с использованием cURL
private function getPageContent(string $url): string|false
{
$startTime = microtime(true);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36');
curl_setopt($ch, CURLOPT_REFERER, $this->lastRequestUrl);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
$content = curl_exec($ch);
$endTime = microtime(true);
$totalTime = $endTime - $startTime;
$logMessage = date('Y-m-d H:i:s') . " | URL: " . $url . " | Время выполнения: " . round($totalTime, 3) . " сек.\n";
$result = file_put_contents(self::LOG_FILE2, $logMessage, FILE_APPEND);
if ($result === false) {
error_log("Не удалось записать в лог-файл.");
}
curl_close($ch);
$this->lastRequestUrl = $url;
return $content;
}
// Функция для извлечения ссылок и названий категорий
private function extractCategoryLinks(string $html, int $parent = 0): void
{
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
$xpath = new DOMXPath($dom);
// Находим все элементы <a> внутри <div class="catalog-list-items">
$aElements = $xpath->query('//section[@class="catalog-products-list"]/div[@class="catalog-list-items"]/a');
foreach ($aElements as $element) {
$href = $element->getAttribute('href');
$nameElement = $xpath->query('./div[@class="catalog-list-item-name"]', $element)->item(0);
$name = trim($nameElement->textContent);
$imageElement = $xpath->query('./img[@class="catalog-list-item-image"]', $element)->item(0);
$imageUrl = $imageElement ? $imageElement->getAttribute('src') : '';
// Преобразуем относительные ссылки в абсолютные
$absoluteUrl = rtrim(self::BASE_URL, '/') . $href;
// Сохраняем категорию в базу данных
$this->saveCategory($name, $absoluteUrl, $imageUrl, $parent);
}
}
// Функция для извлечения ссылок подкатегорий
private function extractSubcategoryLinks(string $html, int $parent): void
{
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
$xpath = new DOMXPath($dom);
// Находим все элементы <a> внутри <div class="index-catalog-list-items">
$aElements = $xpath->query('//div[@class="index-catalog-list-items"]/a');
foreach ($aElements as $element) {
$href = $element->getAttribute('href');
$nameElement = $xpath->query('./div[@class="index-catalog-list-item-name"]', $element)->item(0);
$name = trim($nameElement->textContent);
$imageElement = $xpath->query('./img', $element)->item(0);
$imageUrl = $imageElement ? $imageElement->getAttribute('src') : '';
// Преобразуем относительные ссылки в абсолютные
$absoluteUrl = rtrim(self::BASE_URL, '/') . $href;
// Сохраняем подкатегорию в базу данных
$this->saveCategory($name, $absoluteUrl, $imageUrl, $parent);
}
}
// Функция для скачивания изображений
private function downloadImage(string $imageUrl, string $categoryName, string $entityName): void
{
$categoryDir = self::IMAGES_DIR . $this->sanitizeFilename($categoryName) . '/';
if (!is_dir($categoryDir)) {
mkdir($categoryDir, 0777, true);
}
$filename = $categoryDir . $this->sanitizeFilename($entityName) . '.' . pathinfo($imageUrl, PATHINFO_EXTENSION);
if (file_exists($filename)) {
$this->log("Файл " . $filename . " уже существует, пропуск.");
return;
}
$imageContent = $this->getPageContent($imageUrl);
if ($imageContent === false) {
$this->log("Не удалось скачать изображение: " . $imageUrl);
return;
}
$result = file_put_contents($filename, $imageContent);
if ($result === false) {
$this->log("Не удалось сохранить изображение: " . $filename);
} else {
$this->log("Изображение сохранено: " . $filename);
}
}
// Функция для извлечения ссылок на товары с учетом пагинации
private function extractProductLinksWithPagination(string $url, int $categoryId): void
{
$pageNumber = 1;
while (true) {
// Формируем URL страницы с учетом номера страницы
$pageUrl = ($pageNumber > 1) ? $url . '?page=' . $pageNumber : $url;
$html = $this->getPageContent($pageUrl);
if ($html === false) {
$this->log("Не удалось получить HTML-код страницы: " . $pageUrl);
break; // Прерываем цикл, если не удалось получить HTML
}
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
$xpath = new DOMXPath($dom);
// Находим все элементы <a> с классом "product-card__name" внутри элемента <div class="product-card --list">
$aElements = $xpath->query('//div[@class="product-card --list"]//a[@class="product-card__name"]');
// Если на странице нет товаров, считаем, что это последняя страница
if ($aElements->length == 0) {
break; // Прерываем цикл, если нет товаров
}
foreach ($aElements as $element) {
$href = $element->getAttribute('href');
$name = trim($element->textContent);
// Преобразуем относительные ссылки в абсолютные
$absoluteUrl = rtrim(self::BASE_URL, '/') . $href;
// Сохраняем товар и связываем с категорией
$this->saveProduct($name, $absoluteUrl, $categoryId);
}
// Проверяем, есть ли следующая страница, сначала проверим наличие блока пагинации
$paginationBlock = $xpath->query('//div[@class="products-pagination"]')->item(0);
if ($paginationBlock) {
$nextPageLink = $xpath->query('//a[contains(@class, "page") and contains(@class, "next")]', $paginationBlock)->item(0);
if (!$nextPageLink) {
break; // Если нет ссылки "next" - заканчиваем
}
} else {
break; // Если нет блока пагинации, значит страница одна
}
// Увеличиваем номер страницы
$pageNumber++;
// Добавляем задержку, чтобы не перегружать сервер
usleep(rand(100000, 300000));
}
}
// == Методы для парсинга свойств товара (шаг 4) ==
public function parseProductDetails(): void {
$this->log("Шаг 4: Парсинг детальных страниц товаров (пакетами по " . self::BATCH_SIZE . ")...");
// Получаем товары из базы данных для текущего шага
$stmt = $this->db->prepare('SELECT id, url FROM products ORDER BY id ASC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', self::BATCH_SIZE, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $this->productStep * self::BATCH_SIZE, SQLITE3_INTEGER);
$result = $stmt->execute();
$productsParsed = 0;
while ($row = $result->fetchArray()) {
$productId = $row['id'];
$productUrl = $row['url'];
$this->log("Парсинг детальной страницы товара (ID: " . $productId . "): " . $productUrl);
$html = $this->getPageContent($productUrl);
if ($html === false) {
$this->log("Не удалось получить HTML-код страницы товара (ID: " . $productId . "): " . $productUrl);
continue;
}
// Извлекаем свойства товара
$productData = $this->extractProductData($html);
// Обновляем информацию о товаре в базе данных
$this->updateProduct(
$productId,
$productData['description'],
$productData['price'],
$productData['price_unit'],
$productData['attributes']
);
usleep(rand(100000, 300000));
$productsParsed++;
}
// Если было обработано меньше, чем размер пакета, значит это последний пакет
if ($productsParsed < self::BATCH_SIZE) {
$this->log("Парсинг детальных страниц товаров завершен.");
$this->saveProcessData(4, 0); // Завершаем шаг и обнуляем прогресс парсинга товаров
} else {
$newProductStep = $this->productStep + 1;
$this->saveProcessData(3, $newProductStep); // Увеличиваем шаг парсинга товаров
$this->log("Обработано " . self::BATCH_SIZE . " товаров. Шаг парсинга товаров: " . $newProductStep);
}
$this->log("Парсинг детальных страниц товаров - текущая итерация завершена.");
}
// == Методы для парсинга свойств товара ==
private function extractProductData(string $html): array {
$data = [
'description' => '',
'price' => null,
'price_unit' => '',
'attributes' => [],
];
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
$xpath = new DOMXPath($dom);
// Описание товара
$descriptionElement = $xpath->query('//div[@itemprop="description"]/div[@class="description-block animated-height-block"]/div[@class="product-description-full"]')->item(0);
$data['description'] = $descriptionElement ? trim($descriptionElement->textContent) : '';
// Характеристики
$attributesBlock = $xpath->query('//div[@id="attributes" and @class="product-description__attributes bordered-block product-nav"]')->item(0);
if ($attributesBlock) {
// Находим все блоки с подзаголовками (subheading)
$subheadingBlocks = $xpath->query('.//div[@class="attributes-block"]', $attributesBlock);
foreach ($subheadingBlocks as $subheadingBlock) {
// Извлекаем подзаголовок
$subheadingElement = $xpath->query('.//div[@class="subheading"]', $subheadingBlock)->item(0);
$subheading = $subheadingElement ? trim($subheadingElement->textContent) : '';
// Находим все свойства в текущем блоке
$propertyElements = $xpath->query('.//div[@class="description-detail-property"]', $subheadingBlock);
$data['attributes'][$subheading] = []; // Создаем массив для свойств подзаголовка
foreach ($propertyElements as $propertyElement) {
// Извлекаем имя и значение свойства
$nameElement = $xpath->query('.//span[@class="descr-name-value"]', $propertyElement)->item(0);
$valueElement = $xpath->query('.//div[@class="description-detail-property-value"]/span', $propertyElement)->item(0);
$name = $nameElement ? trim($nameElement->textContent) : '';
$value = $valueElement ? trim($valueElement->textContent) : '';
// Сохраняем свойство в массив
$data['attributes'][$subheading][$name] = $value;
}
}
}
// TODO: Добавить код для извлечения цены и единицы измерения цены
// Сейчас пример, нужно адаптировать под реальный HTML
$priceElement = $xpath->query('//div[@class="product-card__new-price"]/div[@class="new-price__value"]')->item(0);
if ($priceElement) {
$priceText = trim($priceElement->textContent);
// Пример разбора цены и единицы измерения (нужно адаптировать)
preg_match('/([\d\s]+)\s*([^\d\s\/]+(?:[\/\s]?[^\d\s\/]+)*)$/u', $priceText, $matches);
if (count($matches) == 3) {
$data['price'] = (float)str_replace([' ', ','], ['', '.'], $matches[1]); // Убираем пробелы и меняем запятую на точку
$data['price_unit'] = trim($matches[2]);
}
}
return $data;
}
// Обновляем информацию о товаре в базе данных
private function updateProduct(int $productId, string $description, ?float $price, string $price_unit, array $attributes): void {
$attributesJson = json_encode($attributes, JSON_UNESCAPED_UNICODE);
// Обновляем информацию о товаре в базе данных
$stmt = $this->db->prepare('UPDATE products SET description = :description, price = :price, price_unit = :price_unit, attributes = :attributes, last_update = CURRENT_TIMESTAMP WHERE id = :id');
$stmt->bindValue(':id', $productId, SQLITE3_INTEGER);
$stmt->bindValue(':description', $description, SQLITE3_TEXT);
$stmt->bindValue(':price', $price, is_null($price) ? SQLITE3_NULL : SQLITE3_FLOAT);
$stmt->bindValue(':price_unit', $price_unit, SQLITE3_TEXT);
$stmt->bindValue(':attributes', $attributesJson, SQLITE3_TEXT);
$result = $stmt->execute();
if (!$result) {
$this->log("Ошибка при обновлении информации о товаре (ID: " . $productId . "): " . $this->db->lastErrorMsg());
} else {
$this->log("Информация о товаре обновлена (ID: " . $productId . ")");
}
}
// Сохранение категории в базу данных
private function saveCategory(string $name, string $url, string $image, int $parent = 0): int|false
{
// Проверяем, существует ли категория с таким URL
$stmt = $this->db->prepare('SELECT id FROM categories WHERE url = :url');
$stmt->bindValue(':url', $url, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
if ($row) {
// Категория уже существует, обновляем last_update и возвращаем ее ID
$stmt = $this->db->prepare('UPDATE categories SET last_update = CURRENT_TIMESTAMP, image = :image WHERE id = :id');
$stmt->bindValue(':id', $row['id'], SQLITE3_INTEGER);
$stmt->bindValue(':image', $image, SQLITE3_TEXT);
$stmt->execute();
$this->log("Категория обновлена: " . $name . " (URL: " . $url . ")");
return $row['id'];
}
// Категория не существует, добавляем ее
$stmt = $this->db->prepare('INSERT INTO categories (name, url, image, parent) VALUES (:name, :url, :image, :parent)');
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$stmt->bindValue(':url', $url, SQLITE3_TEXT);
$stmt->bindValue(':image', $image, SQLITE3_TEXT);
$stmt->bindValue(':parent', $parent, SQLITE3_INTEGER);
$result = $stmt->execute();
if ($result) {
$this->log("Категория добавлена: " . $name . " (URL: " . $url . ")");
// Возвращаем ID добавленной категории
return $this->db->lastInsertRowID();
} else {
$this->log("Ошибка при добавлении категории: " . $this->db->lastErrorMsg());
return false;
}
}
// Сохранение товара в базу данных и связывание с категорией
private function saveProduct(string $name, string $url, int $categoryId): void {
// 1. Проверяем, существует ли товар с таким URL
$stmt = $this->db->prepare('SELECT id FROM products WHERE url = :url');
$stmt->bindValue(':url', $url, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
if ($row) {
$productId = $row['id']; // Товар существует, получаем ID
} else {
// 2. Товар не существует, добавляем его
$stmt = $this->db->prepare('INSERT INTO products (name, url) VALUES (:name, :url)');
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$stmt->bindValue(':url', $url, SQLITE3_TEXT);
$result = $stmt->execute();
if (!$result) {
$this->log("Ошибка при добавлении товара: " . $this->db->lastErrorMsg());
return;
}
$productId = $this->db->lastInsertRowID(); // Получаем ID добавленного товара
$this->log("Товар добавлен: " . $name . " (URL: " . $url . ")");
}
// 3. Связываем товар с категорией (если связь еще не существует)
$this->linkCategoryToProduct($categoryId, $productId);
}
// Связывает категорию с товаром
private function linkCategoryToProduct(int $categoryId, int $productId): void {
// Проверяем, существует ли уже связь
$stmt = $this->db->prepare('SELECT 1 FROM category_product WHERE category_id = :category_id AND product_id = :product_id');
$stmt->bindValue(':category_id', $categoryId, SQLITE3_INTEGER);
$stmt->bindValue(':product_id', $productId, SQLITE3_INTEGER);
$result = $stmt->execute();
$row = $result->fetchArray();
if ($row) {
// Связь уже существует, ничего не делаем
return;
}
// Связи нет, добавляем ее
$stmt = $this->db->prepare('INSERT INTO category_product (category_id, product_id) VALUES (:category_id, :product_id)');
$stmt->bindValue(':category_id', $categoryId, SQLITE3_INTEGER);
$stmt->bindValue(':product_id', $productId, SQLITE3_INTEGER);
$result = $stmt->execute();
if (!$result) {
$this->log("Ошибка при связывании категории и товара: " . $this->db->lastErrorMsg());
} else {
$this->log("Товар связан с категорией (ID товара: " . $productId . ", ID категории: " . $categoryId . ")");
}
}
// Функция для "очистки" имени файла (удаляем лишние символы)
private function sanitizeFilename(string $filename): string {
$filename = str_replace(['/', '\\', '?', '*', ':', '|', '"', '<', '>'], '_', $filename); // Заменяем недопустимые символы на "_"
$filename = trim($filename);
return $filename;
}
public function run(): void
{
switch ($this->currentStep) {
case 0:
$this->step1();
break;
case 1:
$this->step2();
break;
case 2:
$this->step3();
break;
case 3:
$this->parseProductDetails();
break;
default:
$this->log("Парсинг завершен.");
}
}
// 1. Создание разделов и подразделов
public function step1(): void
{
$this->log("Шаг 1: Создание разделов и подразделов...");
// 1. Получаем HTML-код страницы каталога
$html = $this->getPageContent(self::CATALOG_URL);
if ($html === false) {
die("Не удалось получить HTML-код страницы каталога.");
}
// 2. Собираем ссылки и названия категорий (разделы)
$this->extractCategoryLinks($html);
// Получаем все разделы из базы
$stmt = $this->db->query('SELECT id, url, name FROM categories WHERE parent = 0 ORDER BY id ASC');
while ($row = $stmt->fetchArray()) {
$pageHtml = $this->getPageContent($row['url']);
if ($pageHtml) {
$this->extractSubcategoryLinks($pageHtml, $row['id']);
} else {
$this->log("Не удалось получить HTML-код страницы раздела: " . $row['name']);
}
usleep(rand(100000, 300000));
}
$this->saveProcessData(1);
$this->log("Шаг 1 завершен. Для продолжения запустите скрипт снова.");
}
// 2. Скачивание изображений
public function step2(): void
{
$this->log("Шаг 2: Скачивание изображений...");
$stmt = $this->db->query('SELECT id, name, image, parent FROM categories ORDER BY id ASC'); // Получаем все категории
while ($row = $stmt->fetchArray()) {
if (!empty($row['image'])) {
$parentStmt = $this->db->prepare('SELECT name FROM categories WHERE id = :id'); // Находим имя родительской категории
$parentStmt->bindValue(':id', $row['parent'], SQLITE3_INTEGER);
$parentResult = $parentStmt->execute();
$parentRow = $parentResult->fetchArray();
$categoryName = ($row['parent'] == 0) ? $row['name'] : $parentRow['name']; // Если нет родителя - имя текущей категории, иначе - имя родительской
$this->downloadImage($row['image'], $categoryName, $row['name']); // имя для создания папки - имя родительской категории, имя картинки - имя текущей категории
}
usleep(rand(100000, 300000));
}
$this->saveProcessData(2);
$this->log("Шаг 2 завершен. Для продолжения запустите скрипт снова.");
}
// 3. Парсинг названий и ссылок на детальные страницы товаров
public function step3(): void
{
$this->log("Шаг 3: Парсинг названий и ссылок на детальные страницы товаров...");
$stmt = $this->db->query('SELECT id, url, name FROM categories WHERE parent > 0 ORDER BY id ASC'); // Только подкатегории
while ($row = $stmt->fetchArray()) {
$this->extractProductLinksWithPagination($row['url'], $row['id']);
usleep(rand(100000, 300000));
}
$this->saveProcessData(3);
$this->log("Шаг 3 завершен. Для продолжения запустите скрипт снова.");
}
}
// Пример использования
$parser = new CableParser();
$parser->run();