]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Добавление парсера
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Sep 2025 07:55:05 +0000 (10:55 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Sep 2025 07:55:05 +0000 (10:55 +0300)
docker/nginx/conf/nginx.conf
erp24/commands/MarketplaceController.php
erp24/controllers/MatrixErpController.php
erp24/models/HtmlImportForm.php [new file with mode: 0644]
erp24/services/FileService.php
erp24/services/ProductParserService.php [new file with mode: 0644]
erp24/views/matrix_erp/parse-flowwow-cards.php [new file with mode: 0644]
erp24/views/products1c-nomenclature-actuality/index.php

index 9689c6d2d1de95f7bc22f5bd626d1382dcbf2aab..855353c0ba5aa2946dfb5745b16fbebcd2f95b38 100644 (file)
@@ -13,7 +13,7 @@ events {
 http {
     include       /etc/nginx/mime.types;
     default_type  application/octet-stream;
-
+    client_max_body_size 128m;
     log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                       '$status $body_bytes_sent "$http_referer" '
                       '"$http_user_agent" "$http_x_forwarded_for"';
@@ -54,7 +54,7 @@ http {
     server {
         listen 443 ssl;
         listen [::]:443 ssl;
-
+        client_max_body_size 128m;
         ssl_certificate /etc/ssl/certs/nginx.crt;
         ssl_certificate_key /etc/ssl/private/nginx.key;
 
index 1777a3bb460e17b9c741d57f11efb7719df78bb2..65b788020bd392ccb9e710f9ebf50139dbda35b3 100644 (file)
@@ -2,7 +2,11 @@
 
 namespace yii_app\commands;
 
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
 use OpenAPI\Client\ObjectSerializer;
+use Symfony\Component\DomCrawler\Crawler;
+use voku\helper\HtmlDomParser;
 use Yii;
 use yii\console\Controller;
 use yii\console\ExitCode;
@@ -201,4 +205,125 @@ class MarketplaceController extends Controller
         return ExitCode::OK;
     }
 
+
+    public function actionParseFlowwow()
+    {
+        {
+            $this->stdout("Начинаем парсинг Flowwow..." . PHP_EOL);
+
+            // URL для парсинга
+            $baseUrl = 'https://flowwow.com/shop/baza-cvetov-24f-6167/';
+
+            $this->stdout("Загружаем основную страницу: {$baseUrl}" . PHP_EOL);
+
+            // Контекст для обхода SSL
+            $context = stream_context_create([
+                "ssl" => [
+                    "verify_peer" => false,
+                    "verify_peer_name" => false,
+                ],
+                "http" => [
+                    "header" => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+                    "timeout" => 30
+                ]
+            ]);
+
+            try {
+                // Загружаем основную страницу
+                $dom = HtmlDomParser::file_get_html($baseUrl, false, $context);
+
+                $categoryWrappers = $dom->findMulti('div.category-wrapper') ?? [];
+                $categoryCount = count($categoryWrappers);
+
+                foreach ($categoryWrappers as $index => $category) {
+                    $categoryNameElement = $category->findOne('h2.category-name');
+                    $categoryName = $categoryNameElement ? trim((string)$categoryNameElement->text()) : 'Без категории';
+
+                    $productCards = $category->findMulti('div.category-content-item a.product-card') ?? [];
+                    $productCount = count($productCards);
+
+                    foreach ($productCards as $productIndex => $card) {
+                        $productPath = $card->getAttribute('href') ?: null;
+                        if (!$productPath) { continue; }
+
+                        $productUrl = 'https://flowwow.com' . $productPath;
+
+                        $nameElement  = $card->findOne('div.name');
+                        $priceElement = $card->findOne('div.price span');
+                        $imgElement   = $card->findOne('img');
+
+                        $productName = $nameElement ? trim((string)$nameElement->text()) : '';
+                        $priceText   = $priceElement ? trim((string)$priceElement->text()) : '';
+                        $price       = (int)preg_replace('/[^\d]/u', '', $priceText);
+                        $imageUrl    = $imgElement ? (string)$imgElement->getAttribute('src') : '';
+
+                        $productData = [
+                            'category' => $categoryName,
+                            'name' => $productName,
+                            'price' => $price,
+                            'image_url' => $imageUrl,
+                            'product_url' => $productUrl
+                        ];
+
+                        try {
+                            $this->stdout("Загружаем страницу товара..." . PHP_EOL);
+
+                            // Загружаем страницу товара
+                            $productDom = HtmlDomParser::file_get_html($productUrl, false, $context);
+                            if ($productDom !== false) {
+                                $this->stdout("Страница товара загружена, извлекаем данные..." . PHP_EOL);
+
+                                // Извлечение описания
+                                $descriptionElement = $productDom->findOne('div.product-card-content');
+                                if ($descriptionElement) {
+                                    $productData['description'] = trim((string)$descriptionElement->text());
+                                }
+
+                                $properties = [];
+                                foreach ($productDom->findMulti('div.property-item') ?? [] as $item) {
+                                    $propName  = $item->findOne('div.property-name')?->text();
+                                    $propValue = $item->findOne('div.property-value')?->text();
+                                    if ($propName !== null && $propValue !== null) {
+                                        $properties[trim((string)$propName)] = trim((string)$propValue);
+                                    }
+                                }
+                                $productData['properties'] = $properties;
+
+                                $this->stdout("Характеристики извлечены: " . count($properties) . " свойств" . PHP_EOL);
+
+                               // $productDom->close();
+                            }
+                        } catch (\Exception $e) {
+                            $this->stdout("Ошибка при загрузке страницы товара: " . $e->getMessage() . PHP_EOL);
+                            $productData['error'] = 'Не удалось загрузить страницу товара: ' . $e->getMessage();
+                        }
+
+                        $products[] = $productData;
+                        $this->stdout("Товар добавлен в результат: {$productName}" . PHP_EOL);
+
+                        // Задержка между запросами
+                        $delay = 500000; // 0.5 секунды
+                        $this->stdout("Ждем {$delay} микросекунд перед следующим запросом..." . PHP_EOL);
+                        usleep($delay);
+                    }
+                }
+
+               // $dom->close();
+
+                $this->stdout("Парсинг завершен. Обработано товаров: " . count($products) . PHP_EOL);
+
+                // Сохраняем результат в файл (опционально)
+                $filename = date('Y-m-d_H-i-s') . '_flowwow_products.json';
+                file_put_contents($filename, json_encode($products, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+                $this->stdout("Результат сохранен в файл: {$filename}" . PHP_EOL);
+
+                return ExitCode::OK;
+
+            } catch (\Exception $e) {
+                $this->stdout("Произошла ошибка: " . $e->getMessage() . PHP_EOL);
+                return ExitCode::SOFTWARE;
+            }
+        }
+    }
+
 }
\ No newline at end of file
index 8afa9cf18a365cbb1065d5d5d9e88493d7db0516..a093b2f3e08c1a2e67f0a59793f9fb0012866f97 100644 (file)
@@ -2,9 +2,19 @@
 
 namespace app\controllers;
 
+use app\models\HtmlImportForm;
+use DOMDocument;
+use DOMXPath;
+use Exception;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Symfony\Component\DomCrawler\Crawler;
+use voku\helper\HtmlDomParser;
 use Yii;
 use yii\base\DynamicModel;
 use yii\helpers\Json;
+use yii\web\UploadedFile;
+use yii_app\records\Images;
 use yii_app\records\MatrixErp;
 use yii_app\records\MatrixErpMedia;
 use yii_app\records\MatrixErpProperty;
@@ -13,13 +23,19 @@ use yii\web\Controller;
 use yii\web\NotFoundHttpException;
 use yii\filters\VerbFilter;
 use yii_app\records\Products1c;
+use yii_app\services\FileService;
 use yii_app\services\MarketplaceService;
+use yii_app\services\ProductParserService;
 
 /**
  * MatrixErpController implements the CRUD actions for MatrixErp model.
  */
 class MatrixErpController extends Controller
 {
+    const OUT_DIR =
+//        __DIR__ . "/../json";
+        "/www/api2/json";
+
     /**
      * @inheritDoc
      */
@@ -38,7 +54,8 @@ class MatrixErpController extends Controller
         );
     }
 
-    public static function fillInGuidsFromMarketplaceAndAdditional() {
+    public static function fillInGuidsFromMarketplaceAndAdditional()
+    {
         $existings = MatrixErp::find()->all();
         $existingMapGuidGroupNames = [];
         foreach ($existings as $existing) {
@@ -112,11 +129,9 @@ class MatrixErpController extends Controller
         $query = MatrixErp::find()
 //            ->select([])
             ->joinWith('matrixProperty')
-            ->joinWith('matrixMedia')
-
-        ;
+            ->joinWith('matrixMedia');
 
-        if(Yii::$app->request->isPost && $filterModel->load(Yii::$app->request->post())){
+        if (Yii::$app->request->isPost && $filterModel->load(Yii::$app->request->post())) {
             $nameFilter = $filterModel->nameFilter;
             $groupNameFilter = $filterModel->groupNameFilter;
             $activeFilter = $filterModel->activeFilter;
@@ -140,7 +155,7 @@ class MatrixErpController extends Controller
 
         if (isset($nameFilter) && $nameFilter != "") {
             if (!empty($nameFilter)) {
-                $query->andFilterWhere(['like', 'matrix_erp.name',  $nameFilter]);;
+                $query->andFilterWhere(['like', 'matrix_erp.name', $nameFilter]);;
             }
         }
         $test = 1;
@@ -238,7 +253,8 @@ class MatrixErpController extends Controller
         ]);
     }
 
-    public function actionDelete($id) {
+    public function actionDelete($id)
+    {
         $matrixErp = MatrixErp::findOne($id);
         $matrixErp->active = 0;
         $matrixErp->deleted_at = date('Y-m-d H:i:s');
@@ -264,4 +280,194 @@ class MatrixErpController extends Controller
 
         throw new NotFoundHttpException('The requested page does not exist.');
     }
-}
+
+
+    public function actionParseFlowwowCards()
+    {
+        $model = new HtmlImportForm();
+        $results = [];
+
+        if ($model->load(Yii::$app->request->post())) {
+            $model->files = UploadedFile::getInstances($model, 'files');
+
+            if ($model->validate()) {
+                foreach ($model->files as $file) {
+                    $results[] = $this->processFile($file, $model->category);
+                }
+
+                Yii::$app->session->setFlash('importResults', $results);
+                return $this->refresh();
+            }
+        }
+
+        return $this->render('/matrix_erp/parse-flowwow-cards', [
+            'model' => $model,
+            'results' => Yii::$app->session->getFlash('importResults', []),
+        ]);
+    }
+
+    /**
+     * Обработка одного файла: парсинг → поиск MatrixErp по артикулу → upsert MatrixErpProperty по GUID.
+     */
+    protected function processFile(UploadedFile $file, string $category): array
+    {
+        $res = [
+            'file' => $file->name,
+            'status' => 'ok',
+            'articule' => null,
+            'guid' => null,
+            'errors' => [],
+        ];
+
+        try {
+            $html = file_get_contents($file->tempName);
+
+            $service = new ProductParserService();
+
+            $parsed = $service->parseProductHtml($html);
+
+            $products = $this->normalizeParsed($parsed);
+            var_dump($products);die();
+            if (empty($products)) {
+                throw new \RuntimeException('Парсер вернул пустой результат.');
+            }
+
+            foreach ($products as $p) {
+                $art = $this->extractArticule($p['name'] ?? '');
+                $res['articule'] = $res['articule'] ?? $art;
+
+                if (!$art) {
+                    $res['status'] = 'error';
+                    $res['errors'][] = 'Артикул не найден в названии.';
+                    continue;
+                }
+
+                $matrix = MatrixErp::find()->where(['articule' => $art])->one();
+                if (!$matrix) {
+                    $res['status'] = 'error';
+                    $res['errors'][] = "Товар с артикулом {$art} не найден в MatrixErp.";
+                    continue;
+                }
+
+                $res['guid'] = $matrix->guid;
+
+                if (!$this->upsertProperty($matrix, $p, $category)) {
+                    $res['status'] = 'error';
+                    $res['errors'][] = "Не удалось сохранить свойства для GUID {$matrix->guid}.";
+                }
+            }
+        } catch (\Throwable $e) {
+            $res['status'] = 'error';
+            $res['errors'][] = $e->getMessage();
+            Yii::error($e->getMessage() . ' in ' . $file->name, __METHOD__);
+        }
+
+        return $res;
+    }
+
+
+    protected function normalizeParsed($parsed): array
+    {
+        if (!$parsed) return [];
+        if (is_array($parsed) && $this->isAssoc($parsed) && isset($parsed['name'])) {
+            return [$parsed];
+        }
+        return array_values((array)$parsed);
+    }
+
+    protected function isAssoc(array $arr): bool
+    {
+        // Аналог ArrayHelper::isAssociative для совместимости
+        if ([] === $arr) return false;
+        return array_keys($arr) !== range(0, count($arr) - 1);
+    }
+
+    /**
+     * Артикул из названия: ищем (FW0125) или просто FW0125, либо "Артикул: FW0125".
+     */
+    protected function extractArticule(string $name): ?string
+    {
+        $pattern = `/\\((?[A-Z]{2,4}\\d{3,6})\\)/u`;
+            if (preg_match($pattern, $name, $m)) {
+                return strtoupper($m['art']);
+            }
+
+        return null;
+    }
+
+    /**
+     * Upsert в matrix_erp_property по guid товара.
+     */
+    protected function upsertProperty(MatrixErp $matrix, array $matrixProduct, string $category, string $subcategory): bool
+    {
+        $prop = MatrixErpProperty::find()->where(['guid' => $matrix->guid])->one();
+        $nowDt = date('Y-m-d H:i:s');
+        $nowD = date('Y-m-d');
+
+        if (!$prop) {
+            $prop = new MatrixErpProperty();
+            $prop->guid = $matrix->guid;
+            $prop->matrix_erp_id = $matrix->guid;
+            $prop->date = $nowD;
+            $prop->created_admin_id = Yii::$app->user->id ?? null;
+            $prop->created_at = $nowDt;
+        } else {
+            $prop->updated_admin_id = Yii::$app->user->id ?? null;
+            $prop->updated_at = $nowDt;
+        }
+
+            try {
+                $uploadImage = FileService::downloadAsUploadedFile($externalUrl, maxBytes: 8_000_000);
+            } catch (\Throwable $e) {
+                Yii::error("Ошибка загрузки изображения по ссылке: " . $e->getMessage());
+
+                \Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
+                return ['error' => 'Не удалось скачать файл по ссылке'];
+            }
+
+
+        if ($uploadImage) {
+            if (Images::isImageFile($uploadImage, ['png', 'jpg', 'jpeg', 'webp', 'gif'])) {
+                $image = new Images();
+                $imageId = $image->loadImage($uploadImage); // ваш существующий метод (как при обычной загрузке)
+
+                if (!empty($imageId)) {
+                    $prop->image_id = $imageId;
+                    $prop->external_image_url = MarketplaceService::getProductImageUrl($imageId);
+                    if (!empty($oldFile)) {
+                        $oldFile->delete();
+                    }
+                    if (!$prop->save()) {
+                        Yii::error("Ошибка сохранения ссылок на картинки " . json_encode($modelEdit->getErrors(), JSON_UNESCAPED_UNICODE));
+                    }
+                }
+            } else {
+                \Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
+                return ['error' => 'Файл не является изображением либо запрещённое расширение'];
+            }
+        } else {
+            \Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
+            return ['error' => 'Не передан файл и не указана ссылка'];
+        }
+
+
+        $prop->display_name = $matrixProduct['name'] ?? $prop->display_name;
+        $prop->description = $matrixProduct['description'] ?? $prop->description;
+        $prop->external_image_url = $matrixProduct['image_url'] ?? $prop->external_image_url;
+        $prop->product_url = $matrixProduct['product_url'] ?? $prop->product_url;
+
+        // Категории
+        $prop->flowwow_category = $category;
+        $prop->flowwow_subcategory = $category;
+        $prop->flowwow_subcategory = $matrixProduct['properties']['subcategory'] ?? $prop->flowwow_subcategory;
+        $prop->yandex_category = $matrixProduct['properties']['yandex_category'] ?? $prop->yandex_category;
+
+
+        if (isset($matrixProduct['properties']['length'])) $prop->length = (float)$matrixProduct['properties']['length'];
+        if (isset($matrixProduct['properties']['width'])) $prop->width = (float)$matrixProduct['properties']['width'];
+        if (isset($matrixProduct['properties']['height'])) $prop->height = (float)$matrixProduct['properties']['height'];
+        if (isset($matrixProduct['properties']['weight'])) $prop->weight = (float)$matrixProduct['properties']['weight'];
+
+        return $prop->save();
+    }
+}
\ No newline at end of file
diff --git a/erp24/models/HtmlImportForm.php b/erp24/models/HtmlImportForm.php
new file mode 100644 (file)
index 0000000..e0f6f20
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+namespace app\models;
+
+use yii\base\Model;
+use yii\web\UploadedFile;
+
+class HtmlImportForm extends Model
+{
+    public $category;
+    public $subcategory;
+    /** @var UploadedFile[] */
+    public $files;
+
+    public function rules(): array
+    {
+        return [
+            [['category', 'subcategory'], 'required'],
+            [['category', 'subcategory'], 'string', 'max' => 255],
+
+            [['files'], 'required'],
+            [
+                ['files'],
+                'file',
+                'skipOnEmpty' => false,
+                'extensions'  => ['html', 'htm'],
+                'maxFiles'    => 100,
+            ],
+        ];
+    }
+
+    public function attributeLabels(): array
+    {
+        return [
+            'category' => 'Название категории',
+            'subcategory' => 'Название подкатегории',
+            'files'    => 'HTML файлы',
+        ];
+    }
+}
+
index 8e363a69b817b31d00fac5e976acacd5f681e82d..e0dca70c7ce9f7600b23a780c531db9a472165ee 100755 (executable)
@@ -2,9 +2,13 @@
 
 namespace yii_app\services;
 
+use GuzzleHttp\Client;
 use Yii;
 use yii\helpers\ArrayHelper;
+use yii\helpers\FileHelper;
 use yii\helpers\Url;
+
+use yii\web\UploadedFile;
 use yii_app\helpers\ImageHelper;
 use yii_app\records\Files;
 use yii_app\records\Images;
@@ -267,4 +271,84 @@ class FileService
         return $result;
     }
 
+    /**
+     * Скачивает файл по URL и возвращает "виртуальный" UploadedFile.
+     * Бросает исключение при ошибке/превышении лимита.
+     *
+     * @param string $url           Ссылка на файл (http/https)
+     * @param int    $maxBytes      Максимальный размер, байт (например, 8 МБ)
+     * @param int    $timeout       Таймаут запроса, сек
+     */
+    public static function downloadAsUploadedFile(string $url, int $maxBytes = 8_000_000, int $timeout = 20): UploadedFile
+    {
+        $scheme = parse_url($url, PHP_URL_SCHEME);
+        if (!in_array($scheme, ['http', 'https'], true)) {
+            throw new \RuntimeException('Недопустимая схема URL');
+        }
+
+        $client = new Client([
+            'timeout' => $timeout,
+            'verify'  => true,
+            'headers' => [
+                'User-Agent' => 'Mozilla/5.0 (compatible; Yii2 Guzzle Downloader)',
+                'Accept'     => 'image/*,*/*;q=0.8',
+            ],
+        ]);
+
+        $tmpDir   = \Yii::getAlias('@runtime/http-downloads');
+        FileHelper::createDirectory($tmpDir);
+        $tmpFile  = tempnam($tmpDir, 'img_');
+
+        $downloaded = 0;
+        $progress = function ($downloadTotal, $downloadedBytes) use (&$downloaded, $maxBytes) {
+            $downloaded = $downloadedBytes;
+            if ($downloaded > $maxBytes) {
+                throw new \RuntimeException('Файл слишком большой');
+            }
+        };
+
+        $response = $client->request('GET', $url, [
+            'sink'     => $tmpFile,
+            'progress' => $progress,
+        ]);
+
+        if ($response->getStatusCode() !== 200) {
+            @unlink($tmpFile);
+            throw new \RuntimeException('HTTP ' . $response->getStatusCode());
+        }
+
+        $mime = FileHelper::getMimeType($tmpFile) ?: 'application/octet-stream';
+        if (strncmp($mime, 'image/', 6) !== 0) {
+            @unlink($tmpFile);
+            throw new \RuntimeException('Загруженный файл не является изображением');
+        }
+
+        $pathFromUrl = parse_url($url, PHP_URL_PATH) ?: '';
+        $basename    = basename($pathFromUrl);
+        $name        = $basename ?: ('image_' . uniqid());
+        $ext         = pathinfo($name, PATHINFO_EXTENSION);
+
+         if ($ext === '') {
+            $map = [
+                'image/jpeg' => 'jpg',
+                'image/png'  => 'png',
+                'image/webp' => 'webp',
+                'image/gif'  => 'gif',
+            ];
+            $ext  = $map[$mime] ?? 'jpg';
+            $name = $name . '.' . $ext;
+        }
+
+        /** @var UploadedFile $uploaded */
+        $uploaded = new UploadedFile([
+            'name'     => $name,
+            'tempName' => $tmpFile,
+            'type'     => $mime,
+            'size'     => filesize($tmpFile),
+            'error'    => UPLOAD_ERR_OK,
+        ]);
+
+        return $uploaded;
+    }
+
 }
diff --git a/erp24/services/ProductParserService.php b/erp24/services/ProductParserService.php
new file mode 100644 (file)
index 0000000..7f7e6b9
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+
+namespace yii_app\services;
+use DOMDocument;
+use DOMXPath;
+
+class ProductParserService {
+    public function parseProductHtml(string $html): array
+    {
+
+        $dom = new DOMDocument();
+        libxml_use_internal_errors(true);
+        $dom->loadHTML($html);
+        libxml_clear_errors();
+
+        $xpath = new DOMXPath($dom);
+
+        return [
+            'name' => $this->extractName($xpath),
+            'price' => $this->extractPrice($xpath),
+            'old_price' => $this->extractOldPrice($xpath),
+            'rating' => $this->extractRating($xpath),
+            'reviews_count' => $this->extractReviewsCount($xpath),
+            'image_url' => $this->extractImageUrl($xpath),
+            'description' => $this->extractDescription($xpath),
+            'properties' => $this->extractProperties($xpath),
+
+        ];
+    }
+
+    private
+    function extractName(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//h1")->item(0);
+        return $node ? trim($node->nodeValue) : '';
+    }
+
+    private
+    function extractPrice(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//span[@class='footer-price']")->item(0);
+        return $node ? trim($node->nodeValue) : '';
+    }
+
+    private
+    function extractOldPrice(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//div[@class='footer-old-price']")->item(0);
+        return $node ? trim($node->nodeValue) : '';
+    }
+
+
+    private
+    function extractRating(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//div[@class='rating']//div[contains(@class, 'el-rate')]")->item(0);
+        return $node ? $node->getAttribute('data-score') : '';
+    }
+
+    private
+    function extractReviewsCount(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//div[@class='review-count']")->item(0);
+        return $node ? trim($node->nodeValue) : '';
+    }
+
+    private
+    function extractImageUrl(DOMXPath $xpath): string
+    {
+        $q1 = "//div[@id='js-detect-events']" .
+            "//div[contains(concat(' ', normalize-space(@class), ' '), ' swiper-slide-active ')]" .
+            "//img[contains(concat(' ', normalize-space(@class), ' '), ' main-image-content ')]";
+
+        $img = $xpath->query($q1)->item(0);
+
+        if (!$img) {
+            $q2 = "//div[@id='js-detect-events']//img[contains(concat(' ', normalize-space(@class), ' '), ' main-image-content ')]";
+            $img = $xpath->query($q2)->item(0);
+        }
+
+        $src = null;
+        if ($img) {
+            $src = $img->getAttribute('data-src');
+        }
+
+        if (!$src) {
+            $zoom = $xpath->query("//div[@id='js-image-zoom']")->item(0);
+            if ($zoom && preg_match('/background-image:\s*url\([\"\']?(.*?)[\"\']?\)/i', $zoom->getAttribute('style'), $m)) {
+                $src = $m[1];
+            }
+        }
+        return $src;
+    }
+
+    private
+    function extractDescription(DOMXPath $xpath): string
+    {
+        $node = $xpath->query("//div[@class='product-properties-general']" . "//div[@class='property-item']" .  "//p[@class='pre-line']" . "//span")->item(0);
+        return $node ? trim($node->nodeValue) : '';
+    }
+
+    function extractProperties(DOMXPath $xpath): array
+    {
+        $properties = [];
+
+        $nodes = $xpath->query("//div[contains(concat(' ', normalize-space(@class), ' '), ' property-item ')]");
+
+        foreach ($nodes as $node) {
+
+            $nameNode = $xpath->query(".//h3[contains(concat(' ', normalize-space(@class), ' '), ' property-name ')]", $node)->item(0);
+            if (!$nameNode) {
+                continue;
+            }
+            $propName = trim($nameNode->textContent);
+
+            if (mb_stripos($propName, 'Размер') !== false) {
+                $sizeSpans = $xpath->query(
+                    ".//li[contains(concat(' ', normalize-space(@class), ' '), ' size-item ')]" .
+                    "//span[contains(concat(' ', normalize-space(@class), ' '), ' size-text ')]",
+                    $node
+                );
+
+                $sizes = [];
+                foreach ($sizeSpans as $span) {
+                    $text = trim(preg_replace('/\s+/u', ' ', $span->textContent));
+
+                    if (preg_match('/(Ширина|Высота|Длина)\s*[-:–—]\s*(\d+(?:[.,]\d+)?)\s*см/ui', $text, $m)) {
+                        $label = ucfirst(mb_strtolower($m[1]));
+                        $value = (float) str_replace(',', '.', $m[2]);
+                        $sizes[$label] = $value;
+                    }
+                }
+
+                if ($sizes) {
+                    $properties['Размер'] = $sizes;
+                    if (isset($sizes['Ширина'])) $properties['width']  = $sizes['Ширина'];
+                    if (isset($sizes['Высота'])) $properties['height'] = $sizes['Высота'];
+                }
+
+                continue;
+            }
+
+
+            $valueNode = $xpath->query(
+                ".//*[contains(concat(' ', normalize-space(@class), ' '), ' property-text ') " .
+                "   or contains(concat(' ', normalize-space(@class), ' '), ' pre-line ')]",
+                $node
+            )->item(0);
+
+            if ($valueNode) {
+                $properties[$propName] = trim(preg_replace('/\s+/u', ' ', $valueNode->textContent));
+            }
+        }
+
+
+        if (!isset($properties['width']) || !isset($properties['height'])) {
+            $tagSpans = $xpath->query("//ul[contains(@class,'size-tag')]//li//span[not(contains(@class,'size-tag-icon'))]");
+            $seen = [];
+            foreach ($tagSpans as $span) {
+                $text = trim($span->textContent);
+                if (preg_match('/(\d+(?:[.,]\d+)?)\s*см/ui', $text, $m)) {
+                    $val = (float) str_replace(',', '.', $m[1]);
+                    $seen[] = $val;
+                }
+            }
+            if (!empty($seen)) {
+                if (!isset($properties['width'])  && isset($seen[0])) $properties['width']  = $seen[0];
+                if (!isset($properties['height']) && isset($seen[1])) $properties['height'] = $seen[1];
+            }
+        }
+
+        return $properties;
+    }
+
+}
\ No newline at end of file
diff --git a/erp24/views/matrix_erp/parse-flowwow-cards.php b/erp24/views/matrix_erp/parse-flowwow-cards.php
new file mode 100644 (file)
index 0000000..e1426f2
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+/** @var yii\web\View $this */
+/** @var app\models\HtmlImportForm $model */
+/** @var array $results */
+
+use yii\helpers\Html;
+use yii\widgets\ActiveForm;
+
+$this->title = 'Импорт свойств из HTML';
+?>
+<div class="card">
+    <div class="card-body">
+        <h3><?= Html::encode($this->title) ?></h3>
+
+        <?php $form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?>
+
+        <?= $form->field($model, 'category')->textInput(['maxlength' => true, 'placeholder' => 'Например: Цветы']) ?>
+        <?= $form->field($model, 'subcategory')->textInput(['maxlength' => true, 'placeholder' => 'Например: Монобукеты']) ?>
+
+        <?= $form->field($model, 'files[]')->fileInput([
+            'multiple' => true,
+            'accept'   => '.html,.htm',
+        ]) ?>
+
+        <div class="form-group">
+            <?= Html::submitButton('Сохранить', ['class' => 'btn btn-primary']) ?>
+        </div>
+
+        <?php ActiveForm::end(); ?>
+    </div>
+</div>
+
+<?php if (!empty($results)): ?>
+    <div class="card mt-3">
+        <div class="card-body">
+            <h5>Результаты импорта</h5>
+            <div class="table-responsive">
+                <table class="table table-sm table-bordered align-middle">
+                    <thead>
+                    <tr>
+                        <th>Файл</th>
+                        <th>Артикул</th>
+                        <th>GUID</th>
+                        <th>Статус</th>
+                        <th>Ошибки</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <?php foreach ($results as $r): ?>
+                        <tr>
+                            <td><?= Html::encode($r['file']) ?></td>
+                            <td><?= Html::encode($r['articule'] ?? '') ?></td>
+                            <td><?= Html::encode($r['guid'] ?? '') ?></td>
+                            <td>
+                                <?php if (($r['status'] ?? 'ok') === 'ok'): ?>
+                                    <span class="badge bg-success">ok</span>
+                                <?php else: ?>
+                                    <span class="badge bg-danger">error</span>
+                                <?php endif; ?>
+                            </td>
+                            <td>
+                                <?php if (!empty($r['errors'])): ?>
+                                    <ul class="mb-0">
+                                        <?php foreach ($r['errors'] as $e): ?>
+                                            <li><?= Html::encode($e) ?></li>
+                                        <?php endforeach; ?>
+                                    </ul>
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+<?php endif; ?>
index 6aa70cef4add5628d462e61ea1c37b9cd54a95e1..1f95de8983246e65ca9385fb5ad3a1b6738f477a 100644 (file)
@@ -25,7 +25,7 @@ $this->registerJsFile('/js/products1cNomenclatureActuality/index.js', ['position
 function monthList()
 {
     $list = [];
-    $start = new DateTime('2025-01');
+    $start = new DateTime('2024-01');
     $end = new DateTime('2026-12');
     while ($start <= $end) {
         $key = $start->format('Y-m');