Play 详情页完整 URL(须含 id= 包名) */ protected array $apps = [ 'google-AFVegas-Nights-228' => 'https://play.google.com/store/apps/details?id=com.oipjuwenkjhde.kjhbnmuiejnd', 'google-PrimeAF-Gaming-229' => 'https://play.google.com/store/apps/details?id=com.rewubsbdqkk.koeesowemli', // 'google-test111' => 'https://play.google.com/store/apps/details?id=com.rewubsbdqkk.123', ]; protected int $delistedConfirmTimes = 3; protected int $unknownConfirmTimes = 3; protected int $counterExpireSeconds = 3600; protected int $httpMaxAttempts = 3; protected int $sleepMinMicroseconds = 200000; protected int $sleepMaxMicroseconds = 500000; public function handle() { if ($this->apps === []) { $this->warn('未配置 Google Play 检测列表(CheckGooglePlayStore::$apps),跳过。'); return 0; } $env = env('APP_ENV', 'local'); $results = []; $delistedNotified = []; $unknownNotified = []; foreach ($this->apps as $name => $url) { $result = $this->checkSingleApp($name, $url); $results[] = $result; Util::WriteLog( 'google_play_check', sprintf( '[%s] %s | %s | %s', strtoupper($result['status']), $name, $url, $result['reason'] ) ); $pkg = $result['package']; if ($result['status'] === 'online') { $this->clearCounters($pkg); } elseif ($result['status'] === 'delisted') { $count = $this->increaseCounter($this->getDelistedCounterKey($pkg)); $this->clearCounter($this->getUnknownCounterKey($pkg)); if ($count >= $this->delistedConfirmTimes) { if (!$this->hasAlreadyNotified('delisted', $pkg)) { $delistedNotified[] = [ 'name' => $name, 'url' => $url, 'reason' => $result['reason'], 'count' => $count, ]; $this->markNotified('delisted', $pkg); } } } elseif ($result['status'] === 'unknown') { $count = $this->increaseCounter($this->getUnknownCounterKey($pkg)); $this->clearCounter($this->getDelistedCounterKey($pkg)); if ($count >= $this->unknownConfirmTimes) { if (!$this->hasAlreadyNotified('unknown', $pkg)) { $unknownNotified[] = [ 'name' => $name, 'url' => $url, 'reason' => $result['reason'], 'count' => $count, ]; $this->markNotified('unknown', $pkg); } } } usleep(random_int($this->sleepMinMicroseconds, $this->sleepMaxMicroseconds)); } if (!empty($delistedNotified)) { $this->notifyDelisted($env, $delistedNotified); $this->warn('已发送下架告警: ' . count($delistedNotified) . ' 个'); } if (!empty($unknownNotified)) { $this->notifyUnknown($env, $unknownNotified); $this->warn('已发送检测异常告警: ' . count($unknownNotified) . ' 个'); } foreach ($results as $item) { $this->line(sprintf( '[%s] %s - %s', strtoupper($item['status']), $item['name'], $item['reason'] )); } if (empty($delistedNotified) && empty($unknownNotified)) { $this->info('本次检测完成,无需告警。'); } return empty($delistedNotified) ? 0 : 1; } /** * @return array{name:string,url:string,package:string,status:string,reason:string} */ protected function checkSingleApp(string $name, string $playUrl): array { $parsed = $this->parsePlayStoreUrl($playUrl); if ($parsed === null) { return [ 'name' => $name, 'url' => $playUrl, 'package' => '_badurl_' . md5($playUrl), 'status' => 'unknown', 'reason' => '无法从 URL 解析包名 id=', ]; } $package = $parsed['package']; $requestUrl = $this->buildDetailsUrl($package); $resp = $this->httpGetWithRetry($requestUrl, $this->httpMaxAttempts); if (!empty($resp['error'])) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'unknown', 'reason' => '请求失败: ' . $resp['error'], ]; } $code = (int) ($resp['code'] ?? 0); $body = (string) ($resp['body'] ?? ''); if (in_array($code, [403, 429, 500, 502, 503, 504], true)) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'unknown', 'reason' => "HTTP {$code}", ]; } if ($code === 404) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'delisted', 'reason' => 'Play 返回 HTTP 404(包不存在或已下架)', ]; } if ($code !== 200) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'unknown', 'reason' => "HTTP {$code}", ]; } if ($this->isPlayNotFoundPage($body)) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'delisted', 'reason' => 'Play 错误页(Not Found 等)', ]; } if ($this->isLikelyNormalDetailPage($body)) { $this->clearNotifiedFlags($package); return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'online', 'reason' => '详情页正常(含 Play 前端特征)', ]; } $len = strlen($body); if ($len < 15000) { return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'unknown', 'reason' => "HTTP 200 但正文过短({$len} bytes),无法判断", ]; } return [ 'name' => $name, 'url' => $playUrl, 'package' => $package, 'status' => 'unknown', 'reason' => 'HTTP 200 但未识别为正常详情页(页面结构可能变更)', ]; } protected function isPlayNotFoundPage(string $body): bool { if (stripos($body, 'Not Found') !== false) { return true; } if (preg_match('/requested\s+URL\s+was\s+not\s+found/i', $body)) { return true; } return false; } protected function isLikelyNormalDetailPage(string $body): bool { if ($this->isPlayNotFoundPage($body)) { return false; } if (strlen($body) < 30000) { return false; } // 当前 Play Web 详情页常见内嵌数据;404 短页不含 if (strpos($body, 'WIZ_global_data') === false) { return false; } return true; } /** * 从详情链接解析包名。 */ protected function parsePlayStoreUrl(string $url): ?array { $parts = parse_url($url); if (empty($parts['host']) || stripos($parts['host'], 'play.google.com') === false) { return null; } $query = $parts['query'] ?? ''; if ($query === '') { return null; } parse_str($query, $q); if (empty($q['id']) || !is_string($q['id'])) { return null; } $id = $q['id']; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9._]*$/', $id)) { return null; } return ['package' => $id]; } protected function buildDetailsUrl(string $package): string { return 'https://play.google.com/store/apps/details?id=' . rawurlencode($package) . '&hl=en&gl=US'; } /** * @return array{code:int,body:string,error:string} */ protected function httpGetWithRetry(string $url, int $maxAttempts = 3): array { $attempt = 0; $last = [ 'code' => 0, 'body' => '', 'error' => '', ]; while ($attempt < $maxAttempts) { $attempt++; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 45, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_HTTPHEADER => [ 'Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.9', 'Cache-Control: no-cache', ], CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); $last = [ 'code' => $code, 'body' => is_string($body) ? $body : '', 'error' => $error ?: '', ]; if ($last['error'] === '' && ($code === 200 || $code === 404)) { return $last; } $shouldRetry = $last['error'] !== '' || in_array($code, [429, 500, 502, 503, 504], true); if (!$shouldRetry || $attempt >= $maxAttempts) { return $last; } $sleepSeconds = $attempt === 1 ? 2 : ($attempt === 2 ? 5 : 8); sleep($sleepSeconds); } return $last; } protected function getDelistedCounterKey(string $package): string { return 'google:play:delisted:' . $package; } protected function getUnknownCounterKey(string $package): string { return 'google:play:unknown:' . $package; } protected function getDelistedNotifiedKey(string $package): string { return 'google:play:notified:delisted:' . $package; } protected function getUnknownNotifiedKey(string $package): string { return 'google:play:notified:unknown:' . $package; } protected function increaseCounter(string $key): int { $count = (int) Redis::incr($key); Redis::expire($key, $this->counterExpireSeconds); return $count; } protected function clearCounters(string $package): void { $this->clearCounter($this->getDelistedCounterKey($package)); $this->clearCounter($this->getUnknownCounterKey($package)); } protected function clearCounter(string $key): void { try { Redis::del($key); } catch (\Throwable $e) { Util::WriteLog('google_play_check', 'Redis del error: ' . $e->getMessage()); } } protected function hasAlreadyNotified(string $type, string $package): bool { try { $key = $type === 'delisted' ? $this->getDelistedNotifiedKey($package) : $this->getUnknownNotifiedKey($package); return (bool) Redis::exists($key); } catch (\Throwable $e) { Util::WriteLog('google_play_check', 'Redis exists error: ' . $e->getMessage()); return false; } } protected function markNotified(string $type, string $package): void { try { $key = $type === 'delisted' ? $this->getDelistedNotifiedKey($package) : $this->getUnknownNotifiedKey($package); Redis::setex($key, $this->counterExpireSeconds, 1); } catch (\Throwable $e) { Util::WriteLog('google_play_check', 'Redis setex error: ' . $e->getMessage()); } } protected function clearNotifiedFlags(string $package): void { try { Redis::del( $this->getDelistedNotifiedKey($package), $this->getUnknownNotifiedKey($package) ); } catch (\Throwable $e) { Util::WriteLog('google_play_check', 'Redis clear notified flags error: ' . $e->getMessage()); } } protected function notifyDelisted(string $env, array $items): void { $lines = ["[{$env}] Google Play 下架/不可访问检测告警"]; foreach ($items as $item) { $lines[] = '• ' . $item['name']; $lines[] = ' 原因: ' . $item['reason']; $lines[] = ' 连续次数: ' . $item['count']; $lines[] = ' 链接: ' . $item['url']; } $this->sendTelegramMessage(implode("\n", $lines)); } protected function notifyUnknown(string $env, array $items): void { $lines = ["[{$env}] Google Play 检测异常告警"]; foreach ($items as $item) { $lines[] = '• ' . $item['name']; $lines[] = ' 原因: ' . $item['reason']; $lines[] = ' 连续次数: ' . $item['count']; $lines[] = ' 链接: ' . $item['url']; } $this->sendTelegramMessage(implode("\n", $lines)); } protected function sendTelegramMessage(string $message): void { try { TelegramBot::getDefault()->sendMsgAndImportant($message); } catch (\Throwable $e) { Util::WriteLog('google_play_check', 'Telegram send error: ' . $e->getMessage()); $this->error('Telegram 发送失败: ' . $e->getMessage()); } } }