¿Cómo monto mis páginas web de viajes? (II)

Hace tiempo expliqué cómo montaba mis páginas webs de viajes. Hay algunos cambios en el año y medio transcurrido:

  1. Subo las fotografías por WebDAV con el siguiente script en Python:

    #!/usr/bin/env python3
    
    # (c)2015 Jesús Cea Avión - jcea@jcea.es - https://www.jcea.es/
    # This code is licensed under AGPLv3.
    
    import easywebdav, requests
    import sys, os.path, urllib.parse, threading, queue
    
    usuario = "XXXX"
    clave = "XXXX"
    
    destino = sys.argv[1]
    ficheros = sys.argv[2:]
    ficheros.sort()
    
    webdav = easywebdav.connect("www.jcea.es",
            username = usuario, password = clave,
            protocol = 'https')
    
    ls = webdav.ls(destino)  # https://github.com/amnong/easywebdav/issues/30
    conflictos = set(map(os.path.basename, ficheros)) & \
                    set(map(lambda x: urllib.parse.unquote(os.path.basename(x.name)), ls))
    if conflictos :
        raise RuntimeError('Conflicto de nombres: "%s"' \
                %', '.join(sorted(conflictos)))
    
    def generar_preview() :
        auth = requests.auth.HTTPBasicAuth(usuario, clave)
        while True :
            url = cola_preview.get()
            if url is None :
                break
            requests.get(url, auth = auth)  # Fire and Forget
    
    cola_preview = queue.Queue()
    t_preview = threading.Thread(target = generar_preview)
    t_preview.setDaemon(True)
    t_preview.start()
    
    l = len(ficheros)
    for n,i in enumerate(ficheros, 1) :
        print("%s (%d/%d)" %(i, n, l))
        nombre = os.path.basename(i)
        webdav.upload(i, destino + '/' + nombre)
        cola_preview.put('https://www.jcea.es' + destino + '/' + nombre + '/Generar_preview')
    
    cola_preview.put(None)  # Pedimos que el hilo se suicide
    t_preview.join()
    
    print("Hemos subido %d ficheros" %l)
    

    El funcionamiento del programa es bien sencillo. Lo más interesante es que mientras se está subiendo una fotografía, se está pidiendo al servidor ZOPE que genere la preview de la fotografía anterior. También nos aseguramos de que las fotos se suben en orden, algo crítico en mi entorno. El programa tiene mucho cuidado en no sobreescribir ficheros.

    No hay control de errores porque el proceso se ejecuta de forma manual. Si hay problemas, lo volvemos a ejecutar.

  2. La creación de previews de las fotografías se realizaba de forma separada, manual, y carpeta a carpeta. Lento y tenía timeouts del proxy con frecuencia. El script del punto anterior pide generar las previews foto a foto, en paralelo con la subida de las mismas. El proceso se solapa y no consume tiempo adicional, podemos ir viendo cómo queda la página web a medida que se suben fotos, etc.

    He tenido que modificar el script ZOPE Generar_preview para contemplar esta posibilidad:

    # Import a standard function, and get the HTML request and response objects.
    from Products.PythonScripts.standard import html_quote
    request = container.REQUEST
    RESPONSE =  request.RESPONSE
    
    if context.meta_type == 'ExtImage' :
      j = (context,)
    else :
      j=context.objectValues(["ExtImage"])
    
    for i in j :
     scale = max(i.height(), i.width())/461.0
     h,w=round(i.height()/scale),round(i.width()/scale)
     i.manage_create_prev(maxx=w, maxy=h, ratio=True)
     print "<br>"+i.Generar_metadata()+" "+i.Generar_gps()+ " <font size=-2>"+i.Generar_datetimeoriginal()+"</font>"
    
    print "<br><br>Convertimos %d im&aacute;genes" %len(j)
    return printed
    

    Si indicamos una imagen, genera la preview de dicha imagen. Si le indicamos un directorio, genera las previews de todas las imágenes en ese directorio.

  3. Ahora extraemos mucha más información EXIF de las fotografías. Tenemos un External Method de ZOPE con el siguiente contenido:

    import exifread
    
    # Los "str()" son por:
    # http://www.joonis.de/en/zope/dtml-mixed-encoding
    
    def generar_datetimeoriginal(self) :
      f = open(self._get_fsname(self.filename), "rb")
      tags = exifread.process_file(f, details=False)
      datetimeoriginal = ""
      if "EXIF DateTimeOriginal" in tags :
        datetimeoriginal = tags["EXIF DateTimeOriginal"].values
        datetimeoriginal = str(datetimeoriginal.replace(":", "-", 2))
      if not self.hasProperty('exif_datetimeoriginal') :
        self.manage_addProperty('exif_datetimeoriginal', "", 'string')
      if datetimeoriginal == "" :
        self.manage_delProperties(("exif_datetimeoriginal",))
      else :
        self.manage_changeProperties({"exif_datetimeoriginal":datetimeoriginal})
      return datetimeoriginal
    
    def generar_metadata(self) :
      f = open(self._get_fsname(self.filename), "rb")
      # Si usamos "details=False" no vemos los tags de HDR.
      tags = exifread.process_file(f, details=True)
    
      ISO = ""
      if "EXIF ISOSpeedRatings" in tags :
        ISO = str(tags["EXIF ISOSpeedRatings"].values[0])
    
      if not self.hasProperty('exif_ISO') :
        self.manage_addProperty('exif_ISO', "", 'string')
      if ISO == "" :
        self.manage_delProperties(("exif_ISO",))
      else :
        self.manage_changeProperties({"exif_ISO":ISO})
    
      exposure = ""
      if "EXIF ExposureTime" in tags :
        exposure = str(tags["EXIF ExposureTime"].values[0])
    
      if not self.hasProperty('exif_exposure') :
        self.manage_addProperty('exif_exposure', "", 'string')
      if exposure == "" :
        self.manage_delProperties(("exif_exposure",))
      else :
        self.manage_changeProperties({"exif_exposure":exposure})
    
      aperture = ""
      if "EXIF FNumber" in tags :
        aperture = tags["EXIF FNumber"].values[0]
        aperture = "%.1f" %(float(aperture.num)/aperture.den)
    
      if not self.hasProperty('exif_aperture') :
        self.manage_addProperty('exif_aperture', "", 'string')
      if aperture == "" :
        self.manage_delProperties(("exif_aperture",))
      else :
        self.manage_changeProperties({"exif_aperture":aperture})
    
      model = ""
      maker = ""
      if "Image Model" in tags :
        model = str(tags["Image Model"].values)
      if "Image Make" in tags :
        maker = str(tags["Image Make"].values)
        if not model.startswith(maker) :
          model = maker+" "+model
    
      if not self.hasProperty('exif_model') :
        self.manage_addProperty('exif_model', "", 'string')
      if model == "" :
        self.manage_delProperties(("exif_model",))
      else :
        self.manage_changeProperties({"exif_model":model})
    
      focal = ""
      if "EXIF FocalLength" in tags :
        focal = tags["EXIF FocalLength"].values[0]
        focal = float(focal.num)/focal.den
    
        # Equivalencia a 35mm. Crop Factor
        # http://www.devicespecifications.com/en/model/5d342ce2
        if model == "Canon PowerShot SX60 HS" :
            focal = focal * 5.61
        elif model == "bq Aquaris E5" :
            focal = focal * 7.61
        elif model == "SAMSUNG GT-I9100" :
            focal = focal * 7.61
        elif model == "Apple iPhone 6" :
            focal = focal * 7.21
        elif model == "Apple iPhone 5s" :
            focal = focal * 7.08
        elif model == "Apple iPhone 4":
            focal = focal * 7.61
        else :
            focal = ''
    
        if focal != '' :
            focal = "%.1f" %focal
    
      if not self.hasProperty('exif_focal') :
        self.manage_addProperty('exif_focal', "", 'string')
      if focal == "" :
        self.manage_delProperties(("exif_focal",))
      else :
        self.manage_changeProperties({"exif_focal":focal})
    
      hdr = ""
      if maker == "Canon" :
        if "MakerNote EasyShootingMode" in tags :
          if tags["MakerNote EasyShootingMode"].printable == "High Dynamic Range" :
            hdr = "HDR"
      if maker == "Apple" :
        if "MakerNote HDRImageType" in tags :
          if tags["MakerNote HDRImageType"].printable == "HDR Image" :
            hdr = "HDR"
    
      if not self.hasProperty('exif_hdr') :
        self.manage_addProperty('exif_hdr', "", 'string')
      if hdr == "" :
        self.manage_delProperties(("exif_hdr",))
      else :
        self.manage_changeProperties({"exif_hdr":hdr})
    
      return model+" ISO "+ISO+ " "+exposure+" sec "+"f/"+aperture+" "+focal+"mm "+hdr
    
    
    def generar_gps(self) :
      f = open(self._get_fsname(self.filename), "rb")
      tags = exifread.process_file(f, details=False)
      latitud = longitud = ""
      if "GPS GPSLongitude" in tags :
        lat = tags["GPS GPSLatitude"].values
        lon = tags["GPS GPSLongitude"].values
        if (lat[0].den==1) and (lon[0].den==1) :
          minseg = lat[2].num/60.0/lat[2].den if lat[2].num else 0
          latitud  = "%s %d&deg; %.3f" %(tags["GPS GPSLatitudeRef"].values,
                    lat[0].num, lat[1].num/float(lat[1].den)+minseg)
          minseg = lon[2].num/60.0/lon[2].den if lon[2].num else 0
          longitud = "%s %d&deg; %.3f" %(tags["GPS GPSLongitudeRef"].values,
                    lon[0].num, lon[1].num/float(lon[1].den)+minseg)
        else :
          longitud = latitud = "XXXXXXXXX"
      latitud = str(latitud)
      longitud = str(longitud)
      if not self.hasProperty('longitud') :
        self.manage_addProperty('longitud', "", 'string')
        self.manage_addProperty('latitud', "", 'string')
      self.manage_changeProperties({"longitud":longitud,"latitud":latitud})
      if latitud == "" :
        self.manage_delProperties(("longitud", "latitud"))
      return latitud+" "+longitud
    

    Hay una rutina nueva, generar_metadata(), que proporciona información como el valor ISO, tiempo de exposición, nivel de zoom, modelo de la cámara, HDR, etc.

    Si estamos procesando una fotografía de una cámara conocida, nos proporcionará la distancia focal de la fotografía, equivalente a una cámara de 35mm [1].

    [1]

    En el caso de usar la lente frontal del teléfono (por ejemplo, un selfie), el valor será incorrecto. Es algo a mejorar en el futuro.

  4. Voy añadiendo metadatos adicionales de vez en cuando y me gusta que su efecto sea retroactivo. Así, tengo el siguiente script ZOPE Generar_metadata_recursivo para hacer eso: repasar todas las fotografías bajo un directorio dado -de forma recursiva- y regenerar sus metadatos:

    # Import a standard function, and get the HTML request and response objects.
    from Products.PythonScripts.standard import html_quote
    request = container.REQUEST
    RESPONSE =  request.RESPONSE
    
    for i in context.objectValues(['ExtImage', 'Folder', 'Folder (Ordered)']) :
     m = i.meta_type
     if m == 'ExtImage' :
        print "<br>"+i.Generar_metadata()
     elif m in ('Folder', 'Folder (Ordered)') :
        print i.Generar_metadata_recursivo()
    return printed
    
  5. La gestión de la navegación dentro de un viaje dado ha cambiado mucho. La idea es que sea mucho más simple navegar dentro de un viaje de varios días, con fotografías de diferentes personas, y que mejoras futuras en el sistema se puedan aplicar de forma retroactiva.

    Ahora el index_html de ZOPE contendrá lo siguiente:

    <dtml-let nivel="REQUEST.PARENTS.index(REQUEST.PUBLISHED.aq_inner.aq_parent)"
              id="'' if not nivel else REQUEST.PARENTS[0].getId()"
              subir="'../'*nivel">
    
    <dtml-let v="menu2(entry=id)" title="v[0]" title_multiline="v[1]" v="v[2]">
    
    <dtml-var standard_html_header>
    
    <h1 align=center><dtml-var title_multiline></h1>
    <p align=center> <i>Última Actualización:
    <dtml-var fecha></i>
    
    <dtml-var texto>
    
    <dtml-with expr="REQUEST.PARENTS[0]">
    <dtml-with expr="{'alguna_foto':[]}" mapping>
    
    <div style="font-size:200%; text-align:center;">
    <dtml-var v>
    </div>
    
    <dtml-in "objectValues(['ExtImage'])">
    <dtml-call expr="alguna_foto.append(True)">
    <p id="<dtml-var title_or_id>"><a href="&dtml-getId;"><dtml-var "tag(preview=1,align='left')"></a>
    <dtml-var foto>
    </dtml-in>
    
    <dtml-in expr="('Elena',)">
    <dtml-let Nombre=sequence-item nombre="Nombre.lower()" obj="get(nombre)">
    <dtml-if obj>
    
    <h1>Fotografías tomadas por <dtml-var Nombre></h1>
    
    <dtml-in "obj.objectValues(['ExtImage'])">
    <dtml-call expr="alguna_foto.append(True)">
    <p id="<dtml-var title_or_id>"><a href="&dtml-nombre;/&dtml-getId;"><dtml-var "tag(preview=1,align='left')"></a>
    <dtml-var foto>
    </dtml-in>
    </dtml-if>
    </dtml-let>
    </dtml-in>
    
    <dtml-if expr="len(alguna_foto)">
    <div style="font-size:200%; text-align:center;">
    <dtml-var v>
    </div>
    </dtml-if>
    </dtml-with>
    </dtml-with>
    
    <p><hr>
    <p><ul>
    <li><a href="&dtml-subir;../">
    <b>Mis Fotografías</b></a>
    <li><a href="&dtml-subir;../../">
    <b>La Página de Jesús Cea Avión</b></a>
    </dtml-let>
    
    </ul>
    
    <p><dtml-var standard_html_footer>
    
    </dtml-let>
    

    Aquí se imprime el texto de entrada y los menús de navegación, se visualizan las fotos de toda la gente, etc. Como siempre en ZOPE, si ese fichero no existe, se usará el del directorio padre. [2]

    [2]

    Para que este código fuera general tendría que tener la lista de personas en otro lugar. Es una mejora futura.

    En el fichero texto ponemos el texto que queremos incluir al principio de la página web. Si ese fichero no existe, se usará el del directorio padre. Aquí, típicamente, doy contexto sobre el viaje en general o sobre qué pasó ese día concreto.

    La visualización en sí de una foto se realiza a través del fichero foto. De nuevo, si no existe ese fichero, se usará la versión en el directorio padre. El contenido actual es:

    <font size=+1><a href="#<dtml-var title_or_id>"><dtml-var title_or_id></a></font>
    <br><dtml-var "size()">
    <dtml-if exif_datetimeoriginal><br><dtml-var exif_datetimeoriginal></dtml-if>
    <dtml-if latitud><br><i><dtml-var latitud> <dtml-var longitud></i></dtml-if>
    <p><dtml-if exif_model><font size="-2"><dtml-var exif_model></font></dtml-if>
    <dtml-if exif_ISO><font size="-2">, ISO <dtml-var exif_ISO></font></dtml-if>
    <dtml-if exif_aperture><font size="-2">, f/<dtml-var exif_aperture></font></dtml-if>
    <dtml-if exif_exposure><font size="-2">, <dtml-var exif_exposure> sec</font></dtml-if>
    <dtml-if exif_focal><font size="-2">, <dtml-var exif_focal> mm</font></dtml-if>
    <dtml-if exif_hdr><font size="-2">, HDR</font></dtml-if>
    <p><dtml-var descr>
    <br clear="all">
    

    Obsérvese que ahora cada foto tiene un permalink para poder enlazarla.

    Cada directorio puede contener un fichero menu_entries. Si no lo tiene, se busca en el directorio padre (útil para gestionar los viajes largos por días o por actividad). Un contenido posible es:

    1
    2
    3
    4
    5
    6
    ==Cuenca (octubre 2015)=
    segobriga=segobriga/=Segóbriga:=(10 de octubre)
    ucles=ucles/=Uclés:=(10 de octubre)
    cuenca=cuenca/=Cuenca:=(11 de octubre)
    ucles2=ucles2/=Uclés otra vez:=(12 de octubre)
    comarca=comarca/=Regreso a Madrid:=(12 de octubre)
    

    Ahí aparece el título del viaje y las diferentes entradas del menú de navegación.

    El menú en sí se gestiona con el script ZOPE menu2 (tengo un script ZOPE menu, incompatible):

    while context is not None :
      menu_entries = getattr(context, 'menu_entries')
      if menu_entries is not None :
        break
      context = context.aq_parent
    
    menu_entries = str(menu_entries).split('\n')
    
    prefix = '../' if entry else ''
    
    primero = True
    v = []
    for i in menu_entries :
      i = i.strip()
      if i == '' : continue
    
      tag,url,textoA,textoB = i.split('=')
      url = prefix+url
    
      if primero :
        titulo_prefijo = textoA+(" "+textoB if textoB else '')
      else :
        v.append("<br/>")
    
      if tag == entry :
        v.append('<b>%s</b> %s' %(textoA, textoB))
        titulo = titulo2 = titulo_prefijo
        if not primero :
          titulo += ": "+textoA+(" "+textoB if textoB else '')
          titulo2 +="<br/>"+textoA+(" "+textoB if textoB else '')
      else :
        v.append('<a href="%s"><b>%s</b></a> %s' %(url, textoA, textoB))
    
      primero = False
    
    if len(v) == 1 :
      v = []  # No nos interesan los menús que solo tienen una entrada
    
    return titulo, titulo2, ''.join(v)
    

    La gran ventaja de trabajar con ficheros separados es que se pueden sobrecargar en directorios hijos con facilidad. Además, cuando esté satisfecho con el resultado, puedo mover esos ficheros al directorio superior con el efecto de a) no repetir esa información en todos los directorios de viajes y b) actualizando alguno de esos ficheros, se actualizan TODOS los viajes.

  6. La funcionalidad de usar Google Maps para mostrar un fichero KML hospedado en mi propio servidor web ha desaparecido. He estado una temporada buscando alternativas y, finalmente, me he decidido por la biblioteca Javascript Leaflet. Esto es tema de otro artículo. Lo podéis ver funcionando en https://www.jcea.es/pics/italia2015, más concretamente en https://www.jcea.es/pics/italia2015/italia2015.kml/mapa.

    Actualización 20160205: He publicado el artículo: Mapas y capas de información.

  7. Actualización 20160128: Artículo sobre cómo geoetiquetar las fotografías de mi cámara digital WIFI desde el móvil: Geolocalización en una cámara de fotos WIFI (¿Cómo monto mis páginas web de viajes? (II bis)).

Año y medio de evolución resumido en diez minutos de lectura :-).