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());
}
}
}