-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrss_archiver.py
1521 lines (1361 loc) · 57.1 KB
/
rss_archiver.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
import feedparser
import sqlite3
import curses
import html2text
import os
import subprocess
import logging
from datetime import datetime, timedelta
import dateutil.parser # Per fare il parsing delle date in formati diversi
import requests
from bs4 import BeautifulSoup
import argparse
import time
import json
import gzip
# Ottieni il percorso della directory dello script
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Configurazioni principali con percorsi assoluti
DB_PATH = os.path.join(SCRIPT_DIR, "db", "rss_archiver.db")
FEEDS_FILE = os.path.join(SCRIPT_DIR, "config", "feeds.txt")
LOG_FILE = os.path.join(SCRIPT_DIR, "logs", "archiver.log")
ARCHIVE_DIR = os.path.join(SCRIPT_DIR, "archive")
PRINTER_NAME = "Canon" # Nome della stampante configurata in CUPS
CACHE_DURATION_HOURS = 24 # Intervallo di tempo per ricaricare gli articoli (in ore)
ARCHIVE_THRESHOLD_DAYS = 30 # Soglia in giorni per archiviare gli articoli
# Configurazione del logging
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# ----------------------------------------------------------------------------
# FUNZIONI PER IL DATABASE
# ----------------------------------------------------------------------------
def initialize_db(db_name=DB_PATH):
"""
Inizializza il database SQLite creando le tabelle necessarie.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
# Crea la tabella 'sources' se non esiste
c.execute('''
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
url TEXT UNIQUE
)
''')
# Crea la tabella 'articles' se non esiste, includendo 'source_id'
c.execute('''
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
link TEXT UNIQUE,
published TEXT,
content TEXT,
scraped_at TEXT,
source_id INTEGER,
FOREIGN KEY(source_id) REFERENCES sources(id)
)
''')
# Crea la tabella 'tags' se non esiste
c.execute('''
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT UNIQUE
)
''')
# Crea la tabella 'article_tags' se non esiste
c.execute('''
CREATE TABLE IF NOT EXISTS article_tags (
article_id INTEGER,
tag_id INTEGER,
FOREIGN KEY(article_id) REFERENCES articles(id),
FOREIGN KEY(tag_id) REFERENCES tags(id),
PRIMARY KEY (article_id, tag_id)
)
''')
conn.commit()
conn.close()
logging.info("Database initialized.")
def save_source(db_name, name, url):
"""
Salva una fonte RSS nel database.
Se la fonte esiste già, restituisce il suo ID.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
try:
c.execute('INSERT INTO sources (name, url) VALUES (?, ?)', (name, url))
source_id = c.lastrowid
logging.info(f"Source saved: {name} ({url})")
except sqlite3.IntegrityError:
# Se la fonte esiste già, recuperiamo il suo ID
c.execute('SELECT id FROM sources WHERE url = ?', (url,))
row = c.fetchone()
if row:
source_id = row[0]
logging.info(f"Source already exists: {name} ({url})")
else:
source_id = None
conn.commit()
conn.close()
return source_id
def update_source_name(db_name, source_id, new_name):
"""
Aggiorna il nome di una fonte RSS.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('UPDATE sources SET name = ? WHERE id = ?', (new_name, source_id))
conn.commit()
conn.close()
logging.info(f"Source ID {source_id} renamed to {new_name}")
def delete_source(db_name, source_id):
"""
Elimina una fonte RSS dal database.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('DELETE FROM sources WHERE id = ?', (source_id,))
conn.commit()
conn.close()
logging.info(f"Source ID {source_id} deleted")
def save_article(db_name, title, link, published, content, source_id, scraped_now=False):
"""
Salva un articolo nel database.
Evita il salvataggio duplicato controllando il campo 'link' come UNIQUE.
Aggiorna 'content' e 'scraped_at' se 'scraped_now' è True.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
try:
if scraped_now:
c.execute('''
INSERT INTO articles (title, link, published, content, scraped_at, source_id)
VALUES (?, ?, ?, ?, ?, ?)
''', (title, link, published, content, datetime.utcnow().isoformat(), source_id))
article_id = c.lastrowid
logging.info(f"Article saved and scraped: {title}")
else:
c.execute('''
INSERT INTO articles (title, link, published, content, scraped_at, source_id)
VALUES (?, ?, ?, ?, ?, ?)
''', (title, link, published, content, datetime.utcnow().isoformat(), source_id))
article_id = c.lastrowid
logging.info(f"Article saved: {title}")
except sqlite3.IntegrityError:
# Se l'articolo esiste già, aggiorniamo il contenuto se necessario
if scraped_now:
c.execute('''
UPDATE articles
SET content = ?, scraped_at = ?, source_id = ?
WHERE link = ?
''', (content, datetime.utcnow().isoformat(), source_id, link))
article_id = c.execute('SELECT id FROM articles WHERE link = ?', (link,)).fetchone()[0]
logging.info(f"Article updated and re-scraped: {title}")
else:
# Se l'articolo esiste già e non è un aggiornamento, recuperiamo il suo ID
c.execute('SELECT id FROM articles WHERE link = ?', (link,))
row = c.fetchone()
if row:
article_id = row[0]
logging.info(f"Article already exists: {title}")
else:
article_id = None
conn.commit()
conn.close()
# ----------------------------------------------------------------------------
# FUNZIONI PER L'ELABORAZIONE TESTO E FEED
# ----------------------------------------------------------------------------
def fetch_feeds(feed_urls, progress_win=None):
"""
Scarica i feed RSS dalle URL fornite e restituisce una lista di 'FeedParserDict'.
Se progress_win è fornito, aggiorna la progress bar e il messaggio corrente.
"""
feeds = []
total = len(feed_urls)
for idx, url in enumerate(feed_urls, start=1):
try:
feed = feedparser.parse(url)
if feed.bozo:
raise feed.bozo_exception
feeds.append(feed)
logging.info(f"Fetched feed: {url}")
if progress_win:
feed_title = feed.feed.get('title', 'Unknown Source')
update_progress_bar(progress_win, idx, total, f"Elaborazione feed: {feed_title}")
except Exception as e:
logging.error(f"Error fetching feed {url}: {e}")
if progress_win:
update_progress_bar(progress_win, idx, total, f"Errore nel fetch del feed: {url}")
return feeds
def fetch_full_article(link):
"""
Effettua lo scraping della pagina web dell'articolo per ottenere il contenuto completo.
Restituisce il testo estratto o una stringa vuota in caso di fallimento.
"""
try:
response = requests.get(link, timeout=10)
response.raise_for_status()
except requests.RequestException as e:
logging.error(f"Error fetching article content from {link}: {e}")
return ""
try:
soup = BeautifulSoup(response.content, 'html.parser')
# Tenta di estrarre il contenuto principale dell'articolo.
# Questo metodo è molto generico e potrebbe non funzionare per tutti i siti.
# Per una migliore estrazione, considera l'uso di librerie come newspaper3k.
# Ecco un esempio semplice:
article = soup.find('article')
if not article:
# Fallback: estrai tutto il testo dai tag <p>
paragraphs = soup.find_all('p')
text = '\n'.join([para.get_text() for para in paragraphs])
else:
paragraphs = article.find_all('p')
text = '\n'.join([para.get_text() for para in paragraphs])
return text
except Exception as e:
logging.error(f"Error parsing article content from {link}: {e}")
return ""
def process_feeds(db_name, progress_win=None):
"""
Legge la lista di feed dal file FEEDS_FILE, li scarica e salva gli articoli nel DB.
Converte l'HTML in testo semplice grazie a html2text e, se necessario, effettua lo scraping per ottenere il contenuto completo.
Implementa un sistema di caching per evitare di scaricare ripetutamente lo stesso articolo.
"""
feed_urls = read_feeds()
feeds = fetch_feeds(feed_urls, progress_win)
h = html2text.HTML2Text()
h.ignore_links = True
h.ignore_images = True
total_feeds = len(feeds)
for feed_idx, feed in enumerate(feeds, start=1):
source_name = feed.feed.get('title', 'Unknown Source')
source_url = feed.feed.get('link', 'Unknown URL')
source_id = save_source(db_name, source_name, source_url)
for entry in feed.entries:
title = entry.title
link = entry.link
# Tenta di recuperare la data di pubblicazione
if 'published' in entry:
published = entry.published
elif 'updated' in entry:
published = entry.updated
else:
published = ''
# Controlla se l'articolo è già presente nel DB
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('SELECT content, scraped_at FROM articles WHERE link = ?', (link,))
row = c.fetchone()
conn.close()
needs_scraping = True
if row:
content_existing, scraped_at_str = row
if content_existing and len(content_existing) >= 200:
# Verifica se il cache è ancora valida
if scraped_at_str:
scraped_at = dateutil.parser.parse(scraped_at_str)
if datetime.utcnow() - scraped_at < timedelta(hours=CACHE_DURATION_HOURS):
needs_scraping = False
if needs_scraping:
# Recupera il contenuto completo dell'articolo
full_content = fetch_full_article(link)
if full_content:
content = full_content
scraped_now = True
else:
# Se non riesce a ottenere il contenuto completo, usa il summary
if hasattr(entry, 'content') and len(entry.content) > 0:
raw_content = entry.content[0].value
content = h.handle(raw_content)
else:
raw_content = entry.get('summary', '')
content = h.handle(raw_content)
scraped_now = False
else:
# Usa il contenuto esistente
content = row[0]
scraped_now = False
# Salviamo nel DB (inserimento o aggiornamento)
save_article(db_name, title, link, published, content, source_id, scraped_now)
# ----------------------------------------------------------------------------
# FUNZIONI PER L'ARCHIVIAZIONE DEGLI ARTICOLI
# ----------------------------------------------------------------------------
def archive_old_articles(db_name, threshold_days=ARCHIVE_THRESHOLD_DAYS):
"""
Archivia gli articoli più vecchi di 'threshold_days' giorni.
Gli articoli archiviati vengono esportati in file JSON compressi,
organizzati per anno e mese, e poi rimossi dal database principale.
"""
cutoff_date = datetime.utcnow() - timedelta(days=threshold_days)
cutoff_iso = cutoff_date.isoformat()
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('''
SELECT id, title, link, published, content, source_id FROM articles
WHERE published IS NOT NULL AND datetime(published) < ?
''', (cutoff_iso,))
old_articles = c.fetchall()
conn.close()
if not old_articles:
logging.info("Nessun articolo da archiviare.")
return
# Organizza gli articoli per anno e mese
archive_dict = {}
for article in old_articles:
id_, title, link, published, content, source_id = article
try:
pub_datetime = dateutil.parser.parse(published)
year = pub_datetime.year
month = pub_datetime.month
key = f"{year}/{month:02d}"
if key not in archive_dict:
archive_dict[key] = []
archive_dict[key].append({
'id': id_,
'title': title,
'link': link,
'published': published,
'content': content,
'source_id': source_id
})
except Exception as e:
logging.error(f"Errore nel parsing della data per l'articolo ID {id_}: {e}")
# Salva gli articoli in file JSON compressi
for key, articles in archive_dict.items():
year, month = key.split('/')
archive_path = os.path.join(ARCHIVE_DIR, year, month)
os.makedirs(archive_path, exist_ok=True)
# Nome del file basato sulla data odierna e su un timestamp
today = datetime.utcnow().strftime("%Y_%m_%d")
timestamp = datetime.utcnow().strftime("%H%M%S")
file_name = f"articles_{today}_{timestamp}.json.gz"
file_path = os.path.join(archive_path, file_name)
try:
with gzip.open(file_path, 'wt', encoding='utf-8') as f:
json.dump(articles, f, ensure_ascii=False, indent=2)
logging.info(f"Archiviati {len(articles)} articoli in {file_path}")
except Exception as e:
logging.error(f"Errore nell'archiviazione degli articoli in {file_path}: {e}")
# Rimuovi gli articoli archiviati dal database principale
article_ids = [article[0] for article in old_articles]
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.executemany('DELETE FROM articles WHERE id = ?', [(id_,) for id_ in article_ids])
conn.commit()
conn.close()
logging.info(f"Rimossi {len(article_ids)} articoli dal database principale.")
# ----------------------------------------------------------------------------
# FUNZIONI PER LA GESTIONE DEI FEED (LETTORE/SCRITTURA FILE)
# ----------------------------------------------------------------------------
def add_feed(url):
"""
Aggiunge un feed al file di testo FEEDS_FILE.
"""
with open(FEEDS_FILE, 'a') as f:
f.write(url + '\n')
logging.info(f"Added new feed: {url}")
def delete_feed_from_file(url):
"""
Rimuove un feed dal file di testo FEEDS_FILE.
"""
if not os.path.exists(FEEDS_FILE):
return
with open(FEEDS_FILE, 'r') as f:
feeds = [line.strip() for line in f if line.strip() and line.strip() != url]
with open(FEEDS_FILE, 'w') as f:
for feed in feeds:
f.write(feed + '\n')
logging.info(f"Deleted feed from file: {url}")
def read_feeds():
"""
Legge la lista di feed dal file FEEDS_FILE.
Crea il file se non esiste.
"""
if not os.path.exists(FEEDS_FILE):
os.makedirs(os.path.dirname(FEEDS_FILE), exist_ok=True)
open(FEEDS_FILE, 'w').close()
logging.info(f"Created empty feeds file at {FEEDS_FILE}")
try:
with open(FEEDS_FILE, 'r') as f:
feeds = [line.strip() for line in f if line.strip()]
logging.info(f"Read {len(feeds)} feeds from {FEEDS_FILE}")
return feeds
except Exception as e:
logging.error(f"Error reading feeds from {FEEDS_FILE}: {e}")
return []
# ----------------------------------------------------------------------------
# UTILITY PER L'ORDINAMENTO DELLE DATE
# ----------------------------------------------------------------------------
def parse_date_str(date_str):
"""
Tenta di convertire la data (stringa) in un oggetto datetime.
Se fallisce, restituisce None.
"""
try:
return dateutil.parser.parse(date_str)
except:
return None
# ----------------------------------------------------------------------------
# FUNZIONI PER L'INTERFACCIA TESTUALE (CURSES)
# ----------------------------------------------------------------------------
def ui_main(stdscr, db_name):
"""
Menu principale dell'applicazione.
"""
curses.start_color()
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLUE)
while True:
stdscr.clear()
# Aggiungi un bordo
height, width = stdscr.getmaxyx()
border_text = " RSS Archiver "
stdscr.attron(curses.color_pair(6) | curses.A_BOLD)
stdscr.addstr(0, max((width - len(border_text)) // 2, 0), border_text)
stdscr.attroff(curses.color_pair(6) | curses.A_BOLD)
stdscr.border()
# Menu Opzioni
menu_options = [
"1. Visualizza Articoli per Fonte",
"2. Cerca Articoli",
"3. Aggiungi Feed RSS",
"4. Aggiorna Articoli",
"5. Gestisci Feed RSS",
"6. Archivia Articoli Obsoleti",
"7. Esci"
]
start_y = 3
for idx, option in enumerate(menu_options, start=start_y):
if option.startswith("5. Gestisci"):
safe_addstr(stdscr, idx, 2, option, curses.color_pair(4))
else:
safe_addstr(stdscr, idx, 2, option, curses.color_pair(2) if idx == start_y else 0)
# Istruzioni
instruction = "Seleziona un'opzione premendo il numero corrispondente."
safe_addstr(stdscr, start_y + len(menu_options) + 2, 2, instruction, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('1'):
select_source(stdscr, db_name)
elif key == ord('2'):
search_ui(stdscr, db_name)
elif key == ord('3'):
add_feed_ui(stdscr)
elif key == ord('4'):
update_articles_ui(stdscr, db_name)
elif key == ord('5'):
manage_feeds_ui(stdscr, db_name)
elif key == ord('6'):
archive_articles_ui(stdscr, db_name)
elif key == ord('7'):
break
else:
# Mostra un messaggio di errore temporaneo
show_message(stdscr, "Opzione non valida! Premi un tasto per continuare.", curses.color_pair(5))
def select_source(stdscr, db_name):
"""
Mostra un elenco delle fonti e permette di selezionare una fonte per visualizzare i suoi articoli.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('SELECT id, name FROM sources ORDER BY name ASC')
sources = c.fetchall()
conn.close()
if not sources:
show_message(stdscr, "Nessuna fonte RSS aggiunta. Aggiungi una fonte prima di procedere.", curses.color_pair(5))
return
page = 0
sources_per_page = 5 # Numero di fonti per pagina
total_pages = (len(sources) + sources_per_page - 1) // sources_per_page
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
title_text = f" Seleziona una Fonte (Pagina {page + 1}/{total_pages}) "
stdscr.attron(curses.color_pair(6) | curses.A_BOLD)
stdscr.addstr(0, max((width - len(title_text)) // 2, 0), title_text)
stdscr.attroff(curses.color_pair(6) | curses.A_BOLD)
start = page * sources_per_page
end = start + sources_per_page
current_sources = sources[start:end]
y_offset = 2
for idx, (source_id, source_name) in enumerate(current_sources, start=1):
line_num = idx
line_text = f"{idx}. {source_name}"
safe_addstr(stdscr, y_offset, 2, line_text, curses.color_pair(2) | curses.A_UNDERLINE)
y_offset += 1
# Istruzioni per la navigazione
navigation = "Premi un numero per selezionare la fonte, 'n' per pagina successiva, 'p' per precedente, 'q' per tornare indietro."
safe_addstr(stdscr, height - 2, 2, navigation, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('q'):
break
elif key == ord('n') and page < total_pages - 1:
page += 1
elif key == ord('p') and page > 0:
page -= 1
elif ord('1') <= key <= ord(str(min(9, len(current_sources)))):
selection = key - ord('0') - 1
if 0 <= selection < len(current_sources):
selected_source_id, selected_source_name = current_sources[selection]
display_articles_by_source(stdscr, db_name, selected_source_id, selected_source_name)
else:
# Mostra un messaggio di errore temporaneo
show_message(stdscr, "Input non valido! Premi un tasto per continuare.", curses.color_pair(5))
def display_articles_by_source(stdscr, db_name, source_id, source_name):
"""
Mostra gli articoli di una specifica fonte in modo paginato.
Limita la visualizzazione a 9 articoli per pagina per facilitare la selezione.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('''
SELECT id, title, published FROM articles
WHERE source_id = ?
ORDER BY
CASE
WHEN published IS NOT NULL THEN datetime(published)
ELSE datetime('1970-01-01')
END DESC
''', (source_id,))
rows = c.fetchall()
conn.close()
articles = []
for art_id, title, published_str in rows:
dt = parse_date_str(published_str) # None se parsing fallisce
articles.append((art_id, title, published_str, dt))
# Ordiniamo decrescentemente in base a dt (chi non ha data, va in fondo)
articles.sort(key=lambda x: x[3] if x[3] else datetime.min, reverse=True)
page = 0
articles_per_page = 9 # Limitazione a 9 articoli per pagina
total_pages = (len(articles) + articles_per_page - 1) // articles_per_page
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
title_text = f" Articoli di '{source_name}' (Pagina {page + 1}/{total_pages}) "
stdscr.attron(curses.color_pair(6) | curses.A_BOLD)
stdscr.addstr(0, max((width - len(title_text)) // 2, 0), title_text)
stdscr.attroff(curses.color_pair(6) | curses.A_BOLD)
start = page * articles_per_page
end = start + articles_per_page
current_articles = articles[start:end]
y_offset = 2
for idx, (art_id, title, published_str, dt) in enumerate(current_articles, start=1):
line_num = idx
line_text = f"{idx}. {title} ({published_str})"
safe_addstr(stdscr, y_offset, 2, line_text, curses.color_pair(1))
y_offset += 1
# Istruzioni per la navigazione
navigation = "Premi un numero (1-9) per leggere l'articolo, 'n' per pagina successiva, 'p' per precedente, 'q' per tornare indietro."
safe_addstr(stdscr, height - 2, 2, navigation, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('q'):
break
elif key == ord('n') and page < total_pages - 1:
page += 1
elif key == ord('p') and page > 0:
page -= 1
elif ord('1') <= key <= ord(str(min(9, len(current_articles)))):
selection = key - ord('0') - 1
if 0 <= selection < len(current_articles):
article_id = current_articles[selection][0]
show_article(stdscr, db_name, article_id)
else:
# Mostra un messaggio di errore temporaneo
show_message(stdscr, "Input non valido! Premi un tasto per continuare.", curses.color_pair(5))
def show_article(stdscr, db_name, article_id):
"""
Mostra un menù di azioni (visualizza, salva, stampa, modifica tag, torna indietro) per l’articolo selezionato.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('SELECT title, content, published FROM articles WHERE id = ?', (article_id,))
article = c.fetchone()
if article:
title, content, pubdate = article
c.execute('''
SELECT tags.tag FROM tags
JOIN article_tags ON tags.id = article_tags.tag_id
WHERE article_tags.article_id = ?
''', (article_id,))
tags = [row[0] for row in c.fetchall()]
conn.close()
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
safe_addstr(stdscr, 0, 2, title, curses.A_BOLD | curses.color_pair(2))
# Pubblicazione
safe_addstr(stdscr, 1, 2, f"Pubblicato: {pubdate}", curses.color_pair(3))
# Tags
tags_line = "Tags: " + ", ".join(tags) if tags else "Tags: nessuno"
safe_addstr(stdscr, 2, 2, tags_line, curses.color_pair(3))
# Opzioni
action_options = [
"1. Visualizza a Schermo",
"2. Salva su File",
"3. Stampa",
"4. Modifica Tags",
"5. Torna Indietro"
]
start_y = 4
for idx, option in enumerate(action_options, start=start_y):
safe_addstr(stdscr, idx, 4, option, curses.color_pair(2))
# Istruzioni
instruction = "Seleziona un'opzione premendo il numero corrispondente."
safe_addstr(stdscr, start_y + len(action_options) + 1, 4, instruction, curses.color_pair(3))
stdscr.refresh()
while True:
key = stdscr.getch()
if key == ord('1'):
display_full_article(stdscr, title, content)
break
elif key == ord('2'):
save_article_to_file(stdscr, title, content)
break
elif key == ord('3'):
print_article(stdscr, title, content)
break
elif key == ord('4'):
edit_tags_ui(stdscr, db_name, article_id, tags)
break
elif key == ord('5'):
break
else:
show_message(stdscr, "Opzione non valida! Premi un tasto per continuare.", curses.color_pair(5))
def display_full_article(stdscr, title, content):
"""
Visualizza l'articolo a schermo con una semplice paginazione (max righe visibili).
Premi 'n' per pagina successiva, 'p' per pagina precedente, 'q' per uscire.
"""
lines = content.splitlines()
page = 0
lines_per_page = curses.LINES - 6 # Spazio per titolo, pubblicazione, tag e footer
total_pages = (len(lines) + lines_per_page - 1) // lines_per_page
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
safe_addstr(stdscr, 0, 2, title, curses.A_BOLD | curses.color_pair(2))
# Contenuto
start = page * lines_per_page
end = start + lines_per_page
chunk = lines[start:end]
for idx, line in enumerate(chunk, start=2):
safe_addstr(stdscr, idx, 2, line)
# Footer
footer = f"Pagina {page + 1}/{total_pages} | [n] Avanti, [p] Indietro, [q] Esci"
safe_addstr(stdscr, lines_per_page + 3, 2, footer, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('q'):
break
elif key == ord('n') and page < total_pages - 1:
page += 1
elif key == ord('p') and page > 0:
page -= 1
else:
show_message(stdscr, "Input non valido! Premi un tasto per continuare.", curses.color_pair(5))
def save_article_to_file(stdscr, title, content):
"""
Chiede all'utente il percorso dove salvare il file e scrive il contenuto dell'articolo.
"""
curses.echo()
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
prompt = "Inserisci il percorso del file (es. /home/pi/articolo.txt): "
safe_addstr(stdscr, 2, 2, prompt, curses.color_pair(3))
stdscr.refresh()
filepath_bytes = stdscr.getstr(3, 2, width - 4)
curses.noecho()
try:
filepath = filepath_bytes.decode('utf-8').strip()
if not filepath:
raise ValueError("Percorso del file non può essere vuoto.")
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"Title: {title}\n\n{content}")
success_msg = "Articolo salvato con successo!"
safe_addstr(stdscr, 5, 2, success_msg, curses.color_pair(2))
logging.info(f"Article saved to file: {filepath}")
except Exception as e:
err_msg = f"Errore nel salvataggio: {e}"
safe_addstr(stdscr, 5, 2, err_msg, curses.color_pair(5))
logging.error(f"Error saving article to file: {e}")
# Istruzioni finali
final_msg = "Premi un tasto per continuare."
safe_addstr(stdscr, 7, 2, final_msg, curses.color_pair(3))
stdscr.refresh()
stdscr.getch()
def print_article(stdscr, title, content):
"""
Invia l'articolo alla stampante di rete 'Canon' configurata in CUPS.
"""
full_content = f"Title: {title}\n\n{content}"
temp_print_file = "/tmp/temp_article.txt"
try:
with open(temp_print_file, 'w', encoding='utf-8') as f:
f.write(full_content)
# Comando per stampare usando CUPS
subprocess.run(['lp', '-d', PRINTER_NAME, temp_print_file], check=True)
logging.info(f"Article sent to printer {PRINTER_NAME}")
show_message(stdscr, "Articolo inviato alla stampante con successo!", curses.color_pair(2))
except subprocess.CalledProcessError as e:
logging.error(f"Error printing article: {e}")
show_message(stdscr, f"Errore nella stampa: {e}", curses.color_pair(5))
except Exception as e:
logging.error(f"Error writing to temp file for printing: {e}")
show_message(stdscr, f"Errore nella stampa: {e}", curses.color_pair(5))
finally:
# Rimuovi il file temporaneo se esiste
if os.path.exists(temp_print_file):
os.remove(temp_print_file)
def edit_tags_ui(stdscr, db_name, article_id, current_tags):
"""
Interfaccia per modificare le tag di un articolo.
Permette di rimuovere tag esistenti e aggiungerne di nuove.
"""
curses.echo()
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
prompt_remove = "Tag attuali (separati da virgola). Inserisci quelli da rimuovere (lascia vuoto per nessuna rimozione): "
safe_addstr(stdscr, 2, 2, prompt_remove, curses.color_pair(3))
safe_addstr(stdscr, 3, 2, f"{', '.join(current_tags)}", curses.color_pair(1))
stdscr.refresh()
remove_input_bytes = stdscr.getstr(4, 2, width - 4)
curses.noecho()
tags_to_remove = [tag.strip() for tag in remove_input_bytes.decode('utf-8').split(',') if tag.strip()]
if tags_to_remove:
remove_tags(db_name, article_id, tags_to_remove)
curses.echo()
stdscr.clear()
stdscr.border()
prompt_add = "Inserisci le nuove tag da aggiungere (separati da virgola): "
safe_addstr(stdscr, 2, 2, prompt_add, curses.color_pair(3))
stdscr.refresh()
add_input_bytes = stdscr.getstr(3, 2, width - 4)
curses.noecho()
tags_to_add = [tag.strip() for tag in add_input_bytes.decode('utf-8').split(',') if tag.strip()]
if tags_to_add:
add_tags(db_name, article_id, tags_to_add)
# Messaggio di conferma
success_msg = "Tag aggiornate con successo!"
safe_addstr(stdscr, 5, 2, success_msg, curses.color_pair(2))
logging.info(f"Tags updated for article ID {article_id}: Added {tags_to_add}, Removed {tags_to_remove}")
# Istruzioni finali
final_msg = "Premi un tasto per continuare."
safe_addstr(stdscr, 7, 2, final_msg, curses.color_pair(3))
stdscr.refresh()
stdscr.getch()
def add_tags(db_name, article_id, tags_to_add):
"""
Aggiunge nuove tag a un articolo.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
for tag in tags_to_add:
c.execute('INSERT OR IGNORE INTO tags (tag) VALUES (?)', (tag,))
# Recupera l'id del tag appena inserito o esistente
c.execute('SELECT id FROM tags WHERE tag = ?', (tag,))
tag_row = c.fetchone()
if tag_row:
tag_id = tag_row[0]
try:
c.execute('INSERT INTO article_tags (article_id, tag_id) VALUES (?, ?)', (article_id, tag_id))
except sqlite3.IntegrityError:
pass # Relazione già esistente
conn.commit()
conn.close()
def remove_tags(db_name, article_id, tags_to_remove):
"""
Rimuove tag da un articolo.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
for tag in tags_to_remove:
# Recupera l'id del tag
c.execute('SELECT id FROM tags WHERE tag = ?', (tag,))
tag_row = c.fetchone()
if tag_row:
tag_id = tag_row[0]
# Rimuove la relazione
c.execute('DELETE FROM article_tags WHERE article_id = ? AND tag_id = ?', (article_id, tag_id))
conn.commit()
conn.close()
def manage_feeds_ui(stdscr, db_name):
"""
Interfaccia per gestire le sorgenti RSS.
Permette di elencare, eliminare e rinominare le sorgenti RSS.
"""
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
title_text = " Gestisci Feed RSS "
stdscr.attron(curses.color_pair(6) | curses.A_BOLD)
stdscr.addstr(0, max((width - len(title_text)) // 2, 0), title_text)
stdscr.attroff(curses.color_pair(6) | curses.A_BOLD)
# Opzioni di gestione
options = [
"1. Elenca Tutti i Feed",
"2. Elimina un Feed",
"3. Rinomina un Feed",
"4. Torna al Menu Principale"
]
start_y = 2
for idx, option in enumerate(options, start=start_y):
safe_addstr(stdscr, idx, 2, option, curses.color_pair(2) if idx == start_y else 0)
# Istruzioni
instruction = "Seleziona un'opzione premendo il numero corrispondente."
safe_addstr(stdscr, start_y + len(options) + 2, 2, instruction, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('1'):
list_feeds_ui(stdscr, db_name)
elif key == ord('2'):
delete_feed_ui(stdscr, db_name)
elif key == ord('3'):
rename_feed_ui(stdscr, db_name)
elif key == ord('4'):
break
else:
show_message(stdscr, "Opzione non valida! Premi un tasto per continuare.", curses.color_pair(5))
def list_feeds_ui(stdscr, db_name):
"""
Elenca tutte le sorgenti RSS con nome e URL.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('SELECT id, name, url FROM sources ORDER BY name ASC')
feeds = c.fetchall()
conn.close()
if not feeds:
show_message(stdscr, "Nessun feed RSS presente.", curses.color_pair(5))
return
page = 0
feeds_per_page = 10 # Numero di feed per pagina
total_pages = (len(feeds) + feeds_per_page - 1) // feeds_per_page
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
stdscr.border()
# Titolo
title_text = f" Elenco dei Feed RSS (Pagina {page + 1}/{total_pages}) "
stdscr.attron(curses.color_pair(6) | curses.A_BOLD)
stdscr.addstr(0, max((width - len(title_text)) // 2, 0), title_text)
stdscr.attroff(curses.color_pair(6) | curses.A_BOLD)
start = page * feeds_per_page
end = start + feeds_per_page
current_feeds = feeds[start:end]
y_offset = 2
for idx, (feed_id, name, url) in enumerate(current_feeds, start=1):
line_text = f"{start + idx}. Nome: {name} | URL: {url}"
safe_addstr(stdscr, y_offset, 2, line_text, curses.color_pair(1))
y_offset += 1
# Istruzioni per la navigazione
navigation = "Premi 'n' per pagina successiva, 'p' per precedente, 'q' per tornare indietro."
safe_addstr(stdscr, height - 2, 2, navigation, curses.color_pair(3))
stdscr.refresh()
key = stdscr.getch()
if key == ord('q'):
break
elif key == ord('n') and page < total_pages - 1:
page += 1
elif key == ord('p') and page > 0:
page -= 1
else:
show_message(stdscr, "Input non valido! Premi un tasto per continuare.", curses.color_pair(5))
def delete_feed_ui(stdscr, db_name):
"""
Interfaccia per eliminare un feed RSS.
"""
conn = sqlite3.connect(db_name)
c = conn.cursor()
c.execute('SELECT id, name, url FROM sources ORDER BY name ASC')
feeds = c.fetchall()
conn.close()
if not feeds:
show_message(stdscr, "Nessun feed RSS presente da eliminare.", curses.color_pair(5))
return
page = 0
feeds_per_page = 10 # Numero di feed per pagina
total_pages = (len(feeds) + feeds_per_page - 1) // feeds_per_page