fix: import OSM en 1 seule requête Overpass (évite le 429 rate limit)
This commit is contained in:
@@ -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,93 +49,78 @@ 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 ($elements as $el) {
|
||||
$osmId = $el['type'] . '/' . $el['id'];
|
||||
$osmName = $el['tags']['name'] ?? $el['tags']['name:fr'] ?? null;
|
||||
if ($osmName === null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
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']);
|
||||
|
||||
$io->writeln(sprintf(' + %-45s [%s]', $osmName, $type));
|
||||
|
||||
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->setOsmId($osmId);
|
||||
$this->em->persist($point);
|
||||
}
|
||||
|
||||
++$created;
|
||||
$lat = $el['lat'] ?? ($el['center']['lat'] ?? null);
|
||||
$lon = $el['lon'] ?? ($el['center']['lon'] ?? null);
|
||||
if ($lat === null || $lon === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$region = $this->detectRegion((float) $lat, (float) $lon);
|
||||
if ($region === null) {
|
||||
continue; // hors de nos zones d'intérêt
|
||||
}
|
||||
|
||||
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) {
|
||||
$this->em->flush();
|
||||
$point = new CoastalPoint();
|
||||
$point->setName($osmName);
|
||||
$point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lon, $lat));
|
||||
$point->setType($type);
|
||||
$point->setRegion($region);
|
||||
$point->setOsmId($osmId);
|
||||
$this->em->persist($point);
|
||||
}
|
||||
|
||||
sleep(1); // respect Overpass rate limit
|
||||
++$created;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$verb = $dryRun ? 'trouvé(s)' : 'créé(s)';
|
||||
@@ -142,57 +130,69 @@ 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';
|
||||
if (($tags['harbour'] ?? '') === 'fishing') return 'fishing';
|
||||
if (($tags['amenity'] ?? '') === 'ferry_terminal') return 'port';
|
||||
if (($tags['leisure'] ?? '') === 'marina') return 'port';
|
||||
if (($tags['sport'] ?? '') === 'surfing') return 'surf';
|
||||
if (($tags['harbour'] ?? '') === 'fishing') return 'fishing';
|
||||
if (($tags['amenity'] ?? '') === 'ferry_terminal') return 'port';
|
||||
if (($tags['leisure'] ?? '') === 'marina') return 'port';
|
||||
return 'beach';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user