Overview
The generar_historia.py script is the heart of Historia Diaria. It connects to the OpenRouter API, generates AI-powered stories, manages the history, and outputs HTML pages using a template system.
The script runs automatically via GitHub Actions every day at 13:00 UTC (8:00 AM Lima time).
Dependencies
The script requires the following Python modules:
import os
import requests
import json
import re
from datetime import datetime
import uuid
os: Access environment variables (API key)
requests: HTTP client for API calls
json: Parse API responses and manage history
re: Extract structured data from AI responses
datetime: Handle date formatting and file naming
uuid: Generate unique image seeds
Environment Variables
The script requires one environment variable:
Your OpenRouter API key for authentication. The script reads it using: api_key = os.environ.get( "OPENROUTER_API_KEY" )
If OPENROUTER_API_KEY is not set, the API call will fail and generate an error story.
Script Structure
The script executes in 8 sequential steps:
1. API Connection (Lines 8-27)
Connects to OpenRouter API and generates a story using the stepfun/step-3.5-flash:free model.
prompt = """
Escribe una historia corta de ciencia ficción o fantasía.
Debes devolver tu respuesta EXACTAMENTE en este formato:
<TITULO>Aquí va el título</TITULO>
<HISTORIA>Aquí va la historia en unos dos o tres párrafos.</HISTORIA>
<IMAGEN>una_sola_palabra_clave_en_ingles</IMAGEN>
"""
try :
response = requests.post(
url = "https://openrouter.ai/api/v1/chat/completions" ,
headers = { "Authorization" : f "Bearer { api_key } " , "Content-Type" : "application/json" },
data = json.dumps({ "model" : "stepfun/step-3.5-flash:free" , "messages" : [{ "role" : "user" , "content" : prompt}]})
)
respuesta_ia = response.json()[ 'choices' ][ 0 ][ 'message' ][ 'content' ]
except Exception as e:
respuesta_ia = "<TITULO>Error</TITULO><HISTORIA>Fallo al conectar con la API.</HISTORIA><IMAGEN>error</IMAGEN>"
API Endpoint URL : https://openrouter.ai/api/v1/chat/completionsModel : stepfun/step-3.5-flash:freeError Handling : Catches all exceptions and generates fallback error story
Extracts title and story content from the AI response using regex patterns.
titulo_match = re.search( r '<TITULO> ( . *? ) </TITULO>' , respuesta_ia, re. DOTALL )
historia_match = re.search( r '<HISTORIA> ( . *? ) </HISTORIA>' , respuesta_ia, re. DOTALL )
nuevo_titulo = titulo_match.group( 1 ).strip() if titulo_match else "Historia sin título"
nueva_historia = historia_match.group( 1 ).strip() if historia_match else "Error al generar la historia."
The re.DOTALL flag allows the patterns to match across multiple lines.
3. Image Generation (Lines 36-38)
Generates a unique, reproducible image URL using Picsum with a UUID seed.
codigo_unico = uuid.uuid4().hex
url_imagen = f "https://picsum.photos/seed/ { codigo_unico } /600/350"
Each run generates a different UUID, ensuring unique images every day while keeping them stable (same seed = same image).
4. Date Handling (Lines 40-50)
Formats the current date for file naming and history organization.
fecha_hoy = datetime.now()
año = fecha_hoy.strftime( "%Y" )
mes_num = fecha_hoy.strftime( "%m" )
dia = fecha_hoy.strftime( " %d " )
meses_español = { "01" : "Enero" , "02" : "Febrero" , "03" : "Marzo" , "04" : "Abril" ,
"05" : "Mayo" , "06" : "Junio" , "07" : "Julio" , "08" : "Agosto" ,
"09" : "Septiembre" , "10" : "Octubre" , "11" : "Noviembre" , "12" : "Diciembre" }
nombre_mes = meses_español[mes_num]
nombre_archivo_hoy = f "historia- { año } - { mes_num } - { dia } .html"
llave_mes = f " { nombre_mes } { año } "
File Name Format historia-YYYY-MM-DD.htmlExample: historia-2026-03-05.html
Month Key Format Nombre_Mes YYYYExample: Marzo 2026
5. History Logic (Lines 52-73)
Manages the historial.json file with update-or-insert logic.
if os.path.exists( 'historial.json' ):
with open ( 'historial.json' , 'r' , encoding = 'utf-8' ) as f:
historial = json.load(f)
else :
historial = {}
if llave_mes not in historial:
historial[llave_mes] = []
historia_actualizada = False
for item in historial[llave_mes]:
if item[ 'archivo' ] == nombre_archivo_hoy:
item[ 'titulo' ] = nuevo_titulo # Actualiza el título
historia_actualizada = True
break
if not historia_actualizada:
historial[llave_mes].insert( 0 , { "titulo" : nuevo_titulo, "archivo" : nombre_archivo_hoy})
with open ( 'historial.json' , 'w' , encoding = 'utf-8' ) as f:
json.dump(historial, f, ensure_ascii = False , indent = 4 )
Load existing history
Reads historial.json if it exists, otherwise creates empty dict
Check if month exists
Creates a new month array if the current month isn’t in history
Update or insert
If today’s file already exists: updates the title
If it’s a new day: inserts at position 0 (most recent first)
Save to disk
Writes updated history back to historial.json with UTF-8 encoding
Builds the HTML sidebar menu from the history data.
menu_html = ""
for mes, historias in historial.items():
menu_html += f '<h3 class="mes-titulo"> { mes } </h3>'
menu_html += '<ul class="lista-historias">'
for item in historias:
menu_html += f '<li><a href=" { item[ "archivo" ] } "> { item[ "titulo" ] } </a></li>'
menu_html += '</ul>'
This generates structured HTML with:
Month headers using the .mes-titulo class
Unordered lists with the .lista-historias class
Links to individual story HTML files
7. Template Replacement (Lines 84-91)
Reads plantilla.html and replaces all template variables.
with open ( 'plantilla.html' , 'r' , encoding = 'utf-8' ) as archivo:
contenido_html = archivo.read()
contenido_html = contenido_html.replace( ' {{ TITULO }} ' , nuevo_titulo)
contenido_html = contenido_html.replace( ' {{ HISTORIA }} ' , nueva_historia)
contenido_html = contenido_html.replace( ' {{ IMAGEN_URL }} ' , url_imagen)
contenido_html = contenido_html.replace( ' {{ MENU }} ' , menu_html)
8. File Output (Lines 93-98)
Writes the final HTML to two locations.
with open ( 'index.html' , 'w' , encoding = 'utf-8' ) as archivo:
archivo.write(contenido_html)
with open (nombre_archivo_hoy, 'w' , encoding = 'utf-8' ) as archivo:
archivo.write(contenido_html)
print ( "¡Página y menú generados con éxito!" )
index.html Always shows the latest story (homepage)
historia-YYYY-MM-DD.html Archived version with unique filename
Error Handling
The script includes minimal but functional error handling:
Any exception during the API call triggers a fallback: except Exception as e:
respuesta_ia = "<TITULO>Error</TITULO><HISTORIA>Fallo al conectar con la API.</HISTORIA><IMAGEN>error</IMAGEN>"
This ensures the workflow never crashes - it simply generates an “Error” story.
If regex extraction fails, the script uses default values:
Title : "Historia sin título"
Story : "Error al generar la historia."
Best Practices
File Encoding : All file operations use encoding='utf-8' to properly handle Spanish characters (á, é, í, ó, ú, ñ).
Testing Locally : Run the script manually to test before deployment:export OPENROUTER_API_KEY = "your-key-here"
python generar_historia.py
Template Variables All available template placeholders
History JSON Structure of historial.json
GitHub Actions Setup Automation workflow configuration
OpenRouter Setup API key configuration