File manager - Edit - /var/www/html/portalHomolog/app/Console/Commands/MobiangraValidarFeeds.php
Back
<?php namespace App\Console\Commands; use App\Services\Mobiangra\MobiangraService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use ZipArchive; /** * Valida feeds GTFS / GTFS-RT públicos do MobiAngra e gera relatório Markdown. */ class MobiangraValidarFeeds extends Command { protected $signature = 'mobiangra:validar-feeds {--base-url= : URL base (env MOBIANGRA_VALIDAR_BASE_URL ou produção)} {--freshness : Compara feed.pb após 30 segundos} {--output= : Caminho do relatório .md}'; protected $description = 'Valida feeds GTFS/RT do MobiAngra e gera relatório Markdown'; private string $baseUrl; private string $reportPath; private string $urlFeedPb; private string $urlGtfsZip; /** @var list<string> */ private array $legacyRedirectUrls = []; private ?string $secaoAtual = null; /** @var list<array{secao: string, teste: string, status: string, detalhe: string}> */ private array $linhas = []; private int $ok = 0; private int $fail = 0; private int $warn = 0; public function __construct( private readonly MobiangraService $mobiangraService, ) { parent::__construct(); } public function handle(): int { $this->baseUrl = rtrim( (string) ($this->option('base-url') ?: env('MOBIANGRA_VALIDAR_BASE_URL', 'https://www.angra.rj.gov.br')), '/' ); $this->urlFeedPb = "{$this->baseUrl}/mobiangra/gtfs/feed.pb"; $this->urlGtfsZip = "{$this->baseUrl}/mobiangra/gtfs/gtfs.zip"; $this->legacyRedirectUrls = [ 'feed.pb (legado /gtfs/)' => "{$this->baseUrl}/gtfs/feed.pb", 'gtfs.zip (legado /gtfs/)' => "{$this->baseUrl}/gtfs/gtfs.zip", 'feed.pb (legado /mobiangra/)' => "{$this->baseUrl}/mobiangra/feed.pb", 'gtfs.zip (legado /mobiangra/)' => "{$this->baseUrl}/mobiangra/gtfs.zip", ]; $dir = base_path('temp/mobiangra'); if (! is_dir($dir)) { mkdir($dir, 0755, true); } $this->reportPath = (string) ($this->option('output') ?: $dir . '/relatorio-validacao-gtfs-' . now()->format('Y-m-d-His') . '.md'); $this->info("Validação GTFS - base: {$this->baseUrl}"); $this->line(" Canônico RT: {$this->urlFeedPb}"); $this->line(" Canônico ZIP: {$this->urlGtfsZip}"); $pbBody = null; $pbSha = null; $this->bloco('0. Estrutura local (publicação estática)'); $this->testarArquivosLocais(); $this->bloco('1. Cabeçalhos HTTP - feed.pb (canônico)'); $headPb = $this->fetchHeaders($this->urlFeedPb); $this->assertHeadersFeedPb($headPb); $this->bloco('2. Cabeçalhos HTTP - gtfs.zip (canônico)'); $headZip = $this->fetchHeaders($this->urlGtfsZip); $this->assertHeadersGtfsZip($headZip); $this->bloco('3. Redirecionamentos de URLs legadas'); foreach ($this->legacyRedirectUrls as $label => $legacyUrl) { $this->redirectLegado($label, $legacyUrl); } $this->bloco('4. Download e integridade - feed.pb'); try { $pbBody = $this->download($this->urlFeedPb); $pbSha = hash('sha256', $pbBody); $this->teste('Tamanho > 0', strlen($pbBody) > 0, (string) strlen($pbBody) . ' bytes'); $this->teste('Não é HTML', ! $this->looksLikeHtml($pbBody), 'sha256: ' . $pbSha); } catch (\Throwable $e) { $this->teste('Download feed.pb', false, $e->getMessage()); } $this->bloco('5. Decodificação GTFS-RT (protobuf)'); if ($pbBody !== null) { $this->decodeProtobuf($pbBody); } else { $this->teste('Decode protobuf', null, 'Download indisponível'); } $this->bloco('6. Download e integridade - gtfs.zip'); try { $zipBody = $this->download($this->urlGtfsZip); $this->teste('Tamanho > 1 KB', strlen($zipBody) > 1000, (string) strlen($zipBody) . ' bytes'); $this->validarZip($zipBody); } catch (\Throwable $e) { $this->teste('Download gtfs.zip', false, $e->getMessage()); } if ($this->option('freshness') && $pbSha !== null) { $this->bloco('7. Frescor do feed (30s)'); $this->line(' Aguardando 30 segundos…'); sleep(30); try { $hash2 = hash('sha256', $this->download($this->urlFeedPb)); $alterou = $pbSha !== $hash2; $this->teste( 'Hash alterou em 30s', $alterou ? true : null, $alterou ? 'Geração ativa' : 'Idêntico (frota parada ou cron?)' ); } catch (\Throwable $e) { $this->teste('Segundo download', false, $e->getMessage()); } } $this->bloco('8. Paridade remoto × public/mobiangra-assets/gtfs/'); $localPb = $this->mobiangraService->getPublicGtfsProtobufPath(); if ($pbBody !== null && is_file($localPb)) { $igual = file_get_contents($localPb) === $pbBody; $this->teste( 'feed.pb remoto = cópia publicada', $igual ? true : null, $igual ? 'Sincronizado' : 'Diferente (normal se RT atualizou após publicação)' ); } else { $this->teste( 'Paridade public/feed.pb', null, is_file($localPb) ? 'Download remoto indisponível' : "Arquivo ausente: {$localPb}" ); } $this->gravarRelatorio(); $this->newLine(); $this->info("Relatório: {$this->reportPath}"); $this->info("Resumo: {$this->ok} OK | {$this->fail} falha(s) | {$this->warn} aviso(s)"); if ($this->fail > 0) { $this->error('Corrija as falhas antes de informar o admin do MobiAngra.'); return self::FAILURE; } if ($this->warn > 0) { $this->warn('Sem falhas críticas; revise os avisos no relatório.'); } else { $this->info('Aprovado para informar o admin.'); } return self::SUCCESS; } private function testarArquivosLocais(): void { $dir = $this->mobiangraService->getPublicGtfsDirectory(); $pb = $this->mobiangraService->getPublicGtfsProtobufPath(); $zip = $this->mobiangraService->getPublicGtfsZipPath(); $this->teste('Diretório public GTFS existe', is_dir($dir), $dir); $this->teste('feed.pb publicado', is_file($pb), is_file($pb) ? (string) filesize($pb) . ' bytes' : $pb); $this->teste('gtfs.zip publicado', is_file($zip), is_file($zip) ? (string) filesize($zip) . ' bytes' : $zip); $storagePb = $this->mobiangraService->getProtobufAbsolutePath(); $this->teste( 'Storage .pb existe', is_file($storagePb), is_file($storagePb) ? basename(dirname($storagePb)) . '/' . basename($storagePb) : $storagePb ); } /** * @param array{status: string, headers: array<string, string>} $head */ private function assertHeadersFeedPb(array $head): void { $this->teste('HTTP 200', str_starts_with($head['status'] ?? '', '200')); $this->teste('Content-Type application/x-protobuf', $this->headerContains($head, 'content-type', 'application/x-protobuf')); $this->teste('Content-Disposition inline', str_contains(strtolower($this->headerValue($head, 'content-disposition') ?? ''), 'inline')); $cache = strtolower($this->headerValue($head, 'cache-control') ?? ''); $this->teste('Cache-Control no-cache', str_contains($cache, 'no-cache')); $this->teste( 'Cache-Control sem must-revalidate (perfil legado)', $cache !== '' && ! str_contains($cache, 'must-revalidate') ); $this->teste('Sem Set-Cookie', $this->headerValue($head, 'set-cookie') === null); $this->assertSemCors($head); } /** * @param array{status: string, headers: array<string, string>} $head */ private function assertHeadersGtfsZip(array $head): void { $this->teste('HTTP 200', str_starts_with($head['status'] ?? '', '200')); $this->teste('Content-Type application/zip', $this->headerContains($head, 'content-type', 'application/zip')); $this->teste('Sem Set-Cookie', $this->headerValue($head, 'set-cookie') === null); $this->assertSemCors($head); } /** * @param array{status: string, headers: array<string, string>} $head */ private function assertSemCors(array $head): void { if ($this->hasCors($head)) { $this->teste('Sem headers CORS', false, $this->corsHeadersResumo($head)); return; } $this->teste('Sem headers CORS', true); } /** * @param array{headers: array<string, string>} $head */ private function corsHeadersResumo(array $head): string { $found = []; foreach ($head['headers'] as $key => $value) { if (str_starts_with($key, 'access-control-')) { $found[] = "{$key}: {$value}"; } } return implode('; ', $found); } private function bloco(string $titulo): void { $this->secaoAtual = $titulo; $this->newLine(); $this->line("<fg=cyan>{$titulo}</>"); } /** * @param bool|null $pass true=ok, false=fail, null=aviso */ private function teste(string $nome, ?bool $pass, string $detalhe = '-'): void { $status = match ($pass) { true => 'ok', false => 'fail', default => 'warn', }; $this->linhas[] = [ 'secao' => $this->secaoAtual ?? 'Geral', 'teste' => $nome, 'status' => $status, 'detalhe' => $detalhe, ]; match ($status) { 'ok' => $this->ok++, 'fail' => $this->fail++, 'warn' => $this->warn++, default => null, }; $icon = match ($status) { 'ok' => '✓', 'fail' => '✗', default => '!', }; $this->line(" [{$icon}] {$nome}" . ($detalhe !== '-' ? " - {$detalhe}" : '')); } /** * @return array{status: string, headers: array<string, string>} */ private function fetchHeaders(string $url): array { $response = Http::withOptions(['allow_redirects' => false]) ->timeout(60) ->head($url); return [ 'status' => (string) $response->status(), 'headers' => $this->normalizeHeaders($response->headers()), ]; } private function download(string $url): string { $response = Http::withHeaders([ 'User-Agent' => 'Google-Transit-FeedValidator', 'Accept' => '*/*', ]) ->withOptions(['cookies' => false]) ->timeout(120) ->get($url); if (! $response->successful()) { throw new \RuntimeException("HTTP {$response->status()} em {$url}"); } return $response->body(); } private function redirectLegado(string $label, string $url): void { $response = Http::withOptions(['allow_redirects' => false])->timeout(30)->head($url); $code = (string) $response->status(); $headers = $this->normalizeHeaders($response->headers()); $location = strtolower($headers['location'] ?? ''); if (in_array($code, ['301', '302'], true) && str_contains($location, '/mobiangra/gtfs/')) { $headersPacket = ['headers' => $headers]; if ($this->hasCors($headersPacket)) { $this->teste("{$label} → canônico (sem CORS no {$code})", false, $this->corsHeadersResumo($headersPacket)); return; } $this->teste("{$label} → canônico", true, "HTTP {$code} → {$location}"); return; } if ($code === '200' && str_contains($url, '/gtfs/')) { $this->teste( "{$label} → canônico", null, "HTTP 200 direto (pasta public/gtfs/ antiga ainda ativa? ideal: 301 para /mobiangra/gtfs/)" ); return; } $this->teste("{$label} → canônico", false, "HTTP {$code}" . ($location ? " → {$location}" : '')); } private function decodeProtobuf(string $body): void { $python = $this->resolvePython(); if ($python === null) { $this->teste('Python disponível', null, 'Instale Python para decode automático'); return; } $script = $this->protobufDecodeScript(); $tmpPb = tempnam(sys_get_temp_dir(), 'mobiangra-'); $tmpPy = tempnam(sys_get_temp_dir(), 'mobiangra-decode-') . '.py'; try { file_put_contents($tmpPb, $body); file_put_contents($tmpPy, $script); $cmd = str_contains($python, ' ') ? explode(' ', $python) : [$python]; $cmd[] = $tmpPy; $cmd[] = $tmpPb; $result = Process::timeout(30)->run($cmd); if ($result->successful()) { $out = trim(str_replace("\r", '', $result->output())); $this->teste('Protobuf decodificado', true, trim(Str::before($out, "\n") ?: $out)); foreach (array_slice(explode("\n", $out), 1, 5) as $line) { if (trim($line) !== '') { $this->line(' ' . trim($line)); } } } elseif (str_contains($result->errorOutput(), 'gtfs-realtime-bindings')) { $this->teste('Pacote gtfs-realtime-bindings', null, 'pip install gtfs-realtime-bindings'); } else { $this->teste('Protobuf válido', false, trim($result->errorOutput() ?: $result->output())); } } finally { @unlink($tmpPb); @unlink($tmpPy); } } private function protobufDecodeScript(): string { return <<<'PY' import sys from pathlib import Path try: from google.transit import gtfs_realtime_pb2 except ImportError: print("MISSING_BINDINGS", file=sys.stderr) sys.exit(3) path = Path(sys.argv[1]) data = path.read_bytes() if data.lstrip()[:15].lower().startswith(b"<!doctype") or data.lstrip()[:5] == b"<html": print("HTML_NOT_PROTOBUF", file=sys.stderr) sys.exit(1) feed = gtfs_realtime_pb2.FeedMessage() feed.ParseFromString(data) print(f"entities: {len(feed.entity)}") if feed.header.gtfs_realtime_version: print(f"version: {feed.header.gtfs_realtime_version}") for e in list(feed.entity)[:3]: vp = e.vehicle lat = vp.position.latitude if vp.HasField("position") else None route = vp.trip.route_id if vp.HasField("trip") else "" print(f" id={e.id} route={route} lat={lat}") sys.exit(0) PY; } private function validarZip(string $body): void { $tmp = tempnam(sys_get_temp_dir(), 'mobiangra-gtfs-'); $path = $tmp . '.zip'; rename($tmp, $path); try { file_put_contents($path, $body); $zip = new ZipArchive(); if ($zip->open($path) !== true) { $this->teste('ZIP abre', false, 'ZipArchive::open falhou'); return; } $n = $zip->numFiles; $lista = []; for ($i = 0; $i < $n; $i++) { $name = $zip->getNameIndex($i); if (is_string($name)) { $lista[] = basename($name); } } $zip->close(); $this->teste('ZIP íntegro', $n > 0, "{$n} arquivo(s): " . implode(', ', array_slice(array_unique($lista), 0, 9))); $required = ['routes.txt', 'stops.txt', 'trips.txt']; $found = array_filter($required, fn ($f) => in_array($f, $lista, true)); $this->teste( 'Arquivos GTFS mínimos', count($found) === count($required), 'Presentes: ' . implode(', ', $found) ?: 'nenhum' ); } finally { @unlink($path); } } private function gravarRelatorio(): void { $aprovado = $this->fail === 0; $veredito = $aprovado ? ($this->warn > 0 ? 'APROVADO COM RESSALVAS' : 'APROVADO') : 'REPROVADO'; $publicDir = (string) config('mobiangra.public_gtfs_dir', 'mobiangra-assets/gtfs'); $publicPb = (string) config('mobiangra.public_gtfs_feed_pb_path', 'mobiangra-assets/gtfs/feed.pb'); $publicZip = (string) config('mobiangra.public_gtfs_zip_path', 'mobiangra-assets/gtfs/gtfs.zip'); $md = []; $md[] = '# Relatório de validação - Feeds GTFS MobiAngra'; $md[] = ''; $md[] = '| Campo | Valor |'; $md[] = '|-------|-------|'; $md[] = '| Data/hora | ' . now()->format('d/m/Y H:i:s') . ' |'; $md[] = '| Base URL | `' . $this->baseUrl . '` |'; $md[] = '| Veredito | **' . $veredito . '** |'; $md[] = '| OK / Falhas / Avisos | ' . "{$this->ok} / {$this->fail} / {$this->warn}" . ' |'; $md[] = ''; $md[] = '## Estrutura atual (após reorganização)'; $md[] = ''; $md[] = '| Item | Caminho |'; $md[] = '|------|---------|'; $md[] = '| URL canônica GTFS-RT | `' . $this->urlFeedPb . '` |'; $md[] = '| URL canônica GTFS estático | `' . $this->urlGtfsZip . '` |'; $md[] = '| Publicação estática | `public/' . $publicDir . '/` |'; $md[] = '| Arquivo feed.pb | `public/' . $publicPb . '` |'; $md[] = '| Arquivo gtfs.zip | `public/' . $publicZip . '` |'; $md[] = '| Storage GTFS .txt | `storage/app/' . config('mobiangra.gtfs_dir') . '/` |'; $md[] = ''; $md[] = '### Redirecionamentos legados (301)'; $md[] = ''; foreach ($this->legacyRedirectUrls as $label => $url) { $md[] = '- `' . $url . '` → `/mobiangra/gtfs/...`'; } $md[] = ''; $md[] = '## Resultados'; $md[] = ''; $md[] = '| Seção | Teste | Status | Detalhe |'; $md[] = '|-------|-------|--------|---------|'; foreach ($this->linhas as $linha) { $badge = match ($linha['status']) { 'ok' => 'OK', 'fail' => 'FALHA', default => 'AVISO', }; $detalhe = str_replace('|', '\\|', $linha['detalhe']); $md[] = "| {$linha['secao']} | {$linha['teste']} | {$badge} | {$detalhe} |"; } $md[] = ''; $md[] = '## URLs para cadastro (Google Transit / validadores)'; $md[] = ''; $md[] = '- **GTFS Realtime:** `' . $this->urlFeedPb . '`'; $md[] = '- **GTFS Static:** `' . $this->urlGtfsZip . '`'; $md[] = ''; $md[] = '## Comando para repetir'; $md[] = ''; $md[] = '```bash'; $md[] = 'composer mobiangra:validar-feeds'; $md[] = '# local:'; $md[] = 'MOBIANGRA_VALIDAR_BASE_URL=http://angra.rj.gov.br.test composer mobiangra:validar-feeds'; $md[] = '```'; $md[] = ''; $md[] = '---'; $md[] = '_Gerado por `php artisan mobiangra:validar-feeds`_'; file_put_contents($this->reportPath, implode("\n", $md) . "\n"); } private function resolvePython(): ?string { foreach (['py -3', 'python3', 'python'] as $bin) { $parts = str_contains($bin, ' ') ? explode(' ', $bin) : [$bin]; $check = Process::run(array_merge($parts, ['-c', 'import sys'])); if ($check->successful()) { return $bin; } } return null; } /** * @param array{status?: string, headers: array<string, string>} $packet */ private function headerValue(array $packet, string $name): ?string { return $packet['headers'][strtolower($name)] ?? null; } /** * @param array{headers: array<string, string>} $packet */ private function headerContains(array $packet, string $name, string $needle): bool { $value = $this->headerValue($packet, $name); return $value !== null && str_contains(strtolower($value), strtolower($needle)); } /** * @param array{headers: array<string, string>} $packet */ private function hasCors(array $packet): bool { foreach (array_keys($packet['headers']) as $key) { if (str_starts_with($key, 'access-control-')) { return true; } } return false; } /** * @param array<string, array<int, string>|string> $raw * @return array<string, string> */ private function normalizeHeaders(array $raw): array { $out = []; foreach ($raw as $key => $values) { $out[strtolower($key)] = is_array($values) ? implode(', ', $values) : (string) $values; } return $out; } private function looksLikeHtml(string $body): bool { $start = strtolower(ltrim(substr($body, 0, 512))); return str_starts_with($start, '<!doctype') || str_starts_with($start, '<html'); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings