diff --git a/app/common/controller/AuthController.php b/app/common/controller/AuthController.php index 26323e2..a568b82 100644 --- a/app/common/controller/AuthController.php +++ b/app/common/controller/AuthController.php @@ -38,6 +38,29 @@ class AuthController extends BaseController return $this->error($e->getMessage().$e->getLine().$e->getFile()); } } + + /** + * JSAPI鉴权签名 + * @param Request $request + * @return \support\Response + */ + public function signature(Request $request) + { + try { + $app_id = $request->get('app_id', ''); + $url = $request->get('url', ''); + if (empty($app_id)) { + throw new BusinessException('app_id 参数不能为空'); + } + if (empty($url)) { + throw new BusinessException('url 参数不能为空'); + } + $result = (new AuthService())->feishuSignature($app_id, $url); + return $this->success($result); + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getLine().$e->getFile()); + } + } public function login(Request $request) { diff --git a/app/common/controller/UsedCarController.php b/app/common/controller/UsedCarController.php index ce2a133..2c3433d 100644 --- a/app/common/controller/UsedCarController.php +++ b/app/common/controller/UsedCarController.php @@ -5,6 +5,7 @@ use app\common\controller\BaseController; use app\common\service\OssService; use app\common\traits\crud\CrudControllerTrait; use app\common\service\UsedCarService; +use support\Request; class UsedCarController extends BaseController { @@ -66,4 +67,14 @@ class UsedCarController extends BaseController } return $data; } + + public function crawlKeyword(Request $request){ + try { +// $keyword = $request->get('keyword'); + $data = $this->service->crawlKeyword($request); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } } \ No newline at end of file diff --git a/app/common/logic/AuthLogic.php b/app/common/logic/AuthLogic.php index f2541ee..f4510f1 100644 --- a/app/common/logic/AuthLogic.php +++ b/app/common/logic/AuthLogic.php @@ -309,7 +309,7 @@ class AuthLogic /** * 获取飞书应用信息 */ - public function feishuAppInfo(string $app_id, string $systemCode) + public function feishuAppInfo(string $app_id, string $systemCode='') { $fsAppInfo = CorpFeishuApp::where('app_id', $app_id)->first(); return [ diff --git a/app/common/service/AuthService.php b/app/common/service/AuthService.php index 727ac41..6f961a3 100644 --- a/app/common/service/AuthService.php +++ b/app/common/service/AuthService.php @@ -21,6 +21,75 @@ class AuthService return ['auth_url' => $feishu->authorize($certificate, $mobile)]; } + /** + * JSAPI鉴权签名 + */ + public function feishuSignature(string $app_id, string $url = '') + { + $logic = new AuthLogic(); + $certificate = $logic->feishuAppInfo($app_id); + $feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], 'xinyouhe'); + + // 获取 jsapi_ticket + $jsapiTicket = $feishu->jsapiTicket(); + + // 处理 URL:移除 # 和 ? 后面的参数 + $url = $this->cleanUrl($url); + + // 生成随机字符串 + $noncestr = $this->generateNonceStr(); + + // 获取毫秒级时间戳 + $timestamp = (int)round(microtime(true) * 1000); + + // 拼接签名字符串 + $verifyStr = "jsapi_ticket={$jsapiTicket}&noncestr={$noncestr}×tamp={$timestamp}&url={$url}"; + + // 生成 SHA1 签名 + $signature = sha1($verifyStr); + + return [ + 'jsapi_ticket' => $jsapiTicket, + 'noncestr' => $noncestr, + 'timestamp' => $timestamp, + 'url' => $url, + 'signature' => $signature + ]; + } + + /** + * 清理 URL,移除 # 和 ? 后面的参数 + */ + private function cleanUrl(string $url): string + { + // 移除 # 后面的内容 + $pos = strpos($url, '#'); + if ($pos !== false) { + $url = substr($url, 0, $pos); + } + + // 移除 ? 后面的内容 + $pos = strpos($url, '?'); + if ($pos !== false) { + $url = substr($url, 0, $pos); + } + + return $url; + } + + /** + * 生成随机字符串(由数字、字母组成) + */ + private function generateNonceStr(int $length = 16): string + { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $noncestr = ''; + for ($i = 0; $i < $length; $i++) { + $noncestr .= $chars[mt_rand(0, strlen($chars) - 1)]; + } + return $noncestr; + } + /** * 登录,支持多类型 */ diff --git a/app/common/service/CrawlService.php b/app/common/service/CrawlService.php new file mode 100644 index 0000000..f437ba6 --- /dev/null +++ b/app/common/service/CrawlService.php @@ -0,0 +1,90 @@ +find('div.card-name')->texts()->map(function($item) use(&$series) { + $series[] = $item; + }); + return $series; + } + + public function seriesModel($series) + { + $href = $this->modelSearch($series); + + //例如 $seriesUrl = "https://www.dongchedi.com/auto/series/4865?anchorId=carModels" + if (empty($href)) { + return []; + } + + $seriesUrl = (strpos($href, 'http') === 0) ? $href : ("https://www.dongchedi.com" . $href); + $ql = QueryList::get($seriesUrl); + + $nodes = $ql->find('div.tw-grid div.tw-relative div.tw-items-center>a'); + $texts = $nodes->texts()->all(); + $links = $nodes->attrs('href')->all(); + $nodes = $ql->find('div.tw-grid div.tw-text-color-gray-800'); + $price = $nodes->texts()->all(); + + $results = []; + foreach ($texts as $index => $text) { + $link = $links[$index] ?? ''; + if (!empty($link) && strpos($link, 'http') !== 0) { + $link = 'https://www.dongchedi.com' . $link; + } + $results[] = [ + 'title' => trim($text), + 'link' => $link, + 'msrp' => (int)(str_replace("万", "", $price[$index]) * 10000) ?? 0, + ]; + } + + return $results; + } + +// public function modelDesc(array $models) +// { +// +// } + + /** + * 返回车系地址 + * @param $series + * @return mixed|string + */ + public function modelSearch($series) + { + $url = "https://www.dongchedi.com/search?keyword=".$series; + $ql = QueryList::get($url ); + $href = ''; + $ql->find('a.fold-btn')->attrs('href') + ->map(function ($item) use(&$href) { + $href = $item; + }); + return $href; + } + + /** + * 关键词搜索的更多页面,通过面包屑抓取品牌与系列 + * @param $series + * @return mixed|string + */ + public function crawlBrandSeries($series) + { + $url = "https://www.dongchedi.com/auto/series/" . $series . "?anchorId=carModels"; + $ql = QueryList::get($url); + $result = $ql->find('div.breadcrumb_wrap__2GLeU span')->texts()->all(); + + return ['brand' => $result[2], 'series' => $result[3]]; + } +} \ No newline at end of file diff --git a/app/common/service/UsedCarService.php b/app/common/service/UsedCarService.php index 04af25c..57b12d2 100644 --- a/app/common/service/UsedCarService.php +++ b/app/common/service/UsedCarService.php @@ -5,7 +5,9 @@ namespace app\common\service; use app\common\validate\UsedCarValidate; use app\common\traits\crud\CrudServiceTrait; use app\common\model\UsedCar; +use GuzzleHttp\Client; use support\exception\BusinessException; +use support\Log; use support\Request; class UsedCarService @@ -95,4 +97,153 @@ class UsedCarService $data['user_id'] = $request->user['id']; return $data; } + + + + /** + * 抓取车型数据(通过接口) + * @param Request $request + * @return array + * @throws \Exception + */ + public function crawlKeyword(Request $request): array + { + $keyword = $request->get('keyword'); + + // 执行抓取 + $crawlService = new CrawlService(); + + //懂车帝模糊搜索返回车系地址|单个车系id抓取 + $href = $crawlService->modelSearch($keyword); + $seriesId = $this->extractSeriesId($href); //懂车帝车系id + +// if (empty($seriesId)) { //没有抓取到懂车帝系列id时,按关键词为品牌名抓取所有的系列名 +// $crawlSeries = $crawlService->brandSeries($keyword); +// } + //按关键词为品牌名抓取所有的系列名 + $crawlSeries = $crawlService->brandSeries($keyword); + + if (empty($seriesId) && empty($crawlSeries)) { + throw new \Exception('抓取失败,未找到该系列车型'); + } + + $brandSerieses = []; + if(empty($seriesId) && !empty($crawlSeries) || count($crawlSeries)>1){ //关键词为品牌名时抓取所有车系的懂车帝id + $href = $crawlService->modelSearch($crawlSeries[0]); + $seriesId = $this->extractSeriesId($href); //懂车帝车系id + //抓取品牌与系列 + $brandSeries = $crawlService->crawlBrandSeries($seriesId); + foreach ($crawlSeries as $item) { +// $brandSerieses[] = $crawlService->brandSeries($keyword); + $brandSerieses[] = ['brand' => $brandSeries['brand'], 'series' => $item]; + } + }elseif (!empty($seriesId) && empty($crawlSeries)){ + //抓取品牌与系列 + $brandSerieses[] = $crawlService->crawlBrandSeries($seriesId); + } + + $result = []; + try { + foreach ($brandSerieses as $brandSeries) { + $item = []; + $item['brand'] = ['brand_name' => $brandSeries['brand']]; + $item['series'] = ['series_name' => $brandSeries['series']]; + + //获取车型数据 + $item['model'] = []; + if (count($brandSerieses)<2) { + $item['model'] = $this->crawlSeriesModel($seriesId); + } + $result[] = $item; + } + + return ['is_series' => (count($brandSerieses)<2)?1:0, 'data' => $result]; + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + /** + * 获取车系下车型数据 + * @return array + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function crawlSeriesModel($seriesId) + { + $client = new Client([ + 'verify' => false, // 禁用 SSL 验证 + 'timeout' => 10 + ]); + try { + $response = $client->get('https://www.dongchedi.com/motor/pc/car/series/car_list', [ + 'query' => ['series_id' => $seriesId], + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ] + ]); + $data = json_decode($response->getBody()->getContents(), true); + if($data['status'] != 0 || (!isset($data['data']) && !isset($data['data']['tab_list']))){ + Log::error('抓取车型数据失败' . $data['message'], $data); + throw new \Exception('抓取车型数据失败' . $data['message']); + } + $models = []; + foreach ($data['data']['tab_list'] as $item) { + if($item['tab_text'] == '在售' || $item['tab_text'] == '停售'){ + unset($item['data'][0]); + $models = array_merge($models, $item['data']); + } + } + foreach ($models as $key => $model) { + if(!isset($model["info"]['car_name']) || $model['type'] != "1115"){ + unset($models[$key]); + continue; + } + $official_price = 0; + if(!isset($model["info"]['official_price'])){ + $official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0; + }else{ + $official_price = $model["info"]['official_price'] * 10000; + } + $models[$key] = [ + 'title' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'], + 'brand_name' => $model["info"]['brand_name'], + 'series_name' => $model["info"]['series_name'], + 'year' => $model["info"]['year'], + 'msrp' => $official_price, + 'car_id' => $model["info"]['id'], + ]; + } + + return array_values($models); + + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } + + /** + * 从懂车帝URL中提取series ID + * @param string $url 完整的URL + * @return string|null series ID或null + */ + function extractSeriesId($url) { + // 解析URL获取路径部分 + $path = parse_url($url, PHP_URL_PATH); + + if (!$path) { + return null; + } + + // 按斜杠分割路径 + $pathParts = explode('/', trim($path, '/')); + + // 查找 'series' 的位置,下一个元素就是series ID + $seriesIndex = array_search('series', $pathParts); + + if ($seriesIndex !== false && isset($pathParts[$seriesIndex + 1])) { + return $pathParts[$seriesIndex + 1]; + } + + return null; + } } \ No newline at end of file diff --git a/app/common/utils/Feishu.php b/app/common/utils/Feishu.php index bb4f98d..39c9121 100644 --- a/app/common/utils/Feishu.php +++ b/app/common/utils/Feishu.php @@ -109,7 +109,7 @@ class Feishu */ public function tenantAccessToken() { - $tenantAccessToken = Cache::get($this->abbr.'-tenantAccessToken'); + $tenantAccessToken = Cache::get($this->appId.'-tenantAccessToken'); if (!$tenantAccessToken) { $this->options["json"] = [ "app_id" => $this->appId, @@ -118,7 +118,7 @@ class Feishu $rst = $this->requestResult('auth/v3/tenant_access_token/internal', 'POST'); if ($rst) { $tenantAccessToken = $rst['tenant_access_token']; - Cache::set($this->abbr.'-tenantAccessToken', $tenantAccessToken, $rst['expire']); + Cache::set($this->appId.'-tenantAccessToken', $tenantAccessToken, $rst['expire']); } else { throw new BusinessException('tenant_access_token 获取失败'); } @@ -209,6 +209,31 @@ class Feishu return $this->requestResult('authen/v1/user_info', "GET"); } + /** + * 获取 jsapi_ticket + */ + public function jsapiTicket() + { + $jsapiTicket = Cache::get($this->appId.'-jsapiTicket'); + if (!$jsapiTicket) { + $this->options["headers"] = [ + "Authorization" => "Bearer " . $this->tenantAccessToken(), + "Content-Type" => "application/json; charset=utf-8" + ]; + // 清除 json 选项,确保 GET 请求不发送 body + unset($this->options["json"]); + $rst = $this->requestResult('jssdk/ticket/get', 'GET'); + if ($rst && isset($rst['data']['ticket'])) { + $jsapiTicket = $rst['data']['ticket']; + $expire = $rst['data']['expire_in'] ?? 7200; + Cache::set($this->appId.'-jsapiTicket', $jsapiTicket, $expire); + } else { + throw new BusinessException('jsapi_ticket 获取失败'); + } + } + return $jsapiTicket; + } + // public function user_access_token($code = "") // { // $url = ""; diff --git a/config/route.php b/config/route.php index 450c176..713416e 100644 --- a/config/route.php +++ b/config/route.php @@ -47,6 +47,9 @@ Route::group('/common', function () { Route::any('/register', [app\common\controller\AuthController::class, 'register']); Route::any('/refresh/token', [app\common\controller\AuthController::class, 'refresh']); + //JSAPI鉴权签名 + Route::any('/feishu/signature', [app\common\controller\AuthController::class, 'signature']); + // // 调试 Route::any('/feishu/auth', [app\common\controller\AuthController::class, 'feishuAuth']); @@ -58,5 +61,6 @@ Route::group('/common', function () { Route::any('/store', [app\common\controller\UsedCarController::class, 'store']); Route::any('/update', [app\common\controller\UsedCarController::class, 'update']); Route::any('/destroy', [app\common\controller\UsedCarController::class, 'destroy']); + Route::any('/crawl', [app\common\controller\UsedCarController::class, 'crawlKeyword']); }); }); \ No newline at end of file