fix: import OSM en 1 seule requête Overpass (évite le 429 rate limit)

This commit is contained in:
Gwadaking
2026-04-09 01:04:44 -04:00
parent fb00ec8fa2
commit d0113de4c0

View File

@@ -14,27 +14,30 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsCommand( #[AsCommand(
name: 'app:import-coastal-points', name: 'app:import-coastal-points',
description: 'Importe les plages/ports/spots depuis OpenStreetMap (Overpass API) — idempotent', description: 'Importe les plages/ports/spots depuis OpenStreetMap (1 seule requête Overpass) — idempotent',
)] )]
class ImportCoastalPointsCommand extends Command class ImportCoastalPointsCommand extends Command
{ {
private const OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; private const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
// bbox format Overpass : sud,ouest,nord,est /**
* Bboxes par île : [sud, ouest, nord, est]
* Utilisées pour :
* 1. construire les statements Overpass (1 requête globale)
* 2. déterminer la région d'un point en PHP
*/
private const ISLANDS = [ private const ISLANDS = [
// Guadeloupe : Basse-Terre + Grande-Terre séparés pour éviter que la bbox 'Basse-Terre' => ['bbox' => [15.82, -61.82, 16.52, -61.55], 'region' => 'Guadeloupe'],
// englobe trop de mer et rate des côtes 'Grande-Terre' => ['bbox' => [15.97, -61.55, 16.52, -61.07], 'region' => 'Guadeloupe'],
'Basse-Terre' => ['bbox' => '15.82,-61.82,16.52,-61.55', 'region' => 'Guadeloupe'], 'Marie-Galante' => ['bbox' => [15.83, -61.40, 16.08, -61.10], 'region' => 'Guadeloupe'],
'Grande-Terre' => ['bbox' => '15.97,-61.55,16.52,-61.07', 'region' => 'Guadeloupe'], 'La Désirade' => ['bbox' => [16.27, -61.10, 16.37, -60.82], 'region' => 'Guadeloupe'],
'Marie-Galante' => ['bbox' => '15.83,-61.40,16.08,-61.10', 'region' => 'Guadeloupe'], 'Les Saintes' => ['bbox' => [15.83, -61.68, 15.90, -61.55], 'region' => 'Guadeloupe'],
'La Désirade' => ['bbox' => '16.27,-61.10,16.37,-60.82', 'region' => 'Guadeloupe'], 'Martinique' => ['bbox' => [14.38, -61.25, 14.88, -60.80], 'region' => 'Martinique'],
'Les Saintes' => ['bbox' => '15.83,-61.68,15.90,-61.55', 'region' => 'Guadeloupe'], 'Sainte-Lucie' => ['bbox' => [13.70, -61.10, 14.15, -60.85], 'region' => 'Sainte-Lucie'],
'Martinique' => ['bbox' => '14.38,-61.25,14.88,-60.80', 'region' => 'Martinique'], 'Barbade' => ['bbox' => [13.00, -59.80, 13.40, -59.40], 'region' => 'Barbade'],
'Sainte-Lucie' => ['bbox' => '13.70,-61.10,14.15,-60.85', 'region' => 'Sainte-Lucie'], 'Saint-Martin' => ['bbox' => [17.85, -63.20, 18.18, -62.95], 'region' => 'Saint-Martin'],
'Barbade' => ['bbox' => '13.00,-59.80,13.40,-59.40', 'region' => 'Barbade'], 'Saint-Barthélemy' => ['bbox' => [17.85, -62.90, 17.98, -62.78], 'region' => 'Saint-Barthélemy'],
'Saint-Martin' => ['bbox' => '17.85,-63.20,18.18,-62.95', 'region' => 'Saint-Martin'], 'Dominique' => ['bbox' => [15.20, -61.50, 15.65, -61.24], 'region' => 'Dominique'],
'Saint-Barthélemy' => ['bbox' => '17.85,-62.90,17.98,-62.78', 'region' => 'Saint-Barthélemy'],
'Dominique' => ['bbox' => '15.20,-61.50,15.65,-61.24', 'region' => 'Dominique'],
]; ];
public function __construct( public function __construct(
@@ -46,81 +49,69 @@ class ImportCoastalPointsCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche sans persister en base');
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche sans persister en base')
->addOption('island', null, InputOption::VALUE_REQUIRED, 'Importer une seule île (ex: Guadeloupe)');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run'); $dryRun = (bool) $input->getOption('dry-run');
$filter = $input->getOption('island');
$io->title('Import OpenStreetMap — Points côtiers Antilles'); $io->title('Import OpenStreetMap — Points côtiers Antilles');
if ($dryRun) { if ($dryRun) {
$io->note('Mode dry-run : aucune écriture en base'); $io->note('Mode dry-run : aucune écriture en base');
} }
$io->text('Envoi d\'une seule requête Overpass pour toutes les îles…');
$elements = $this->queryOverpass($io);
if (empty($elements)) {
$io->warning('Aucun résultat Overpass.');
return Command::FAILURE;
}
$io->text(sprintf('%d éléments OSM reçus, attribution des régions…', count($elements)));
$repo = $this->em->getRepository(CoastalPoint::class); $repo = $this->em->getRepository(CoastalPoint::class);
$created = 0; $created = 0;
$skipped = 0; $skipped = 0;
$islands = self::ISLANDS;
if (is_string($filter)) {
$islands = array_filter(
$islands,
static fn(string $k) => mb_strtolower($k) === mb_strtolower($filter),
ARRAY_FILTER_USE_KEY
);
if (empty($islands)) {
$io->error("Île inconnue : {$filter}. Valeurs : " . implode(', ', array_keys(self::ISLANDS)));
return Command::FAILURE;
}
}
foreach ($islands as $islandName => $config) {
$io->section("{$islandName}");
$elements = $this->queryOverpass($config['bbox'], $io);
foreach ($elements as $el) { foreach ($elements as $el) {
$osmId = $el['type'] . '/' . $el['id']; $osmId = $el['type'] . '/' . $el['id'];
$osmName = $el['tags']['name'] ?? $el['tags']['name:fr'] ?? null; $osmName = $el['tags']['name'] ?? $el['tags']['name:fr'] ?? null;
if ($osmName === null) { if ($osmName === null) {
continue; continue;
} }
// Idempotence par osmId
if ($repo->findOneBy(['osmId' => $osmId]) !== null) {
++$skipped;
continue;
}
// Idempotence par nom+région (couvre les points seedés manuellement)
if ($repo->findOneBy(['name' => $osmName, 'region' => $config['region']]) !== null) {
++$skipped;
continue;
}
$lat = $el['lat'] ?? ($el['center']['lat'] ?? null); $lat = $el['lat'] ?? ($el['center']['lat'] ?? null);
$lon = $el['lon'] ?? ($el['center']['lon'] ?? null); $lon = $el['lon'] ?? ($el['center']['lon'] ?? null);
if ($lat === null || $lon === null) { if ($lat === null || $lon === null) {
continue; continue;
} }
$type = $this->mapType($el['tags']); $region = $this->detectRegion((float) $lat, (float) $lon);
if ($region === null) {
continue; // hors de nos zones d'intérêt
}
$io->writeln(sprintf(' + %-45s [%s]', $osmName, $type)); if ($repo->findOneBy(['osmId' => $osmId]) !== null) {
++$skipped;
continue;
}
if ($repo->findOneBy(['name' => $osmName, 'region' => $region]) !== null) {
++$skipped;
continue;
}
$type = $this->mapType($el['tags']);
$io->writeln(sprintf(' + %-45s [%-8s] %s', $osmName, $type, $region));
if (!$dryRun) { if (!$dryRun) {
$point = new CoastalPoint(); $point = new CoastalPoint();
$point->setName($osmName); $point->setName($osmName);
$point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lon, $lat)); $point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lon, $lat));
$point->setType($type); $point->setType($type);
$point->setRegion($config['region']); $point->setRegion($region);
$point->setOsmId($osmId); $point->setOsmId($osmId);
$this->em->persist($point); $this->em->persist($point);
} }
@@ -132,9 +123,6 @@ class ImportCoastalPointsCommand extends Command
$this->em->flush(); $this->em->flush();
} }
sleep(1); // respect Overpass rate limit
}
$verb = $dryRun ? 'trouvé(s)' : 'créé(s)'; $verb = $dryRun ? 'trouvé(s)' : 'créé(s)';
$io->success(sprintf('%d point(s) %s, %d ignoré(s) (déjà existants).', $created, $verb, $skipped)); $io->success(sprintf('%d point(s) %s, %d ignoré(s) (déjà existants).', $created, $verb, $skipped));
@@ -142,51 +130,63 @@ class ImportCoastalPointsCommand extends Command
} }
/** /**
* Une seule requête Overpass avec toutes les bboxes en union.
*
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
private function queryOverpass(string $bbox, SymfonyStyle $io): array private function queryOverpass(SymfonyStyle $io): array
{ {
$query = <<<OVERPASS $statements = '';
[out:json][timeout:40]; foreach (self::ISLANDS as $config) {
( [$s, $w, $n, $e] = $config['bbox'];
node["natural"="beach"]["name"]({$bbox}); $bbox = "{$s},{$w},{$n},{$e}";
node["natural"="beach"]["name:fr"]({$bbox});
node["leisure"="beach_resort"]["name"]({$bbox}); $statements .= " node[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
node["leisure"="marina"]["name"]({$bbox}); $statements .= " node[\"natural\"=\"beach\"][\"name:fr\"]({$bbox});\n";
node["harbour"]["name"]({$bbox}); $statements .= " node[\"leisure\"=\"beach_resort\"][\"name\"]({$bbox});\n";
node["seamark:type"="harbour"]["name"]({$bbox}); $statements .= " node[\"leisure\"=\"marina\"][\"name\"]({$bbox});\n";
node["amenity"="ferry_terminal"]["name"]({$bbox}); $statements .= " node[\"harbour\"][\"name\"]({$bbox});\n";
node["sport"="surfing"]["name"]({$bbox}); $statements .= " node[\"amenity\"=\"ferry_terminal\"][\"name\"]({$bbox});\n";
node["waterway"="dock"]["name"]({$bbox}); $statements .= " node[\"sport\"=\"surfing\"][\"name\"]({$bbox});\n";
way["natural"="beach"]["name"]({$bbox}); $statements .= " way[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
way["natural"="beach"]["name:fr"]({$bbox}); $statements .= " way[\"natural\"=\"beach\"][\"name:fr\"]({$bbox});\n";
way["leisure"="beach_resort"]["name"]({$bbox}); $statements .= " way[\"leisure\"=\"marina\"][\"name\"]({$bbox});\n";
way["leisure"="marina"]["name"]({$bbox}); $statements .= " way[\"harbour\"][\"name\"]({$bbox});\n";
way["harbour"]["name"]({$bbox}); $statements .= " relation[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
relation["natural"="beach"]["name"]({$bbox}); }
relation["natural"="beach"]["name:fr"]({$bbox});
); $query = "[out:json][timeout:60];\n(\n{$statements});\nout center;";
out center;
OVERPASS;
try { try {
$response = $this->httpClient->request('POST', self::OVERPASS_URL, [ $response = $this->httpClient->request('POST', self::OVERPASS_URL, [
'body' => ['data' => $query], 'body' => ['data' => $query],
'timeout' => 35, 'timeout' => 65,
]); ]);
$data = $response->toArray(); $data = $response->toArray();
return $data['elements'] ?? []; return $data['elements'] ?? [];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$io->warning('Overpass error : ' . $e->getMessage()); $io->error('Overpass error : ' . $e->getMessage());
return []; return [];
} }
} }
/** /**
* @param array<string, string> $tags * Détermine la région d'un point par appartenance à une bbox.
* En cas de recouvrement, prend la première bbox correspondante.
*/ */
private function detectRegion(float $lat, float $lon): ?string
{
foreach (self::ISLANDS as $config) {
[$s, $w, $n, $e] = $config['bbox'];
if ($lat >= $s && $lat <= $n && $lon >= $w && $lon <= $e) {
return $config['region'];
}
}
return null;
}
/** @param array<string, string> $tags */
private function mapType(array $tags): string private function mapType(array $tags): string
{ {
if (($tags['sport'] ?? '') === 'surfing') return 'surf'; if (($tags['sport'] ?? '') === 'surfing') return 'surf';