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(
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
{
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 = [
// Guadeloupe : Basse-Terre + Grande-Terre séparés pour éviter que la bbox
// englobe trop de mer et rate des côtes
'Basse-Terre' => ['bbox' => '15.82,-61.82,16.52,-61.55', 'region' => 'Guadeloupe'],
'Grande-Terre' => ['bbox' => '15.97,-61.55,16.52,-61.07', 'region' => 'Guadeloupe'],
'Marie-Galante' => ['bbox' => '15.83,-61.40,16.08,-61.10', 'region' => 'Guadeloupe'],
'La Désirade' => ['bbox' => '16.27,-61.10,16.37,-60.82', 'region' => 'Guadeloupe'],
'Les Saintes' => ['bbox' => '15.83,-61.68,15.90,-61.55', 'region' => 'Guadeloupe'],
'Martinique' => ['bbox' => '14.38,-61.25,14.88,-60.80', 'region' => 'Martinique'],
'Sainte-Lucie' => ['bbox' => '13.70,-61.10,14.15,-60.85', 'region' => 'Sainte-Lucie'],
'Barbade' => ['bbox' => '13.00,-59.80,13.40,-59.40', 'region' => 'Barbade'],
'Saint-Martin' => ['bbox' => '17.85,-63.20,18.18,-62.95', 'region' => 'Saint-Martin'],
'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'],
'Basse-Terre' => ['bbox' => [15.82, -61.82, 16.52, -61.55], 'region' => 'Guadeloupe'],
'Grande-Terre' => ['bbox' => [15.97, -61.55, 16.52, -61.07], 'region' => 'Guadeloupe'],
'Marie-Galante' => ['bbox' => [15.83, -61.40, 16.08, -61.10], 'region' => 'Guadeloupe'],
'La Désirade' => ['bbox' => [16.27, -61.10, 16.37, -60.82], 'region' => 'Guadeloupe'],
'Les Saintes' => ['bbox' => [15.83, -61.68, 15.90, -61.55], 'region' => 'Guadeloupe'],
'Martinique' => ['bbox' => [14.38, -61.25, 14.88, -60.80], 'region' => 'Martinique'],
'Sainte-Lucie' => ['bbox' => [13.70, -61.10, 14.15, -60.85], 'region' => 'Sainte-Lucie'],
'Barbade' => ['bbox' => [13.00, -59.80, 13.40, -59.40], 'region' => 'Barbade'],
'Saint-Martin' => ['bbox' => [17.85, -63.20, 18.18, -62.95], 'region' => 'Saint-Martin'],
'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(
@@ -46,81 +49,69 @@ class ImportCoastalPointsCommand extends Command
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche sans persister en base')
->addOption('island', null, InputOption::VALUE_REQUIRED, 'Importer une seule île (ex: Guadeloupe)');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche sans persister en base');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$filter = $input->getOption('island');
$io->title('Import OpenStreetMap — Points côtiers Antilles');
if ($dryRun) {
$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);
$created = 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) {
$osmId = $el['type'] . '/' . $el['id'];
$osmName = $el['tags']['name'] ?? $el['tags']['name:fr'] ?? null;
if ($osmName === null) {
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);
$lon = $el['lon'] ?? ($el['center']['lon'] ?? null);
if ($lat === null || $lon === null) {
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) {
$point = new CoastalPoint();
$point->setName($osmName);
$point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lon, $lat));
$point->setType($type);
$point->setRegion($config['region']);
$point->setRegion($region);
$point->setOsmId($osmId);
$this->em->persist($point);
}
@@ -132,9 +123,6 @@ class ImportCoastalPointsCommand extends Command
$this->em->flush();
}
sleep(1); // respect Overpass rate limit
}
$verb = $dryRun ? 'trouvé(s)' : 'créé(s)';
$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>>
*/
private function queryOverpass(string $bbox, SymfonyStyle $io): array
private function queryOverpass(SymfonyStyle $io): array
{
$query = <<<OVERPASS
[out:json][timeout:40];
(
node["natural"="beach"]["name"]({$bbox});
node["natural"="beach"]["name:fr"]({$bbox});
node["leisure"="beach_resort"]["name"]({$bbox});
node["leisure"="marina"]["name"]({$bbox});
node["harbour"]["name"]({$bbox});
node["seamark:type"="harbour"]["name"]({$bbox});
node["amenity"="ferry_terminal"]["name"]({$bbox});
node["sport"="surfing"]["name"]({$bbox});
node["waterway"="dock"]["name"]({$bbox});
way["natural"="beach"]["name"]({$bbox});
way["natural"="beach"]["name:fr"]({$bbox});
way["leisure"="beach_resort"]["name"]({$bbox});
way["leisure"="marina"]["name"]({$bbox});
way["harbour"]["name"]({$bbox});
relation["natural"="beach"]["name"]({$bbox});
relation["natural"="beach"]["name:fr"]({$bbox});
);
out center;
OVERPASS;
$statements = '';
foreach (self::ISLANDS as $config) {
[$s, $w, $n, $e] = $config['bbox'];
$bbox = "{$s},{$w},{$n},{$e}";
$statements .= " node[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
$statements .= " node[\"natural\"=\"beach\"][\"name:fr\"]({$bbox});\n";
$statements .= " node[\"leisure\"=\"beach_resort\"][\"name\"]({$bbox});\n";
$statements .= " node[\"leisure\"=\"marina\"][\"name\"]({$bbox});\n";
$statements .= " node[\"harbour\"][\"name\"]({$bbox});\n";
$statements .= " node[\"amenity\"=\"ferry_terminal\"][\"name\"]({$bbox});\n";
$statements .= " node[\"sport\"=\"surfing\"][\"name\"]({$bbox});\n";
$statements .= " way[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
$statements .= " way[\"natural\"=\"beach\"][\"name:fr\"]({$bbox});\n";
$statements .= " way[\"leisure\"=\"marina\"][\"name\"]({$bbox});\n";
$statements .= " way[\"harbour\"][\"name\"]({$bbox});\n";
$statements .= " relation[\"natural\"=\"beach\"][\"name\"]({$bbox});\n";
}
$query = "[out:json][timeout:60];\n(\n{$statements});\nout center;";
try {
$response = $this->httpClient->request('POST', self::OVERPASS_URL, [
'body' => ['data' => $query],
'timeout' => 35,
'timeout' => 65,
]);
$data = $response->toArray();
return $data['elements'] ?? [];
} catch (\Throwable $e) {
$io->warning('Overpass error : ' . $e->getMessage());
$io->error('Overpass error : ' . $e->getMessage());
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
{
if (($tags['sport'] ?? '') === 'surfing') return 'surf';