车辆基本数据抓取
This commit is contained in:
@@ -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 '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user