planetalibre.lua 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. --
  2. -- PlanetaLibre -- An Atom and RSS feed aggregator for Gemini written in Lua.
  3. --
  4. -- Copyright (C) 2023-2024 Ricardo García Jiménez <ricardogj08@riseup.net>
  5. --
  6. -- This program is free software: you can redistribute it and/or modify
  7. -- it under the terms of the GNU General Public License as published by
  8. -- the Free Software Foundation, either version 3 of the License, or
  9. -- (at your option) any later version.
  10. --
  11. -- This program is distributed in the hope that it will be useful,
  12. -- but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. -- GNU General Public License for more details.
  15. --
  16. -- You should have received a copy of the GNU General Public License
  17. -- along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. --
  19. -- NOTA: Utilizar solo programación estructurada.
  20. -- Módulos.
  21. local socket = require('socket')
  22. local socket_url = require('socket.url')
  23. local ssl = require('ssl')
  24. local uuid = require('uuid')
  25. require('feedparser')
  26. -- Configuración de la base de datos.
  27. local driver = require('luasql.sqlite3')
  28. local sql_env = assert(driver.sqlite3())
  29. -- Configuraciones de la aplicación.
  30. local settings = {
  31. gemini = {
  32. scheme = 'gemini',
  33. host = '/',
  34. port = 1965,
  35. },
  36. ssl = {
  37. mode = 'client',
  38. protocol = 'tlsv1_2'
  39. },
  40. mime = {
  41. 'application/xml',
  42. 'text/xml',
  43. 'application/atom+xml',
  44. 'application/rss+xml'
  45. },
  46. timeout = 8,
  47. file = 'feeds.txt',
  48. output = '.',
  49. header = 'header.gemini',
  50. footer = 'footer.gemini',
  51. capsule = 'PlanetaLibre',
  52. domain = 'localhost',
  53. limit = 64,
  54. lang = 'es',
  55. repo = 'https://notabug.org/ricardogj08/planetalibre',
  56. version = '4.0',
  57. license = 'CC-BY-4.0' -- https://spdx.org/licenses
  58. }
  59. -- Ejecuta las migraciones de la base de datos.
  60. local function migrations()
  61. -- Crea la tabla de las cápsulas.
  62. assert(sql_conn:execute([[
  63. CREATE TABLE IF NOT EXISTS capsules (
  64. id CHAR(36) NOT NULL,
  65. link TEXT NOT NULL,
  66. name VARCHAR(125) NOT NULL,
  67. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  68. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  69. CONSTRAINT capsules_id_primary PRIMARY KEY(id),
  70. CONSTRAINT capsules_link_unique UNIQUE(link)
  71. )
  72. ]]))
  73. -- Crea la tabla de las publicaciones.
  74. assert(sql_conn:execute([[
  75. CREATE TABLE IF NOT EXISTS posts (
  76. id CHAR(36) NOT NULL,
  77. capsule_id CHAR(36) NOT NULL,
  78. link TEXT NOT NULL,
  79. title VARCHAR(255) NOT NULL,
  80. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  81. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  82. CONSTRAINT posts_id_primary PRIMARY KEY(id),
  83. CONSTRAINT posts_capsule_id_foreign FOREIGN KEY(capsule_id)
  84. REFERENCES capsules(id)
  85. ON DELETE CASCADE
  86. ON UPDATE RESTRICT,
  87. CONSTRAINT posts_link_unique UNIQUE(link)
  88. )
  89. ]]))
  90. end
  91. -- Analiza una URL.
  92. local function parse_url(url)
  93. return socket_url.parse(url, settings.gemini)
  94. end
  95. -- Construye una URL.
  96. local function build_url(url)
  97. return socket_url.build(parse_url(url))
  98. end
  99. -- Construye una URL de PlanetaLibre.
  100. local function base_url(uri)
  101. local parsed_path = socket_url.parse_path(settings.domain)
  102. local parsed_url = settings.gemini
  103. -- Obtiene el dominio desde la configuración y
  104. -- mantiene los subdirectorios si se encuentran presentes.
  105. parsed_url.host = table.remove(parsed_path, 1)
  106. -- Agrega la uri con los subdirectorios si se encuentran presentes.
  107. table.insert(parsed_path, uri)
  108. -- Convierte el array de uris a un string.
  109. parsed_url.path = '/'..socket_url.build_path(parsed_path)
  110. return socket_url.build(parsed_url)
  111. end
  112. -- Cliente de peticiones para el protocolo Gemini.
  113. local client = socket.protect(function(url)
  114. ::client::
  115. local response = nil
  116. -- Analiza la URL de la petición.
  117. local parsed_url, err = parse_url(url)
  118. if err then
  119. return response, err
  120. end
  121. -- Comprueba el protocolo de la petición.
  122. if parsed_url.scheme ~= settings.gemini.scheme then
  123. err = 'Invalid url scheme'
  124. return response, err
  125. end
  126. -- Crea un objeto TCP maestro.
  127. local conn = assert(socket.tcp())
  128. -- Crea una función try que cierra el objeto TCP en caso de errores.
  129. local try = socket.newtry(function()
  130. conn:close()
  131. end)
  132. -- Define el tiempo máximo de espera por bloque en modo no seguro.
  133. conn:settimeout(settings.timeout)
  134. -- Realiza la conexión a un host remoto y
  135. -- transforma el objeto TCP maestro a cliente.
  136. try(conn:connect(parsed_url.host, settings.gemini.port))
  137. -- Transforma el objeto TCP cliente para conexiones seguras.
  138. conn = try(ssl.wrap(conn, settings.ssl))
  139. -- Define el tiempo máximo de espera por bloque en modo seguro.
  140. conn:settimeout(settings.timeout)
  141. -- Define el nombre del host al que se intenta conectar.
  142. conn:sni(parsed_url.host)
  143. -- Realiza la conexión segura.
  144. try(conn:dohandshake())
  145. url = socket_url.build(parsed_url)
  146. -- Construye la petición.
  147. local request = string.format('%s\r\n', url)
  148. -- Realiza la petición.
  149. try(conn:send(request))
  150. -- Obtiene el encabezado de la respuesta.
  151. local header = conn:receive('*l')
  152. -- Obtiene el código de estado y la meta de la respuesta.
  153. local status, meta = string.match(header, '(%d+)%s+(.+)')
  154. status = string.sub(status, 1, 1)
  155. local redirect = false
  156. -- Comprueba el código de estado del encabezado.
  157. if status == '2' then
  158. -- Comprueba el mime type de la respuesta.
  159. for _, mime in ipairs(settings.mime) do
  160. -- Obtiene el cuerpo de la respuesta.
  161. if string.find(meta, mime, 1, true) then
  162. response = conn:receive('*a')
  163. break
  164. end
  165. end
  166. if not response then
  167. err = 'Invalid mime type'
  168. end
  169. elseif status == '3' then
  170. redirect = true
  171. elseif status == '4' or status == '5' then
  172. err = meta
  173. elseif status == '6' then
  174. err = 'Client certificate required'
  175. else
  176. err = 'Invalid response from server'
  177. end
  178. -- Cierra el objeto TCP cliente.
  179. conn:close()
  180. -- Soluciona las redirecciones.
  181. if redirect then
  182. url = socket_url.absolute(url, meta)
  183. goto client
  184. end
  185. return response, err
  186. end)
  187. -- Imprime mensajes de éxito.
  188. local function log_success(message)
  189. print(string.format('[success] %s', message))
  190. end
  191. -- Imprime mensajes de errores.
  192. local function log_error(title, description)
  193. print(string.format('[error] %s - %s', title, description))
  194. end
  195. -- Imprime mensajes informativos.
  196. local function log_info(message)
  197. print(string.format('==== %s ====', message))
  198. end
  199. -- Obtiene un timestamp del tiempo actual en UTC.
  200. local function now_timestamp()
  201. return os.time(os.date('!*t'))
  202. end
  203. -- Convierte un timestamp a un datetime.
  204. local function timestamp_to_datetime(timestamp)
  205. return os.date('%F %T', timestamp)
  206. end
  207. -- Obtiene un datetime del tiempo actual en UTC.
  208. local function now_datetime()
  209. return timestamp_to_datetime(now_timestamp())
  210. end
  211. -- Obtiene un timestamp del tiempo actual desde hace un año en UTC.
  212. local function now_prev_year_timestamp()
  213. return now_timestamp() - 365 * 24 * 60 * 60
  214. end
  215. -- Obtiene un datetime del tiempo actual desde hace un año en UTC.
  216. local function now_prev_year_datetime()
  217. return timestamp_to_datetime(now_prev_year_timestamp())
  218. end
  219. -- Convierte un datetime a un timestamp.
  220. local function datetime_to_timestamp(datetime)
  221. local pattern = '(%d+)-(%d+)-(%d+)%s+(%d+):(%d+):(%d+)'
  222. local values = { string.match(datetime, pattern) }
  223. local keys = { 'year', 'month', 'day', 'hour', 'min', 'sec' }
  224. local timetable = {}
  225. -- Genera el timetable del datetime.
  226. for index, key in ipairs(keys) do
  227. timetable[key] = values[index]
  228. end
  229. return os.time(timetable)
  230. end
  231. -- Convierte un datetime a un dateatom.
  232. local function datetime_to_dateatom(datetime)
  233. return os.date('%FT%TZ', datetime_to_timestamp(datetime))
  234. end
  235. -- Obtiene un dateatom del tiempo actual en UTC.
  236. local function now_dateatom()
  237. return datetime_to_dateatom(now_datetime())
  238. end
  239. -- Convierte un datetime a un date.
  240. local function datetime_to_date(datetime)
  241. return os.date('%F', datetime_to_timestamp(datetime))
  242. end
  243. -- Escapa los caracteres especiales de un valor para la base de datos.
  244. local function escape(value)
  245. return sql_conn:escape(value)
  246. end
  247. -- Consulta la información de una cápsula.
  248. local function find_capsule_by(field, value)
  249. local cursor = assert(sql_conn:execute(string.format([[
  250. SELECT id, name
  251. FROM capsules
  252. WHERE %s = '%s'
  253. LIMIT 1
  254. ]], escape(field),
  255. escape(value))))
  256. local capsule = cursor:fetch({}, 'a')
  257. cursor:close()
  258. return capsule
  259. end
  260. -- Registra la información de una nueva cápsula.
  261. local function insert_capsule(data)
  262. local id = uuid()
  263. assert(sql_conn:execute(string.format([[
  264. INSERT INTO capsules(id, name, link, created_at, updated_at)
  265. VALUES('%s', '%s', '%s', '%s', '%s')
  266. ]], escape(id),
  267. escape(data.name),
  268. escape(data.link),
  269. escape(now_datetime()),
  270. escape(now_datetime()))))
  271. return find_capsule_by('id', id)
  272. end
  273. -- Modifica la información de una cápsula.
  274. local function update_capsule(data, id)
  275. assert(sql_conn:execute(string.format([[
  276. UPDATE capsules
  277. SET name = '%s', updated_at = '%s'
  278. WHERE id = '%s'
  279. ]], escape(data.name),
  280. escape(now_datetime()),
  281. escape(id))))
  282. end
  283. -- Consulta la información de una publicación.
  284. local function find_post_by(field, value)
  285. local cursor = assert(sql_conn:execute(string.format([[
  286. SELECT id, title, updated_at
  287. FROM posts
  288. WHERE %s = '%s'
  289. LIMIT 1
  290. ]], escape(field),
  291. escape(value))))
  292. local post = cursor:fetch({}, 'a')
  293. cursor:close()
  294. return post
  295. end
  296. -- Registra la información de una nueva publicación.
  297. local function insert_post(data)
  298. local id = uuid()
  299. assert(sql_conn:execute(string.format([[
  300. INSERT INTO posts(id, capsule_id, title, link, created_at, updated_at)
  301. VALUES('%s', '%s', '%s', '%s', '%s', '%s')
  302. ]], escape(id),
  303. escape(data.capsule_id),
  304. escape(data.title),
  305. escape(data.link),
  306. escape(now_datetime()),
  307. escape(data.updated_at))))
  308. return find_post_by('id', id)
  309. end
  310. -- Modifica la información de una publicación.
  311. local function update_post(data, id)
  312. assert(sql_conn:execute(string.format([[
  313. UPDATE posts
  314. SET title = '%s', updated_at = '%s'
  315. WHERE id = '%s'
  316. ]], escape(data.title),
  317. escape(data.updated_at),
  318. escape(id))))
  319. end
  320. -- Obtiene todas las publicaciones ordenadas por fecha de modificación.
  321. local function get_posts()
  322. local cursor = assert(sql_conn:execute(string.format([[
  323. SELECT c.name AS capsule_name, c.link AS capsule_link, p.title, p.link, p.updated_at
  324. FROM posts AS p
  325. INNER JOIN capsules AS c
  326. ON p.capsule_id = c.id
  327. ORDER BY p.updated_at DESC
  328. LIMIT %u
  329. ]], escape(settings.limit))))
  330. return function()
  331. return cursor:fetch({}, 'a')
  332. end
  333. end
  334. -- Comprueba la información de una cápsula
  335. -- desde la información del feed.
  336. local function check_capsule(feed_info)
  337. -- Establece la información de la cápsula desde el feed.
  338. local data = { name = feed_info.title, link = build_url(feed_info.link) }
  339. -- Comprueba si la cápsula se encuentra registrada.
  340. local capsule = find_capsule_by('link', data.link)
  341. -- Registra la información de la cápsula si no existe en la base de datos.
  342. if not capsule then
  343. capsule = insert_capsule(data)
  344. -- Actualiza el nombre de la cápsula si es diferente en la base de datos.
  345. elseif capsule.name ~= data.name then
  346. update_capsule({ name = data.name }, capsule.id)
  347. end
  348. return capsule
  349. end
  350. -- Comprueba la información de las publicaciones
  351. -- desde la información de las entradas del feed.
  352. local function check_posts(entries, capsule)
  353. local prev_timestamp = now_prev_year_timestamp()
  354. -- Registra cada entrada de la cápsula con
  355. -- una antigüedad mayor desde hace un año.
  356. for _, entry in ipairs(entries) do
  357. if entry.updated_parsed > prev_timestamp then
  358. -- Establece la información de la publicación desde las entradas del feed.
  359. local data = {
  360. title = entry.title,
  361. link = build_url(entry.link),
  362. capsule_id = capsule.id,
  363. updated_at = timestamp_to_datetime(entry.updated_parsed)
  364. }
  365. -- Comprueba si la publicación se encuentra registrada.
  366. local post = find_post_by('link', data.link)
  367. -- Registra la información de la publicación si no existe en la base de datos.
  368. if not post then
  369. post = insert_post(data)
  370. -- Actualiza el título y la fecha de modificación de
  371. -- la publicación si es diferente en la base de datos.
  372. elseif post.title ~= data.title or
  373. post.updated_at ~= data.updated_at
  374. then
  375. update_post({ title = data.title, updated_at = data.updated_at }, post.id)
  376. end
  377. end
  378. end
  379. end
  380. -- Escanea un archivo externo con las URLs de los feeds
  381. -- y almacena cada una de sus entradas en la base de datos.
  382. local function scan_feeds()
  383. local file = assert(io.open(settings.file, 'r'))
  384. for url in file:lines() do
  385. local status, err = pcall(function()
  386. -- Obtiene el cuerpo del feed desde la url del archivo.
  387. local feed = assert(client(url))
  388. -- Analiza el cuerpo del feed.
  389. local parsed_feed = assert(feedparser.parse(feed))
  390. -- Comprueba la información de la cápsula.
  391. local capsule = check_capsule(parsed_feed.feed)
  392. -- Comprueba la información de las entradas.
  393. check_posts(parsed_feed.entries, capsule)
  394. end)
  395. if status then
  396. log_success(url)
  397. else
  398. log_error(url, err)
  399. end
  400. end
  401. file:close()
  402. end
  403. -- Construye un path desde la salida del programa.
  404. local function build_path(path)
  405. return string.format('%s/%s', settings.output, path)
  406. end
  407. -- Genera el encabezado del feed de Atom de PlanetaLibre.
  408. local function open_atomfeed()
  409. local feed_url = base_url('atom.xml')
  410. local home_url = base_url('index.gemini')
  411. return string.format([[
  412. <?xml version="1.0" encoding="utf-8"?>
  413. <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="%s">
  414. <title>%s</title>
  415. <link href="%s" rel="self" type="application/atom+xml"/>
  416. <link href="%s" rel="alternate" type="text/gemini"/>
  417. <updated>%s</updated>
  418. <id>%s</id>
  419. <author>
  420. <name>%s</name>
  421. <uri>%s</uri>
  422. </author>
  423. <rights>%s</rights>
  424. <generator uri="%s" version="%s">
  425. PlanetaLibre
  426. </generator>
  427. ]], settings.lang,
  428. settings.capsule,
  429. feed_url,
  430. home_url,
  431. now_dateatom(),
  432. feed_url,
  433. settings.capsule,
  434. home_url,
  435. settings.license,
  436. settings.repo,
  437. settings.version)
  438. end
  439. -- Genera la etiqueta de cierre del feed de Atom de PlanetaLibre.
  440. local function close_atomfeed()
  441. return '</feed>\n'
  442. end
  443. -- Genera una entrada para el feed de Atom de PlanetaLibre.
  444. local function entry_atomfeed(post)
  445. return string.format([[
  446. <entry>
  447. <title>%s</title>
  448. <link href="%s" rel="alternate" type="text/gemini"/>
  449. <id>%s</id>
  450. <updated>%s</updated>
  451. <author>
  452. <name>%s</name>
  453. <uri>%s</uri>
  454. </author>
  455. </entry>
  456. ]], post.title,
  457. post.link,
  458. post.link,
  459. datetime_to_dateatom(post.updated_at),
  460. post.capsule_name,
  461. post.capsule_link)
  462. end
  463. -- Genera un link de Gemini.
  464. local function gemtext_link(link, title, description)
  465. return string.format('=> %s %s - %s\n', link, title, description)
  466. end
  467. -- Genera un heading de Gemini.
  468. local function gemtext_heading(title)
  469. return string.format('\n### %s\n\n', title)
  470. end
  471. -- Genera la cápsula y el feed de Atom de PlanetaLibre.
  472. local function generate_planetalibre()
  473. local homepage = assert(io.open(build_path('index.gemini'), 'w+'))
  474. local atomfeed = assert(io.open(build_path('atom.xml'), 'w+'))
  475. local header = io.open(settings.header)
  476. local test_date = nil
  477. -- Agrega el header en el archivo de la página principal.
  478. if header then
  479. homepage:write(header:read('*a'))
  480. header:close()
  481. end
  482. -- Agrega el encabezado en el archivo del feed de atom.
  483. atomfeed:write(open_atomfeed())
  484. -- Agrega las publicaciones en el archivo
  485. -- de la página principal y el feed de atom.
  486. for post in get_posts() do
  487. -- Obtiene la fecha de la publicación.
  488. local post_date = datetime_to_date(post.updated_at)
  489. -- Agrupa las publicaciones por día.
  490. if post_date ~= test_date then
  491. test_date = post_date
  492. homepage:write(gemtext_heading(test_date))
  493. end
  494. -- Agrega las publicaciones en el archivo
  495. -- de la página principal y el feed de atom.
  496. homepage:write(gemtext_link(post.link, post.capsule_name, post.title))
  497. atomfeed:write(entry_atomfeed(post))
  498. end
  499. -- Agrega la etiqueta de cierre en el archivo del feed de atom.
  500. atomfeed:write(close_atomfeed())
  501. atomfeed:close()
  502. local footer = io.open(settings.footer)
  503. -- Agrega el footer en el archivo de la página principal.
  504. if footer then
  505. homepage:write('\n'..footer:read('*a'))
  506. footer:close()
  507. end
  508. homepage:close()
  509. end
  510. -- Imprime un mensaje de ayuda.
  511. local function shelp()
  512. print(string.format([[
  513. PlanetaLibre %s - An Atom and RSS feed aggregator for Gemini written in Lua.
  514. Synopsis:
  515. planetalibre [OPTIONS]
  516. Options:
  517. --capsule <STRING> - Capsule name [default: PlanetaLibre].
  518. --domain <STRING> - Capsule domain name [default: localhost].
  519. --file <FILE> - File to read feed URLs from Gemini [default: feeds.txt].
  520. --footer <FILE> - Homepage footer [default: footer.gemini].
  521. --header <FILE> - Homepage header [default: header.gemini].
  522. --lang <STRING> - Capsules language [default: es].
  523. --license <STRING> - Capsule license [default: CC-BY-4.0].
  524. --limit <NUMBER> - Maximum number of posts [default: 64].
  525. --output <PATH> - Output directory [default: .].]], settings.version))
  526. os.exit()
  527. end
  528. -- Opciones de uso en la terminal.
  529. local function usage()
  530. for itr = 1, #arg, 2 do
  531. local option = arg[itr]
  532. local param = arg[itr + 1] or shelp()
  533. if option == '--capsule' then
  534. settings.capsule = param
  535. elseif option == '--domain' then
  536. settings.domain = param
  537. elseif option == '--file' then
  538. settings.file = param
  539. elseif option == '--footer' then
  540. settings.footer = param
  541. elseif option == '--header' then
  542. settings.header = param
  543. elseif option == '--lang' then
  544. settings.lang = param
  545. elseif option == '--license' then
  546. settings.license = param
  547. elseif option == '--limit' then
  548. settings.limit = param
  549. elseif option == '--output' then
  550. settings.output = param
  551. else
  552. shelp()
  553. end
  554. end
  555. end
  556. -- Elimina publicaciones y cápsulas antigüas
  557. -- desde hace un año en la base de datos.
  558. local function clean_database()
  559. local prev_datetime = escape(now_prev_year_datetime())
  560. assert(sql_conn:execute(string.format([[
  561. DELETE FROM posts
  562. WHERE updated_at < '%s'
  563. ]], prev_datetime)))
  564. assert(sql_conn:execute(string.format([[
  565. DELETE FROM capsules
  566. WHERE id IN(
  567. SELECT c.id
  568. FROM capsules AS c
  569. LEFT JOIN posts AS p
  570. ON c.id = p.capsule_id
  571. WHERE c.updated_at < '%s'
  572. GROUP BY c.id
  573. HAVING COUNT(p.id) = 0
  574. )
  575. ]], prev_datetime)))
  576. end
  577. -- Función principal.
  578. local function main()
  579. usage()
  580. log_info('Running database migrations')
  581. sql_conn = assert(sql_env:connect('database.sqlite'))
  582. migrations()
  583. uuid.seed()
  584. log_info('Scanning feed URLs from Gemini')
  585. scan_feeds()
  586. log_info('Generating homepage and Atom feed')
  587. generate_planetalibre()
  588. log_info('Deleting old posts and capsules')
  589. clean_database()
  590. -- Cierra la conexión con la base de datos.
  591. sql_conn:close()
  592. sql_env:close()
  593. end
  594. main()