diff --git a/app/common/service/UsedCarDetailService.php b/app/common/service/UsedCarDetailService.php index 4f90c97..4b93076 100644 --- a/app/common/service/UsedCarDetailService.php +++ b/app/common/service/UsedCarDetailService.php @@ -10,6 +10,9 @@ use support\exception\BusinessException; use support\Log; use support\Request; use support\Redis; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use QL\QueryList; class UsedCarDetailService { @@ -175,6 +178,21 @@ class UsedCarDetailService } } + // 如果提供了 crawl_car_id,自动抓取懂车帝车辆参数 + if (!empty($data['crawl_car_id'])) { + try { + $params = $this->crawlParams($data['crawl_car_id']); + // 合并抓取的数据,手动提交的字段优先级更高 + $data = array_merge($params, $data); + } catch (\Throwable $e) { + // 抓取失败不阻断创建,记录日志 + Log::warning('抓取车辆参数失败', [ + 'crawl_car_id' => $data['crawl_car_id'], + 'error' => $e->getMessage(), + ]); + } + } + return $data; } @@ -448,4 +466,374 @@ class UsedCarDetailService 'total' => $query->count(), ]; } + + /** + * 抓取懂车帝车辆参数 + * + * @param string $crawlCarId 车型ID + * @return array 格式化后的参数数据 + * @throws BusinessException + */ + public function crawlParams(string $crawlCarId): array + { + $url = 'https://www.dongchedi.com/auto/params-carIds-' . $crawlCarId; + + // 请求头配置 + $headers = [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/', + ]; + + try { + $client = new Client(); + $response = $client->request('GET', $url, [ + 'headers' => $headers, + 'timeout' => 10, + 'verify' => false, // 禁用SSL验证 + ]); + + $html = (string)$response->getBody(); + + if (empty($html)) { + throw new BusinessException('页面内容为空'); + } + + // 解析HTML获取JSON数据 + $ql = QueryList::html($html); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + if (empty($jsonText)) { + throw new BusinessException('未找到 __NEXT_DATA__ JSON'); + } + + $raw = json_decode($jsonText, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BusinessException('JSON解析失败: ' . json_last_error_msg()); + } + + // 提取数据 + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + if (empty($properties) || empty($carInfo)) { + throw new BusinessException('数据结构异常,无法提取参数'); + } + + // 构建值映射表和分组 + $valuesMap = $this->buildValuesMap($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + // 映射到数据库字段 + return $this->mapParamsToDbFields($groups, $carInfo); + + } catch (GuzzleException $e) { + Log::error('抓取懂车帝参数失败', [ + 'crawl_car_id' => $crawlCarId, + 'error' => $e->getMessage(), + ]); + throw new BusinessException('抓取参数失败: ' . $e->getMessage()); + } catch (\Throwable $e) { + Log::error('解析懂车帝参数失败', [ + 'crawl_car_id' => $crawlCarId, + 'error' => $e->getMessage(), + ]); + throw new BusinessException('解析参数失败: ' . $e->getMessage()); + } + } + + /** + * 构建值映射表 + */ + private function buildValuesMap(array $carInfo): array + { + $valuesMap = []; + + // 顶层字段 + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // info中的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 构建车辆规格分组 + */ + private function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // 分类字段:type = 0 / 2 + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // 普通字段:挂到最近的分类下 + if ($currentKey === null) { + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + return array_values($groups); + } + + /** + * 从分组数据中获取字段值 + */ + private function getFieldValue($groups, string $field): ?string + { + if (is_string($groups)) { + $groups = json_decode($groups, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + } + + if (!is_array($groups)) { + return null; + } + + foreach ($groups as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (($item['key'] ?? null) === $field) { + return $item['value'] ?? null; + } + + if (!empty($item['children']) && is_array($item['children'])) { + foreach ($item['children'] as $child) { + if (($child['key'] ?? null) === $field) { + return $child['value'] ?? null; + } + } + } + } + } + } + + return null; + } + + /** + * 解析尺寸字符串 (如 "4890x1890x1680" 转为长宽高) + */ + private function parseDimensions(string $dimensionStr): array + { + $result = ['body_length' => '', 'body_width' => '', 'body_height' => '']; + + if (empty($dimensionStr)) { + return $result; + } + + $parts = explode('x', strtolower(str_replace(['×', 'X', 'X'], 'x', $dimensionStr))); + if (count($parts) >= 3) { + $result['body_length'] = trim($parts[0]); + $result['body_width'] = trim($parts[1]); + $result['body_height'] = trim($parts[2]); + } + + return $result; + } + + /** + * 转换安全配置值 (●→标配, ○→选装, -→空) + */ + private function convertSafetyValue(?string $value): string + { + if ($value === '●') { + return '标配'; + } elseif ($value === '○') { + return '选装'; + } elseif ($value === '-') { + return ''; + } + return $value ?? ''; + } + + /** + * 映射参数到数据库字段 + * DongChedi 字段 → 数据库字段 + */ + private function mapParamsToDbFields(array $groups, array $carInfo): array + { + $result = []; + + // 从 car_info 直接获取的字段 + $result['official_guide_price'] = $carInfo['official_price'] ?? ''; + + // 1. 基本信息 + $result['manufacturer'] = $this->getFieldValue($groups, 'sub_brand_name') ?? ''; + $result['level'] = $this->getFieldValue($groups, 'jb') ?? ''; + $result['energy_type'] = $this->getFieldValue($groups, 'fuel_form') ?? ''; + $result['listing_time'] = $this->getFieldValue($groups, 'market_time') ?? ''; + $result['engine'] = $this->getFieldValue($groups, 'engine_description') ?? ''; + $result['electric_motor'] = $this->getFieldValue($groups, 'electric_description') ?? ''; + $result['nedc_range'] = $this->parseNumeric($this->getFieldValue($groups, 'nedc_range')); + $result['cltc_range'] = $this->parseNumeric($this->getFieldValue($groups, 'cltc_range')); + $result['fast_charge_platform'] = $this->getFieldValue($groups, 'fast_charge_platform') ?? ''; + $result['charging_time'] = $this->parseDecimal($this->getFieldValue($groups, 'charging_time')); + $result['fast_charge_percent'] = $this->parseNumeric($this->getFieldValue($groups, 'fast_charge_percent')); + $result['max_power_kw'] = $this->parseNumeric($this->getFieldValue($groups, 'energy_elect_max_power')); + $result['max_torque_nm'] = $this->parseNumeric($this->getFieldValue($groups, 'energy_elect_max_torque')); + $result['transmission_desc'] = $this->getFieldValue($groups, 'gearbox_description') ?? ''; + $result['dimensions'] = $this->getFieldValue($groups, 'length_width_height') ?? ''; + $result['body_structure'] = $this->getFieldValue($groups, 'car_body_struct') ?? ''; + $result['max_speed'] = $this->parseNumeric($this->getFieldValue($groups, 'max_speed')); + $result['acceleration_100'] = $this->parseDecimal($this->getFieldValue($groups, 'acceleration_time')); + $result['nedc_fuel_consumption'] = $this->parseDecimal($this->getFieldValue($groups, 'fuel_comprehensive')); + $result['power_consumption_100'] = $this->parseDecimal($this->getFieldValue($groups, 'power_consumption_100')); + $result['equivalent_fuel_consumption'] = $this->parseDecimal($this->getFieldValue($groups, 'equivalent_fuel_consumption')); + $result['maintenance_cost_60k'] = $this->parseDecimal($this->getFieldValue($groups, 'maintain_cost')); + + // 2. 车身信息 (解析尺寸) + $dimensionStr = $this->getFieldValue($groups, 'length_width_height'); + $dimensions = $this->parseDimensions($dimensionStr ?? ''); + $result['body_length'] = $dimensions['body_length']; + $result['body_width'] = $dimensions['body_width']; + $result['body_height'] = $dimensions['body_height']; + + $result['wheelbase'] = $this->parseNumeric($this->getFieldValue($groups, 'wheelbase')); + $result['door_count'] = $this->parseNumeric($this->getFieldValue($groups, 'door_nums')); + $result['door_open_type'] = $this->getFieldValue($groups, 'door_open_way') ?? ''; + $result['seat_count'] = $this->parseNumeric($this->getFieldValue($groups, 'seat_count')); + + // 3. 变速箱 + $result['gear_count'] = $this->parseNumeric($this->getFieldValue($groups, 'stalls')); + $result['transmission_type'] = $this->getFieldValue($groups, 'gearbox_type') ?? ''; + + // 4. 底盘/转向 + $result['drive_mode'] = $this->getFieldValue($groups, 'driver_form') ?? ''; + $result['front_suspension'] = $this->getFieldValue($groups, 'front_suspension_form') ?? ''; + $result['rear_suspension'] = $this->getFieldValue($groups, 'rear_suspension_form') ?? ''; + $result['steering_type'] = $this->getFieldValue($groups, 'power_steering_type') ?? ''; + + // 5. 主动安全配置 + $result['has_abs'] = $this->convertSafetyValue($this->getFieldValue($groups, 'abs_anti_lock')); + $result['has_ebd'] = $this->convertSafetyValue($this->getFieldValue($groups, 'brake_force')); + $result['has_bra'] = $this->convertSafetyValue($this->getFieldValue($groups, 'brake_assist')); + $result['has_tcs'] = $this->convertSafetyValue($this->getFieldValue($groups, 'traction_control')); + $result['has_esp'] = $this->convertSafetyValue($this->getFieldValue($groups, 'body_stability_system')); + $result['has_active_brake'] = $this->convertSafetyValue($this->getFieldValue($groups, 'active_brake')); + $result['has_lane_assist'] = $this->convertSafetyValue($this->getFieldValue($groups, 'line_support')); + $result['has_lka'] = $this->convertSafetyValue($this->getFieldValue($groups, 'lane_keeping_assist')); + $result['has_lane_centering'] = $this->convertSafetyValue($this->getFieldValue($groups, 'lane_center')); + $result['has_fatigue_remind'] = $this->convertSafetyValue($this->getFieldValue($groups, 'fatigue_driving_warning')); + + // 6. 车轮/制动 + $result['front_brake_type'] = $this->getFieldValue($groups, 'front_brake_type') ?? ''; + $result['rear_brake_type'] = $this->getFieldValue($groups, 'rear_brake_type') ?? ''; + $result['parking_brake_type'] = $this->getFieldValue($groups, 'park_brake_type') ?? ''; + $result['front_tire_spec'] = $this->getFieldValue($groups, 'front_tire_size') ?? ''; + $result['rear_tire_spec'] = $this->getFieldValue($groups, 'rear_tire_size') ?? ''; + + // 确保空字符串不被转为null + foreach ($result as $key => $value) { + if ($value === null) { + $result[$key] = ''; + } + } + + return $result; + } + + /** + * 解析数值字段 + */ + private function parseNumeric(?string $value): string + { + if ($value === null || $value === '') { + return ''; + } + // 提取数字部分 + if (preg_match('/\d+/', $value, $matches)) { + return $matches[0]; + } + return ''; + } + + /** + * 解析小数 + */ + private function parseDecimal(?string $value): string + { + if ($value === null || $value === '') { + return ''; + } + // 提取数字和小数点 + if (preg_match('/[\d.]+/', $value, $matches)) { + return $matches[0]; + } + return ''; + } } \ No newline at end of file