tural před 5 dny
rodič
revize
fc851d6ab0

+ 1 - 0
.gitignore

@@ -20,3 +20,4 @@ yarn-error.log
 /.vscode
 /.zed
 .DS_Store
+/app/Services/Parser/tmp/*

+ 23 - 0
app/Console/Commands/ParseCars.php

@@ -0,0 +1,23 @@
+<?php
+namespace App\Console\Commands;
+
+use App\Services\Parser\Parser;
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
+use Illuminate\Console\Command;
+
+#[Signature('app:parse-cars')]
+#[Description('Command description')]
+class ParseCars extends Command
+{
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $cars = (new Parser())->run();
+
+        file_put_contents('cars.json', json_encode($cars, JSON_PRETTY_PRINT));
+        dd($cars);
+    }
+}

+ 182 - 0
app/Services/Parser/Parser.php

@@ -0,0 +1,182 @@
+<?php
+namespace App\Services\Parser;
+
+use Exception;
+use Illuminate\Support\Facades\Http;
+
+class Parser
+{
+    /**
+     * Пространство имён папки Portals
+     */
+    const PORTALS_NAMESPACE = 'App\\Services\\Parser\\Portals\\';
+
+    /**
+     * Конфигурация (из config/parser.php)
+     */
+    private array $config;
+
+    public function __construct()
+    {
+        $this->config = config('parser');
+    }
+
+    // -------------------------------------------------------------------------
+    // Public API
+    // -------------------------------------------------------------------------
+
+    /**
+     * Главная точка входа.
+     * 1. Обходит активные порталы из конфига
+     * 2. Вызывает parse() на каждом → данные оседают в tmp
+     * 3. Читает результаты через getResult()
+     * 4. Отправляет HTML в ChatGPT и получает JSON
+     * 5. Возвращает массив готовых данных для создания Car
+     */
+    public function run(): array
+    {
+        $cars = [];
+
+        foreach ($this->getActivePortals() as $portalInstance) {
+            // Шаг 1: загружаем страницы и сохраняем HTML во временные файлы
+            $portalInstance->parse();
+
+            // Шаг 2: читаем HTML из tmp
+            $results = $portalInstance->getResult();
+
+            // Шаг 3: каждый HTML → ChatGPT → массив полей Car
+            foreach ($results as $item) {
+                try {
+                    $carData = $this->processWithGpt($item['html']);
+
+                    if ($carData !== null) {
+                        $cars[] = $carData;
+                    }
+                } catch (Exception $e) {
+                    error_log('[Parser] GPT error for file ' . ($item['file'] ?? '?') . ': ' . $e->getMessage());
+                }
+            }
+
+            // clearData() — не вызываем пока, но метод готов
+            // $portalInstance->clearData();
+        }
+
+        return $cars;
+    }
+
+    // -------------------------------------------------------------------------
+    // Portal loading
+    // -------------------------------------------------------------------------
+
+    /**
+     * Возвращает массив инстансов активных порталов из конфига.
+     * Пример конфига:
+     *   'portals' => ['Encar' => true, 'Drom' => false]
+     *
+     * @return object[]
+     */
+    private function getActivePortals(): array
+    {
+        $portals   = $this->config['portals'] ?? [];
+        $instances = [];
+
+        foreach ($portals as $name => $enabled) {
+            if (! $enabled) {
+                continue;
+            }
+
+            $class = self::PORTALS_NAMESPACE . $name;
+
+            if (! class_exists($class)) {
+                error_log("[Parser] Класс портала не найден: $class");
+                continue;
+            }
+
+            $instance = new $class();
+
+            // Убеждаемся что у класса есть нужные методы
+            if (! method_exists($instance, 'parse') || ! method_exists($instance, 'getResult')) {
+                error_log("[Parser] Портал $name не реализует parse() / getResult()");
+                continue;
+            }
+
+            $instances[] = $instance;
+        }
+
+        return $instances;
+    }
+
+    // -------------------------------------------------------------------------
+    // GPT integration
+    // -------------------------------------------------------------------------
+
+    /**
+     * Отправляет HTML в ChatGPT, получает JSON, декодирует в массив.
+     * Возвращает null если GPT вернул невалидный JSON.
+     */
+    private function processWithGpt(string $html): ?array
+    {
+        $prompt = $this->buildPrompt($html);
+        $raw    = $this->callChatGpt($prompt);
+
+        // Убираем возможные markdown-обёртки ```json … ```
+        $json = preg_replace('/^```(?:json)?\s*/i', '', trim($raw));
+        $json = preg_replace('/\s*```$/', '', $json);
+
+        $data = json_decode($json, true);
+
+        if (! is_array($data)) {
+            error_log('[Parser] GPT вернул невалидный JSON: ' . substr($raw, 0, 200));
+            return null;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Подставляет HTML в промт из конфига (плейсхолдер {html})
+     */
+    private function buildPrompt(string $html): string
+    {
+        $template = $this->config['gpt_prompt'] ?? '{html}';
+
+        // Обрезаем HTML чтобы не превысить контекст модели (~100k символов достаточно)
+        $trimmedHtml = mb_substr($html, 0, 80000);
+
+        return str_replace('{html}', $trimmedHtml, $template);
+    }
+
+    /**
+     * Отправляет запрос к OpenAI Chat Completions API и возвращает текст ответа.
+     *
+     * @throws Exception при сетевых ошибках или ошибках API
+     */
+    private function callChatGpt(string $prompt): string
+    {
+        $cfg    = $this->config['openai'] ?? [];
+        $apiKey = $cfg['api_key'] ?? config('services.openai.key', '');
+        $model  = $cfg['model'] ?? 'gpt-4o';
+        $maxTok = $cfg['max_tokens'] ?? 2000;
+        $temp   = $cfg['temperature'] ?? 0;
+
+        if (empty($apiKey)) {
+            throw new Exception('OPENAI_API_KEY не задан в конфиге или .env');
+        }
+
+        $response = Http::withToken($apiKey)
+            ->timeout(60)
+            ->post('https://api.openai.com/v1/chat/completions', [
+                'model'    => $model,
+                'messages' => [
+                    ['role' => 'user', 'content' => $prompt],
+                ],
+            ]);
+
+        if ($response->failed()) {
+            $error = $response->json('error.message', 'unknown');
+            throw new Exception("OpenAI API error [{$response->status()}]: {$error}");
+        }
+
+        return $response->json('choices.0.message.content', '');
+    }
+}

+ 231 - 0
app/Services/Parser/Portals/Encar.php

@@ -0,0 +1,231 @@
+<?php
+namespace App\Services\Parser\Portals;
+
+use Illuminate\Support\Facades\Http;
+
+class Encar
+{
+    /**
+     * Интервал между запросами в секундах.
+     * Переопределяет глобальный request_interval из config/parser.php.
+     */
+    const REQUEST_INTERVAL = 2;
+
+    /**
+     * Папка для временного хранения HTML-файлов
+     */
+    const TMP_DIR = __DIR__ . '/../tmp';
+
+    /**
+     * User-Agent для HTTP-запросов
+     */
+    const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
+
+    // -------------------------------------------------------------------------
+    // Public API
+    // -------------------------------------------------------------------------
+
+    /**
+     * Точка входа: загружает страницы и сохраняет HTML во временную папку.
+     */
+    public function parse(): void
+    {
+        $contents = $this->getContent();
+        $this->saveContent($contents);
+    }
+
+    /**
+     * Читает все HTML-файлы из tmp и возвращает массив:
+     * [
+     *   ['file' => '/absolute/path/to/file.html', 'html' => '<html>...'],
+     *   ...
+     * ]
+     */
+    public function getResult(): array
+    {
+        $tmpDir = self::TMP_DIR;
+
+        if (! is_dir($tmpDir)) {
+            return [];
+        }
+
+        $files   = glob($tmpDir . '/*.html') ?: [];
+        $results = [];
+
+        foreach ($files as $file) {
+            $html = file_get_contents($file);
+
+            if ($html === false) {
+                error_log("[Encar] Не удалось прочитать файл: $file");
+                continue;
+            }
+
+            $results[] = [
+                'file' => $file,
+                'html' => $html,
+            ];
+        }
+
+        return $results;
+    }
+
+    /**
+     * Удаляет все HTML-файлы из tmp.
+     * Метод готов, но пока не вызывается из Parser.
+     */
+    public function clearData(): void
+    {
+        $tmpDir = self::TMP_DIR;
+
+        if (! is_dir($tmpDir)) {
+            return;
+        }
+
+        $files = glob($tmpDir . '/*.html') ?: [];
+
+        foreach ($files as $file) {
+            if (! unlink($file)) {
+                error_log("[Encar] Не удалось удалить файл: $file");
+            }
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Private
+    // -------------------------------------------------------------------------
+
+    /**
+     * Список ссылок для парсинга
+     */
+    private function getLinks(): array
+    {
+        return [
+            'https://fem.encar.com/cars/detail/41664398?pageid=dc_carsearch&listAdvType=pic&carid=41664398&view_type=checked&wtClick_korList=015&advClickPosition=kor_pic_p1_g1',
+            'https://fem.encar.com/cars/detail/41939059?pageid=dc_carsearch&listAdvType=pic&carid=41939059&view_type=checked&wtClick_korList=015&advClickPosition=kor_pic_p1_g2',
+        ];
+    }
+
+    /**
+     * Загружает HTML-контент по всем ссылкам.
+     * Возвращает массив: [ ['url' => ..., 'html' => ...], ... ]
+     */
+    private function getContent(): array
+    {
+        $links   = $this->getLinks();
+        $results = [];
+
+        foreach ($links as $index => $url) {
+            $html = $this->fetchUrl($url);
+
+            if ($html !== false) {
+                $body = $this->extractBody($html);
+
+                if ($body !== null) {
+                    $results[] = [
+                        'url'  => $url,
+                        'html' => $body,
+                    ];
+                } else {
+                    error_log("[Encar] Не удалось извлечь <body>: $url");
+                }
+            } else {
+                error_log("[Encar] Не удалось загрузить: $url");
+            }
+
+            // Пауза между запросами (кроме последнего)
+            if ($index < count($links) - 1) {
+                sleep(self::REQUEST_INTERVAL);
+            }
+        }
+
+        return $results;
+    }
+
+/**
+ * Извлекает содержимое тега <body> из HTML-строки.
+ * Возвращает null если тег не найден.
+ */
+    private function extractBody(string $html): ?string
+    {
+        $dom = new \DOMDocument();
+
+        // Подавляем ошибки парсинга (битый HTML, корейские символы и т.д.)
+        libxml_use_internal_errors(true);
+
+        // UTF-8 hint — иначе DOMDocument может неверно определить кодировку
+        $dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
+
+        libxml_clear_errors();
+
+        $body = $dom->getElementsByTagName('body')->item(0);
+
+        if ($body === null) {
+            return null;
+        }
+
+        // Сериализуем innerHTML тега <body> (без самих тегов <body></body>)
+        $innerHTML = '';
+        foreach ($body->childNodes as $child) {
+            $innerHTML .= $dom->saveHTML($child);
+        }
+
+        return trim($innerHTML);
+    }
+
+    /**
+     * Выполняет HTTP GET-запрос с кастомным User-Agent
+     */
+    private function fetchUrl(string $url): string | false
+    {
+        $response = Http::withHeaders([
+            'User-Agent'      => self::USER_AGENT,
+            'Accept'          => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+            'Accept-Language' => 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
+            'Connection'      => 'keep-alive',
+        ])
+            ->timeout(30)
+            ->withOptions(['allow_redirects' => true])
+            ->get($url);
+
+        if ($response->failed()) {
+            return false;
+        }
+
+        return $response->body();
+    }
+
+    /**
+     * Сохраняет полученный HTML в файлы в папке tmp
+     */
+    private function saveContent(array $contents): void
+    {
+        if (! is_dir(self::TMP_DIR)) {
+            mkdir(self::TMP_DIR, 0755, true);
+        }
+
+        foreach ($contents as $item) {
+            $carId    = $this->extractCarId($item['url']);
+            $filename = self::TMP_DIR . '/' . $carId . '_' . time() . '.html';
+
+            $written = file_put_contents($filename, $item['html']);
+
+            if ($written === false) {
+                error_log("[Encar] Не удалось сохранить файл: $filename");
+            } else {
+                echo "[Encar] Сохранено: $filename" . PHP_EOL;
+            }
+        }
+    }
+
+    /**
+     * Извлекает carid из URL, либо возвращает md5-хэш URL
+     */
+    private function extractCarId(string $url): string
+    {
+        if (preg_match('/carid=(\d+)/', $url, $matches)) {
+            return $matches[1];
+        }
+
+        return md5($url);
+    }
+}

+ 75 - 0
config/parser.php

@@ -0,0 +1,75 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Активные порталы
+    |--------------------------------------------------------------------------
+    | Ключ — имя класса в App\Services\Parser\Portals\
+    | Значение true/false — включён/выключен
+    */
+    'portals'          => [
+        'Encar' => true,
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Интервал между запросами (секунды)
+    |--------------------------------------------------------------------------
+    | Можно переопределить глобально здесь, либо константой внутри портала.
+    | Портальная константа REQUEST_INTERVAL имеет приоритет.
+    */
+    'request_interval' => 2,
+
+    /*
+    |--------------------------------------------------------------------------
+    | Промт для ChatGPT
+    |--------------------------------------------------------------------------
+    | {html} — плейсхолдер, куда подставляется HTML страницы автомобиля.
+    | Ответ модели должен быть строго JSON (без markdown-обёртки).
+    */
+    'gpt_prompt'       => <<<'PROMPT'
+        Вы — помощник по извлечению данных об автомобилях.
+
+        Извлеките всю доступную информацию об объявлении автомобиля из HTML-кода ниже и верните её в виде одного корректного JSON-объекта.
+
+        Правила:
+
+        * Верните ТОЛЬКО чистый JSON, без Markdown, без обратных кавычек и без пояснений.
+        * Все ключи должны точно соответствовать предоставленной схеме.
+        * Если какое-либо поле отсутствует в HTML, используйте значение null.
+        * Для булевых полей используйте true/false (не 1/0 и не строки).
+        * Для числовых полей используйте числа (не строки).
+        * Допустимые значения для `engine_type`: `petrol` | `diesel` | `hybrid` | `electric` | `gas` | `other`
+        * Допустимые значения для `transmission`: `manual` | `automatic` | `robot` | `variator` | `electric`
+        * Допустимые значения для `drive`: `FWD` | `RWD` | `AWD` | `4WD`
+        * Допустимые значения для `steering`: `left` | `right`
+        * Допустимые значения для `pts`: `original` | `duplicate` | `electronic` | `null`
+        * Допустимые значения для `condition`: `new` | `used`
+        * Поле `status` всегда устанавливайте в значение `"draft"`
+        * Поле `options` должно содержать массив строк с описанием найденных на странице опций и оснащения автомобиля
+        * Поле `photos_gallery` должно содержать массив абсолютных URL-адресов изображений, найденных на странице
+        * Поле `photo_main` должно содержать URL основного (обложечного) изображения
+        * В объекте `prices` извлекайте все найденные цены и валюты; отсутствующие значения оставляйте как `null`
+        * Поле `country_origin` должно содержать название страны на русском языке (например: `"Корея"`, `"Япония"`)
+        * Информацию о марке, модели и модификации надо перевести на англйиский, остальное на русский язык
+
+
+        HTML:
+        {html}
+        PROMPT,
+
+    /*
+    |--------------------------------------------------------------------------
+    | ChatGPT / OpenAI настройки
+    |--------------------------------------------------------------------------
+    */
+    'openai'           => [
+        'api_key'     => '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',
+        'model'       => 'chat-latest',
+        //'max_tokens'  => 2000,
+        'temperature' => 0,
+    ],
+
+];