diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-26 09:53:39 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-26 09:53:52 +0100 |
| commit | 84db1ca075dc63ccb02da825948d95ad09f94e4d (patch) | |
| tree | b9678bec669304bb3201a1e40a86bb3828150fac /build_xunta | |
| parent | 291450f2add8ddd6ed8757b2bdbfceb476be3033 (diff) | |
Convert submodules to regular repo files, add custom feeds
Diffstat (limited to 'build_xunta')
| m--------- | build_xunta | 0 | ||||
| -rw-r--r-- | build_xunta/.gitignore | 8 | ||||
| -rw-r--r-- | build_xunta/LICENCE | 287 | ||||
| -rw-r--r-- | build_xunta/LICENCE-MITRAMS.md | 94 | ||||
| -rw-r--r-- | build_xunta/README.md | 34 | ||||
| -rw-r--r-- | build_xunta/agency_mappings.json | 1019 | ||||
| -rw-r--r-- | build_xunta/build_static_feed.py | 364 | ||||
| -rw-r--r-- | build_xunta/gen_parroquias.py | 172 |
8 files changed, 1978 insertions, 0 deletions
diff --git a/build_xunta b/build_xunta deleted file mode 160000 -Subproject caf5806f7ff0bd78983631d32724c12999d0f3a diff --git a/build_xunta/.gitignore b/build_xunta/.gitignore new file mode 100644 index 0000000..ce296bc --- /dev/null +++ b/build_xunta/.gitignore @@ -0,0 +1,8 @@ +feed/ +__pycache__/ +*.pyc + +galicia-latest.osm.pbf +gtfs_xunta.zip +parroquias.geojson +.venv/
\ No newline at end of file diff --git a/build_xunta/LICENCE b/build_xunta/LICENCE new file mode 100644 index 0000000..5afc293 --- /dev/null +++ b/build_xunta/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version.
\ No newline at end of file diff --git a/build_xunta/LICENCE-MITRAMS.md b/build_xunta/LICENCE-MITRAMS.md new file mode 100644 index 0000000..577d471 --- /dev/null +++ b/build_xunta/LICENCE-MITRAMS.md @@ -0,0 +1,94 @@ +# Licencia de datos abiertos del Ministerio de Transportes y Movilidad Sostenible + +La presente Licencia de datos abiertos del Ministerio de Transportes y Movilidad Sostenible (en adelante, LDA) regula +la reutilización de los datos abiertos del Ministerio de Transportes y Movilidad Sostenible (en adelante, MITRAMS) en las +condiciones fijadas en este documento. Por la misma queda vinculada la persona, empresa, organización o entidad (en adelante, +agente reutilizador) que va a hacer uso de los documentos contenidos o descritos en la presente licencia o de cualquier dato derivado +del mismo y que sean definidos como datos de carácter abierto del MITRAMS. + +Las presentes condiciones generales definidas en esta licencia permiten la reutilización de los documentos sometidos a ellas +para fines comerciales y no comerciales + +El concepto de documento es el recogido en el Anexo de la Ley 37/2007, añadido por el art. único.13 de la Ley 18/2015, de 9 de julio de 16 de noviembre, +sobre reutilización de la información del sector público, por lo que comprende toda información o parte de ella, cualquiera que sea su soporte o forma +de expresión, sea esta textual, gráfica, sonora, visual o audiovisual, incluyendo los metadatos asociados y los datos contenidos con los niveles más +elevados de precisión y desagregación. No se considerarán documentos los programas informáticos que estén protegidos por la legislación específica aplicable a los mismos. + +Se entiende por reutilización el uso de documentos que obran en poder de las Administraciones y organismos del sector público (referido en el +artículo 2 de la modificación por la disposición final 13.1 de la Ley 9/2017, de 8 de noviembre a la precitada Ley 37/2007) por personas físicas +o jurídicas, con fines comerciales o no comerciales, siempre que dicho uso no constituya una actividad administrativa pública. Queda excluido de +este concepto el intercambio de documentos entre Administraciones y organismos del sector público en el ejercicio de las funciones públicas que +tengan atribuidas. La reutilización autorizada incluye, a modo ilustrativo, actividades como la copia, difusión, modificación, adaptación, +extracción, reordenación y combinación de la información. + +Esta autorización conlleva, asimismo, la cesión gratuita y no exclusiva de los derechos de propiedad intelectual, en su caso, correspondientes a +tales documentos, autorizándose la realización de actividades de reproducción, distribución, comunicación pública o transformación, necesarias +para desarrollar la actividad de reutilización autorizada, en cualquier modalidad y bajo cualquier formato, para todo el mundo y por el plazo +máximo permitido por la Ley. + +La presente licencia debe ser entendida pues como una licencia-tipo de las previstas en la letra b) del apartado 2 del artículo 4 de la precitada +Ley 37/2007, modificada por el art. único.2 de la Ley 18/2015, de 9 de julio. + +## Ámbito + +La LDA autoriza a: + +1. Descargar datos del MITRAMS. +2. Compartir (copiar, distribuir) los datos anteriores y obtenidos del MITRAMS ofreciendo los datos bajo el mismo tipo de licencia. +3. Los datos generados por el sector público pueden utilizarse como materia prima para servicios + de valor añadido y productos innovadores que impulsan la economía. En consecuencia, las obras derivadas añadiendo valor pueden ofrecerse + bajo licencias diferentes. + +## Restricciones + +Son de aplicación las siguientes restricciones a las condiciones generales para la reutilización de los documentos sometidos a ellas: + +1. El agente reutilizador tiene expresamente prohibido desnaturalizar el sentido de la información, estando obligado a: + 1. No manipular con mala fe ni falsear la información. + 2. Garantizar que la información mostrada en su sistema esté siempre + actualizada. + 3. No utilizar la información para menoscabar o dañar la imagen pública del MITRAMS. + 4. No utilizar la información en sitios en los que la información del MITRAMS pueda + relacionarse con actos ilegales o intenciones de sabotaje hacia el MITRAMS o hacia otras entidades, organizaciones o personas. +2. Debe citarse al MITRAMS como fuente de datos, especificando si son datos en bruto o explotados. En plataformas digitales; + webs, foros, blogs, apps, etcdebe quedar claramente la indicación Powered by MITRAMS" incluyendo enlace a la página + oficial del MITRAMS (<https://www.transportes.gob.es/>) +3. Deben conservarse, no alterarse ni suprimirse, los metadatos sobre la fecha de actualización y las condiciones de + reutilización aplicables incluidos, en su caso, como atributos complementarios a la información puesta a disposición para su reutilización. +4. No se podrá indicar, insinuar o sugerir que el MITRAMS, como titular de la información reutilizada, participa, patrocina o apoya + expresamente el producto final del agente reutilizador, salvo que el MITRAMS así lo autorice. +5. MITRAMS monitorizará el acceso realizado por parte de los sistemas del agente reutilizador. Si se detectara un acceso indebido o abusivo + de modo que pudiera penalizar los recursos de los sistemas de MITRAMS y comprometer la disponibilidad de los mismos por colapso o retardo, MITRAMS + podrá bloquear el acceso al identificador del agente reutilizador. + +## Garantías y responsabilidades + +1. Cada parte manifiesta y garantiza que tiene pleno poder para suscribir el presente Acuerdo. +2. MITRAMS se reserva el derecho de poder incluir cualquier modificación en sus sistemas de Datos Abiertos, + tanto en su interfaz de acceso y uso, como en su contenido y diseño. El agente reutilizador deberá realizar la + actualización y adaptación de su sistema para una correcta integración con la información de MITRAMS. +3. MITRAMS garantiza que posee plenos derechos sobre la titularidad y veracidad de los datos susceptibles de cesión a los que permite el acceso. +4. La utilización de los conjuntos de datos se realizará por parte del agente de reutilización bajo su propia cuenta + y riesgo, correspondiéndoles en exclusiva a ellos responder frente a terceros por los daños que pudieran derivarse de ella. +5. MITRAMS no será responsable del uso que de su información hagan los agentes reutilizadores ni tampoco de los daños sufridos o + pérdidas económicas que, de forma directa o indirecta, produzcan o puedan producir perjuicios económicos, materiales o sobre datos, + provocados por el uso de la información reutilizada. +6. MITRAMS no garantiza la continuidad en la puesta a disposición de la información reutilizable, ni en contenido ni en forma, + ni asume responsabilidades por cualquier error u omisión contenido en ellos. +7. Los datos se proporcionan tal y cómo están. No se puede garantizar que todos los datos sean estrictamente correctos. Cualquier perjuicio + que pudiera producir a un tercero por el uso de datos que no sean estrictamente correctos no podrá ser imputado al MITRAMS. La aceptación de + las condiciones de esta LDA exime de esta responsabilidad al MITRAMS. +8. El agente reutilizador se halla sometido a la normativa aplicable en materia de reutilización de la información del sector + público, incluyendo el régimen sancionador previsto en el artículo 11 de la Ley 37/2007, de 16 de noviembre, sobre reutilización + de la información del sector público. El incumplimiento de esta licencia podría dar lugar a acciones restrictivas por parte del MITRAMS. +9. La ley aplicable en caso de disputa o conflicto de interpretación de los términos que configuran este aviso legal, será la ley + española. Para la resolución de cualquier conflicto que pudiera surgir, el MITRAMS y el usuario de los servicios de puesta a disposición + de los documentos reutilizables acuerdan someterse a los Jueces y Tribunales de Madrid. + +La presente licencia se entenderá sin perjuicio de las restricciones, garantías y responsabilidades que, en su caso, pudieran haber sido establecidas +por la licencia de uso de datos de la fuente original de la que proviene cada conjunto de datos recogido en el Punto de Acceso Nacional de Transporte +Multimodal, que también serán de aplicación, prevaleciendo en caso de discrepancia la más restrictiva de ambas licencias. + +--- + +Adaptado del texto HTML disponible en <https://nap.transportes.gob.es/licencia-datos> diff --git a/build_xunta/README.md b/build_xunta/README.md new file mode 100644 index 0000000..67f8125 --- /dev/null +++ b/build_xunta/README.md @@ -0,0 +1,34 @@ +# Feed GTFS mejorado de la Xunta de Galicia + +Este repositorio contendrá un feed GTFS (General Transit Feed Specification) mejorado a partir del feed oficial de la Xunta de Galicia, publicado en el [Punto de Acceso Nacional](https://nap.transportes.gob.es/Files/Detail/1386) del Ministerio de Transportes y Movilidad Sostenible de España. + +## Mejoras que se realizan + +- **Restricciones de tráfico**: Se marcan las paradas del concello de salida como "solo subida", y las del concello de llegada como "solo bajada", cuando uno u otro son A Coruña, Lugo, Ourense, Santiago o Vigo. De este modo se reduce la probabilidad de calcular rutas que no se pueden realizar por prohibiciones de tráfico (que corresponde al transporte urbano). +- **Añadir nombre de parroquia y concello**: Se añade al campo `stop_desc` el nombre de la parroquia y concello donde se ubica la parada, con datos de OpenStreetMap, separados por ` -- ` para su más fácil transformación y uso en otras aplicaciones. En algunos casos la parroquia puede ser igual al Concello donde se encuentra. Ejemplos: `Salcedo -- Pontevedra`, `Elviña -- A Coruña`. +- **Separación de rutas en agencias**: Se crean agencias separadas para cada operador, asignando las rutas correspondientes a estas. Este proceso incluye añadir los datos manualmente en [agency_mappings.json](./agency_mappings.json) a partir de los adjudicatarios, con sus colores de marca e información de contacto (para aquel cuya web tenga datos más detallados sobre el servicio). Las adjudicaciones están disponibles en estos 4 expedientes de Contratos de Galicia: + - [XG600-XG743](https://www.contratosdegalicia.gal/licitacion?OP=50&N=501362&lang=gl) + - [XG603, XG630, XG641, XG686](https://www.contratosdegalicia.gal/licitacion?OP=50&N=573083&lang=gl) + - [XG800-XG891](https://www.contratosdegalicia.gal/licitacion?OP=50&N=640920&lang=gl) + - [XG635](https://www.contratosdegalicia.gal/licitacion?OP=50&N=823020&lang=gl) + +## Mejoras planificadas + +Las mejoras previstas incluyen: + +- **Uso de nomenclaturas de líneas de los operadores**: Además, o en lugar de, utilizar la nomenclatura oficial de la Xunta para las líneas `XG<contrato><línea>`, se emplearán las nomenclaturas utilizadas por los operadores en caso de haberlos. Por ejemplo, las líneas operadas por ALSA en los entornos de A Coruña y Ferrol, Lugove en el Val Miñor/Baixo Miño; y Autocares Rías Baixas en Pontevedra. +- **Datos sobre tarifas y precios**: Se añadirán datos relacionados con las tarifas y precios de los billetes, a partir de la información que proporciona la Xunta en Excel y el portal <https://bus.gal>. + +## Mejoras no planificadas + +No se planea modificar la información de líneas, recorridos, horarios o paradas, dado que estos datos están sujetos a variación por la administración y es una cantidad inmensa de mejoras que habría que realizar, y que no puede ser hecha mediante scripts automáticos. + +## Contribuciones + +Las contribuciones son bienvenidas. Si deseas colaborar en la mejora del feed GTFS, por favor abre un issue o envía un pull request con tus propuestas o cambios. + +## Licencia + +El código propio de este proyecto está bajo la [European Union Public License v1.2 o posterior](./LICENCE). El feed GTFS original y el proporcionado por este proyecto están sujetos a la licencia del feed original, disponible en el archivo [`LICENCE-MITRAMS.md`](LICENCE-MITRAMS.md). + +Este repositorio utiliza datos de OpenStreetMap, que están bajo licencia [Open Data Commons Open Database License (ODbL)](https://opendatacommons.org/licenses/odbl/) y pueden requerir dar crédito por su uso. diff --git a/build_xunta/agency_mappings.json b/build_xunta/agency_mappings.json new file mode 100644 index 0000000..ced6581 --- /dev/null +++ b/build_xunta/agency_mappings.json @@ -0,0 +1,1019 @@ +{ + "XG600": { + "agency_name": "Operador XG600", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG601": { + "agency_name": "Operador XG601", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG602": { + "agency_name": "Operador XG602", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG604": { + "agency_name": "Autos González/Monbus", + "agency_email": "autocares@autosgonzalez.com", + "agency_phone": "+34 900 441 222", + "agency_url": "https://www.autosgonzalez.com/", + "route_color": "8B7CB4", + "route_text_color": "FFFFFF" + }, + "XG605": { + "agency_name": "Operador XG605", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG610": { + "agency_name": "Operador XG610", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG611": { + "agency_name": "Operador XG611", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG612": { + "agency_name": "Operador XG612", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG613": { + "agency_name": "Operador XG613", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG614": { + "agency_name": "Operador XG614", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG615": { + "agency_name": "Operador XG615", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG618": { + "agency_name": "Operador XG618", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG620": { + "agency_name": "Operador XG620", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG621": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG622": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG623": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG624": { + "agency_name": "Autos González/Monbus", + "agency_email": "autocares@autosgonzalez.com", + "agency_phone": "+34 900 441 222", + "agency_url": "https://www.autosgonzalez.com/", + "route_color": "8B7CB4", + "route_text_color": "FFFFFF" + }, + "XG625": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG626": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG627": { + "agency_name": "Abalo/Monbus", + "agency_email": "autocares@autocaresabalo.com", + "agency_phone": "+34 986 540 101", + "agency_url": "https://autocaresabalo.com/", + "route_color": "203584", + "route_text_color": "FFFFFF" + }, + "XG628": { + "agency_name": "Rías Baixas/Monbus", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG632": { + "agency_name": "Operador XG632", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG633": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG634": { + "agency_name": "Operador XG634", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG635": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG636": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG637": { + "agency_name": "Operador XG637", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG638": { + "agency_name": "Operador XG638", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG639": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG640": { + "agency_name": "Operador XG640", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG642": { + "agency_name": "ALSA Ferrol", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://www.alsaferrol.es/", + "route_color": "3FC8EB", + "route_text_color": "FFFFFF" + }, + "XG643": { + "agency_name": "Operador XG643", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG644": { + "agency_name": "Operador XG644", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG645": { + "agency_name": "Operador XG645", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG646": { + "agency_name": "Operador XG646", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG647": { + "agency_name": "Operador XG647", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG648": { + "agency_name": "Operador XG648", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG649": { + "agency_name": "Operador XG649", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG651": { + "agency_name": "Operador XG651", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG654": { + "agency_name": "Operador XG654", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG656": { + "agency_name": "Operador XG656", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG658": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG659": { + "agency_name": "Operador XG659", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG660": { + "agency_name": "Operador XG660", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG661": { + "agency_name": "Operador XG661", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG662": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG663": { + "agency_name": "Operador XG663", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG664": { + "agency_name": "Operador XG664", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG665": { + "agency_name": "Operador XG665", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG666": { + "agency_name": "Operador XG666", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG667": { + "agency_name": "Operador XG667", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG668": { + "agency_name": "Operador XG668", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG669": { + "agency_name": "Operador XG669", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG670": { + "agency_name": "Operador XG670", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG671": { + "agency_name": "Operador XG671", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG672": { + "agency_name": "Operador XG672", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG673": { + "agency_name": "Operador XG673", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG676": { + "agency_name": "Operador XG676", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG677": { + "agency_name": "Operador XG677", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG680": { + "agency_name": "Operador XG680", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG681": { + "agency_name": "Operador XG681", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG682": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG684": { + "agency_name": "Operador XG684", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG685": { + "agency_name": "Operador XG685", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG687": { + "agency_name": "Operador XG687", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG688": { + "agency_name": "Operador XG688", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG689": { + "agency_name": "Operador XG689", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG690": { + "agency_name": "Operador XG690", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG691": { + "agency_name": "Operador XG691", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG692": { + "agency_name": "Operador XG692", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG695": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG696": { + "agency_name": "Operador XG696", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG698": { + "agency_name": "Operador XG698", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG699": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG701": { + "agency_name": "Operador XG701", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG703": { + "agency_name": "Operador XG703", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG705": { + "agency_name": "Operador XG705", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG706": { + "agency_name": "Operador XG706", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG707": { + "agency_name": "Operador XG707", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG708": { + "agency_name": "Operador XG708", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG709": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG714": { + "agency_name": "Operador XG714", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG717": { + "agency_name": "Operador XG717", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG719": { + "agency_name": "ALSA-Autos Rodríguez Eocar", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://eocar.es/", + "route_color": "FF0000 ", + "route_text_color": "FFFFFF" + }, + "XG720": { + "agency_name": "Operador XG720", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG721": { + "agency_name": "Operador XG721", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG723": { + "agency_name": "Operador XG723", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG724": { + "agency_name": "Operador XG724", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG727": { + "agency_name": "Operador XG727", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG728": { + "agency_name": "Operador XG728", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG729": { + "agency_name": "Operador XG729", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG730": { + "agency_name": "Operador XG730", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG732": { + "agency_name": "Operador XG732", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG743": { + "agency_name": "Operador XG743", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG800": { + "agency_name": "Grabanxa", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "FF7A01", + "route_text_color": "FFFFFF" + }, + "XG802": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG804": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG807": { + "agency_name": "Monbus/Seoane/Abalo", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG811": { + "agency_name": "Operador XG811", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG813": { + "agency_name": "Operador XG813", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG814": { + "agency_name": "Abalo/Rías Baixas", + "agency_email": "autocares@autocaresabalo.com", + "agency_phone": "+34 986 540 101", + "agency_url": "https://autocaresabalo.com/", + "route_color": "203584", + "route_text_color": "FFFFFF" + }, + "XG817": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG830": { + "agency_name": "Operador XG830", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG833": { + "agency_name": "Operador XG833", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG835": { + "agency_name": "García Castro", + "agency_email": "info@autocaresgarciacastro.com", + "agency_phone": "+34 629 039 544", + "agency_url": "https://autocaresgarciacastro.com/", + "route_color": "00213B", + "route_text_color": "FFFFFF" + }, + "XG843": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG845": { + "agency_name": "Operador XG845", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG846": { + "agency_name": "Operador XG846", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG847": { + "agency_name": "Operador XG847", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG848": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG852": { + "agency_name": "Cerqueiro", + "agency_email": "cangas@autobusescerqueiro.com", + "agency_phone": "+34 986 320 254 ", + "agency_url": "https://autobusescerqueiro.com/", + "route_color": "C10707", + "route_text_color": "FFFFFF" + }, + "XG859": { + "agency_name": "Lugove", + "agency_email": "cliente@lugove.gal", + "agency_phone": "+34 986 608 045", + "agency_url": "https://lugove.gal", + "route_color": "18A1DF", + "route_text_color": "FFFFFF" + }, + "XG860": { + "agency_name": "Lázara/Rías Baixas", + "agency_email": "info@autocareslazara.es", + "agency_phone": "+34 986 580 485", + "agency_url": "https://autocareslazara.eu/", + "route_color": "3d107b", + "route_text_color": "FFFFFF" + }, + "XG863": { + "agency_name": "Lázara/Rías Baixas", + "agency_email": "info@autocareslazara.es", + "agency_phone": "+34 986 580 485", + "agency_url": "https://autocareslazara.eu/", + "route_color": "3d107b", + "route_text_color": "FFFFFF" + }, + "XG871": { + "agency_name": "Monbus-Hércules Norte", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG872": { + "agency_name": "Operador XG872", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG881": { + "agency_name": "ALSA-Cal Pita", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://www.alsacalpita.es/", + "route_color": "3FC8EB", + "route_text_color": "FFFFFF" + }, + "XG883": { + "agency_name": "Lugove", + "agency_email": "cliente@lugove.gal", + "agency_phone": "+34 986 608 045", + "agency_url": "https://lugove.gal", + "route_color": "18A1DF", + "route_text_color": "FFFFFF" + }, + "XG884": { + "agency_name": "Operador XG884", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG888": { + "agency_name": "Ojea/Monbus", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 986 640 846", + "agency_url": "https://empresaojea.com", + "route_color": "006600", + "route_text_color": "FFFFFF" + }, + "XG889": { + "agency_name": "Operador XG889", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG890": { + "agency_name": "Operador XG890", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG891": { + "agency_name": "Operador XG891", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG603": { + "agency_name": "Operador XG603", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG630": { + "agency_name": "Operador XG630", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG641": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG686": { + "agency_name": "Operador XG686", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + } + +} diff --git a/build_xunta/build_static_feed.py b/build_xunta/build_static_feed.py new file mode 100644 index 0000000..9c2a3b1 --- /dev/null +++ b/build_xunta/build_static_feed.py @@ -0,0 +1,364 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "requests", +# "shapely", +# "tqdm", +# ] +# /// + +from argparse import ArgumentParser +from collections import defaultdict +import csv +import json +import logging +import os +import shutil +import tempfile +import zipfile +from pathlib import Path + +import requests +from shapely.geometry import Point, shape +from shapely.strtree import STRtree +from tqdm import tqdm + + +def _load_boundaries(path: Path) -> tuple[ + dict[str, dict], # muni_by_ine: {ine_5 -> {shape, props}} + dict[str, list[dict]], # parishes_by_muni: {ine_5 -> [{shape, props}, ...]} +]: + logging.info("Loading boundaries from %s …", path) + with open(path, encoding="utf-8") as fh: + geojson = json.load(fh) + + muni_by_ine: dict[str, dict] = {} + parishes_by_muni: dict[str, list] = defaultdict(list) + + for feature in geojson["features"]: + props = feature["properties"] + geom = shape(feature["geometry"]) + level = props["admin_level"] + ine_muni = props.get("ine_muni", "") + + if level == 8: + if ine_muni: + muni_by_ine[ine_muni] = {"shape": geom, "props": props} + elif level == 9: + ref_ine = props.get("ref_ine", "") + parent_ine = ref_ine[:5] if ref_ine else ine_muni + if parent_ine: + parishes_by_muni[parent_ine].append({"shape": geom, "props": props}) + + logging.info( + "Loaded %d municipalities, %d parishes grouped into %d municipalities.", + len(muni_by_ine), + sum(len(v) for v in parishes_by_muni.values()), + len(parishes_by_muni), + ) + return muni_by_ine, dict(parishes_by_muni) + + +def _build_parish_trees( + parishes_by_muni: dict[str, list[dict]], +) -> dict[str, tuple[STRtree, list[dict]]]: + trees: dict[str, tuple[STRtree, list[dict]]] = {} + for ine, parish_list in parishes_by_muni.items(): + geoms = [p["shape"] for p in parish_list] + trees[ine] = (STRtree(geoms), parish_list) + return trees + + +def _find_parish( + point: Point, + ine_muni: str, + parish_trees: dict[str, tuple[STRtree, list[dict]]], +) -> dict | None: + entry = parish_trees.get(ine_muni) + if entry is None: + return None + tree, parish_list = entry + hits = tree.query(point, predicate="intersects") + if len(hits) == 0: + return None + if len(hits) == 1: + return parish_list[hits[0]]["props"] + best = min(hits, key=lambda i: parish_list[i]["shape"].centroid.distance(point)) + return parish_list[best]["props"] + + +def build_stop_desc( + stop: dict, + muni_by_ine: dict[str, dict], + parish_trees: dict[str, tuple[STRtree, list[dict]]], +) -> str: + """Return a stop_desc string of the form 'Parish (Municipality)', or an + empty string if neither can be resolved.""" + zone_id = stop.get("zone_id", "") + ine_muni = zone_id[:5] if len(zone_id) >= 5 else "" + + muni_entry = muni_by_ine.get(ine_muni) if ine_muni else None + muni_name = muni_entry["props"]["name"] if muni_entry else "" + + try: + lat = float(stop["stop_lat"]) + lon = float(stop["stop_lon"]) + except ValueError: + return muni_name + + parish_props = _find_parish(Point(lon, lat), ine_muni, parish_trees) + parish_name = parish_props["name"] if parish_props else "" + + if parish_name and muni_name: + return f"{parish_name} -- {muni_name}" + return parish_name or muni_name + + +if __name__ == "__main__": + parser = ArgumentParser( + description="Build static GTFS feed for Galicia (Xunta) with parish/municipality stop descriptions." + ) + parser.add_argument( + "nap_apikey", + type=str, + help="NAP API Key (https://nap.transportes.gob.es/)" + ) + parser.add_argument( + "--boundaries", + type=Path, + default=Path(os.path.join(os.path.dirname(__file__), "parroquias.geojson")), + help="Path to the boundaries GeoJSON produced by gen_parroquias.py " + "(default: parroquias.geojson next to this script).", + ) + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true" + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + # Boundaries + muni_by_ine, parishes_by_muni = _load_boundaries(args.boundaries) + logging.info("Building per-municipality parish trees …") + parish_trees = _build_parish_trees(parishes_by_muni) + + # Download & unpack feed + INPUT_GTFS_FD, INPUT_GTFS_ZIP = tempfile.mkstemp(suffix=".zip", prefix="xunta_in_") + INPUT_GTFS_PATH = tempfile.mkdtemp(prefix="xunta_in_") + OUTPUT_GTFS_PATH = tempfile.mkdtemp(prefix="xunta_out_") + OUTPUT_GTFS_ZIP = os.path.join(os.path.dirname(__file__), "gtfs_xunta.zip") + + FEED_URL = "https://nap.transportes.gob.es/api/Fichero/download/1584" + + logging.info("Downloading GTFS feed...") + response = requests.get(FEED_URL, headers={"ApiKey": args.nap_apikey}) + response.raise_for_status() + with open(INPUT_GTFS_ZIP, "wb") as f: + f.write(response.content) + + with zipfile.ZipFile(INPUT_GTFS_ZIP, "r") as zip_ref: + zip_ref.extractall(INPUT_GTFS_PATH) + + STOPS_FILE = os.path.join(INPUT_GTFS_PATH, "stops.txt") + STOP_TIMES_FILE = os.path.join(INPUT_GTFS_PATH, "stop_times.txt") + TRIPS_FILE = os.path.join(INPUT_GTFS_PATH, "trips.txt") + + # Copy unchanged files + for filename in ["trips.txt", + "calendar.txt", "calendar_dates.txt", + "shapes.txt"]: + src = os.path.join(INPUT_GTFS_PATH, filename) + if os.path.exists(src): + shutil.copy(src, os.path.join(OUTPUT_GTFS_PATH, filename)) + else: + logging.debug("File %s not present in the input feed, skipping.", filename) + + # Load agency list + AGENCY_MAPPINGS_JSON_FILE = Path(os.path.join(os.path.dirname(__file__), "agency_mappings.json")) + with open(AGENCY_MAPPINGS_JSON_FILE, encoding="utf-8") as f: + agency_mappings: dict[str, dict[str, str]] = json.load(f) + + with open(os.path.join(OUTPUT_GTFS_PATH, "agency.txt"), "w", encoding="utf-8", newline="") as agency_out: + fieldnames = ["agency_id", "agency_name", "agency_url", "agency_email", + "agency_phone", "agency_timezone", "agency_lang"] + writer = csv.DictWriter(agency_out, fieldnames=fieldnames) + writer.writeheader() + for agency_id, mapping in agency_mappings.items(): + writer.writerow({ + "agency_id": agency_id, + "agency_name": mapping["agency_name"], + "agency_url": mapping["agency_url"], + "agency_email": mapping["agency_email"], + "agency_phone": mapping["agency_phone"], + "agency_timezone": "Europe/Madrid", + "agency_lang": "es", + }) + + # Load routes, mapping to agency_id by first 5 chars of route_short_name, and apply route_color/route_text_color from the mapping if available + with open(os.path.join(INPUT_GTFS_PATH, "routes.txt"), encoding="utf-8-sig", newline="") as routes_fh: + reader = csv.DictReader(routes_fh) + routes = list(reader) + route_fieldnames = set(reader.fieldnames or routes[0].keys()) + + for route in routes: + short_name = route.get("route_short_name", "") + agency_key = short_name[:5] if len(short_name) >= 5 else "" + + mapping = agency_mappings.get(agency_key, None) + route["agency_id"] = agency_key if mapping else "unknown" + if route["agency_id"] == "unknown": + logging.error("Route %s: could not determine agency_id from route_short_name '%s'.", route["route_id"], short_name) + continue + if mapping is None: + logging.error("Route %s: no agency mapping found for key '%s'.", route["route_id"], agency_key) + continue + + if "route_color" in mapping: + route["route_color"] = mapping["route_color"] + route_fieldnames.add("route_color") + if "route_text_color" in mapping: + route["route_text_color"] = mapping["route_text_color"] + route_fieldnames.add("route_text_color") + + with open(os.path.join(OUTPUT_GTFS_PATH, "routes.txt"), "w", encoding="utf-8", newline="") as routes_out: + writer = csv.DictWriter(routes_out, fieldnames=route_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(routes) + + # Build stops.txt with stop_desc + logging.info("Enriching stops with parish/municipality descriptions …") + with open(STOPS_FILE, encoding="utf-8-sig", newline="") as in_fh: + reader = csv.DictReader(in_fh) + stops = list(reader) + base_fieldnames = list(reader.fieldnames or stops[0].keys()) + + unmatched = 0 + for stop in tqdm(stops, desc="Enriching stops", unit="stop"): + desc = build_stop_desc(stop, muni_by_ine, parish_trees) + stop["stop_desc"] = desc + if not desc: + unmatched += 1 + logging.debug("Stop %s: could not resolve parish/municipality.", stop["stop_id"]) + + if unmatched: + logging.warning("%d stops (%.1f%%) could not be matched to a parish/municipality.", + unmatched, 100 * unmatched / len(stops)) + + out_fieldnames = base_fieldnames if "stop_desc" in base_fieldnames else base_fieldnames + ["stop_desc"] + with open(os.path.join(OUTPUT_GTFS_PATH, "stops.txt"), "w", + encoding="utf-8", newline="") as out_fh: + writer = csv.DictWriter(out_fh, fieldnames=out_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(stops) + + logging.info("stops.txt written with stop_desc for %d stops.", len(stops)) + + # Interurban lines may not pick up or drop off passengers within cities that + # have their own urban network. The rule is applied per trip: + # - If the FIRST stop is in a restricted municipality, all consecutive + # stops in that municipality (from the start) are marked pickup-only + # (dropoff_type=1) until the first stop outside it. + # - If the LAST stop is in a restricted municipality, all consecutive + # stops in that municipality (from the end) are marked dropoff-only + # (pickup_type=1) until the last stop outside it. + # - Stops in restricted municipalities that appear only in the middle of + # a trip are left with regular pickup/dropoff. + RESTRICTED_MUNIS = {"15030", "27028", "32054", "15078", "36057"} + + # Build stop_id -> INE code dict from the already-loaded stops (O(1) lookups) + stop_ine: dict[str, str] = {} + for stop in stops: + zone_id = stop.get("zone_id", "") + stop_ine[stop["stop_id"]] = zone_id[:5] if len(zone_id) >= 5 else "" + + logging.info("Applying traffic restrictions for municipalities: %s …", + ", ".join(sorted(RESTRICTED_MUNIS))) + + with open(STOP_TIMES_FILE, encoding="utf-8-sig", newline="") as st_fh: + st_reader = csv.DictReader(st_fh) + all_stop_times = list(st_reader) + st_fieldnames = list(st_reader.fieldnames or all_stop_times[0].keys()) + + # Ensure pickup_type / dropoff_type columns exist (GTFS optional, default 0) + for col in ("pickup_type", "dropoff_type"): + if col not in st_fieldnames: + st_fieldnames.append(col) + for st in all_stop_times: + st.setdefault("pickup_type", "0") + st.setdefault("dropoff_type", "0") + + # Group by trip_id and sort each group by stop_sequence + trips_stop_times: dict[str, list[dict]] = defaultdict(list) + for st in all_stop_times: + trips_stop_times[st["trip_id"]].append(st) + for seq in trips_stop_times.values(): + seq.sort(key=lambda x: int(x["stop_sequence"])) + + restricted_trips = 0 + for seq in trips_stop_times.values(): + n = len(seq) + + # Prefix: how many consecutive stops from the START are in a restricted muni + prefix_end = 0 # exclusive end index + while prefix_end < n and stop_ine.get(seq[prefix_end]["stop_id"], "") in RESTRICTED_MUNIS: + prefix_end += 1 + + # Suffix: how many consecutive stops from the END are in a restricted muni + suffix_start = n - 1 # will become inclusive start index after adjustment + while suffix_start >= 0 and stop_ine.get(seq[suffix_start]["stop_id"], "") in RESTRICTED_MUNIS: + suffix_start -= 1 + suffix_start += 1 # inclusive start of the suffix run + + first_is_restricted = prefix_end > 0 + last_is_restricted = suffix_start < n + + if not first_is_restricted and not last_is_restricted: + continue + + # If prefix and suffix meet or overlap, the whole trip is within restricted + # munis (likely a purely urban service not subject to these rules) — skip. + if first_is_restricted and last_is_restricted and prefix_end >= suffix_start: + continue + + if first_is_restricted: + for st in seq[:prefix_end]: + st["pickup_type"] = "0" # regular pickup + st["drop_off_type"] = "1" # no dropoff + + if last_is_restricted: + for st in seq[suffix_start:]: + st["pickup_type"] = "1" # no pickup + st["drop_off_type"] = "0" # regular dropoff + + restricted_trips += 1 + + logging.info("Traffic restrictions applied to %d trips.", restricted_trips) + + with open(os.path.join(OUTPUT_GTFS_PATH, "stop_times.txt"), "w", + encoding="utf-8", newline="") as st_out_fh: + writer = csv.DictWriter(st_out_fh, fieldnames=st_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(all_stop_times) + + # Package output ZIP + with zipfile.ZipFile(OUTPUT_GTFS_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(OUTPUT_GTFS_PATH): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, OUTPUT_GTFS_PATH) + zipf.write(file_path, arcname) + + logging.info("GTFS feed zipped to %s", OUTPUT_GTFS_ZIP) + + # Cleanup + os.close(INPUT_GTFS_FD) + os.remove(INPUT_GTFS_ZIP) + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) + diff --git a/build_xunta/gen_parroquias.py b/build_xunta/gen_parroquias.py new file mode 100644 index 0000000..fa3984d --- /dev/null +++ b/build_xunta/gen_parroquias.py @@ -0,0 +1,172 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "osmium", +# "shapely", +# "requests", +# "tqdm", +# ] +# /// + +import json +import logging +import sys +from argparse import ArgumentParser +from pathlib import Path + +import osmium +import osmium.geom +import requests +from shapely.wkb import loads as wkb_loads +from tqdm import tqdm + +GEOFABRIK_URL = "https://download.geofabrik.de/europe/spain/galicia-latest.osm.pbf" +DEFAULT_PBF = "galicia-latest.osm.pbf" +DEFAULT_OUTPUT = "parroquias.geojson" + +_wkb_factory = osmium.geom.WKBFactory() + + +class _AdminBoundaryHandler(osmium.SimpleHandler): + """Collects administrative boundary areas at the requested admin levels.""" + + def __init__(self, admin_levels: set[str]) -> None: + super().__init__() + self.admin_levels = admin_levels + self.features: list[dict] = [] + self._geom_errors = 0 + + def area(self, a: osmium.osm.Area) -> None: # type: ignore[name-defined] + tags = a.tags + if tags.get("boundary") != "administrative": + return + level = tags.get("admin_level") + if level not in self.admin_levels: + return + + try: + wkb = _wkb_factory.create_multipolygon(a) + geom = wkb_loads(wkb, hex=True) + except Exception: + self._geom_errors += 1 + return + + ref_ine = tags.get("ref:ine", "") + self.features.append( + { + "type": "Feature", + "geometry": geom.__geo_interface__, + "properties": { + "osm_type": "way" if a.from_way() else "relation", + "osm_id": a.orig_id(), + "admin_level": int(level), + "name": tags.get("name", ""), + "name_gl": tags.get("name:gl", ""), + # ref:ine full code (e.g. "15017000000" for a municipality, + # "15017030000" for a parish). First 5 chars are always the + # 5-digit INE municipality code (PP+MMM). + "ref_ine": ref_ine, + "ine_muni": tags.get("ine:municipio", ref_ine[:5] if ref_ine else ""), + "wikidata": tags.get("wikidata", ""), + }, + } + ) + + +def _download_pbf(url: str, dest: Path) -> None: + """Stream-download *url* to *dest*, showing a progress bar. + + Skips the download silently if *dest* already exists. + """ + if dest.exists(): + logging.info("PBF already present at %s — skipping download.", dest) + return + + logging.info("Downloading %s …", url) + with requests.get(url, stream=True, timeout=60) as resp: + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + with open(dest, "wb") as fh, tqdm( + total=total, unit="B", unit_scale=True, desc=dest.name + ) as bar: + for chunk in resp.iter_content(chunk_size=1 << 20): + fh.write(chunk) + bar.update(len(chunk)) + + logging.info("Download complete: %s (%.1f MB)", dest, dest.stat().st_size / 1e6) + + +def main() -> None: + parser = ArgumentParser( + description=( + "Extract Galician parish (admin_level=9) and municipality " + "(admin_level=8) boundaries from an OSM PBF file." + ) + ) + parser.add_argument( + "--pbf", + type=Path, + default=Path(DEFAULT_PBF), + help=f"Path to OSM PBF file. Downloaded from Geofabrik if absent " + f"(default: {DEFAULT_PBF}).", + ) + parser.add_argument( + "--output", + type=Path, + default=Path(DEFAULT_OUTPUT), + help=f"Output GeoJSON file (default: {DEFAULT_OUTPUT}).", + ) + parser.add_argument( + "--no-download", + action="store_true", + help="Do not attempt to download the PBF; fail if it is missing.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging.", + ) + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + if not args.no_download: + _download_pbf(GEOFABRIK_URL, args.pbf) + + if not args.pbf.exists(): + logging.error("PBF file not found: %s", args.pbf) + sys.exit(1) + + logging.info("Parsing admin boundaries from %s …", args.pbf) + handler = _AdminBoundaryHandler(admin_levels={"8", "9"}) + handler.apply_file(str(args.pbf), locations=True, idx="flex_mem") + + n = len(handler.features) + logging.info( + "Found %d boundary features (%d geometry errors skipped).", + n, + handler._geom_errors, + ) + if n == 0: + logging.warning( + "No boundaries found — check that the PBF covers Galicia and " + "contains boundary=administrative relations at admin_level 8/9." + ) + + geojson = { + "type": "FeatureCollection", + "features": handler.features, + } + + args.output.write_text( + json.dumps(geojson, ensure_ascii=False, indent=None), + encoding="utf-8", + ) + logging.info("Saved %d features to %s", n, args.output) + + +if __name__ == "__main__": + main() |
